Compare commits

...

26 Commits

Author SHA1 Message Date
Michael Chihlas
934a3da32a docs: README for Phase 1 MVP
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:13:03 -04:00
Michael Chihlas
39f249fe5d feat: keep rust click-through cache synced with UI toggle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:35:47 -04:00
Michael Chihlas
7f31a90e05 docs(plan): record Win32-opacity decision
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>
2026-05-08 22:34:33 -04:00
Michael Chihlas
faaf9a90ba feat(rust): native Win32 window opacity via SetLayeredWindowAttributes
Tauri 2.11 doesn't expose set_opacity on its WebviewWindow (open issue
tauri-apps/tauri#3279). Drop down to the Win32 API directly: ensure
WS_EX_LAYERED is set on the overlay HWND, then SetLayeredWindowAttributes
with an 0..255 alpha. This is true OS-level window translucency — the
same call Tauri will eventually wrap upstream — not a CSS shim.

windows crate version is pinned to 0.61 to match Tauri 2.11.x's
transitive dependency, so WebviewWindow::hwnd() returns a compatible
HWND with no diamond-dependency conflict.

Replaces the earlier eval-based opacity workaround in apply_state and
set_window_opacity. Both call sites now go through apply_window_opacity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:34:05 -04:00
Michael Chihlas
ca4bd8da7a feat(rust): register plugins, global shortcut, commands 2026-05-08 19:25:14 -04:00
Michael Chihlas
c1085e073a feat(rust): commands module with click-through toggle helpers 2026-05-08 19:19:31 -04:00
Michael Chihlas
87843e3be4 docs(plan): drop core:window:allow-set-opacity
Not a real Tauri 2.11.x permission identifier. Discovered when cargo
check listed the actual valid permissions. Opacity is Rust-side only
and routes through our custom command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:14:07 -04:00
Michael Chihlas
4782c087b4 feat(security): per-window capabilities, overlay locked down
Removed `core:window:allow-set-opacity` from the default capability —
that permission identifier doesn't exist in Tauri 2.11.x. Window
opacity is exposed only on the Rust side; the JS layer routes through
our custom `set_window_opacity` command (added in Task 16).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:13:49 -04:00
Michael Chihlas
7e21a39c7f chore(rust): add store, window-state, global-shortcut, tokio
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:13:42 -04:00
Michael Chihlas
0bc76fc95e feat: wire App + main with persist + hooks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:59:44 -04:00
Michael Chihlas
4dc5604566 feat: control panel UI components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:55:49 -04:00
Michael Chihlas
8359e4df45 feat: live overlay wiring and backend click-through sync hooks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:53:18 -04:00
Michael Chihlas
fdeb6e8a19 feat: load + debounced persist via tauri-plugin-store
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:51:14 -04:00
Michael Chihlas
a91be65354 docs(plan): add set_window_opacity Rust command
Tauri 2 JS WebviewWindow does not expose setOpacity (only the Rust
side has set_opacity), so the JS layer routes through a custom
command. Surfaced during Task 9 implementation; updates Tasks 16 and
17 to register the command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:55:25 -04:00
Michael Chihlas
2269e443b8 feat: overlay window API boundary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:53:47 -04:00
Michael Chihlas
ce88841209 feat: zustand store for overlay state 2026-05-08 17:49:34 -04:00
Michael Chihlas
7bfc09207d feat: debounce helper with flush
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:47:25 -04:00
Michael Chihlas
45adba1643 feat: url normalization and http(s) allowlist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:44:44 -04:00
Michael Chihlas
1e4bd8082b feat: add overlay state types and IPC constants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:42:09 -04:00
Michael Chihlas
36dc73ac32 chore: remove scaffold App.css and react.svg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:41:17 -04:00
Michael Chihlas
2f0814ad19 feat: wire tailwind v4 with charcoal design tokens 2026-05-08 17:40:14 -04:00
Michael Chihlas
d1e010bb65 chore: add frontend deps (zustand, lucide-react, tauri plugins, tailwind v4) 2026-05-08 17:38:02 -04:00
Michael Chihlas
ed89979a08 chore: tighten tsconfig and set dark page base 2026-05-08 17:32:34 -04:00
Michael Chihlas
6e62673f7e 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>
2026-05-08 17:25:32 -04:00
Michael Chihlas
4a3d380b54 docs: add Phase 1 MVP implementation plan
Twenty bite-sized tasks covering project hygiene, Tailwind v4 setup,
Zustand store + tested URL/debounce helpers, the WebviewWindow API
boundary, persistence, Rust plugin registration with click-through
toggle, capability files (overlay locked down), manual verification
checklist, and README + tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:14:24 -04:00
Michael Chihlas
605b5dc860 docs: add Phase 1 MVP design
Captures the brainstormed design for the two-window overlay app:
architecture, frontend structure, Rust backend split, persistence
model, click-through escape hatch, and the manual verification
checklist that defines "Phase 1 done."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:05:31 -04:00
38 changed files with 12258 additions and 206 deletions

