Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/b2-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions crates/b2-desktop/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 157 additions & 0 deletions crates/b2-desktop/src/launch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! 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.
//! `epoch_ms` is **when the milestone happened**, on every mark and whichever side timed
//! it; a webview mark carries the host's receipt separately as `received_epoch_ms`, so
//! the IPC is a quantity you can read rather than one folded into the gap. 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<Theme>) -> 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.
///
/// `epoch_ms` is the webview's own reading (`performance.timeOrigin + performance.now()`),
/// **not** the host's clock at receipt, so the field means one thing on all five marks and
/// the query above is honest about every one of them. Put the receipt in `epoch_ms` and
/// the two marks that matter most would each carry an IPC hop they did not spend — the
/// instrument reporting its own latency as the app's, which is the one error a timing
/// probe must not make.
///
/// The receipt is kept as `received_epoch_ms` rather than dropped: it is the only evidence
/// that the hop was ordinary, and a launch where it is not is a launch to distrust.
pub fn webview_mark(name: &str, webview_epoch_ms: f64) {
tracing::debug!(
target: "b2::launch",
mark = name,
epoch_ms = webview_epoch_ms,
received_epoch_ms = 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);
}
}
34 changes: 34 additions & 0 deletions crates/b2-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod chat;
mod commands;
mod error;
mod keychain;
mod launch;
mod logging;
mod menu;
mod stats;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,47 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>B2</title>
<!--
The launch ground (GH #225). Tauri shows the window as soon as it exists, which is
well before style.css arrives — under `tauri dev` the stylesheet is served *through*
the module graph, which widens that gap further — so without this the first paint is
the platform's white whatever theme the app is about to draw itself in.

The host gives the *window* a ground too (`crates/b2-desktop/src/launch.rs`), and
this is not that rule twice: that one covers the instant before any HTML exists, and
is painted over the moment the webview draws an unstyled document white on top of
it. This is the document's own ground, which is what survives the moment it lands.

This is a deliberate second home for exactly two values, `--bg` light and dark, and
it stays that: the ground, not a shell. ground.test.ts fails if either drifts from
style.css, which is what makes the duplication safe to have.

It is CSS with no script on purpose. The CSP (tauri.conf.json) grants
`style-src 'unsafe-inline'` but no inline script, and script-src is what a Markdown
renderer must never loosen (ADR-0016). So this follows the OS, which is right for
the "System" default and right for a pinned theme that agrees with the OS. Someone
who pinned *against* their OS still sees one ground change when main.ts's
`loadTheme` runs — the pin lives in localStorage, which only script can read — and
one ground change is what they get today anyway, on top of the white.

In a cascade layer so this can never outlive its moment: unlayered rules always beat
layered ones regardless of source order, so style.css wins the instant it lands
whichever way the bundler emitted it, and `html` here can't shadow its `html, body`.
-->
<style>
@layer launch {
html {
background: #faf9f7;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
html {
background: #16161a;
color-scheme: dark;
}
}
}
</style>
</head>
<body>
<div id="app"></div>
Expand Down
9 changes: 9 additions & 0 deletions ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,15 @@ export const api = {
*/
setZoom: (factor: number): Promise<void> => 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<void> =>
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
Expand Down
Loading