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) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-05-08 17:25:32 -04:00
parent 4a3d380b54
commit 6e62673f7e

View File

@@ -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<void> {
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<void>((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<void> {
@@ -913,23 +945,18 @@ export async function isOverlayOpen(): Promise<boolean> {
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<void> {
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<void> {
}
}
const writeToDisk = debounce(async (state: OverlayPersistedState): Promise<void> => {
// 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<void> {
try {
const s = await getStore();
await s.set(STORE_KEY, state);
@@ -1008,23 +1040,40 @@ const writeToDisk = debounce(async (state: OverlayPersistedState): Promise<void>
} 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<void> {
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<void> {
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> {
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<void> {
await listen<HotkeyRegistrationPayload>(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<void> {
const maxAttempts = 20; // ~1 second total worst case
for (let i = 0; i < maxAttempts; i++) {
let status: HotkeyStatus;
try {
status = await invoke<HotkeyStatus>("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<R: Runtime>(
.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<crate::HotkeyStatus, String> {
let state = app.state::<crate::AppState>();
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<bool>,
/// 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<HotkeyStatus>,
}
#[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 &registration_result {
Ok(()) => HotkeyRegistrationPayload {
ok: true,
error: None,
},
Err(e) => HotkeyRegistrationPayload {
ok: false,
error: Some(
"Hotkey unavailable — click-through escape disabled",
),
},
let status = match &registration_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::<AppState>();
*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 &registration_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<void> {
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<boolean | null>(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.