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).
This commit is contained in:
CRTkafa 2026-10-01 04:11:53 +07:00
parent 5e950f4f62
commit fc0d34d8ce
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]
}

View file

@ -151,7 +151,7 @@ const COMMANDS = {
*/ */
export const HOST_COMMANDS = { export const HOST_COMMANDS = {
shell: [ shell: [
'tabs_sync', 'tab_navigate', 'tab_action', 'tab_zoom', 'tab_find', 'safe_mode_get', 'safe_mode_set', 'open_external_url', 'shell_focus', 'shell_ready', 'tab_still_ready', 'shell_raise', 'window_system_menu', 'tabs_sync', 'tab_navigate', 'tab_action', 'tab_zoom', 'tab_find', 'safe_mode_get', 'safe_mode_set', 'diag_get', 'diag_log', 'open_external_url', 'shell_focus', 'shell_ready', 'tab_still_ready', 'shell_raise', 'window_system_menu',
'profiles_get', 'profiles_set', 'i18n_get', 'i18n_set', 'profiles_get', 'profiles_set', 'i18n_get', 'i18n_set',
'plugins_list', 'plugin_set_enabled', 'plugins_open_folder', 'custom_css_get', 'custom_css_set', 'features_get', 'features_set', 'mode_get', 'mode_set', 'settings_get', 'settings_set', 'plugins_list', 'plugin_set_enabled', 'plugins_open_folder', 'custom_css_get', 'custom_css_set', 'features_get', 'features_set', 'mode_get', 'mode_set', 'settings_get', 'settings_set',
], ],

View file

@ -15,6 +15,7 @@ import { useEffect, useLayoutEffect, useReducer, useRef, useState, type Keyboard
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { parseUpdateStatus, type UpdateStatus } from '../updateModel.ts'; import { parseUpdateStatus, type UpdateStatus } from '../updateModel.ts';
import { invoke, listen } from './bridge.ts'; import { invoke, listen } from './bridge.ts';
import { describe, diag } from './diag.ts';
import { decks } from './deckStore.ts'; import { decks } from './deckStore.ts';
import { refreshDev } from './devHost.ts'; import { refreshDev } from './devHost.ts';
import { useDev, usePaneTick, useT } from './hooks.ts'; import { useDev, usePaneTick, useT } from './hooks.ts';
@ -107,6 +108,7 @@ export function AppMenu() {
}, []); }, []);
function show(request: AppMenuRequest = {}) { function show(request: AppMenuRequest = {}) {
diag(`menu open: anchor=${request.anchor ?? 'button'} keyboard=${!!request.keyboard} hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`);
const logo = request.anchor === 'logo' ? document.querySelector('.bt-titlebar-logo') : null; const logo = request.anchor === 'logo' ? document.querySelector('.bt-titlebar-logo') : null;
const rect = (logo ?? button.current)?.getBoundingClientRect(); const rect = (logo ?? button.current)?.getBoundingClientRect();
if (rect) { if (rect) {
@ -135,7 +137,8 @@ export function AppMenu() {
} }
/** `escape`: back to what had focus before. `action` and `away`: nothing focused, so typing returns to the active pane. */ /** `escape`: back to what had focus before. `action` and `away`: nothing focused, so typing returns to the active pane. */
function close(how: 'escape' | 'action' | 'away' = 'away') { function close(how: 'escape' | 'action' | 'away' = 'away', why = '') {
diag(`menu close: ${how}${why ? ` (${why})` : ''} hasFocus=${document.hasFocus()}`);
clearTimeout(hover.current.timer); clearTimeout(hover.current.timer);
hover.current = { el: null }; hover.current = { el: null };
focusNext.current = null; focusNext.current = null;
@ -173,7 +176,11 @@ export function AppMenu() {
// The window lost the keyboard: the menu closes, unless the focus is back a moment later (taking the // The window lost the keyboard: the menu closes, unless the focus is back a moment later (taking the
// keyboard from a tab for Alt+F / F10 can pass through a blur on its way to the shell). // keyboard from a tab for Alt+F / F10 can pass through a blur on its way to the shell).
let leaving: ReturnType<typeof setTimeout> | undefined; let leaving: ReturnType<typeof setTimeout> | undefined;
const away = () => { clearTimeout(leaving); leaving = setTimeout(() => { if (!document.hasFocus()) close(); }, 160); }; const away = () => {
diag(`menu: window blur hasFocus=${document.hasFocus()}`);
clearTimeout(leaving);
leaving = setTimeout(() => { if (!document.hasFocus()) close('away', 'blur'); else diag('menu: blur passed, focus is back'); }, 160);
};
const resized = () => close(); const resized = () => close();
// Keys while nothing in the menu has focus (a click on its padding): Esc closes, arrows enter it. // Keys while nothing in the menu has focus (a click on its padding): Esc closes, arrows enter it.
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {

View file

@ -12,6 +12,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { invoke, listen } from './bridge.ts'; import { invoke, listen } from './bridge.ts';
import { describe, diag } from './diag.ts';
import { useT } from './hooks.ts'; import { useT } from './hooks.ts';
import { formatNumber } from './i18n.ts'; import { formatNumber } from './i18n.ts';
import { Icon } from './icons.tsx'; import { Icon } from './icons.tsx';
@ -47,6 +48,8 @@ export function FindBar({ tabId, url, onClose }: { tabId: string; url: string; o
useEffect(() => { useEffect(() => {
invoke('shell_focus').catch(() => {}); invoke('shell_focus').catch(() => {});
input.current?.focus(); input.current?.focus();
diag(`find bar open: hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`);
setTimeout(() => diag(`find bar +300ms: hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`), 300);
const again = (event: Event) => { if ((event as CustomEvent<string>).detail === tabId) { input.current?.focus(); input.current?.select(); } }; const again = (event: Event) => { if ((event as CustomEvent<string>).detail === tabId) { input.current?.focus(); input.current?.select(); } };
const onStep = (event: Event) => { const detail = (event as CustomEvent<{ id: string; direction: 1 | -1 }>).detail; if (detail.id === tabId) step(detail.direction); }; const onStep = (event: Event) => { const detail = (event as CustomEvent<{ id: string; direction: 1 | -1 }>).detail; if (detail.id === tabId) step(detail.direction); };
window.addEventListener('bt-find', again); window.addEventListener('bt-find', again);

View file

@ -31,6 +31,7 @@ import { NewTabPage } from './NewTabPage.tsx';
import type { PaletteAction, PaletteContext } from './paletteModel.ts'; import type { PaletteAction, PaletteContext } from './paletteModel.ts';
import { PaneThemeHost, openPaneTheme } from './PaneTheme.tsx'; import { PaneThemeHost, openPaneTheme } from './PaneTheme.tsx';
import { focusedCommands, installPluginCommands, notePanelActive, refreshPluginNames, runPluginCommand } from './pluginCommandStore.ts'; import { focusedCommands, installPluginCommands, notePanelActive, refreshPluginNames, runPluginCommand } from './pluginCommandStore.ts';
import { initDiag } from './diag.ts';
import { installSafeMode } from './safeStore.ts'; import { installSafeMode } from './safeStore.ts';
import { hasFeature, site } from './site.ts'; import { hasFeature, site } from './site.ts';
import { import {
@ -609,6 +610,8 @@ export default function Shell() {
// The site's own settings and events (e.g. X's Twitter/X wording). // The site's own settings and events (e.g. X's Twitter/X wording).
useEffect(() => site().start?.(), []); useEffect(() => site().start?.(), []);
// Focus and layering diagnostics, when the app was started with them (`--diag`).
useEffect(() => { void initDiag(); }, []);
useEffect(() => { useEffect(() => {
const unlisten = [ const unlisten = [

View file

@ -0,0 +1,36 @@
// Focus and layering diagnostics from the shell (waybetter-host `diag.rs`): off unless the app was
// started with `--diag` (or WAYBETTER_DIAG=1). While on, each event is one short line in the app's
// log folder (`diag.log`): menu requests and closes with their reason, overlays and raising, native
// focus calls and the shell document's focus, blur and visibility. Event names and states only;
// never a URL, a query or page text.
import { invoke } from './bridge.ts';
let enabled = false;
/** Asks native code once whether diagnostics are on, and starts following the document's focus. */
export async function initDiag(): Promise<void> {
try { enabled = await invoke<boolean>('diag_get') === true; } catch { enabled = false; }
if (!enabled) return;
const state = () => `hasFocus=${document.hasFocus()} visible=${document.visibilityState} active=${describe(document.activeElement)}`;
window.addEventListener('focus', () => diag(`window focus (${state()})`));
window.addEventListener('blur', () => diag(`window blur (${state()})`));
document.addEventListener('visibilitychange', () => diag(`visibility ${document.visibilityState}`));
diag(`diagnostics on (${state()})`);
}
/** An element as a few plain words: its tag and first class (never its text). */
export function describe(element: Element | null): string {
if (!element) return 'none';
const name = element.tagName.toLowerCase();
const first = typeof element.className === 'string' ? element.className.split(/\s+/).filter(Boolean)[0] : '';
return first ? `${name}.${first}` : name;
}
/** One event line (nothing happens while diagnostics are off). */
export function diag(line: string): void {
if (!enabled) return;
invoke('diag_log', { line: line.slice(0, 280) }).catch(() => {});
}
export const diagOn = () => enabled;

View file

@ -9,6 +9,7 @@ import type { PaneInfo } from './accountModel.ts';
import { currentWindow, invoke } from './bridge.ts'; import { currentWindow, invoke } from './bridge.ts';
import { columnSourceUrl, composeTabId, deckById, decks } from './deckStore.ts'; import { columnSourceUrl, composeTabId, deckById, decks } from './deckStore.ts';
import type { PaletteAction } from './paletteModel.ts'; import type { PaletteAction } from './paletteModel.ts';
import { diag } from './diag.ts';
import { site } from './site.ts'; import { site } from './site.ts';
import { latestOnly } from './syncGate.ts'; import { latestOnly } from './syncGate.ts';
@ -195,6 +196,7 @@ function columnBoxes() {
export function setOverlay(delta: number) { export function setOverlay(delta: number) {
const before = shell.overlay; const before = shell.overlay;
shell.overlay = Math.max(0, before + delta); shell.overlay = Math.max(0, before + delta);
diag(`overlay ${before} -> ${shell.overlay}`);
if ((before === 0) !== (shell.overlay === 0)) { if ((before === 0) !== (shell.overlay === 0)) {
window.dispatchEvent(new Event('bt-overlay-changed')); window.dispatchEvent(new Event('bt-overlay-changed'));
syncLayer(); syncLayer();
@ -251,12 +253,16 @@ function syncLayer() {
root.classList.add('bt-raised'); root.classList.add('bt-raised');
await frames(); await frames();
layer.raised = true; layer.raised = true;
diag('raise: on');
await invoke('shell_raise', { on: true }); await invoke('shell_raise', { on: true });
diag(`raised (hasFocus=${document.hasFocus()})`);
} else { } else {
// Under the tabs first, then opaque again: the panes never blink. // Under the tabs first, then opaque again: the panes never blink.
const refocus = document.hasFocus() && (!document.activeElement || document.activeElement === document.body); const refocus = document.hasFocus() && (!document.activeElement || document.activeElement === document.body);
layer.raised = false; layer.raised = false;
diag('raise: off');
await invoke('shell_raise', { on: false }); await invoke('shell_raise', { on: false });
diag(`lowered (hasFocus=${document.hasFocus()})`);
await frames(); await frames();
if (shell.overlay === 0) root.classList.remove('bt-raised'); if (shell.overlay === 0) root.classList.remove('bt-raised');
// Closed from the keyboard or a click in the shell (not by the window losing focus) with // Closed from the keyboard or a click in the shell (not by the window losing focus) with