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:
@@ -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 ®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::<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 ®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<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.
|
||||
|
||||

|
||||
|
||||
## Features (Phase 1 / MVP)
|
||||
|
||||
- Frameless, transparent overlay window pinned over other apps.
|
||||
|
||||
Reference in New Issue
Block a user