From 579df7b2c20b0d3b3e4d5b7ff5c870ea8a0c1c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 06:03:17 +0000 Subject: [PATCH 1/2] desktop+ui: the window opens on B2's ground, and a launch is measurable (GH #225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window is shown as soon as it exists, which is long before the webview has a stylesheet, so a launch begins with the platform's white. This does not hide the window — that was tried and reverted, and it fails for a reason that has not gone away: WebKit throttles `requestAnimationFrame` in an offscreen page, so "reveal when real pixels exist" never fires and the safety timer becomes the launch time. It gives the window a ground instead. Two surfaces, one colour, because they are painted by different things at different moments. `launch::ground` dresses the *window* from the OS theme before the event loop runs; that is the only half no frontend change can reach, and it is painted over the instant the webview draws an unstyled document white on top of it. So `index.html` carries the same two values in a cascade layer for the *document*, which is what survives from then on — CSS with no script, since the CSP grants no inline script and script-src is not something a Markdown renderer loosens (ADR-0016). The honest limit: both layers ask the OS, and a theme pinned in Settings lives in localStorage, which the host cannot see and must not (a viewing choice is never host state). Right for the System default and for a pin that agrees with the OS; a pin that disagrees still gets one ground change when `loadTheme` runs, which is what it gets today on top of the white. That colour pair now exists in three places, so two tests make the duplication checked rather than merely regretted: `ground.test.ts` reads index.html against style.css, and `ground_matches_the_stylesheet` reads style.css against the Rust consts. Both were confirmed to fail on a one-hex drift. The issue asks for before-and-after numbers, and a launch spans two clocks with only one sink. `b2::launch` is that measurement: five marks on one Unix-epoch axis under the existing B2_LOG_FILE dataset — `window-ready`, `page-load-started`, `page-load-finished` from the host, then `boot-start` and `first-frame` from the webview via `launch_mark`, sent after the frame they time so the probe never adds the latency it is measuring. Deliberately not done: revealing the window on a signal. `Builder::on_page_load` does fire host-side and so is immune to the throttle that killed the last attempt, but `Finished` is the document, not the app — it would reveal a window showing the ground and nothing else. That is a decision to take on the numbers this change makes it possible to have, not ahead of them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Jq9rgD3ckbaPd3pdXQk53z --- CLAUDE.md | 5 +- Cargo.lock | 1 + crates/b2-desktop/Cargo.toml | 5 + crates/b2-desktop/src/commands.rs | 18 ++++ crates/b2-desktop/src/launch.rs | 149 ++++++++++++++++++++++++++++++ crates/b2-desktop/src/main.rs | 34 +++++++ ui/index.html | 41 ++++++++ ui/src/api.ts | 9 ++ ui/src/ground.test.ts | 94 +++++++++++++++++++ ui/src/main.ts | 40 ++++++++ 10 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 crates/b2-desktop/src/launch.rs create mode 100644 ui/src/ground.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8c842cc..41726aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,10 @@ the vault, because the device is part of the embedding space's identity (ADR-000 `B2_SLOW_QUERY_MS`, default 100, logged at WARN with `slow=true`), a span per `Vault` op (`b2::vault`), and flow milestones (`b2::ingest`, `b2::search`). The core only *emits* — the subscriber and its clock live in the adapter (`init_logging` in both), so `b2-core` stays - wall-clock-free and the instrumentation is inert unless an adapter opts in. + wall-clock-free and the instrumentation is inert unless an adapter opts in. One target is + the **adapter's own** for that same reason: `b2::launch` (GH #225, `b2-desktop`'s + `launch.rs`) times a desktop launch across the window and the webview — two clocks the + core has neither of — as five `mark`s on one Unix-epoch axis. ## Architecture diff --git a/Cargo.lock b/Cargo.lock index 179359d..5da3014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,7 @@ dependencies = [ "tauri-plugin-opener", "tempfile", "thiserror 2.0.18", + "tracing", "tracing-appender", "tracing-subscriber", ] diff --git a/crates/b2-desktop/Cargo.toml b/crates/b2-desktop/Cargo.toml index 970d217..55654cb 100644 --- a/crates/b2-desktop/Cargo.toml +++ b/crates/b2-desktop/Cargo.toml @@ -64,6 +64,11 @@ notify = "8" # one-shot process; here it would block GUI/reindex threads on log I/O). tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } tracing-appender = "0.2" +# The host's own emitters — launch timing on the `b2::launch` target (GH #225), which is +# the one measurement neither adapter can take from inside the core: it spans the window +# and the webview, and `b2-core` is wall-clock-free by design. Already in the lock via +# b2-core's tree, so it costs no extra build. +tracing = "0.1.41" serde_json = "1.0.150" # The macOS Keychain, where a cloud bearer token is remembered between launches (GH #176, diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index 126b474..a6a168d 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -583,6 +583,24 @@ pub fn set_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), CmdErro .map_err(|e| CmdError::ZoomFailed(e.to_string())) } +/// Record a launch milestone the **webview** timed, onto the host's log (GH #225). +/// +/// The one thing being measured here spans two clocks — the window is the host's and the +/// first frame is the webview's — and only one of them has a sink: the tracing subscriber +/// and its wall clock live in the adapter by deliberate design (`logging.rs`; it is what +/// keeps `b2-core` clock-free). So the webview reads its own clock and this hands the +/// reading over; `epoch_ms` is `performance.timeOrigin + performance.now()`, the same Unix +/// axis `launch::mark` stamps host-side. +/// +/// Holds no rule about *which* milestones exist or what they mean — `ui/src/main.ts` +/// decides that, exactly as it decides which zoom rungs are allowed. Infallible and +/// vault-free: a mark that cannot be written is a log line missing from a debugging run, +/// never a reason to fail anything the user asked for. +#[tauri::command] +pub fn launch_mark(mark: String, epoch_ms: f64) { + crate::launch::webview_mark(&mark, epoch_ms); +} + /// **Flow ④ — one grounded answer**: condense -> retrieve -> assemble -> stream -> cite, /// all of it behind `Vault::ask`. The host's whole contribution is the shape of the /// *delivery*: Tauri runs the `(async)` body on a worker thread, tokens stream to the diff --git a/crates/b2-desktop/src/launch.rs b/crates/b2-desktop/src/launch.rs new file mode 100644 index 0000000..f4df03e --- /dev/null +++ b/crates/b2-desktop/src/launch.rs @@ -0,0 +1,149 @@ +//! What the window looks like before the app does, and how long that lasts (GH #225). +//! +//! Tauri shows a window as soon as it exists, which is well before the webview has a +//! stylesheet, let alone a shell — so the first thing a launch shows is the platform's +//! white. Two halves of an answer live here, and neither hides the window: the reverted +//! attempt at that (reveal-on-signal from the page) died on WebKit throttling +//! `requestAnimationFrame` in an offscreen window, and traded a flash for a fixed +//! multi-second wait. +//! +//! **The ground.** [`ground`] maps the OS theme to B2's `--bg`, and `main` hands it to +//! the window before the event loop runs. This covers the instant no frontend change can +//! reach — before any HTML exists — and it is the *only* half that can, which is also why +//! it is the weaker half: the theme a user pinned in Settings lives in the webview's +//! `localStorage`, deliberately (a viewing choice is never host state), so the host can +//! only ask the OS. Right for the "System" default, and one ground change for a pin that +//! disagrees with the OS. +//! +//! `ui/index.html` carries the same two values, and the pair is not one rule written +//! twice: this one dresses the **window**, and is painted over the moment the webview +//! draws an unstyled document white on top of it; that one is the **document's** own +//! ground, which is what survives from then on. Two surfaces, one colour, and +//! `ground_matches_the_stylesheet` below is what stops them drifting apart. +//! +//! **The measurement.** The issue asks for before-and-after numbers, and a launch spans +//! two clocks: the host's and the webview's. [`mark`] and [`webview_mark`] put both on +//! one axis — Unix epoch milliseconds — in the one JSONL dataset `B2_LOG_FILE` already +//! collects, under the `b2::launch` target the implied `b2=debug` filter picks up. Read a +//! launch with: +//! +//! ```text +//! B2_LOG_FILE=$PWD/logs/launch.jsonl B2_VAULT_PATH=~/notes make app +//! jq -r 'select(.target == "b2::launch") | [.mark, .epoch_ms] | @tsv' logs/launch.jsonl +//! ``` +//! +//! The five marks bracket the whole gap: `window-ready` (the host has the window and its +//! ground), `page-load-started` / `page-load-finished` (the document), then the webview's +//! own `boot-start` (the module graph finished evaluating — under `tauri dev` this is the +//! half the CSS-through-JS serving widens) and `first-frame` (the app is on screen). +//! +//! Emitting is free when no subscriber is installed, which is every launch that did not +//! ask for one, so none of this is gated behind a flag the frontend would have to read. + +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::webview::Color; +use tauri::Theme; + +/// B2's light ground — `--bg` in `ui/style.css`'s `:root`, and the same value +/// `ui/index.html` paints before the stylesheet lands (`ui/src/ground.test.ts` is +/// what keeps those two honest; this one is a third copy in a different language, +/// checked by `ground_matches_the_stylesheet` below). +const GROUND_LIGHT: Color = Color(0xfa, 0xf9, 0xf7, 0xff); + +/// B2's dark ground — `--bg` under `:root[data-theme="dark"]` and the +/// `prefers-color-scheme: dark` block, which `ui/style.css` requires to be identical. +const GROUND_DARK: Color = Color(0x16, 0x16, 0x1a, 0xff); + +/// The window's ground for the OS theme in force, opaque. +/// +/// `None` is a platform that would not answer (`WebviewWindow::theme` is fallible, and +/// `Theme` is `#[non_exhaustive]` — a variant added upstream is a theme this build has no +/// colour for). Both fall back to light, which is what `ui/style.css`'s unqualified +/// `:root` does with the same question: the fallback is the light palette, not white. +pub fn ground(theme: Option) -> Color { + match theme { + Some(Theme::Dark) => GROUND_DARK, + _ => GROUND_LIGHT, + } +} + +/// Record a host-side launch milestone on the shared epoch axis. +pub fn mark(name: &str) { + tracing::debug!(target: "b2::launch", mark = name, epoch_ms = epoch_ms(), "launch"); +} + +/// Record a milestone the *webview* timed, at the moment it says it happened. +/// +/// The webview's clock is its own (`performance.timeOrigin + performance.now()`), so the +/// host writes what it was told and stamps its own receipt beside it: `epoch_ms` is when +/// the mark landed here, `webview_epoch_ms` is when it was taken. The difference is the +/// IPC, which is exactly the thing that must not be mistaken for the gap being measured. +pub fn webview_mark(name: &str, webview_epoch_ms: f64) { + tracing::debug!( + target: "b2::launch", + mark = name, + epoch_ms = epoch_ms(), + webview_epoch_ms, + "launch" + ); +} + +/// Wall-clock milliseconds since the Unix epoch — the one axis the host and the webview +/// can both name. A clock set before 1970 reports 0 rather than panicking; it would make +/// the record useless, which is the honest outcome, and never a crashed launch. +fn epoch_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ground_is_dark_only_for_a_dark_os() { + assert_eq!(ground(Some(Theme::Dark)), GROUND_DARK); + assert_eq!(ground(Some(Theme::Light)), GROUND_LIGHT); + // The two cases that are not a theme at all: a platform that won't say, and a + // variant this build predates. Neither may leave the window white. + assert_eq!(ground(None), GROUND_LIGHT); + } + + #[test] + fn ground_matches_the_stylesheet() { + // The third copy of these two values, and the only one in Rust. `ui/style.css` is + // the source; `ui/index.html` holds the second copy and `ui/src/ground.test.ts` + // pins it. Read the sheet rather than restating it, so the pair can only drift by + // someone editing this assertion on purpose. + let css = include_str!("../../../ui/style.css"); + let hex = |c: Color| format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2); + let grounds: Vec<&str> = css + .match_indices("--bg:") + .filter_map(|(i, _)| css[i..].split(';').next()) + .map(|d| d.trim_start_matches("--bg:").trim()) + .collect(); + assert_eq!( + grounds.first().copied(), + Some(hex(GROUND_LIGHT).as_str()), + "the light ground has drifted from ui/style.css's `:root`" + ); + assert!( + grounds.iter().skip(1).all(|g| *g == hex(GROUND_DARK)), + "the dark ground has drifted from ui/style.css: {grounds:?}" + ); + assert_eq!( + grounds.len(), + 3, + "ui/style.css no longer defines --bg three times" + ); + } + + #[test] + fn epoch_ms_is_a_plausible_wall_clock() { + // 2020-01-01 in ms. Guards the units — a seconds-vs-millis slip here would make + // every launch record silently unjoinable with the webview's. + assert!(epoch_ms() > 1_577_836_800_000.0); + } +} diff --git a/crates/b2-desktop/src/main.rs b/crates/b2-desktop/src/main.rs index 9010879..774c803 100644 --- a/crates/b2-desktop/src/main.rs +++ b/crates/b2-desktop/src/main.rs @@ -23,6 +23,7 @@ mod chat; mod commands; mod error; mod keychain; +mod launch; mod logging; mod menu; mod stats; @@ -430,7 +431,39 @@ fn main() { // pure `AppState` machine stays free of an OS watch handle. Started below once the // app handle exists, and re-pointed on a vault switch (`choose_vault`). .manage(VaultWatcher::default()) + // The document's two milestones, host-side (GH #225). "The window opens blank + // before the app paints" is a claim about a duration, and these are the middle of + // it: the webview reports them whether or not the page ever gets as far as + // running B2's own code, which is exactly what a launch that goes wrong needs. + .on_page_load(|_webview, payload| { + launch::mark(match payload.event() { + tauri::webview::PageLoadEvent::Started => "page-load-started", + // `Finished` is the document, not the app: `boot` has not run, so what + // is on screen at this mark is the ground and nothing else. + tauri::webview::PageLoadEvent::Finished => "page-load-finished", + }); + }) .setup(|app| { + // What the window shows before the app does (GH #225). Tauri shows a window + // as soon as it exists, and the page it will hold does not exist yet — so the + // first paint is the platform's white unless the window is given a ground of + // its own. This is the half of that gap no frontend change can reach: it + // lands before any HTML does, and `ui/index.html` takes it from there. + // Neither hides the window; `launch.rs` has why hiding was tried and reverted. + // + // Best-effort twice over: a window Tauri did not hand us is not something to + // fail a launch for, and a platform that refuses the colour leaves the white + // it would have left anyway. + if let Some(window) = app.get_webview_window("main") { + if let Err(e) = window.set_background_color(Some(launch::ground(window.theme().ok()))) + { + tracing::debug!(target: "b2::launch", error = %e, "the window kept its default ground"); + } + } + // The host's first observation point: the window exists and is dressed. Every + // later mark — the document's two, then the webview's own — is read against + // this one (`launch.rs` has the jq line that reads them back). + launch::mark("window-ready"); // Watch the startup vault, if one resolved. Best-effort: `watch` swallows and // logs a failure, so a platform without a watch backend still launches (the // conflict bar remains the fallback). No vault configured → nothing to watch; @@ -482,6 +515,7 @@ fn main() { commands::embed_stats, commands::menu_chords, commands::set_zoom, + commands::launch_mark, commands::ask, commands::cancel_ask, commands::chat_setup, diff --git a/ui/index.html b/ui/index.html index a5001e1..93b4e99 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,6 +4,47 @@ B2 + +
diff --git a/ui/src/api.ts b/ui/src/api.ts index b1cc61e..649fa9d 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -385,6 +385,15 @@ export const api = { */ setZoom: (factor: number): Promise => invoke("set_zoom", { factor }), + /** + * Hand the host one launch milestone the webview timed (GH #225). `epochMs` is + * `performance.timeOrigin + performance.now()` — the Unix axis the host stamps its own + * marks on, so a launch reads as one ordered list in the `B2_LOG_FILE` dataset rather + * than two clocks nobody can join. The host writes it and holds no opinion about it. + */ + launchMark: (mark: string, epochMs: number): Promise => + invoke("launch_mark", { mark, epochMs }), + /** * Subscribe to the host's debounced filesystem-watch pulse (#14). `handler` fires once * per burst of external Markdown changes; the returned promise resolves to an unlisten diff --git a/ui/src/ground.test.ts b/ui/src/ground.test.ts new file mode 100644 index 0000000..e975cd0 --- /dev/null +++ b/ui/src/ground.test.ts @@ -0,0 +1,94 @@ +// The launch ground (GH #225), pinned against the stylesheet it duplicates. +// +// `index.html` carries B2's two `--bg` values inline so the window paints its own ground +// in the gap between "Tauri showed the window" and "the app has a stylesheet". That is a +// second place that knows what the app looks like, which is the kind of duplication this +// repo avoids — so it is allowed here only because this file makes it *checked*. Drift +// the light or dark ground in style.css and the gate fails here, naming both values. +// +// Two more things are asserted because they are load-bearing and silent when broken: +// the cascade layer (without it the inline rule can outlive its moment and hold the wrong +// ground for a pinned theme), and the absence of any inline script (the CSP grants +// `style-src 'unsafe-inline'` and no script equivalent, so an inline script here would +// not run at all — and the ground would be back to the platform's white). +// +// Pure — reads two files, no DOM — so node runs it straight off the source like the rest. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +const UI_DIR = join(import.meta.dirname, ".."); +const INDEX = readFileSync(join(UI_DIR, "index.html"), "utf8"); +const STYLE = readFileSync(join(UI_DIR, "style.css"), "utf8"); + +/** The `@layer launch { … }` body from index.html — matched by brace counting rather + * than a regex, since the block nests an `@media` inside it. */ +function launchLayer(html: string): string { + const at = html.indexOf("@layer launch"); + assert.notEqual(at, -1, "index.html no longer declares the `@layer launch` block"); + const open = html.indexOf("{", at); + let depth = 0; + for (let i = open; i < html.length; i++) { + if (html[i] === "{") depth++; + else if (html[i] === "}" && --depth === 0) return html.slice(open + 1, i); + } + assert.fail("the `@layer launch` block is unclosed"); +} + +/** Every `#rrggbb` a `background:` declaration names, in source order. */ +function backgrounds(css: string): string[] { + return [...css.matchAll(/background:\s*(#[0-9a-f]{6})/gi)].map((m) => m[1].toLowerCase()); +} + +/** Every `--bg:` value in source order. style.css defines it three times — the light + * `:root`, the pinned `[data-theme="dark"]`, and the `prefers-color-scheme: dark` + * block — and its own comment requires the last two to stay identical. */ +function grounds(css: string): string[] { + return [...css.matchAll(/--bg:\s*(#[0-9a-f]{6})/gi)].map((m) => m[1].toLowerCase()); +} + +test("style.css still defines the two grounds this file compares against", () => { + const [light, pinnedDark, systemDark] = grounds(STYLE); + assert.equal(grounds(STYLE).length, 3, "expected --bg in :root, [data-theme=dark] and the media block"); + assert.notEqual(light, pinnedDark, "the light and dark grounds must differ"); + assert.equal( + pinnedDark, + systemDark, + "style.css's two dark bodies have diverged — a pinned Dark and a dark OS must paint the same ground", + ); +}); + +test("index.html's launch ground matches style.css, light and dark", () => { + const layer = launchLayer(INDEX); + const dark = layer.indexOf("@media"); + assert.notEqual(dark, -1, "the launch layer no longer answers prefers-color-scheme"); + + const [styleLight, styleDark] = grounds(STYLE); + assert.deepEqual( + backgrounds(layer.slice(0, dark)), + [styleLight], + "the launch layer's light ground has drifted from style.css's `--bg`", + ); + assert.deepEqual( + backgrounds(layer.slice(dark)), + [styleDark], + "the launch layer's dark ground has drifted from style.css's dark `--bg`", + ); +}); + +test("the launch ground stays in a cascade layer, so style.css always outranks it", () => { + // Unlayered rules beat layered ones whatever the source order, which is the whole + // reason this is safe to ship: the moment style.css lands it wins, and a pinned theme + // is never held back by a rule that only ever meant to cover the first few frames. + assert.match(INDEX, /@layer launch\s*\{/); +}); + +test("index.html runs no inline script — the CSP would refuse it", () => { + // `default-src 'self'` covers script-src, and loosening that in a Markdown renderer is + // not on the table (ADR-0016). A `