View File

@@ -1,7 +1,49 @@
# Tauri + React + Typescript
# Browserlay
This template should help get you started developing with Tauri, React and Typescript in Vite.
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.
## Recommended IDE Setup
## Features (Phase 1 / MVP)
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
- Frameless, transparent overlay window pinned over other apps.
- Real-time opacity slider (10%100%) backed by Win32 `SetLayeredWindowAttributes` — true OS-level translucency.
- 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 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 v4 + Zustand UI; overlay is a frameless transparent webview spawned at runtime pointing directly at the user-supplied URL. Per-window capability files lock the overlay down — pages it loads can't call any Tauri APIs.
The click-through escape hatch (`Ctrl+Alt+Space`) runs in Rust via `tauri-plugin-global-shortcut` so it works regardless of focus. Window opacity drops down to Win32 directly because Tauri 2.11.x doesn't expose `set_opacity` (open issue [tauri-apps/tauri#3279](https://github.com/tauri-apps/tauri/issues/3279)).
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.

View File

@@ -0,0 +1,261 @@
# Browserlay Phase 1 (MVP) — Design
Date: 2026-05-08
Status: Approved, ready for implementation planning
## Goal
Ship a Windows desktop app that creates a translucent, always-on-top browser overlay. The user types a URL, clicks Open, and gets a frameless transparent window pinned over their other apps. Real-time opacity, always-on-top, and click-through controls. A global hotkey (`Ctrl+Alt+Space`) is the recovery path out of click-through mode.
## Stack (already scaffolded)
- Tauri 2.x (Rust backend, single process)
- React 19 + TypeScript + Vite
- Tailwind CSS v4 (dark-mode only, charcoal palette via CSS variables)
- `lucide-react` for icons
- Bundle id `com.chihlas.browserlay`
## Architecture
Two top-level webview windows in a single Tauri process.
```
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ Control Window (`main`) │ │ Overlay Window (`overlay`) │
│ React app, Tailwind v4 │ │ Tauri WebviewWindow │
│ │ spawns │ • frameless │
│ • URL input │ ───────►│ • transparent │
│ • Opacity slider │ │ • alwaysOnTop=true │
│ • AOT toggle │ │ • loads <user URL> directly │
│ • Click-through toggle │ drives │ • no React shell on top │
│ • Open / Close button │ ◀──────►│ │
└─────────────────────────────┘ └──────────────────────────────┘
│ ▲
│ direct calls via WebviewWindow.getByLabel │
│ .setOpacity / .setAlwaysOnTop / │
│ .setIgnoreCursorEvents │
└────────────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Global shortcut (Ctrl+Alt+Space) │
│ Rust handler → toggles click-through│
│ + opacity pulse on overlay window │
└──────────────────────────────────────┘
```
**Key decisions** (made during brainstorming):
1. Overlay creation lives in JS via `WebviewWindow`. No Rust command for window creation.
2. State management: Zustand from day one (anticipating Phase 2 presets/hotkeys).
3. Persistence: debounced (~300ms) writes to `tauri-plugin-store` on every change.
4. Overlay loads the external URL directly. No in-overlay React shell or toolbar in Phase 1 — controls live entirely on the control panel + the global hotkey. (In-overlay toolbar deferred to Phase 2.)
5. Click-through hotkey is **toggle** (press to flip), with **opacity-pulse** feedback (~180ms dip and restore).
6. URL validation: forgiving (prepends `https://` if scheme missing) but only `http`/`https` allowed. `tauri://`, `file://`, `javascript:`, `data:` rejected.
7. Click-through hotkey handler lives in Rust, not JS — global shortcut runs regardless of focus and Rust is the reliable handler path.
## Frontend structure
```
src/
├── main.tsx # React entry, mounts <App />
├── App.tsx # Layout shell only
├── index.css # Tailwind v4 import + :root design tokens
├── components/
│ ├── ControlPanel.tsx # Top-level form layout
│ ├── UrlField.tsx # URL input + validation feedback
│ ├── OpacitySlider.tsx # Slider + numeric readout
│ ├── ToggleRow.tsx # Reusable toggle (label + switch + hint)
│ ├── OpenOverlayButton.tsx # Primary action (Open / Close)
│ └── StatusBar.tsx # Footer: hotkey hint, error states
├── hooks/
│ └── useClickThroughSync.ts# Subscribes to Rust 'click-through-toggled' event
├── lib/
│ ├── store.ts # Zustand store
│ ├── overlay.ts # Wrapper over WebviewWindow APIs
│ ├── url.ts # normalizeUrl + isAllowedUrl
│ └── persist.ts # tauri-plugin-store load/save with debounce
└── types/
└── overlay.ts # OverlayState, OverlayActions
```
### Zustand store
```ts
type OverlayState = {
url: string;
opacity: number; // 0.1 1.0
alwaysOnTop: boolean;
clickThrough: boolean;
isOpen: boolean; // overlay window currently exists
urlError: string | null;
};
type OverlayActions = {
setUrl: (raw: string) => void;
setOpacity: (v: number) => void;
setAlwaysOnTop: (v: boolean) => void;
setClickThrough: (v: boolean) => void;
_setClickThroughFromBackend: (v: boolean) => void; // silent setter, no apply
openOverlay: () => Promise<void>;
closeOverlay: () => Promise<void>;
};
```
### `lib/overlay.ts` (single Tauri-API boundary)
```
openOverlay(url, {opacity, alwaysOnTop, clickThrough}): Promise<void>
closeOverlay(): Promise<void>
applyOpacity(v): Promise<void>
applyAlwaysOnTop(v): Promise<void>
applyClickThrough(v): Promise<void>
pulseOpacity(target): Promise<void>
```
Each function looks up `WebviewWindow.getByLabel('overlay')`. If null, no-op — store still reflects state and reopening picks it up.
### Live wiring
Zustand subscribers (or `useEffect` per controllable property) watch the store and call the matching `applyX` function whenever values change. UI changes the store; store changes propagate.
### Validation
`setUrl(raw)` runs `normalizeUrl``isAllowedUrl`, sets `url` and `urlError` together. Open button disabled when `urlError` set or field empty.
## Rust backend
```
src-tauri/src/
├── main.rs # Unchanged — calls browserlay_lib::run()
├── lib.rs # Plugin registration + builder
└── commands/
├── mod.rs
└── overlay.rs # toggle_click_through + helpers
```
### Plugin registration (in `lib.rs`)
- `tauri-plugin-store`
- `tauri-plugin-window-state`
- `tauri-plugin-global-shortcut` (with Rust handler)
- `tauri-plugin-opener` (already there from scaffold; keep)
### Click-through toggle (`commands/overlay.rs`)
A private `do_toggle(app: &AppHandle)` function is the single implementation. Both the global-shortcut handler and the `toggle_click_through` Tauri command delegate to it.
`do_toggle` flow:
1. Look up overlay window by label. If absent → emit `click-through-no-overlay` event, return.
2. Read current `ignore_cursor_events` state.
3. Flip via `set_ignore_cursor_events(!current)`.
4. Read current opacity. Pulse: `set_opacity(0.4)``tokio::time::sleep(180ms)``set_opacity(prev)`.
5. Emit `click-through-toggled` event with `{ clickThrough: bool }` payload.
The control panel's `useClickThroughSync` listens to that event and calls `_setClickThroughFromBackend`, which updates the store *without* re-triggering the apply effect (avoiding ping-pong).
### Capabilities
`capabilities/default.json` — control window:
```json
{
"windows": ["main"],
"permissions": [
"core:default",
"core:webview:allow-create-webview-window",
"core:window:allow-set-opacity",
"core:window:allow-set-always-on-top",
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-close",
"store:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered"
]
}
```
`capabilities/overlay.json` — overlay window only, minimal/empty allowlist. The loaded external page must not be able to call Tauri APIs.
> Exact permission identifiers will be reconciled against current Tauri 2 docs at implementation time.
## Persistence
### `tauri-plugin-store` (user prefs)
```json
{
"url": "https://twitch.tv/somechannel",
"opacity": 0.65,
"alwaysOnTop": true,
"clickThrough": false
}
```
- On control-window mount: `lib/persist.ts` reads store and hydrates Zustand. Empty store → defaults.
- On any store change: debounced 300ms write back. `isOpen` and `urlError` not persisted.
- **Not** using Zustand persist middleware — using `tauri-plugin-store` directly to avoid hydration race.
### `tauri-plugin-window-state` (geometry)
Tracks position/size for both `main` and `overlay` automatically. `alwaysOnTop` would be saved by this plugin too, but our store is authoritative — on overlay create we explicitly call `setAlwaysOnTop(store.alwaysOnTop)` after geometry restore.
### First-run defaults
- `url: ""` (Open disabled until typed)
- `opacity: 1.0`
- `alwaysOnTop: true`
- `clickThrough: false`
### First-open overlay placement
Top-right of primary monitor, 800×600, 24px margin. After that, `tauri-plugin-window-state` remembers wherever the user dragged it.
## Click-through escape hatch
The safety-critical piece.
- Hotkey: `Ctrl+Alt+Space`. Hardcoded in Phase 1.
- Registered at app startup via `tauri-plugin-global-shortcut` in Rust.
- Handler delegates to `do_toggle` (above).
- If overlay isn't open: no-op + brief toast on control panel.
- If hotkey registration fails (conflict with another app): surface in StatusBar — "⚠ Hotkey unavailable — click-through escape disabled". **Silent failure is the worst outcome; we explicitly surface this.**
- Phase 2 will add configurable hotkeys.
## Testing & definition of done
No automated tests in Phase 1 — most logic is OS integration where mocks would test mocks. Manual verification:
1. Cold start: only control window opens.
2. URL validation: `twitch.tv/foo` → normalized; `not a url` → error + Open disabled; `tauri://`, `javascript:` rejected.
3. Overlay opens frameless, transparent, top-right of primary monitor.
4. Opacity slider 1.0 → 0.3 fades overlay in real time.
5. AOT off → other windows can cover overlay; AOT on → raised.
6. Click-through off: clicks on overlay scroll/click the loaded page.
7. Click-through on: clicks pass through to apps underneath.
8. With click-through on and our windows unfocused, `Ctrl+Alt+Space` flips it off; overlay opacity blinks; control toggle reflects new state.
9. Persistence: change URL/opacity/toggles → close → reopen → restored.
10. Geometry: drag overlay → close → reopen → position/size restored.
Plus safety-net checks:
- Hotkey conflict surfaces a warning rather than failing silently.
- Closing the overlay via taskbar X recovers correctly on next apply.
**Ship criteria**:
- All 10 checks pass on Windows 11.
- `npm run tauri build` produces a working installer.
- README updated: what the app does, dev setup, build, hotkey documented.
- `main` branch on origin (Gitea) tagged `v0.1.0`.
## Out of scope for Phase 1
Tray icon, presets, configurable hotkeys, edge snap, multi-monitor placement UI, in-overlay toolbar, auto-update. All deferred to Phase 2.
## Workflow notes
- Conventional Commits (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`).
- Frequent commits per meaningful chunk.
- Push to `origin/main` after user approves each chunk (will check before pushing the first few times — pushes are externally visible).

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/tauri.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
<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>

3191
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,20 +7,30 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-window-state": "^2.4.1",
"lucide-react": "^1.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@tauri-apps/cli": "^2",
"@types/node": "^25.6.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.3.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
"vitest": "^4.1.5"
}
}

5171
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,3 +23,18 @@ 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"
[target."cfg(windows)".dependencies]
# Match Tauri 2.11.x's transitive windows version so `WebviewWindow::hwnd()`
# returns a compatible HWND (no diamond-dependency conflict).
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
] }

View File

@@ -1,10 +1,19 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"description": "Capability for the main control window",
"windows": ["main"],
"permissions": [
"core:default",
"opener: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"
]
}

View File

@@ -0,0 +1,7 @@
{
"$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": []
}

View File

@@ -0,0 +1 @@
pub mod overlay;

View File

@@ -0,0 +1,161 @@
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
#[cfg(windows)]
use windows::Win32::Foundation::HWND;
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, SetLayeredWindowAttributes, SetWindowLongPtrW, GWL_EXSTYLE, LWA_ALPHA,
WS_EX_LAYERED,
};
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,
}
/// Native window opacity via SetLayeredWindowAttributes.
///
/// Tauri 2.11 doesn't expose set_opacity on its WebviewWindow (open issue
/// tauri-apps/tauri#3279). We drop down to Win32 directly: ensure the window
/// has the WS_EX_LAYERED extended style, then set its alpha. This is the
/// same call Tauri will eventually wrap when upstream support lands —
/// migrating later is a one-liner.
///
/// `opacity` is clamped to 0.0..=1.0 and translated to a 0..=255 alpha byte.
fn apply_window_opacity<R: Runtime>(window: &WebviewWindow<R>, opacity: f32) -> Result<(), String> {
let clamped = opacity.clamp(0.0, 1.0);
#[cfg(windows)]
{
let hwnd: HWND = window.hwnd().map_err(|e| e.to_string())?;
let alpha: u8 = (clamped * 255.0).round() as u8;
unsafe {
let current_ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
let layered_bit = WS_EX_LAYERED.0 as isize;
if (current_ex_style & layered_bit) == 0 {
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, current_ex_style | layered_bit);
}
SetLayeredWindowAttributes(hwnd, windows::Win32::Foundation::COLORREF(0), alpha, LWA_ALPHA)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(not(windows))]
{
let _ = (window, clamped);
Err("window opacity is only implemented on Windows".to_string())
}
}
/// 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 — visual pulse to signal the state change. The JS
// click-through-toggled handler restores opacity to the user's actual
// target after this event fires, so Rust deliberately does NOT set
// opacity back here.
let _ = apply_window_opacity(&overlay, 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's JS WebviewWindow does NOT expose setOpacity,
/// so the JS layer routes through this command. Internally we use Win32's
/// SetLayeredWindowAttributes — true OS-level window transparency, not a
/// CSS shim. 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)
}

View File

@@ -1,14 +1,95 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
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())
.invoke_handler(tauri::generate_handler![greet])
.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 &registration_result {
Ok(()) => HotkeyStatus::Ok,
Err(e) => {
eprintln!("Failed to register Ctrl+Alt+Space: {e}");
HotkeyStatus::Failed {
error: "Hotkey unavailable — click-through escape disabled".to_string(),
}
}
};
{
let 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");
}

View File

@@ -1,116 +0,0 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}

View File

@@ -1,49 +1,33 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core";
import "./App.css";
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() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
function App(): React.JSX.Element {
useLiveOverlayWiring();
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
const [transient, setTransient] = useState<string | null>(null);
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
setGreetMsg(await invoke("greet", { name }));
}
// 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="container">
<h1>Welcome to Tauri + React</h1>
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
<main className="flex h-screen flex-col bg-[var(--color-bg)]">
<ControlPanel />
<StatusBar transient={transient} />
</main>
);
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,40 @@
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(): React.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>
);
}

View File

@@ -0,0 +1,28 @@
import { useOverlayStore } from "../lib/store";
export function OpacitySlider(): React.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>
);
}

View File

@@ -0,0 +1,56 @@
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(): React.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>
);
}

View File

@@ -0,0 +1,33 @@
import { AlertTriangle, Keyboard } from "lucide-react";
import { useOverlayStore } from "../lib/store";
export type StatusBarProps = { transient: string | null };
export function StatusBar({ transient }: StatusBarProps): React.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>
);
}

View File

@@ -0,0 +1,47 @@
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): React.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>
);
}

View File

@@ -0,0 +1,37 @@
import { Globe } from "lucide-react";
import { useOverlayStore } from "../lib/store";
export function UrlField(): React.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>
);
}

View File

@@ -0,0 +1,48 @@
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]);
}

View File

@@ -0,0 +1,36 @@
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]);
}

32
src/index.css Normal file
View File

@@ -0,0 +1,32 @@
@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;
}

41
src/lib/debounce.test.ts Normal file
View File

@@ -0,0 +1,41 @@
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();
});
});

40
src/lib/debounce.ts Normal file
View File

@@ -0,0 +1,40 @@
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;
}

32
src/lib/hotkey.ts Normal file
View File

@@ -0,0 +1,32 @@
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;
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;
}
console.warn("hotkey status remained pending after retries");
}

159
src/lib/overlay.ts Normal file
View File

@@ -0,0 +1,159 @@
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 setWindowOpacity(OVERLAY_LABEL, 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 setWindowOpacity(OVERLAY_LABEL, 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();
}
/** Sets the opacity of a named window via Rust invoke.
* Tauri JS API v2 does not expose setOpacity on WebviewWindow; we route
* through a Rust command instead. */
async function setWindowOpacity(label: string, opacity: number): Promise<void> {
try {
await invoke("set_window_opacity", { label, opacity });
} catch (err) {
console.warn("Failed to set window opacity", err);
}
}
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);
}
}

92
src/lib/persist.ts Normal file
View File

@@ -0,0 +1,92 @@
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, { defaults: {}, 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);
}

60
src/lib/store.test.ts Normal file
View File

@@ -0,0 +1,60 @@
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);
});
});

46
src/lib/store.ts Normal file
View File

@@ -0,0 +1,46 @@
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,
}),
}));

62
src/lib/url.test.ts Normal file
View File

@@ -0,0 +1,62 @@
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",
});
});
});

33
src/lib/url.ts Normal file
View File

@@ -0,0 +1,33 @@
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() };
}

View File

@@ -1,9 +1,29 @@
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";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
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.
window.addEventListener("beforeunload", () => {
flushPendingPersistSync();
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
void bootstrap();

34
src/types/overlay.ts Normal file
View File

@@ -0,0 +1,34 @@
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";

View File

@@ -1,24 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@@ -1,32 +1,24 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
plugins: [react(), tailwindcss()],
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
? { protocol: "ws", host, port: 1421 }
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
watch: { ignored: ["**/src-tauri/**"] },
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
}));