From 6e62673f7ebb558faf93b5acbdb948e63069ddda Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 8 May 2026 17:25:32 -0400 Subject: [PATCH] docs: address review of MVP plan Patches: - Wire tauri://destroyed listener through onDestroyed callback so taskbar-close keeps isOpen in sync (was: defined onOverlayClosed but never wired). - Drop Rust-side opacity restore in apply_state; JS event handler reads useOverlayStore.getState().opacity and restores. Fixes hotkey-from-50%-opacity stuck at 100%. - Replace event-based hotkey-registration signal with pull-style AppState.hotkey_status + get_hotkey_status command. Eliminates the listener-not-yet-registered race. - Remove unused LogicalPosition/LogicalSize imports (would have failed tsc under noUnusedLocals). - Remove unused urlError selector in OpenOverlayButton (same). - Replace flushPendingPersist with flushPendingPersistSync that fires the write fire-and-forget; document the bounded staleness trade-off (<= 300ms loss on crash). - Drop broken docs/screenshot.png reference from README. - Collapse Task 18 down: syncClickThroughCache now lives in Task 9 alongside lib/overlay.ts; Task 18 only touches the live-wiring hook to keep the cache in sync on UI-side toggles. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-08-browserlay-mvp.md | 336 +++++++++++------- 1 file changed, 211 insertions(+), 125 deletions(-) diff --git a/docs/superpowers/plans/2026-05-08-browserlay-mvp.md b/docs/superpowers/plans/2026-05-08-browserlay-mvp.md index 7fdcb5c..c4e5689 100644 --- a/docs/superpowers/plans/2026-05-08-browserlay-mvp.md +++ b/docs/superpowers/plans/2026-05-08-browserlay-mvp.md @@ -809,11 +809,12 @@ This module is the only place that imports `@tauri-apps/api/webviewWindow`. We w Create `browserlay/src/lib/overlay.ts` with: ```ts +import { invoke } from "@tauri-apps/api/core"; import { WebviewWindow, getCurrentWebviewWindow, } from "@tauri-apps/api/webviewWindow"; -import { LogicalPosition, LogicalSize, currentMonitor } from "@tauri-apps/api/window"; +import { currentMonitor } from "@tauri-apps/api/window"; import { OVERLAY_LABEL } from "../types/overlay"; export type OpenOptions = { @@ -821,6 +822,10 @@ export type OpenOptions = { opacity: number; alwaysOnTop: boolean; clickThrough: boolean; + /** Called when the overlay window is destroyed by *any* path (taskbar close, + * Alt+F4, our own closeOverlay, OS forced close). Use it to reconcile + * control-panel state. Fires exactly once per open. */ + onDestroyed: () => void; }; const FIRST_OPEN_W = 800; @@ -868,20 +873,47 @@ export async function openOverlay(opts: OpenOptions): Promise { skipTaskbar: false, }); + // Wait for the window to actually exist (or surface an OS error). Both + // tauri://created and tauri://error fire at most once per window; we + // unsubscribe whichever didn't win. await new Promise((resolve, reject) => { - const onCreated = w.once("tauri://created", () => { - onError.then((u) => u()); + let settled = false; + let unlistenCreated: (() => void) | null = null; + let unlistenError: (() => void) | null = null; + + void w.once("tauri://created", () => { + if (settled) return; + settled = true; + unlistenError?.(); resolve(); + }).then((u) => { + unlistenCreated = u; + if (settled) u(); }); - const onError = w.once("tauri://error", (e) => { - onCreated.then((u) => u()); + + void w.once("tauri://error", (e) => { + if (settled) return; + settled = true; + unlistenCreated?.(); reject(new Error(`Failed to create overlay window: ${JSON.stringify(e.payload)}`)); + }).then((u) => { + unlistenError = u; + if (settled) u(); }); }); + // Single source of truth for "overlay went away" — fires for *any* close + // path (our closeOverlay, taskbar X, Alt+F4, OS-forced destruction). + void w.once("tauri://destroyed", () => { + opts.onDestroyed(); + }); + // Apply runtime-only state that's not part of the constructor. await w.setOpacity(opts.opacity); await w.setIgnoreCursorEvents(opts.clickThrough); + // Seed the Rust-side click-through cache so the global shortcut handler + // knows the current value when it computes the inverse. + await syncClickThroughCache(opts.clickThrough); } export async function closeOverlay(): Promise { @@ -913,23 +945,18 @@ export async function isOverlayOpen(): Promise { return (await getOverlay()) !== null; } -/** Used to listen for the overlay's destruction so we can sync isOpen. */ -export async function onOverlayClosed(handler: () => void): Promise<() => void> { - const w = await getOverlay(); - if (!w) { - handler(); - return () => undefined; - } - const unlisten = await w.onCloseRequested(() => { - handler(); - }); - return unlisten; -} - /** Convenience for the control window's own handle if needed elsewhere. */ export function getControlWindow(): WebviewWindow { return getCurrentWebviewWindow(); } + +export async function syncClickThroughCache(value: boolean): Promise { + try { + await invoke("sync_click_through_cache", { value }); + } catch (err) { + console.warn("Failed to sync click-through cache to backend", err); + } +} ``` - [ ] **Step 2: Verify tsc accepts the file** @@ -1000,7 +1027,12 @@ export async function hydrateFromDisk(): Promise { } } -const writeToDisk = debounce(async (state: OverlayPersistedState): Promise => { +// We persist on a 300ms debounce. The mirror below tracks "latest known +// pending state" so a synchronous flush at beforeunload time has something +// to issue — even though the actual write is unavoidably async. +let latestPending: OverlayPersistedState | null = null; + +async function writeNow(state: OverlayPersistedState): Promise { try { const s = await getStore(); await s.set(STORE_KEY, state); @@ -1008,23 +1040,40 @@ const writeToDisk = debounce(async (state: OverlayPersistedState): Promise } catch (err) { console.warn("Failed to persist state", err); } +} + +const writeDebounced = debounce((state: OverlayPersistedState): void => { + latestPending = null; + void writeNow(state); }, 300); /** Subscribe to store changes and persist the four user-pref fields. */ export function startPersistSubscription(): () => void { return useOverlayStore.subscribe((s) => { - writeToDisk({ + const snapshot: OverlayPersistedState = { url: s.url, opacity: s.opacity, alwaysOnTop: s.alwaysOnTop, clickThrough: s.clickThrough, - }); + }; + latestPending = snapshot; + writeDebounced(snapshot); }); } -/** Force-write any pending debounced save (call on app exit). */ -export function flushPendingPersist(): void { - writeToDisk.flush(); +/** + * Best-effort synchronous flush for beforeunload. The actual disk write is + * unavoidably async — we fire-and-forget it. If the window is destroyed + * before the write lands, the prior debounce-tick's value (at most 300ms + * stale) will be loaded next session. That bounded staleness is the + * accepted trade-off for a Phase 1 utility app. + */ +export function flushPendingPersistSync(): void { + if (latestPending === null) return; + const snapshot = latestPending; + latestPending = null; + writeDebounced.cancel(); + void writeNow(snapshot); } ``` @@ -1102,6 +1151,7 @@ Create `browserlay/src/hooks/useClickThroughSync.ts` with: import { useEffect } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useOverlayStore } from "../lib/store"; +import { applyOpacity } from "../lib/overlay"; import { EVT_CLICK_THROUGH_NO_OVERLAY, EVT_CLICK_THROUGH_TOGGLED, @@ -1125,6 +1175,12 @@ export function useClickThroughSync( useOverlayStore.getState()._setClickThroughFromBackend( e.payload.clickThrough ); + // Restore the user's target opacity. Rust performed the dip-half of + // the pulse and emitted this event after sleeping ~180ms; we own + // the restore. Reading from the live store guarantees we restore + // to whatever the slider says *now*, not whatever it said when the + // hotkey was pressed. + void applyOpacity(useOverlayStore.getState().opacity); } ); unlistenNoOverlay = await listen(EVT_CLICK_THROUGH_NO_OVERLAY, () => { @@ -1311,7 +1367,6 @@ import { validateUrl } from "../lib/url"; export function OpenOverlayButton(): JSX.Element { const url = useOverlayStore((s) => s.url); - const urlError = useOverlayStore((s) => s.urlError); const isOpen = useOverlayStore((s) => s.isOpen); const opacity = useOverlayStore((s) => s.opacity); const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop); @@ -1319,11 +1374,13 @@ export function OpenOverlayButton(): JSX.Element { const setIsOpen = useOverlayStore((s) => s.setIsOpen); const validation = validateUrl(url); - const disabled = !isOpen && (!validation.ok); + const disabled = !isOpen && !validation.ok; async function handleClick(): Promise { if (isOpen) { await closeOverlay(); + // The destroyed listener registered in openOverlay will also flip + // isOpen, but call it here too so the UI reflects intent immediately. setIsOpen(false); return; } @@ -1333,6 +1390,9 @@ export function OpenOverlayButton(): JSX.Element { opacity, alwaysOnTop, clickThrough, + onDestroyed: () => { + useOverlayStore.getState().setIsOpen(false); + }, }); setIsOpen(true); } @@ -1516,16 +1576,22 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; -import { hydrateFromDisk, startPersistSubscription, flushPendingPersist } from "./lib/persist"; -import { registerHotkeyErrorListener } from "./lib/hotkey"; +import { hydrateFromDisk, startPersistSubscription, flushPendingPersistSync } from "./lib/persist"; +import { fetchHotkeyStatus } from "./lib/hotkey"; async function bootstrap(): Promise { await hydrateFromDisk(); startPersistSubscription(); - await registerHotkeyErrorListener(); + // Pull the hotkey registration result. Pull-style avoids the + // listener-not-yet-registered race that an event-based approach would have. + void fetchHotkeyStatus(); + // beforeunload handlers must be synchronous; we use a sync flush helper + // that issues the write. The async save will race with window destruction + // — whichever wins is fine, the write either lands or we retry next session + // (the prior persisted value is at most 300ms stale). window.addEventListener("beforeunload", () => { - flushPendingPersist(); + flushPendingPersistSync(); }); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( @@ -1538,29 +1604,45 @@ async function bootstrap(): Promise { void bootstrap(); ``` -- [ ] **Step 3: Stub the hotkey error listener (real impl in Task 16)** +- [ ] **Step 3: Create the hotkey status puller** + +Pull-style intentionally: emitting the result from Rust setup would race against the JS listener registration, so we let JS pull on its own schedule. Create `browserlay/src/lib/hotkey.ts` with: ```ts -import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; import { useOverlayStore } from "./store"; -export const EVT_HOTKEY_REGISTRATION = "hotkey-registration"; +type HotkeyStatus = + | { kind: "pending" } + | { kind: "ok" } + | { kind: "failed"; error: string }; -export type HotkeyRegistrationPayload = - | { ok: true } - | { ok: false; error: string }; - -/** Listens for backend reports about whether the global shortcut registered successfully. */ -export async function registerHotkeyErrorListener(): Promise { - await listen(EVT_HOTKEY_REGISTRATION, (e) => { - if (e.payload.ok) { - useOverlayStore.getState().setHotkeyError(null); - } else { - useOverlayStore.getState().setHotkeyError(e.payload.error); +/** Polls the backend until hotkey registration is decided, then surfaces any error + * via the store. Bounded retry: setup-time decision is essentially synchronous + * on the Rust side, so a missed first call is at worst a few-ms wait. */ +export async function fetchHotkeyStatus(): Promise { + const maxAttempts = 20; // ~1 second total worst case + for (let i = 0; i < maxAttempts; i++) { + let status: HotkeyStatus; + try { + status = await invoke("get_hotkey_status"); + } catch (err) { + console.warn("get_hotkey_status invocation failed", err); + return; } - }); + if (status.kind === "pending") { + await new Promise((r) => setTimeout(r, 50)); + continue; + } + useOverlayStore.getState().setHotkeyError( + status.kind === "failed" ? status.error : null, + ); + return; + } + // Setup never completed — leave hotkeyError null and log; user can retry. + console.warn("hotkey status remained pending after retries"); } ``` @@ -1732,16 +1814,13 @@ pub async fn apply_state( .set_ignore_cursor_events(new_state) .map_err(|e| e.to_string())?; - // Opacity pulse for visual feedback. - // We don't have a getter for current opacity in Tauri 2's API, so we - // restore to a value the JS side passes in via the command (or to 1.0 - // for the global shortcut path, which we accept as a small visual quirk). - // The JS side will re-apply the user's true target opacity via the - // live-wiring effect immediately after the toggled event. + // Opacity dip half of the visual pulse. The JS click-through-toggled + // handler is responsible for restoring opacity to the user's actual + // target — Rust deliberately does NOT call set_opacity again, so there + // is no possibility of the global-shortcut path "snapping back" to a + // wrong value (we don't have a current-opacity getter). let _ = overlay.set_opacity(0.4); tokio::time::sleep(Duration::from_millis(180)).await; - // Best-effort restore; JS live-wiring will re-apply the real target. - let _ = overlay.set_opacity(1.0); app.emit( EVT_CLICK_THROUGH_TOGGLED, @@ -1792,6 +1871,16 @@ pub fn sync_click_through_cache( *guard = value; Ok(()) } + +/// Pull the hotkey-registration outcome that was decided during setup. +/// Pull-style avoids the listener-not-yet-registered race that an event-based +/// approach would have. +#[tauri::command] +pub fn get_hotkey_status(app: AppHandle) -> Result { + let state = app.state::(); + let guard = state.hotkey_status.lock().map_err(|_| "poisoned".to_string())?; + Ok(guard.clone()) +} ``` > **Note on the design:** Tauri 2 doesn't expose a getter for `ignore_cursor_events` / `opacity` on `WebviewWindow`. Rather than rely on getters, we keep a tiny Rust-side cache (`AppState.click_through`) that the JS side keeps in sync via `sync_click_through_cache`. The global shortcut handler reads this cache, computes the inverse, calls `apply_state`. The JS-driven toggle path uses `toggle_click_through(current)` and skips the cache. @@ -1820,28 +1909,34 @@ mod commands; use std::sync::Mutex; use serde::Serialize; -use tauri::{Emitter, Manager}; +use tauri::Manager; #[cfg(desktop)] use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; pub struct AppState { pub click_through: Mutex, + /// Hotkey registration outcome, set during setup. JS pulls this via + /// `get_hotkey_status` after the listener-not-yet-registered window is + /// safely past, so the result is never lost to a race. + pub hotkey_status: Mutex, } -#[derive(Clone, Serialize)] -struct HotkeyRegistrationPayload<'a> { - ok: bool, - error: Option<&'a str>, +#[derive(Clone, Serialize, Default)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum HotkeyStatus { + #[default] + Pending, + Ok, + Failed { error: String }, } -const EVT_HOTKEY_REGISTRATION: &str = "hotkey-registration"; - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(AppState { click_through: Mutex::new(false), + hotkey_status: Mutex::new(HotkeyStatus::Pending), }) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) @@ -1879,21 +1974,18 @@ pub fn run() { )?; let registration_result = app.global_shortcut().register(shortcut); - let payload = match ®istration_result { - Ok(()) => HotkeyRegistrationPayload { - ok: true, - error: None, - }, - Err(e) => HotkeyRegistrationPayload { - ok: false, - error: Some( - "Hotkey unavailable — click-through escape disabled", - ), - }, + let status = match ®istration_result { + Ok(()) => HotkeyStatus::Ok, + Err(e) => { + eprintln!("Failed to register Ctrl+Alt+Space: {e}"); + HotkeyStatus::Failed { + error: "Hotkey unavailable — click-through escape disabled".to_string(), + } + } }; - let _ = app.emit(EVT_HOTKEY_REGISTRATION, payload); - if let Err(e) = registration_result { - eprintln!("Failed to register Ctrl+Alt+Space: {e}"); + { + let state = app.state::(); + *state.hotkey_status.lock().expect("poisoned") = status; } } Ok(()) @@ -1901,6 +1993,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::overlay::toggle_click_through, commands::overlay::sync_click_through_cache, + commands::overlay::get_hotkey_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -1910,13 +2003,9 @@ pub fn run() { - [ ] **Step 2: Verify the crate compiles** Run from `browserlay/src-tauri/`: `cargo check` -Expected: compiles. Warnings about unused `e` binding in the `Err(e)` arm are expected — silence by changing `Err(e) =>` to `Err(_) =>`. +Expected: compiles cleanly. -- [ ] **Step 3: Address the `Err(_)` warning** - -In the `match ®istration_result` block, change `Err(e) => HotkeyRegistrationPayload {` to `Err(_) => HotkeyRegistrationPayload {`. Re-run `cargo check`. Expected: clean. - -- [ ] **Step 4: Commit** +- [ ] **Step 3: Commit** ```bash git add browserlay/src-tauri/src/lib.rs @@ -1925,67 +2014,66 @@ git commit -m "feat(rust): register plugins, global shortcut, commands" --- -## Task 18: Sync the Rust click-through cache from JS +## Task 18: Sync click-through cache on every JS-driven change **Files:** -- Modify: `browserlay/src/lib/overlay.ts` - Modify: `browserlay/src/hooks/useLiveOverlayWiring.ts` -- Modify: `browserlay/src/components/OpenOverlayButton.tsx` -The Rust handler needs to know the JS-side click-through truth so the global shortcut can toggle correctly when the JS side hasn't been the one driving the change. We sync on every JS-driven change. +`openOverlay` (Task 9) already exports `syncClickThroughCache` and seeds the Rust-side cache on open. This task ensures the cache stays in sync whenever the user toggles click-through from the control panel — without it, the first global-shortcut press after a UI toggle would compute the inverse from a stale cache. -- [ ] **Step 1: Add a sync helper to lib/overlay.ts** +- [ ] **Step 1: Update useLiveOverlayWiring to sync on every JS-driven change** -Open `browserlay/src/lib/overlay.ts` and append this export at the bottom: +Open `browserlay/src/hooks/useLiveOverlayWiring.ts` and replace its contents with: ```ts -import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useRef } from "react"; +import { useOverlayStore } from "../lib/store"; +import { + applyAlwaysOnTop, + applyClickThrough, + applyOpacity, + syncClickThroughCache, +} from "../lib/overlay"; -export async function syncClickThroughCache(value: boolean): Promise { - try { - await invoke("sync_click_through_cache", { value }); - } catch (err) { - console.warn("Failed to sync click-through cache to backend", err); - } +/** Pushes Zustand changes to the overlay window in real time. No-op when overlay is closed. */ +export function useLiveOverlayWiring(): void { + const opacity = useOverlayStore((s) => s.opacity); + const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop); + const clickThrough = useOverlayStore((s) => s.clickThrough); + const isOpen = useOverlayStore((s) => s.isOpen); + + const lastAppliedClickThrough = useRef(null); + + useEffect(() => { + if (!isOpen) return; + void applyOpacity(opacity); + }, [opacity, isOpen]); + + useEffect(() => { + if (!isOpen) return; + void applyAlwaysOnTop(alwaysOnTop); + }, [alwaysOnTop, isOpen]); + + useEffect(() => { + if (!isOpen) return; + if (lastAppliedClickThrough.current === clickThrough) return; + lastAppliedClickThrough.current = clickThrough; + void applyClickThrough(clickThrough); + void syncClickThroughCache(clickThrough); + }, [clickThrough, isOpen]); } ``` -> Move the `import { invoke } from "@tauri-apps/api/core";` to the top of the file with the other imports. - -- [ ] **Step 2: Sync on overlay open** - -In `browserlay/src/lib/overlay.ts`, in `openOverlay`, after the `await w.setIgnoreCursorEvents(opts.clickThrough);` line, add: - -```ts -await syncClickThroughCache(opts.clickThrough); -``` - -- [ ] **Step 3: Sync on every JS-driven click-through change** - -Open `browserlay/src/hooks/useLiveOverlayWiring.ts` and replace the `useEffect` that handles click-through with: - -```ts -useEffect(() => { - if (!isOpen) return; - if (lastAppliedClickThrough.current === clickThrough) return; - lastAppliedClickThrough.current = clickThrough; - void applyClickThrough(clickThrough); - void syncClickThroughCache(clickThrough); -}, [clickThrough, isOpen]); -``` - -Add to imports at top: `import { applyAlwaysOnTop, applyClickThrough, applyOpacity, syncClickThroughCache } from "../lib/overlay";` - -- [ ] **Step 4: Re-run tests + tsc** +- [ ] **Step 2: Re-run tests + tsc** Run: `npm test && npx tsc --noEmit` Expected: all pass. -- [ ] **Step 6: Commit** +- [ ] **Step 3: Commit** ```bash -git add browserlay/src/lib/overlay.ts browserlay/src/hooks/useLiveOverlayWiring.ts -git commit -m "feat: sync JS click-through state to rust cache for global shortcut" +git add browserlay/src/hooks/useLiveOverlayWiring.ts +git commit -m "feat: keep rust click-through cache synced with UI toggle" ``` --- @@ -2073,8 +2161,6 @@ Open `browserlay/README.md` and replace its contents with: A Windows desktop app that creates a translucent, always-on-top browser overlay. Type a URL, drop the opacity, toggle click-through — keep a stream, dashboard, or doc page floating over your work. -![Phase 1 MVP](docs/screenshot.png) - ## Features (Phase 1 / MVP) - Frameless, transparent overlay window pinned over other apps.