Diagnostics for focus and layering (--diag / WAYBETTER_DIAG=1): one timestamped line per event in the app's log folder

For the macOS reports (the menu never shows and the window goes inactive on a click; the find field gets no keyboard): off unless the app is started with `--diag` or WAYBETTER_DIAG=1. Then native code and the shell append to `diag.log` in the app's log folder (truncated at launch): menu open requests and every close with its reason, overlay counts, raise/lower and `shell_raise` results, native `shell_focus` calls and their result, the shell document's focus/blur/visibility, and where the find bar's focus is right after it opens and 300 ms later. A line is an event and a few words in plain ASCII (capped); never a URL, a query or page text. `diag_get` / `diag_log` are shell-only (in commands::SHELL and the template's list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 04:11:53 +07:00
parent 7f9e172196
commit 43fc8a15f5
8 changed files with 147 additions and 69 deletions

View file

@ -0,0 +1,89 @@
//! Focus and layering diagnostics, off unless asked for at launch (`--diag` or the environment
//! variable `WAYBETTER_DIAG=1`). While on, native code and the shell append one timestamped line per
//! event to `diag.log` in the app's log folder: menu open requests and every close with its reason,
//! overlays and `shell_raise` calls with their results, native focus calls, and the shell document's
//! focus, blur and visibility changes. A line is an event name and a few words: never a URL, a
//! query, page text or anything about an account ([`clean`] keeps plain printable ASCII only and
//! caps the length). The file is truncated at each launch with diagnostics on.
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Instant;
pub const ARG: &str = "--diag";
pub const ENV: &str = "WAYBETTER_DIAG";
pub const FILE: &str = "diag.log";
const MAX_LINE: usize = 300;
static ON: AtomicBool = AtomicBool::new(false);
static SINK: Mutex<Option<(std::fs::File, Instant)>> = Mutex::new(None);
/// Whether this launch asks for diagnostics (the first argument is the program itself).
pub fn asked<I, S>(args: I, env: Option<&str>) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
env.is_some_and(|value| value == "1") || args.into_iter().skip(1).any(|arg| arg.as_ref() == ARG)
}
/// A line as it is written: printable ASCII only (anything else becomes `?`), one line, capped.
pub fn clean(line: &str) -> String {
line.chars().take(MAX_LINE).map(|ch| if (' '..='~').contains(&ch) { ch } else { '?' }).collect()
}
/// Starts writing to `dir/diag.log` (created, truncated). Nothing happens without a folder.
pub fn start(dir: Option<PathBuf>) {
let Some(dir) = dir else { return };
let _ = std::fs::create_dir_all(&dir);
if let Ok(file) = std::fs::File::create(dir.join(FILE)) {
if let Ok(mut sink) = SINK.lock() {
*sink = Some((file, Instant::now()));
ON.store(true, Ordering::SeqCst);
}
}
log("host", "diagnostics on");
}
pub fn active() -> bool {
ON.load(Ordering::SeqCst)
}
/// One event from `source` (`host` or `shell`), with milliseconds since the start.
pub fn log(source: &str, line: &str) {
if !active() {
return;
}
if let Ok(mut sink) = SINK.lock() {
if let Some((file, started)) = sink.as_mut() {
let _ = writeln!(file, "{:>9} {:<5} {}", started.elapsed().as_millis(), clean(source), clean(line));
let _ = file.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asked_by_the_argument_or_the_environment_only() {
assert!(asked(["app", "--diag"], None));
assert!(asked(["app"], Some("1")));
assert!(!asked(["app"], Some("yes")));
assert!(!asked(["--diag"], None), "the first argument is the program");
assert!(!asked(["app", "--diag=1", "diag"], None));
}
#[test]
fn a_line_is_plain_short_ascii() {
assert_eq!(clean("menu close: blur (hasFocus=false)"), "menu close: blur (hasFocus=false)");
assert_eq!(clean("two\nlines\ttab é"), "two?lines?tab ?");
assert_eq!(clean(&"x".repeat(1000)).len(), MAX_LINE);
// Off by default: nothing is written.
assert!(!active());
log("shell", "ignored");
}
}

View file

@ -1,66 +0,0 @@
//! Google Fonts, native side, for every app that sets [`crate::app::AppConfig::fonts`]: the
//! shell-only `gfonts_*` commands ([`crate::commands::GFONTS`]) over [`crate::fonts`]'s downloads
//! and cache (`fonts.googleapis.com` and `fonts.gstatic.com` only, app-data `fonts/`). Site tabs
//! never request fonts and gain no command: native code hands them cached bytes
//! ([`crate::fonts::page_script_for`]).
//!
//! When a font is installed or removed the app's [`crate::app::SiteHooks::fonts_changed`] runs, so
//! it can give every site tab its theme (and the font bytes) again.
use crate::fonts::{self, InstalledFont};
use crate::gfonts::Weights;
use crate::host::shell_only;
use tauri::{AppHandle, Manager, Webview};
/// Opens the font cache in app data. Does nothing unless the app set [`crate::app::AppConfig::fonts`].
pub(crate) fn init(app: &AppHandle) {
if !crate::app::config(app).fonts {
return;
}
if let Ok(dir) = app.path().app_data_dir() {
fonts::init(&dir);
}
}
fn changed(app: &AppHandle) {
crate::app::hooks(app).fonts_changed(app);
}
#[tauri::command]
pub(crate) async fn gfonts_installed(webview: Webview) -> Result<Vec<InstalledFont>, String> {
shell_only(&webview)?;
fonts::installed()
}
#[tauri::command]
pub(crate) async fn gfonts_install(webview: Webview, app: AppHandle, family: String, weights: Weights) -> Result<InstalledFont, String> {
shell_only(&webview)?;
let installed = fonts::install(family, weights).await?;
changed(&app);
Ok(installed)
}
#[tauri::command]
pub(crate) async fn gfonts_remove(webview: Webview, app: AppHandle, family: String) -> Result<(), String> {
shell_only(&webview)?;
fonts::remove(&family)?;
changed(&app);
Ok(())
}
#[tauri::command]
pub(crate) async fn gfonts_faces(webview: Webview, family: String) -> Result<serde_json::Value, String> {
shell_only(&webview)?;
fonts::faces(&family)
}
#[tauri::command]
pub(crate) async fn gfonts_preview(webview: Webview, family: String, weights: Weights, text: String) -> Result<Vec<serde_json::Value>, String> {
shell_only(&webview)?;
fonts::preview(family, weights, text).await
}
/// The fonts commands ([`crate::commands::GFONTS`]).
pub(crate) fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![gfonts_installed, gfonts_install, gfonts_remove, gfonts_faces, gfonts_preview]
}