Replaces the eval-based set_window_opacity in the plan with the SetLayeredWindowAttributes implementation that actually shipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2266 lines
71 KiB
Markdown
2266 lines
71 KiB
Markdown
# Browserlay Phase 1 (MVP) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ship a Windows desktop app that creates a translucent, always-on-top browser overlay window with real-time opacity, click-through, and AOT controls plus a global hotkey escape hatch.
|
||
|
||
**Architecture:** Two-window Tauri 2 app. Control window (`main`) hosts a React+Tailwind+Zustand control panel; overlay window (`overlay`) is spawned at runtime as a frameless transparent webview pointed directly at the user-supplied URL. A Rust-side global shortcut (`Ctrl+Alt+Space`) toggles click-through and emits an event back so the control panel reflects the new state.
|
||
|
||
**Tech Stack:** Tauri 2.x · Rust 2021 · React 19 · TypeScript strict · Vite · Tailwind CSS v4 (`@tailwindcss/vite` + `@theme`) · Zustand v5 · `lucide-react` · `tauri-plugin-store` · `tauri-plugin-window-state` · `tauri-plugin-global-shortcut`.
|
||
|
||
**Design doc:** `docs/plans/2026-05-08-browserlay-mvp-design.md`
|
||
|
||
**Reference resolved during planning** (from official Tauri 2 docs at v2.tauri.app):
|
||
- `WebviewWindow.getByLabel` is `async` → `Promise<WebviewWindow | null>`. Always `await`.
|
||
- Capability prefixes are `core:window:allow-*` and `core:webview:allow-*` (Tauri 2).
|
||
- Rust→JS event emit uses the `Emitter` trait (`use tauri::Emitter`).
|
||
- Global shortcut handler fires on both `Pressed` and `Released` — filter to `Pressed`.
|
||
- `tauri-plugin-window-state` saves alwaysOnTop by default (`StateFlags::all()`); we override at overlay-create time so the Zustand store is authoritative.
|
||
- Tauri 2 Rust window lookup: `app.get_webview_window(label)` (from the `Manager` trait).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Frontend (`browserlay/src/`)
|
||
- Create `main.tsx` (replace scaffold) — React entry, hydrate persisted state before mounting `<App />`.
|
||
- Create `App.tsx` (replace scaffold) — composes `<ControlPanel />`, mounts the live-wiring effect, mounts click-through-sync hook.
|
||
- Create `index.css` (replace scaffold) — Tailwind v4 `@import` + `@theme` design tokens.
|
||
- Create `components/ControlPanel.tsx` — top-level form layout.
|
||
- Create `components/UrlField.tsx` — URL input + validation feedback.
|
||
- Create `components/OpacitySlider.tsx` — slider + percentage readout.
|
||
- Create `components/ToggleRow.tsx` — reusable toggle row.
|
||
- Create `components/OpenOverlayButton.tsx` — Open / Close primary button.
|
||
- Create `components/StatusBar.tsx` — footer with hotkey hint and warning area.
|
||
- Create `hooks/useClickThroughSync.ts` — listens for `click-through-toggled` from Rust.
|
||
- Create `hooks/useLiveOverlayWiring.ts` — pushes Zustand changes to overlay.
|
||
- Create `lib/store.ts` — Zustand store.
|
||
- Create `lib/overlay.ts` — wrapper over `WebviewWindow` APIs.
|
||
- Create `lib/url.ts` — `normalizeUrl`, `isAllowedUrl`.
|
||
- Create `lib/persist.ts` — load + debounced save against `tauri-plugin-store`.
|
||
- Create `lib/debounce.ts` — small generic debounce helper.
|
||
- Create `types/overlay.ts` — `OverlayState`, `OverlayActions`, payload types.
|
||
- Delete `App.css` (no longer needed; Tailwind takes over).
|
||
- Delete `assets/react.svg` (scaffold remnant).
|
||
|
||
### Frontend (`browserlay/`)
|
||
- Modify `package.json` — add deps (`zustand`, `lucide-react`, `@tauri-apps/plugin-store`, `@tauri-apps/plugin-window-state`, `@tauri-apps/plugin-global-shortcut`, `tailwindcss`, `@tailwindcss/vite`).
|
||
- Modify `vite.config.ts` — register `@tailwindcss/vite` plugin.
|
||
- Modify `tsconfig.json` — strict + `noUncheckedIndexedAccess` + path mapping if useful.
|
||
- Modify `index.html` — set title, ensure dark background to avoid white flash.
|
||
- Modify `src-tauri/tauri.conf.json` — add overlay window definition? **No** — overlay is created at runtime; only the `main` window stays in config.
|
||
|
||
### Backend (`browserlay/src-tauri/`)
|
||
- Modify `Cargo.toml` — add plugin crates (`tauri-plugin-store`, `tauri-plugin-window-state`, `tauri-plugin-global-shortcut`) and `tokio` with `time` feature for the pulse sleep.
|
||
- Modify `src/lib.rs` — slim builder, register plugins, register global shortcut at setup, expose commands.
|
||
- Create `src/commands/mod.rs` — module declarations.
|
||
- Create `src/commands/overlay.rs` — `toggle_click_through` command + private `do_toggle` helper.
|
||
- Modify `capabilities/default.json` — add window/webview/store permissions for `main`.
|
||
- Create `capabilities/overlay.json` — minimal capability for the `overlay` window (no Tauri APIs exposed to loaded page).
|
||
|
||
### Docs / metadata
|
||
- Modify `README.md` — what the app does, dev setup, build, hotkey documented.
|
||
- Create `.gitignore` entries (if missing) for build outputs already present.
|
||
|
||
---
|
||
|
||
## Task 1: Project hygiene baseline
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/tsconfig.json`
|
||
- Modify: `browserlay/index.html`
|
||
|
||
- [ ] **Step 1: Tighten tsconfig.json strictness**
|
||
|
||
Open `browserlay/tsconfig.json` and replace its contents with:
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"useDefineForClassFields": true,
|
||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext",
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true,
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"noEmit": true,
|
||
"jsx": "react-jsx",
|
||
"strict": true,
|
||
"noUnusedLocals": true,
|
||
"noUnusedParameters": true,
|
||
"noFallthroughCasesInSwitch": true,
|
||
"noImplicitReturns": true,
|
||
"noUncheckedIndexedAccess": true,
|
||
"exactOptionalPropertyTypes": true
|
||
},
|
||
"include": ["src"],
|
||
"references": [{ "path": "./tsconfig.node.json" }]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Set page title and dark base background**
|
||
|
||
Open `browserlay/index.html` and replace its contents with:
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<link rel="icon" type="image/svg+xml" href="/tauri.svg" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Browserlay</title>
|
||
<style>
|
||
html, body, #root { height: 100%; margin: 0; }
|
||
html { background: #0b0b0d; color-scheme: dark; }
|
||
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 3: Verify build still passes**
|
||
|
||
Run from `browserlay/`: `npm run build`
|
||
Expected: TypeScript compiles, Vite emits `dist/`. (Scaffold App still references `App.css` — that's fine for now; we delete it in Task 4.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/tsconfig.json browserlay/index.html
|
||
git commit -m "chore: tighten tsconfig and set dark page base"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add frontend dependencies
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/package.json`
|
||
|
||
- [ ] **Step 1: Install runtime dependencies**
|
||
|
||
From `browserlay/`:
|
||
|
||
```bash
|
||
npm install zustand@^5 lucide-react @tauri-apps/plugin-store @tauri-apps/plugin-window-state @tauri-apps/plugin-global-shortcut
|
||
```
|
||
|
||
- [ ] **Step 2: Install Tailwind v4 dev dependencies**
|
||
|
||
```bash
|
||
npm install -D tailwindcss @tailwindcss/vite
|
||
```
|
||
|
||
- [ ] **Step 3: Verify package.json**
|
||
|
||
Open `browserlay/package.json` and confirm dependencies now include `zustand`, `lucide-react`, `@tauri-apps/plugin-store`, `@tauri-apps/plugin-window-state`, `@tauri-apps/plugin-global-shortcut`; devDependencies include `tailwindcss` and `@tailwindcss/vite`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/package.json browserlay/package-lock.json
|
||
git commit -m "chore: add frontend deps (zustand, lucide-react, tauri plugins, tailwind v4)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Wire Tailwind v4 into Vite
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/vite.config.ts`
|
||
- Create: `browserlay/src/index.css`
|
||
|
||
- [ ] **Step 1: Register the Tailwind Vite plugin**
|
||
|
||
Open `browserlay/vite.config.ts` and replace its contents with:
|
||
|
||
```ts
|
||
import { defineConfig } from "vite";
|
||
import react from "@vitejs/plugin-react";
|
||
import tailwindcss from "@tailwindcss/vite";
|
||
|
||
const host = process.env.TAURI_DEV_HOST;
|
||
|
||
export default defineConfig(async () => ({
|
||
plugins: [react(), tailwindcss()],
|
||
clearScreen: false,
|
||
server: {
|
||
port: 1420,
|
||
strictPort: true,
|
||
host: host || false,
|
||
hmr: host
|
||
? { protocol: "ws", host, port: 1421 }
|
||
: undefined,
|
||
watch: { ignored: ["**/src-tauri/**"] },
|
||
},
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 2: Create the Tailwind entry CSS with dark charcoal tokens**
|
||
|
||
Create `browserlay/src/index.css` with:
|
||
|
||
```css
|
||
@import "tailwindcss";
|
||
|
||
@theme {
|
||
--color-bg: #0b0b0d;
|
||
--color-surface: #141418;
|
||
--color-surface-elevated: #1c1c22;
|
||
--color-border: #2a2a31;
|
||
--color-border-strong: #3a3a44;
|
||
--color-text: #e8e8ec;
|
||
--color-muted: #9999a3;
|
||
--color-muted-strong: #c8c8d2;
|
||
--color-accent: #7c8cff;
|
||
--color-accent-hover: #93a0ff;
|
||
--color-danger: #ff6b6b;
|
||
--color-warning: #ffb84d;
|
||
|
||
--radius-sm: 6px;
|
||
--radius-md: 10px;
|
||
--radius-lg: 14px;
|
||
|
||
--shadow-soft: 0 1px 2px rgba(0,0,0,0.4), 0 4px 24px rgba(0,0,0,0.25);
|
||
|
||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
|
||
html, body, #root { height: 100%; }
|
||
body {
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
font-family: var(--font-sans);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify Vite builds with Tailwind**
|
||
|
||
Run from `browserlay/`: `npm run build`
|
||
Expected: build completes; emitted CSS includes Tailwind utilities.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/vite.config.ts browserlay/src/index.css
|
||
git commit -m "feat: wire tailwind v4 with charcoal design tokens"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Delete scaffold UI
|
||
|
||
**Files:**
|
||
- Delete: `browserlay/src/App.css`
|
||
- Delete: `browserlay/src/assets/react.svg`
|
||
|
||
- [ ] **Step 1: Remove scaffold files**
|
||
|
||
```bash
|
||
rm browserlay/src/App.css
|
||
rm browserlay/src/assets/react.svg
|
||
```
|
||
|
||
- [ ] **Step 2: Confirm `assets/` directory state**
|
||
|
||
If `browserlay/src/assets/` is now empty, leave it (we'll delete the directory only if no asset is needed by end of Phase 1).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -u browserlay/src
|
||
git commit -m "chore: remove scaffold App.css and react.svg"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Types module
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/types/overlay.ts`
|
||
|
||
- [ ] **Step 1: Define the shared types**
|
||
|
||
Create `browserlay/src/types/overlay.ts` with:
|
||
|
||
```ts
|
||
export type OverlayPersistedState = {
|
||
url: string;
|
||
opacity: number;
|
||
alwaysOnTop: boolean;
|
||
clickThrough: boolean;
|
||
};
|
||
|
||
export type OverlayState = OverlayPersistedState & {
|
||
isOpen: boolean;
|
||
urlError: string | null;
|
||
hotkeyError: string | null;
|
||
};
|
||
|
||
export type OverlayActions = {
|
||
setUrl: (raw: string) => void;
|
||
setOpacity: (v: number) => void;
|
||
setAlwaysOnTop: (v: boolean) => void;
|
||
setClickThrough: (v: boolean) => void;
|
||
/** Internal: applies a click-through change that originated in Rust.
|
||
* Skips the live-wiring effect to avoid ping-pong with the backend. */
|
||
_setClickThroughFromBackend: (v: boolean) => void;
|
||
setHotkeyError: (msg: string | null) => void;
|
||
setIsOpen: (v: boolean) => void;
|
||
hydrate: (s: OverlayPersistedState) => void;
|
||
};
|
||
|
||
export type ClickThroughToggledPayload = { clickThrough: boolean };
|
||
export type ClickThroughNoOverlayPayload = Record<string, never>;
|
||
|
||
export const STORE_FILE = "browserlay.json";
|
||
export const STORE_KEY = "state";
|
||
export const OVERLAY_LABEL = "overlay";
|
||
export const EVT_CLICK_THROUGH_TOGGLED = "click-through-toggled";
|
||
export const EVT_CLICK_THROUGH_NO_OVERLAY = "click-through-no-overlay";
|
||
```
|
||
|
||
- [ ] **Step 2: Confirm tsc accepts the file**
|
||
|
||
Run from `browserlay/`: `npx tsc --noEmit`
|
||
Expected: 0 errors. (Other files don't reference these types yet.)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/types/overlay.ts
|
||
git commit -m "feat: add overlay state types and IPC constants"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: URL normalization + allowlist (with tests)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/url.ts`
|
||
- Create: `browserlay/src/lib/url.test.ts`
|
||
- Modify: `browserlay/package.json` (add `vitest` dev dep)
|
||
- Modify: `browserlay/vite.config.ts` (add test config)
|
||
|
||
- [ ] **Step 1: Add Vitest**
|
||
|
||
From `browserlay/`:
|
||
|
||
```bash
|
||
npm install -D vitest @types/node
|
||
```
|
||
|
||
- [ ] **Step 2: Add test script to package.json**
|
||
|
||
Open `browserlay/package.json` and inside `"scripts"` add a `"test": "vitest run"` entry. Final scripts block should look like:
|
||
|
||
```json
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc && vite build",
|
||
"preview": "vite preview",
|
||
"tauri": "tauri",
|
||
"test": "vitest run"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add Vitest config to vite.config.ts**
|
||
|
||
In `browserlay/vite.config.ts`, change the top of the file and add a `test` block. Replace its contents with:
|
||
|
||
```ts
|
||
/// <reference types="vitest" />
|
||
import { defineConfig } from "vite";
|
||
import react from "@vitejs/plugin-react";
|
||
import tailwindcss from "@tailwindcss/vite";
|
||
|
||
const host = process.env.TAURI_DEV_HOST;
|
||
|
||
export default defineConfig(async () => ({
|
||
plugins: [react(), tailwindcss()],
|
||
clearScreen: false,
|
||
server: {
|
||
port: 1420,
|
||
strictPort: true,
|
||
host: host || false,
|
||
hmr: host
|
||
? { protocol: "ws", host, port: 1421 }
|
||
: undefined,
|
||
watch: { ignored: ["**/src-tauri/**"] },
|
||
},
|
||
test: {
|
||
environment: "node",
|
||
include: ["src/**/*.test.ts"],
|
||
},
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 4: Write the failing tests**
|
||
|
||
Create `browserlay/src/lib/url.test.ts` with:
|
||
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { normalizeUrl, validateUrl } from "./url";
|
||
|
||
describe("normalizeUrl", () => {
|
||
it("prepends https:// to a bare domain", () => {
|
||
expect(normalizeUrl("twitch.tv/foo")).toBe("https://twitch.tv/foo");
|
||
});
|
||
it("preserves existing https://", () => {
|
||
expect(normalizeUrl("https://example.com")).toBe("https://example.com");
|
||
});
|
||
it("preserves existing http://", () => {
|
||
expect(normalizeUrl("http://example.com")).toBe("http://example.com");
|
||
});
|
||
it("trims surrounding whitespace", () => {
|
||
expect(normalizeUrl(" twitch.tv ")).toBe("https://twitch.tv");
|
||
});
|
||
});
|
||
|
||
describe("validateUrl", () => {
|
||
it("accepts a normal https URL", () => {
|
||
expect(validateUrl("https://twitch.tv/foo")).toEqual({ ok: true, url: "https://twitch.tv/foo" });
|
||
});
|
||
it("normalizes a bare domain", () => {
|
||
expect(validateUrl("twitch.tv")).toEqual({ ok: true, url: "https://twitch.tv/" });
|
||
});
|
||
it("rejects empty input", () => {
|
||
expect(validateUrl("")).toEqual({ ok: false, error: "Enter a URL" });
|
||
});
|
||
it("rejects whitespace-only input", () => {
|
||
expect(validateUrl(" ")).toEqual({ ok: false, error: "Enter a URL" });
|
||
});
|
||
it("rejects javascript: scheme", () => {
|
||
expect(validateUrl("javascript:alert(1)")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects file:// scheme", () => {
|
||
expect(validateUrl("file:///c:/secret.html")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects tauri:// scheme", () => {
|
||
expect(validateUrl("tauri://localhost/index.html")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects data: scheme", () => {
|
||
expect(validateUrl("data:text/html,hi")).toEqual({
|
||
ok: false,
|
||
error: "Only http and https URLs are allowed",
|
||
});
|
||
});
|
||
it("rejects unparsable input", () => {
|
||
expect(validateUrl("not a url")).toEqual({
|
||
ok: false,
|
||
error: "That doesn't look like a URL",
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they fail**
|
||
|
||
Run from `browserlay/`: `npm test`
|
||
Expected: FAIL with "Cannot find module './url'" or similar.
|
||
|
||
- [ ] **Step 6: Implement the URL helpers**
|
||
|
||
Create `browserlay/src/lib/url.ts` with:
|
||
|
||
```ts
|
||
export type UrlValidation =
|
||
| { ok: true; url: string }
|
||
| { ok: false; error: string };
|
||
|
||
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
||
|
||
export function normalizeUrl(raw: string): string {
|
||
const trimmed = raw.trim();
|
||
if (trimmed === "") return trimmed;
|
||
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed;
|
||
return `https://${trimmed}`;
|
||
}
|
||
|
||
export function validateUrl(raw: string): UrlValidation {
|
||
const trimmed = raw.trim();
|
||
if (trimmed === "") return { ok: false, error: "Enter a URL" };
|
||
|
||
const candidate = normalizeUrl(trimmed);
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(candidate);
|
||
} catch {
|
||
return { ok: false, error: "That doesn't look like a URL" };
|
||
}
|
||
|
||
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
||
return { ok: false, error: "Only http and https URLs are allowed" };
|
||
}
|
||
if (!parsed.hostname) {
|
||
return { ok: false, error: "That doesn't look like a URL" };
|
||
}
|
||
return { ok: true, url: parsed.toString() };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: all 14 tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add browserlay/package.json browserlay/package-lock.json browserlay/vite.config.ts browserlay/src/lib/url.ts browserlay/src/lib/url.test.ts
|
||
git commit -m "feat: url normalization and http(s) allowlist"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Generic debounce helper (with test)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/debounce.ts`
|
||
- Create: `browserlay/src/lib/debounce.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `browserlay/src/lib/debounce.test.ts` with:
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import { debounce } from "./debounce";
|
||
|
||
describe("debounce", () => {
|
||
beforeEach(() => vi.useFakeTimers());
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
it("delays a single call by the wait period", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d("a");
|
||
expect(fn).not.toHaveBeenCalled();
|
||
vi.advanceTimersByTime(100);
|
||
expect(fn).toHaveBeenCalledTimes(1);
|
||
expect(fn).toHaveBeenCalledWith("a");
|
||
});
|
||
|
||
it("collapses rapid calls into a single trailing call", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d("a"); d("b"); d("c");
|
||
vi.advanceTimersByTime(100);
|
||
expect(fn).toHaveBeenCalledTimes(1);
|
||
expect(fn).toHaveBeenCalledWith("c");
|
||
});
|
||
|
||
it("flush() invokes pending call immediately", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d("a");
|
||
d.flush();
|
||
expect(fn).toHaveBeenCalledWith("a");
|
||
});
|
||
|
||
it("flush() with no pending call is a no-op", () => {
|
||
const fn = vi.fn();
|
||
const d = debounce(fn, 100);
|
||
d.flush();
|
||
expect(fn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL with "Cannot find module './debounce'".
|
||
|
||
- [ ] **Step 3: Implement debounce**
|
||
|
||
Create `browserlay/src/lib/debounce.ts` with:
|
||
|
||
```ts
|
||
export type Debounced<Args extends unknown[]> = ((...args: Args) => void) & {
|
||
flush: () => void;
|
||
cancel: () => void;
|
||
};
|
||
|
||
export function debounce<Args extends unknown[]>(
|
||
fn: (...args: Args) => void,
|
||
waitMs: number
|
||
): Debounced<Args> {
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
let pendingArgs: Args | null = null;
|
||
|
||
const debounced = (...args: Args): void => {
|
||
pendingArgs = args;
|
||
if (timer !== null) clearTimeout(timer);
|
||
timer = setTimeout(() => {
|
||
timer = null;
|
||
const a = pendingArgs!;
|
||
pendingArgs = null;
|
||
fn(...a);
|
||
}, waitMs);
|
||
};
|
||
|
||
debounced.flush = (): void => {
|
||
if (timer === null) return;
|
||
clearTimeout(timer);
|
||
timer = null;
|
||
const a = pendingArgs!;
|
||
pendingArgs = null;
|
||
fn(...a);
|
||
};
|
||
|
||
debounced.cancel = (): void => {
|
||
if (timer !== null) clearTimeout(timer);
|
||
timer = null;
|
||
pendingArgs = null;
|
||
};
|
||
|
||
return debounced;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests pass (URL tests + debounce tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/debounce.ts browserlay/src/lib/debounce.test.ts
|
||
git commit -m "feat: debounce helper with flush"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Zustand store
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/store.ts`
|
||
- Create: `browserlay/src/lib/store.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `browserlay/src/lib/store.test.ts` with:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import { useOverlayStore } from "./store";
|
||
|
||
const initial = useOverlayStore.getState();
|
||
|
||
describe("useOverlayStore", () => {
|
||
beforeEach(() => useOverlayStore.setState(initial, true));
|
||
|
||
it("starts with sensible defaults", () => {
|
||
const s = useOverlayStore.getState();
|
||
expect(s.url).toBe("");
|
||
expect(s.opacity).toBe(1.0);
|
||
expect(s.alwaysOnTop).toBe(true);
|
||
expect(s.clickThrough).toBe(false);
|
||
expect(s.isOpen).toBe(false);
|
||
expect(s.urlError).toBe(null);
|
||
expect(s.hotkeyError).toBe(null);
|
||
});
|
||
|
||
it("setUrl validates and stores both url and urlError", () => {
|
||
useOverlayStore.getState().setUrl("twitch.tv");
|
||
expect(useOverlayStore.getState().url).toBe("twitch.tv");
|
||
expect(useOverlayStore.getState().urlError).toBe(null);
|
||
|
||
useOverlayStore.getState().setUrl("javascript:alert(1)");
|
||
expect(useOverlayStore.getState().urlError).toMatch(/http and https/);
|
||
});
|
||
|
||
it("setOpacity clamps to 0.1..1.0", () => {
|
||
useOverlayStore.getState().setOpacity(0.05);
|
||
expect(useOverlayStore.getState().opacity).toBe(0.1);
|
||
useOverlayStore.getState().setOpacity(2);
|
||
expect(useOverlayStore.getState().opacity).toBe(1.0);
|
||
useOverlayStore.getState().setOpacity(0.5);
|
||
expect(useOverlayStore.getState().opacity).toBe(0.5);
|
||
});
|
||
|
||
it("setAlwaysOnTop and setClickThrough mutate flags", () => {
|
||
useOverlayStore.getState().setAlwaysOnTop(false);
|
||
expect(useOverlayStore.getState().alwaysOnTop).toBe(false);
|
||
useOverlayStore.getState().setClickThrough(true);
|
||
expect(useOverlayStore.getState().clickThrough).toBe(true);
|
||
});
|
||
|
||
it("hydrate replaces persisted fields and clears errors", () => {
|
||
useOverlayStore.getState().setUrl("javascript:alert(1)");
|
||
useOverlayStore.getState().hydrate({
|
||
url: "https://example.com",
|
||
opacity: 0.6,
|
||
alwaysOnTop: false,
|
||
clickThrough: true,
|
||
});
|
||
const s = useOverlayStore.getState();
|
||
expect(s.url).toBe("https://example.com");
|
||
expect(s.opacity).toBe(0.6);
|
||
expect(s.alwaysOnTop).toBe(false);
|
||
expect(s.clickThrough).toBe(true);
|
||
expect(s.urlError).toBe(null);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL ("Cannot find module './store'").
|
||
|
||
- [ ] **Step 3: Implement the store**
|
||
|
||
Create `browserlay/src/lib/store.ts` with:
|
||
|
||
```ts
|
||
import { create } from "zustand";
|
||
import type { OverlayActions, OverlayPersistedState, OverlayState } from "../types/overlay";
|
||
import { validateUrl } from "./url";
|
||
|
||
const clamp = (v: number, min: number, max: number): number =>
|
||
Math.min(max, Math.max(min, v));
|
||
|
||
export const useOverlayStore = create<OverlayState & OverlayActions>()((set) => ({
|
||
url: "",
|
||
opacity: 1.0,
|
||
alwaysOnTop: true,
|
||
clickThrough: false,
|
||
isOpen: false,
|
||
urlError: null,
|
||
hotkeyError: null,
|
||
|
||
setUrl: (raw) => {
|
||
const result = validateUrl(raw);
|
||
set({
|
||
url: raw,
|
||
urlError: result.ok ? null : raw.trim() === "" ? null : result.error,
|
||
});
|
||
},
|
||
|
||
setOpacity: (v) => set({ opacity: clamp(v, 0.1, 1.0) }),
|
||
|
||
setAlwaysOnTop: (v) => set({ alwaysOnTop: v }),
|
||
|
||
setClickThrough: (v) => set({ clickThrough: v }),
|
||
|
||
_setClickThroughFromBackend: (v) =>
|
||
set((s) => (s.clickThrough === v ? s : { clickThrough: v })),
|
||
|
||
setHotkeyError: (msg) => set({ hotkeyError: msg }),
|
||
|
||
setIsOpen: (v) => set({ isOpen: v }),
|
||
|
||
hydrate: (p: OverlayPersistedState) =>
|
||
set({
|
||
url: p.url,
|
||
opacity: clamp(p.opacity, 0.1, 1.0),
|
||
alwaysOnTop: p.alwaysOnTop,
|
||
clickThrough: p.clickThrough,
|
||
urlError: null,
|
||
}),
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/store.ts browserlay/src/lib/store.test.ts
|
||
git commit -m "feat: zustand store for overlay state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Overlay API boundary (`lib/overlay.ts`)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/overlay.ts`
|
||
|
||
This module is the only place that imports `@tauri-apps/api/webviewWindow`. We won't unit test it — its job is thin Tauri API delegation; the real test is the manual verification checklist (Task 19).
|
||
|
||
- [ ] **Step 1: Implement the overlay boundary**
|
||
|
||
Create `browserlay/src/lib/overlay.ts` with:
|
||
|
||
```ts
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import {
|
||
WebviewWindow,
|
||
getCurrentWebviewWindow,
|
||
} from "@tauri-apps/api/webviewWindow";
|
||
import { currentMonitor } from "@tauri-apps/api/window";
|
||
import { OVERLAY_LABEL } from "../types/overlay";
|
||
|
||
export type OpenOptions = {
|
||
url: string;
|
||
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;
|
||
const FIRST_OPEN_H = 600;
|
||
const FIRST_OPEN_MARGIN = 24;
|
||
|
||
async function getOverlay(): Promise<WebviewWindow | null> {
|
||
return await WebviewWindow.getByLabel(OVERLAY_LABEL);
|
||
}
|
||
|
||
export async function openOverlay(opts: OpenOptions): Promise<void> {
|
||
const existing = await getOverlay();
|
||
if (existing) {
|
||
await closeOverlay();
|
||
}
|
||
|
||
// Default to top-right of primary monitor on first open;
|
||
// tauri-plugin-window-state will restore prior geometry on subsequent opens.
|
||
let x = 100;
|
||
let y = 100;
|
||
try {
|
||
const monitor = await currentMonitor();
|
||
if (monitor) {
|
||
const scale = monitor.scaleFactor;
|
||
const logicalW = monitor.size.width / scale;
|
||
x = Math.round(logicalW - FIRST_OPEN_W - FIRST_OPEN_MARGIN);
|
||
y = FIRST_OPEN_MARGIN;
|
||
}
|
||
} catch {
|
||
// best-effort placement; fall through to defaults
|
||
}
|
||
|
||
const w = new WebviewWindow(OVERLAY_LABEL, {
|
||
url: opts.url,
|
||
title: "Browserlay overlay",
|
||
width: FIRST_OPEN_W,
|
||
height: FIRST_OPEN_H,
|
||
x,
|
||
y,
|
||
decorations: false,
|
||
transparent: true,
|
||
alwaysOnTop: opts.alwaysOnTop,
|
||
resizable: true,
|
||
visible: true,
|
||
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) => {
|
||
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();
|
||
});
|
||
|
||
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> {
|
||
const w = await getOverlay();
|
||
if (!w) return;
|
||
await w.close();
|
||
}
|
||
|
||
export async function applyOpacity(v: number): Promise<void> {
|
||
const w = await getOverlay();
|
||
if (!w) return;
|
||
await w.setOpacity(v);
|
||
}
|
||
|
||
export async function applyAlwaysOnTop(v: boolean): Promise<void> {
|
||
const w = await getOverlay();
|
||
if (!w) return;
|
||
await w.setAlwaysOnTop(v);
|
||
}
|
||
|
||
export async function applyClickThrough(v: boolean): Promise<void> {
|
||
const w = await getOverlay();
|
||
if (!w) return;
|
||
await w.setIgnoreCursorEvents(v);
|
||
}
|
||
|
||
/** Returns true if the overlay window currently exists. */
|
||
export async function isOverlayOpen(): Promise<boolean> {
|
||
return (await getOverlay()) !== null;
|
||
}
|
||
|
||
/** 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**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/overlay.ts
|
||
git commit -m "feat: overlay window API boundary"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Persistence (load + debounced save)
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/lib/persist.ts`
|
||
|
||
- [ ] **Step 1: Implement persist**
|
||
|
||
Create `browserlay/src/lib/persist.ts` with:
|
||
|
||
```ts
|
||
import { load, type Store } from "@tauri-apps/plugin-store";
|
||
import { STORE_FILE, STORE_KEY, type OverlayPersistedState } from "../types/overlay";
|
||
import { useOverlayStore } from "./store";
|
||
import { debounce } from "./debounce";
|
||
|
||
const DEFAULTS: OverlayPersistedState = {
|
||
url: "",
|
||
opacity: 1.0,
|
||
alwaysOnTop: true,
|
||
clickThrough: false,
|
||
};
|
||
|
||
let storePromise: Promise<Store> | null = null;
|
||
function getStore(): Promise<Store> {
|
||
if (!storePromise) {
|
||
storePromise = load(STORE_FILE, { autoSave: false });
|
||
}
|
||
return storePromise;
|
||
}
|
||
|
||
function isPersistedShape(v: unknown): v is OverlayPersistedState {
|
||
if (!v || typeof v !== "object") return false;
|
||
const o = v as Record<string, unknown>;
|
||
return (
|
||
typeof o.url === "string" &&
|
||
typeof o.opacity === "number" &&
|
||
typeof o.alwaysOnTop === "boolean" &&
|
||
typeof o.clickThrough === "boolean"
|
||
);
|
||
}
|
||
|
||
/** Load persisted state from disk and hydrate the Zustand store. Safe on first run. */
|
||
export async function hydrateFromDisk(): Promise<void> {
|
||
try {
|
||
const s = await getStore();
|
||
const raw = await s.get<unknown>(STORE_KEY);
|
||
const value = isPersistedShape(raw) ? raw : DEFAULTS;
|
||
useOverlayStore.getState().hydrate(value);
|
||
} catch (err) {
|
||
console.warn("Failed to load persisted state; using defaults", err);
|
||
useOverlayStore.getState().hydrate(DEFAULTS);
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
await s.save();
|
||
} 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) => {
|
||
const snapshot: OverlayPersistedState = {
|
||
url: s.url,
|
||
opacity: s.opacity,
|
||
alwaysOnTop: s.alwaysOnTop,
|
||
clickThrough: s.clickThrough,
|
||
};
|
||
latestPending = snapshot;
|
||
writeDebounced(snapshot);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify tsc accepts the file**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/lib/persist.ts
|
||
git commit -m "feat: load + debounced persist via tauri-plugin-store"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Live wiring + click-through sync hooks
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/hooks/useLiveOverlayWiring.ts`
|
||
- Create: `browserlay/src/hooks/useClickThroughSync.ts`
|
||
|
||
- [ ] **Step 1: Implement live wiring hook**
|
||
|
||
Create `browserlay/src/hooks/useLiveOverlayWiring.ts` with:
|
||
|
||
```ts
|
||
import { useEffect, useRef } from "react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
import {
|
||
applyAlwaysOnTop,
|
||
applyClickThrough,
|
||
applyOpacity,
|
||
} from "../lib/overlay";
|
||
|
||
/** 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);
|
||
|
||
// Track whether the *previous* clickThrough value came from the backend so we
|
||
// don't bounce it back. A simple ref counter works because the silent setter
|
||
// is the only path that calls _setClickThroughFromBackend.
|
||
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);
|
||
}, [clickThrough, isOpen]);
|
||
}
|
||
```
|
||
|
||
> **Note:** The "did this change come from the backend" guard above is informal — it relies on `lastAppliedClickThrough` being equal to the value just written by `_setClickThroughFromBackend`. The sync hook below will set this ref before updating the store, so the `useEffect` no-ops.
|
||
|
||
- [ ] **Step 2: Implement click-through sync hook**
|
||
|
||
Create `browserlay/src/hooks/useClickThroughSync.ts` with:
|
||
|
||
```ts
|
||
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,
|
||
type ClickThroughToggledPayload,
|
||
} from "../types/overlay";
|
||
|
||
/** Subscribes to backend events about click-through state and toast about no-overlay attempts. */
|
||
export function useClickThroughSync(
|
||
onNoOverlay: () => void
|
||
): void {
|
||
useEffect(() => {
|
||
let unlistenToggled: UnlistenFn | undefined;
|
||
let unlistenNoOverlay: UnlistenFn | undefined;
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
unlistenToggled = await listen<ClickThroughToggledPayload>(
|
||
EVT_CLICK_THROUGH_TOGGLED,
|
||
(e) => {
|
||
if (cancelled) return;
|
||
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, () => {
|
||
if (cancelled) return;
|
||
onNoOverlay();
|
||
});
|
||
})().catch((err) => console.warn("event listen failed", err));
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
unlistenToggled?.();
|
||
unlistenNoOverlay?.();
|
||
};
|
||
}, [onNoOverlay]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify tsc accepts the files**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors. (`onNoOverlay` callback type is inferred as `() => void`.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/hooks/useLiveOverlayWiring.ts browserlay/src/hooks/useClickThroughSync.ts
|
||
git commit -m "feat: live overlay wiring and backend click-through sync hooks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: UI components
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src/components/UrlField.tsx`
|
||
- Create: `browserlay/src/components/OpacitySlider.tsx`
|
||
- Create: `browserlay/src/components/ToggleRow.tsx`
|
||
- Create: `browserlay/src/components/OpenOverlayButton.tsx`
|
||
- Create: `browserlay/src/components/StatusBar.tsx`
|
||
- Create: `browserlay/src/components/ControlPanel.tsx`
|
||
|
||
- [ ] **Step 1: UrlField**
|
||
|
||
Create `browserlay/src/components/UrlField.tsx` with:
|
||
|
||
```tsx
|
||
import { Globe } from "lucide-react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export function UrlField(): JSX.Element {
|
||
const url = useOverlayStore((s) => s.url);
|
||
const urlError = useOverlayStore((s) => s.urlError);
|
||
const setUrl = useOverlayStore((s) => s.setUrl);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<label htmlFor="overlay-url" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
|
||
URL
|
||
</label>
|
||
<div
|
||
className={`flex items-center gap-2 rounded-[var(--radius-md)] border px-3 py-2 transition-colors
|
||
bg-[var(--color-surface)]
|
||
${urlError ? "border-[var(--color-danger)]" : "border-[var(--color-border)] focus-within:border-[var(--color-border-strong)]"}`}
|
||
>
|
||
<Globe size={16} className="text-[var(--color-muted)]" aria-hidden="true" />
|
||
<input
|
||
id="overlay-url"
|
||
type="text"
|
||
inputMode="url"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder="twitch.tv/somechannel"
|
||
value={url}
|
||
onChange={(e) => setUrl(e.currentTarget.value)}
|
||
className="w-full bg-transparent outline-none text-[var(--color-text)] placeholder:text-[var(--color-muted)]"
|
||
/>
|
||
</div>
|
||
{urlError && (
|
||
<p className="text-xs text-[var(--color-danger)]">{urlError}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: OpacitySlider**
|
||
|
||
Create `browserlay/src/components/OpacitySlider.tsx` with:
|
||
|
||
```tsx
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export function OpacitySlider(): JSX.Element {
|
||
const opacity = useOverlayStore((s) => s.opacity);
|
||
const setOpacity = useOverlayStore((s) => s.setOpacity);
|
||
const pct = Math.round(opacity * 100);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<label htmlFor="opacity" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
|
||
Opacity
|
||
</label>
|
||
<span className="text-xs tabular-nums text-[var(--color-muted-strong)]">{pct}%</span>
|
||
</div>
|
||
<input
|
||
id="opacity"
|
||
type="range"
|
||
min={0.1}
|
||
max={1.0}
|
||
step={0.01}
|
||
value={opacity}
|
||
onChange={(e) => setOpacity(Number(e.currentTarget.value))}
|
||
className="w-full accent-[var(--color-accent)]"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: ToggleRow**
|
||
|
||
Create `browserlay/src/components/ToggleRow.tsx` with:
|
||
|
||
```tsx
|
||
import type { ReactNode } from "react";
|
||
|
||
export type ToggleRowProps = {
|
||
label: string;
|
||
description?: string;
|
||
icon?: ReactNode;
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
};
|
||
|
||
export function ToggleRow({ label, description, icon, checked, onChange }: ToggleRowProps): JSX.Element {
|
||
return (
|
||
<label className="flex items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 cursor-pointer hover:border-[var(--color-border-strong)] transition-colors">
|
||
<span className="flex items-center gap-2.5 min-w-0">
|
||
{icon && <span className="text-[var(--color-muted-strong)]" aria-hidden="true">{icon}</span>}
|
||
<span className="flex flex-col min-w-0">
|
||
<span className="text-sm text-[var(--color-text)]">{label}</span>
|
||
{description && (
|
||
<span className="text-xs text-[var(--color-muted)] truncate">{description}</span>
|
||
)}
|
||
</span>
|
||
</span>
|
||
<span
|
||
role="switch"
|
||
aria-checked={checked}
|
||
aria-label={label}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
onChange(!checked);
|
||
}}
|
||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors
|
||
${checked ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all
|
||
${checked ? "left-[calc(100%-1.125rem)]" : "left-0.5"}`}
|
||
/>
|
||
</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={(e) => onChange(e.currentTarget.checked)}
|
||
className="sr-only"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: OpenOverlayButton**
|
||
|
||
Create `browserlay/src/components/OpenOverlayButton.tsx` with:
|
||
|
||
```tsx
|
||
import { Play, Square } from "lucide-react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
import { closeOverlay, openOverlay } from "../lib/overlay";
|
||
import { validateUrl } from "../lib/url";
|
||
|
||
export function OpenOverlayButton(): JSX.Element {
|
||
const url = useOverlayStore((s) => s.url);
|
||
const isOpen = useOverlayStore((s) => s.isOpen);
|
||
const opacity = useOverlayStore((s) => s.opacity);
|
||
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
|
||
const clickThrough = useOverlayStore((s) => s.clickThrough);
|
||
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
|
||
|
||
const validation = validateUrl(url);
|
||
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;
|
||
}
|
||
if (!validation.ok) return;
|
||
await openOverlay({
|
||
url: validation.url,
|
||
opacity,
|
||
alwaysOnTop,
|
||
clickThrough,
|
||
onDestroyed: () => {
|
||
useOverlayStore.getState().setIsOpen(false);
|
||
},
|
||
});
|
||
setIsOpen(true);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
void handleClick();
|
||
}}
|
||
disabled={disabled}
|
||
className={`mt-1 flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 py-2.5 text-sm font-medium transition-colors
|
||
${disabled
|
||
? "bg-[var(--color-surface-elevated)] text-[var(--color-muted)] cursor-not-allowed"
|
||
: isOpen
|
||
? "bg-[var(--color-surface-elevated)] text-[var(--color-text)] hover:bg-[var(--color-border-strong)]"
|
||
: "bg-[var(--color-accent)] text-black hover:bg-[var(--color-accent-hover)]"}`}
|
||
>
|
||
{isOpen ? <Square size={14} /> : <Play size={14} />}
|
||
{isOpen ? "Close overlay" : "Open overlay"}
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: StatusBar**
|
||
|
||
Create `browserlay/src/components/StatusBar.tsx` with:
|
||
|
||
```tsx
|
||
import { AlertTriangle, Keyboard } from "lucide-react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export type StatusBarProps = { transient: string | null };
|
||
|
||
export function StatusBar({ transient }: StatusBarProps): JSX.Element {
|
||
const hotkeyError = useOverlayStore((s) => s.hotkeyError);
|
||
const isOpen = useOverlayStore((s) => s.isOpen);
|
||
|
||
return (
|
||
<footer className="border-t border-[var(--color-border)] px-4 py-2 text-xs flex items-center justify-between text-[var(--color-muted)]">
|
||
<span className="flex items-center gap-2">
|
||
<span
|
||
className={`h-1.5 w-1.5 rounded-full ${isOpen ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
|
||
aria-hidden="true"
|
||
/>
|
||
{isOpen ? "Overlay open" : "Overlay closed"}
|
||
</span>
|
||
<span className="flex items-center gap-3">
|
||
{hotkeyError ? (
|
||
<span className="flex items-center gap-1 text-[var(--color-warning)]">
|
||
<AlertTriangle size={12} aria-hidden="true" /> {hotkeyError}
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-1">
|
||
<Keyboard size={12} aria-hidden="true" /> Ctrl+Alt+Space toggles click-through
|
||
</span>
|
||
)}
|
||
{transient && <span className="text-[var(--color-muted-strong)]">{transient}</span>}
|
||
</span>
|
||
</footer>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: ControlPanel**
|
||
|
||
Create `browserlay/src/components/ControlPanel.tsx` with:
|
||
|
||
```tsx
|
||
import { Layers, MousePointerClick } from "lucide-react";
|
||
import { UrlField } from "./UrlField";
|
||
import { OpacitySlider } from "./OpacitySlider";
|
||
import { ToggleRow } from "./ToggleRow";
|
||
import { OpenOverlayButton } from "./OpenOverlayButton";
|
||
import { useOverlayStore } from "../lib/store";
|
||
|
||
export function ControlPanel(): JSX.Element {
|
||
const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop);
|
||
const setAlwaysOnTop = useOverlayStore((s) => s.setAlwaysOnTop);
|
||
const clickThrough = useOverlayStore((s) => s.clickThrough);
|
||
const setClickThrough = useOverlayStore((s) => s.setClickThrough);
|
||
|
||
return (
|
||
<section className="flex flex-1 flex-col gap-4 p-4">
|
||
<header className="flex items-center gap-2">
|
||
<span className="text-base font-semibold tracking-tight text-[var(--color-text)]">Browserlay</span>
|
||
</header>
|
||
<UrlField />
|
||
<OpacitySlider />
|
||
<div className="flex flex-col gap-2">
|
||
<ToggleRow
|
||
label="Always on top"
|
||
description="Keep the overlay above other windows"
|
||
icon={<Layers size={14} />}
|
||
checked={alwaysOnTop}
|
||
onChange={setAlwaysOnTop}
|
||
/>
|
||
<ToggleRow
|
||
label="Click-through"
|
||
description="Mouse passes through to apps below"
|
||
icon={<MousePointerClick size={14} />}
|
||
checked={clickThrough}
|
||
onChange={setClickThrough}
|
||
/>
|
||
</div>
|
||
<OpenOverlayButton />
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify tsc accepts the files**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/components
|
||
git commit -m "feat: control panel UI components"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Wire App.tsx + main.tsx
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src/App.tsx`
|
||
- Modify: `browserlay/src/main.tsx`
|
||
|
||
- [ ] **Step 1: Replace App.tsx**
|
||
|
||
Open `browserlay/src/App.tsx` and replace its contents with:
|
||
|
||
```tsx
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { ControlPanel } from "./components/ControlPanel";
|
||
import { StatusBar } from "./components/StatusBar";
|
||
import { useLiveOverlayWiring } from "./hooks/useLiveOverlayWiring";
|
||
import { useClickThroughSync } from "./hooks/useClickThroughSync";
|
||
import { useOverlayStore } from "./lib/store";
|
||
import { isOverlayOpen } from "./lib/overlay";
|
||
|
||
function App(): JSX.Element {
|
||
useLiveOverlayWiring();
|
||
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
|
||
const [transient, setTransient] = useState<string | null>(null);
|
||
|
||
// Reconcile isOpen with reality on mount (e.g. user closed overlay via taskbar last session).
|
||
useEffect(() => {
|
||
void isOverlayOpen().then(setIsOpen);
|
||
}, [setIsOpen]);
|
||
|
||
const handleNoOverlay = useCallback(() => {
|
||
setTransient("No overlay to toggle");
|
||
const t = setTimeout(() => setTransient(null), 2500);
|
||
return () => clearTimeout(t);
|
||
}, []);
|
||
|
||
useClickThroughSync(handleNoOverlay);
|
||
|
||
return (
|
||
<main className="flex h-screen flex-col bg-[var(--color-bg)]">
|
||
<ControlPanel />
|
||
<StatusBar transient={transient} />
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default App;
|
||
```
|
||
|
||
- [ ] **Step 2: Replace main.tsx**
|
||
|
||
Open `browserlay/src/main.tsx` and replace its contents with:
|
||
|
||
```tsx
|
||
import React from "react";
|
||
import ReactDOM from "react-dom/client";
|
||
import App from "./App";
|
||
import "./index.css";
|
||
import { hydrateFromDisk, startPersistSubscription, flushPendingPersistSync } from "./lib/persist";
|
||
import { fetchHotkeyStatus } from "./lib/hotkey";
|
||
|
||
async function bootstrap(): Promise<void> {
|
||
await hydrateFromDisk();
|
||
startPersistSubscription();
|
||
// 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", () => {
|
||
flushPendingPersistSync();
|
||
});
|
||
|
||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
);
|
||
}
|
||
|
||
void bootstrap();
|
||
```
|
||
|
||
- [ ] **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 { invoke } from "@tauri-apps/api/core";
|
||
import { useOverlayStore } from "./store";
|
||
|
||
type HotkeyStatus =
|
||
| { kind: "pending" }
|
||
| { kind: "ok" }
|
||
| { kind: "failed"; error: string };
|
||
|
||
/** 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");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify tsc accepts everything**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 5: Run frontend tests**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests pass (URL + debounce + store).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/App.tsx browserlay/src/main.tsx browserlay/src/lib/hotkey.ts
|
||
git commit -m "feat: wire App + main with persist + hooks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Add Rust plugin crates and tokio
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src-tauri/Cargo.toml`
|
||
|
||
- [ ] **Step 1: Add plugin dependencies**
|
||
|
||
Open `browserlay/src-tauri/Cargo.toml` and replace its `[dependencies]` block with:
|
||
|
||
```toml
|
||
[dependencies]
|
||
tauri = { version = "2", features = [] }
|
||
tauri-plugin-opener = "2"
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
|
||
tauri-plugin-store = "2"
|
||
tokio = { version = "1", features = ["time", "macros"] }
|
||
|
||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||
tauri-plugin-window-state = "2"
|
||
tauri-plugin-global-shortcut = "2"
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the crate builds**
|
||
|
||
Run from `browserlay/src-tauri/`: `cargo check`
|
||
Expected: compiles (may take a while on first fetch).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/Cargo.toml browserlay/src-tauri/Cargo.lock
|
||
git commit -m "chore(rust): add store, window-state, global-shortcut, tokio"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Capability files
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src-tauri/capabilities/default.json`
|
||
- Create: `browserlay/src-tauri/capabilities/overlay.json`
|
||
|
||
- [ ] **Step 1: Update default capability for the main window**
|
||
|
||
Open `browserlay/src-tauri/capabilities/default.json` and replace its contents with:
|
||
|
||
```json
|
||
{
|
||
"$schema": "../gen/schemas/desktop-schema.json",
|
||
"identifier": "default",
|
||
"description": "Capability for the main control window",
|
||
"windows": ["main"],
|
||
"permissions": [
|
||
"core:default",
|
||
"core:webview:allow-create-webview-window",
|
||
"core:window:allow-set-always-on-top",
|
||
"core:window:allow-set-ignore-cursor-events",
|
||
"core:window:allow-close",
|
||
"core:window:allow-current-monitor",
|
||
"opener:default",
|
||
"store:default",
|
||
"global-shortcut:allow-register",
|
||
"global-shortcut:allow-unregister",
|
||
"global-shortcut:allow-is-registered"
|
||
]
|
||
}
|
||
```
|
||
|
||
> Note: `core:window:allow-set-opacity` is NOT a valid Tauri 2.11.x permission identifier — opacity is only exposed on the Rust `WebviewWindow`, not via Tauri's IPC ACL. Our custom `set_window_opacity` command (Task 16) doesn't need a per-command capability since `core:default` includes invocation of arbitrary app commands.
|
||
|
||
- [ ] **Step 2: Create the overlay capability**
|
||
|
||
Create `browserlay/src-tauri/capabilities/overlay.json` with:
|
||
|
||
```json
|
||
{
|
||
"$schema": "../gen/schemas/desktop-schema.json",
|
||
"identifier": "overlay",
|
||
"description": "Locked-down capability for the overlay window — pages loaded here cannot call Tauri APIs",
|
||
"windows": ["overlay"],
|
||
"permissions": []
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/capabilities/default.json browserlay/src-tauri/capabilities/overlay.json
|
||
git commit -m "feat(security): per-window capabilities, overlay locked down"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Rust commands module + click-through toggle
|
||
|
||
**Files:**
|
||
- Create: `browserlay/src-tauri/src/commands/mod.rs`
|
||
- Create: `browserlay/src-tauri/src/commands/overlay.rs`
|
||
|
||
- [ ] **Step 1: Create the commands module file**
|
||
|
||
Create `browserlay/src-tauri/src/commands/mod.rs` with:
|
||
|
||
```rust
|
||
pub mod overlay;
|
||
```
|
||
|
||
- [ ] **Step 2: Implement overlay commands and the shared toggle helper**
|
||
|
||
Create `browserlay/src-tauri/src/commands/overlay.rs` with:
|
||
|
||
```rust
|
||
use std::time::Duration;
|
||
|
||
use serde::Serialize;
|
||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||
|
||
pub const OVERLAY_LABEL: &str = "overlay";
|
||
pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled";
|
||
pub const EVT_CLICK_THROUGH_NO_OVERLAY: &str = "click-through-no-overlay";
|
||
|
||
#[derive(Clone, Serialize)]
|
||
pub struct ClickThroughToggledPayload {
|
||
#[serde(rename = "clickThrough")]
|
||
pub click_through: bool,
|
||
}
|
||
|
||
/// Applies a specific click-through state to the overlay (sets ignore_cursor_events,
|
||
/// performs the opacity pulse, emits the toggled event). Returns the applied state.
|
||
///
|
||
/// Tauri 2's WebviewWindow doesn't expose getters for `ignore_cursor_events` or
|
||
/// `opacity`, so callers must compute the new state themselves: the JS-side
|
||
/// `toggle_click_through` command receives `current` from the front end and
|
||
/// passes `!current`; the global shortcut handler maintains its own cache via
|
||
/// `AppState.click_through`.
|
||
pub async fn apply_state<R: Runtime>(
|
||
app: &AppHandle<R>,
|
||
new_state: bool,
|
||
) -> Result<Option<bool>, String> {
|
||
let Some(overlay) = app.get_webview_window(OVERLAY_LABEL) else {
|
||
let _ = app.emit(EVT_CLICK_THROUGH_NO_OVERLAY, ());
|
||
return Ok(None);
|
||
};
|
||
|
||
overlay
|
||
.set_ignore_cursor_events(new_state)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
// 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;
|
||
|
||
app.emit(
|
||
EVT_CLICK_THROUGH_TOGGLED,
|
||
ClickThroughToggledPayload {
|
||
click_through: new_state,
|
||
},
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
Ok(Some(new_state))
|
||
}
|
||
|
||
/// Tauri command — invoked from JS. The JS side passes the *current* state so
|
||
/// we can compute the inverse without needing a getter on the Tauri side.
|
||
#[tauri::command]
|
||
pub async fn toggle_click_through<R: Runtime>(
|
||
app: AppHandle<R>,
|
||
current: bool,
|
||
) -> Result<Option<bool>, String> {
|
||
apply_state(&app, !current).await
|
||
}
|
||
|
||
/// Stateless setter — used by the global shortcut handler, which doesn't have
|
||
/// access to the JS-side current value. Reads the *intended* state from a
|
||
/// shared `Mutex<bool>` kept in app state.
|
||
pub async fn toggle_via_global_shortcut<R: Runtime>(
|
||
app: &AppHandle<R>,
|
||
) -> Result<(), String> {
|
||
let state = app.state::<crate::AppState>();
|
||
let new_value = {
|
||
let mut guard = state.click_through.lock().expect("poisoned");
|
||
*guard = !*guard;
|
||
*guard
|
||
};
|
||
apply_state(app, new_value).await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Sync the Rust-side click-through cache with what the JS side just applied.
|
||
/// Called on overlay open/close and whenever the JS toggle changes user-side.
|
||
#[tauri::command]
|
||
pub fn sync_click_through_cache(
|
||
app: AppHandle,
|
||
value: bool,
|
||
) -> Result<(), String> {
|
||
let state = app.state::<crate::AppState>();
|
||
let mut guard = state.click_through.lock().map_err(|_| "poisoned".to_string())?;
|
||
*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())
|
||
}
|
||
|
||
/// Set window opacity. Tauri 2.11 does NOT expose set_opacity on either side
|
||
/// (open issue tauri-apps/tauri#3279), so we drop down to Win32 directly via
|
||
/// SetLayeredWindowAttributes. See `apply_window_opacity` for details.
|
||
#[tauri::command]
|
||
pub fn set_window_opacity<R: Runtime>(
|
||
app: AppHandle<R>,
|
||
label: String,
|
||
opacity: f32,
|
||
) -> Result<(), String> {
|
||
let Some(window) = app.get_webview_window(&label) else {
|
||
return Err(format!("window not found: {label}"));
|
||
};
|
||
apply_window_opacity(&window, opacity)
|
||
}
|
||
```
|
||
|
||
> **Window opacity implementation note:** Tauri 2.11.x's `WebviewWindow` doesn't expose `set_opacity` on either the JS or Rust side (long-running open issue tauri-apps/tauri#3279). To get true OS-level window translucency we drop down to the Win32 API directly. Pin `windows = "0.61"` in `Cargo.toml` to match Tauri's transitive `windows` version (otherwise `WebviewWindow::hwnd()` returns an HWND from a different `windows` major and you get a diamond-dependency type mismatch). The `apply_window_opacity` helper sets `WS_EX_LAYERED` if not already present, then calls `SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)`.
|
||
|
||
> **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.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/src/commands
|
||
git commit -m "feat(rust): commands module with click-through toggle helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Rust lib.rs — register plugins, shortcut, commands
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src-tauri/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Replace lib.rs**
|
||
|
||
Open `browserlay/src-tauri/src/lib.rs` and replace its contents with:
|
||
|
||
```rust
|
||
mod commands;
|
||
|
||
use std::sync::Mutex;
|
||
|
||
use serde::Serialize;
|
||
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, Default)]
|
||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||
pub enum HotkeyStatus {
|
||
#[default]
|
||
Pending,
|
||
Ok,
|
||
Failed { error: String },
|
||
}
|
||
|
||
#[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())
|
||
.setup(|app| {
|
||
#[cfg(desktop)]
|
||
{
|
||
app.handle()
|
||
.plugin(tauri_plugin_window_state::Builder::default().build())?;
|
||
|
||
let shortcut = Shortcut::new(
|
||
Some(Modifiers::CONTROL | Modifiers::ALT),
|
||
Code::Space,
|
||
);
|
||
let shortcut_for_handler = shortcut;
|
||
|
||
app.handle().plugin(
|
||
tauri_plugin_global_shortcut::Builder::new()
|
||
.with_handler(move |app, sc, event| {
|
||
if sc != &shortcut_for_handler {
|
||
return;
|
||
}
|
||
if event.state() != ShortcutState::Pressed {
|
||
return;
|
||
}
|
||
let app_handle = app.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
if let Err(err) =
|
||
commands::overlay::toggle_via_global_shortcut(&app_handle).await
|
||
{
|
||
eprintln!("global shortcut toggle failed: {err}");
|
||
}
|
||
});
|
||
})
|
||
.build(),
|
||
)?;
|
||
|
||
let registration_result = app.global_shortcut().register(shortcut);
|
||
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 state = app.state::<AppState>();
|
||
*state.hotkey_status.lock().expect("poisoned") = status;
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
commands::overlay::toggle_click_through,
|
||
commands::overlay::sync_click_through_cache,
|
||
commands::overlay::get_hotkey_status,
|
||
commands::overlay::set_window_opacity,
|
||
])
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running tauri application");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the crate compiles**
|
||
|
||
Run from `browserlay/src-tauri/`: `cargo check`
|
||
Expected: compiles cleanly.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src-tauri/src/lib.rs
|
||
git commit -m "feat(rust): register plugins, global shortcut, commands"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Sync click-through cache on every JS-driven change
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/src/hooks/useLiveOverlayWiring.ts`
|
||
|
||
`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: Update useLiveOverlayWiring to sync on every JS-driven change**
|
||
|
||
Open `browserlay/src/hooks/useLiveOverlayWiring.ts` and replace its contents with:
|
||
|
||
```ts
|
||
import { useEffect, useRef } from "react";
|
||
import { useOverlayStore } from "../lib/store";
|
||
import {
|
||
applyAlwaysOnTop,
|
||
applyClickThrough,
|
||
applyOpacity,
|
||
syncClickThroughCache,
|
||
} from "../lib/overlay";
|
||
|
||
/** 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]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Re-run tests + tsc**
|
||
|
||
Run: `npm test && npx tsc --noEmit`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add browserlay/src/hooks/useLiveOverlayWiring.ts
|
||
git commit -m "feat: keep rust click-through cache synced with UI toggle"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Manual verification of MVP
|
||
|
||
**Files:** none (manual testing)
|
||
|
||
- [ ] **Step 1: Run the app in dev mode**
|
||
|
||
From `browserlay/`: `npm run tauri dev`
|
||
Expected: control window opens with the new dark UI. No overlay window.
|
||
|
||
- [ ] **Step 2: URL validation checks**
|
||
|
||
In the URL field, type each of these and observe the result:
|
||
- `twitch.tv/foo` → no error, Open enabled.
|
||
- `not a url` → "That doesn't look like a URL" shown, Open disabled.
|
||
- `javascript:alert(1)` → "Only http and https URLs are allowed", Open disabled.
|
||
- `tauri://localhost` → same error.
|
||
- `https://example.com` → no error, Open enabled.
|
||
|
||
- [ ] **Step 3: Open overlay**
|
||
|
||
Type `https://example.com`, click "Open overlay". Expected: a frameless transparent window appears top-right of the primary monitor, ~800×600, showing example.com. The status bar pip turns active and the button changes to "Close overlay".
|
||
|
||
- [ ] **Step 4: Live opacity**
|
||
|
||
Drag the opacity slider from 100% down to ~30%. Expected: the overlay fades visibly in real time. Drag back up to 100% — restored.
|
||
|
||
- [ ] **Step 5: Always-on-top toggle**
|
||
|
||
Toggle AOT off. Click another window (e.g. an editor) so it covers where the overlay sits. Expected: the editor covers the overlay. Toggle AOT back on. Expected: the overlay raises above.
|
||
|
||
- [ ] **Step 6: Click-through off (interactive)**
|
||
|
||
Ensure click-through is off. Click on a link or button on the loaded page. Expected: it activates / scrolls / responds.
|
||
|
||
- [ ] **Step 7: Click-through on (pass-through)**
|
||
|
||
Toggle click-through on. Click where the overlay sits. Expected: the click goes through to the app underneath the overlay.
|
||
|
||
- [ ] **Step 8: Global hotkey escape**
|
||
|
||
With click-through still on, click on a different app entirely (so neither browserlay window has focus). Press `Ctrl+Alt+Space`. Expected: the overlay opacity blinks briefly; click-through is now off; the control panel toggle reflects the new state.
|
||
|
||
- [ ] **Step 9: Persistence — close and reopen**
|
||
|
||
Set URL to `https://example.com`, opacity to 50%, AOT off, click-through on. Close the app entirely. Reopen with `npm run tauri dev`. Expected: control panel shows the same URL, opacity, and toggle states.
|
||
|
||
- [ ] **Step 10: Window geometry persistence**
|
||
|
||
Open the overlay. Drag it to a different position and resize it. Close the overlay (via Close button). Reopen the overlay (same URL). Expected: it appears at the same position and size as before.
|
||
|
||
- [ ] **Step 11: Hotkey-conflict surfacing**
|
||
|
||
(Optional, only if you have a way to register `Ctrl+Alt+Space` from another app.) Run that other app first, then start browserlay. Expected: the StatusBar shows "⚠ Hotkey unavailable — click-through escape disabled".
|
||
|
||
- [ ] **Step 12: Closing the overlay via taskbar**
|
||
|
||
Open the overlay. Right-click its taskbar entry → Close window. Expected: control panel still functions; clicking Open again creates a fresh overlay.
|
||
|
||
- [ ] **Step 13: Production build**
|
||
|
||
Run from `browserlay/`: `npm run tauri build`
|
||
Expected: build completes; `src-tauri/target/release/bundle/msi/` (or `nsis/`) contains an installer. Run the installer; the installed app launches and behaves identically.
|
||
|
||
- [ ] **Step 14: Commit any fixes discovered during verification**
|
||
|
||
If steps 1–13 surfaced bugs, fix them with conventional-commit-style commits. If everything passes as-is, no commit needed for this step.
|
||
|
||
---
|
||
|
||
## Task 20: README + version tag
|
||
|
||
**Files:**
|
||
- Modify: `browserlay/README.md`
|
||
|
||
- [ ] **Step 1: Replace README.md**
|
||
|
||
Open `browserlay/README.md` and replace its contents with:
|
||
|
||
```markdown
|
||
# Browserlay
|
||
|
||
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.
|
||
- Real-time opacity slider (10%–100%).
|
||
- Always-on-top toggle.
|
||
- Click-through toggle — mouse events pass through to the app underneath.
|
||
- **`Ctrl+Alt+Space`** global hotkey to toggle click-through from anywhere (the escape hatch when you can't click the overlay).
|
||
- Last URL, opacity, and toggle states persist between sessions.
|
||
- Window position and size remembered between sessions.
|
||
|
||
## Dev setup
|
||
|
||
Prereqs: Node 18+, Rust toolchain (`rustup`), Tauri 2 prereqs for Windows ([WebView2 runtime ships with Windows 11](https://tauri.app/start/prerequisites/)).
|
||
|
||
```bash
|
||
cd browserlay
|
||
npm install
|
||
npm run tauri dev
|
||
```
|
||
|
||
Frontend tests:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
## Build
|
||
|
||
```bash
|
||
npm run tauri build
|
||
```
|
||
|
||
Outputs an MSI/NSIS installer under `src-tauri/target/release/bundle/`.
|
||
|
||
## Architecture
|
||
|
||
Two-window Tauri 2 app. Control window hosts a React+Tailwind+Zustand UI; overlay is a frameless transparent webview spawned at runtime pointing directly at the user-supplied URL. Click-through hotkey runs in Rust via `tauri-plugin-global-shortcut` so it works regardless of focus. Per-window capability files lock the overlay down — pages it loads can't call any Tauri APIs.
|
||
|
||
See [`docs/plans/2026-05-08-browserlay-mvp-design.md`](docs/plans/2026-05-08-browserlay-mvp-design.md) for the full design.
|
||
|
||
## Roadmap (Phase 2)
|
||
|
||
Presets · configurable hotkeys · system tray icon · edge-snap · multi-monitor placement · in-overlay toolbar.
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add browserlay/README.md
|
||
git commit -m "docs: README for Phase 1 MVP"
|
||
```
|
||
|
||
- [ ] **Step 3: Tag the release**
|
||
|
||
```bash
|
||
git tag -a v0.1.0 -m "Phase 1 MVP"
|
||
```
|
||
|
||
- [ ] **Step 4: Confirm with user before pushing**
|
||
|
||
Per the agreed workflow, do NOT push or push tags without explicit user approval. Surface this and wait:
|
||
|
||
> "All Task 19 verification checks passed. v0.1.0 tagged locally. OK to push to origin/main and push the tag?"
|
||
|
||
Once approved, run:
|
||
|
||
```bash
|
||
git push origin main
|
||
git push origin v0.1.0
|
||
```
|
||
|
||
---
|
||
|
||
## Done criteria for Phase 1
|
||
|
||
- All 14 verification steps in Task 19 pass on Windows 11.
|
||
- `npm run tauri build` produces a working installer.
|
||
- `v0.1.0` tag pushed to `origin/main` after user approval.
|
||
- README documents what the app does, dev setup, build commands, and the hotkey.
|
||
|
||
## Out of scope (Phase 2)
|
||
|
||
Tray icon, presets, configurable hotkeys, edge snap, multi-monitor placement UI, in-overlay toolbar, auto-update.
|