Compare commits
26 Commits
a8255b90d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
934a3da32a | ||
|
|
39f249fe5d | ||
|
|
7f31a90e05 | ||
|
|
faaf9a90ba | ||
|
|
ca4bd8da7a | ||
|
|
c1085e073a | ||
|
|
87843e3be4 | ||
|
|
4782c087b4 | ||
|
|
7e21a39c7f | ||
|
|
0bc76fc95e | ||
|
|
4dc5604566 | ||
|
|
8359e4df45 | ||
|
|
fdeb6e8a19 | ||
|
|
a91be65354 | ||
|
|
2269e443b8 | ||
|
|
ce88841209 | ||
|
|
7bfc09207d | ||
|
|
45adba1643 | ||
|
|
1e4bd8082b | ||
|
|
36dc73ac32 | ||
|
|
2f0814ad19 | ||
|
|
d1e010bb65 | ||
|
|
ed89979a08 | ||
|
|
6e62673f7e | ||
|
|
4a3d380b54 | ||
|
|
605b5dc860 |
50
README.md
50
README.md
@@ -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.
|
||||||
|
|||||||
261
docs/plans/2026-05-08-browserlay-mvp-design.md
Normal file
261
docs/plans/2026-05-08-browserlay-mvp-design.md
Normal 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).
|
||||||
2265
docs/superpowers/plans/2026-05-08-browserlay-mvp.md
Normal file
2265
docs/superpowers/plans/2026-05-08-browserlay-mvp.md
Normal file
File diff suppressed because it is too large
Load Diff
10
index.html
10
index.html
@@ -2,11 +2,15 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|||||||
3191
package-lock.json
generated
Normal file
3191
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -7,20 +7,30 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"@tauri-apps/api": "^2",
|
"zustand": "^5.0.13"
|
||||||
"@tauri-apps/plugin-opener": "^2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^25.6.2",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4",
|
"vite": "^7.0.4",
|
||||||
"@tauri-apps/cli": "^2"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5171
src-tauri/Cargo.lock
generated
Normal file
5171
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,3 +23,18 @@ tauri-plugin-opener = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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",
|
||||||
|
] }
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main control window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
7
src-tauri/capabilities/overlay.json
Normal file
7
src-tauri/capabilities/overlay.json
Normal 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": []
|
||||||
|
}
|
||||||
1
src-tauri/src/commands/mod.rs
Normal file
1
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod overlay;
|
||||||
161
src-tauri/src/commands/overlay.rs
Normal file
161
src-tauri/src/commands/overlay.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,14 +1,95 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
mod commands;
|
||||||
#[tauri::command]
|
|
||||||
fn greet(name: &str) -> String {
|
use std::sync::Mutex;
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
|
||||||
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.manage(AppState {
|
||||||
|
click_through: Mutex::new(false),
|
||||||
|
hotkey_status: Mutex::new(HotkeyStatus::Pending),
|
||||||
|
})
|
||||||
.plugin(tauri_plugin_opener::init())
|
.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 ®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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
116
src/App.css
116
src/App.css
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
src/App.tsx
68
src/App.tsx
@@ -1,49 +1,33 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import reactLogo from "./assets/react.svg";
|
import { ControlPanel } from "./components/ControlPanel";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { StatusBar } from "./components/StatusBar";
|
||||||
import "./App.css";
|
import { useLiveOverlayWiring } from "./hooks/useLiveOverlayWiring";
|
||||||
|
import { useClickThroughSync } from "./hooks/useClickThroughSync";
|
||||||
|
import { useOverlayStore } from "./lib/store";
|
||||||
|
import { isOverlayOpen } from "./lib/overlay";
|
||||||
|
|
||||||
function App() {
|
function App(): React.JSX.Element {
|
||||||
const [greetMsg, setGreetMsg] = useState("");
|
useLiveOverlayWiring();
|
||||||
const [name, setName] = useState("");
|
const setIsOpen = useOverlayStore((s) => s.setIsOpen);
|
||||||
|
const [transient, setTransient] = useState<string | null>(null);
|
||||||
|
|
||||||
async function greet() {
|
// Reconcile isOpen with reality on mount (e.g. user closed overlay via taskbar last session).
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
useEffect(() => {
|
||||||
setGreetMsg(await invoke("greet", { name }));
|
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 (
|
return (
|
||||||
<main className="container">
|
<main className="flex h-screen flex-col bg-[var(--color-bg)]">
|
||||||
<h1>Welcome to Tauri + React</h1>
|
<ControlPanel />
|
||||||
|
<StatusBar transient={transient} />
|
||||||
<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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
40
src/components/ControlPanel.tsx
Normal file
40
src/components/ControlPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/OpacitySlider.tsx
Normal file
28
src/components/OpacitySlider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/OpenOverlayButton.tsx
Normal file
56
src/components/OpenOverlayButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/StatusBar.tsx
Normal file
33
src/components/StatusBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/ToggleRow.tsx
Normal file
47
src/components/ToggleRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/UrlField.tsx
Normal file
37
src/components/UrlField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/hooks/useClickThroughSync.ts
Normal file
48
src/hooks/useClickThroughSync.ts
Normal 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]);
|
||||||
|
}
|
||||||
36
src/hooks/useLiveOverlayWiring.ts
Normal file
36
src/hooks/useLiveOverlayWiring.ts
Normal 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
32
src/index.css
Normal 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
41
src/lib/debounce.test.ts
Normal 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
40
src/lib/debounce.ts
Normal 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
32
src/lib/hotkey.ts
Normal 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
159
src/lib/overlay.ts
Normal 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
92
src/lib/persist.ts
Normal 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
60
src/lib/store.test.ts
Normal 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
46
src/lib/store.ts
Normal 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
62
src/lib/url.test.ts
Normal 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
33
src/lib/url.ts
Normal 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() };
|
||||||
|
}
|
||||||
22
src/main.tsx
22
src/main.tsx
@@ -1,9 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
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.
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
flushPendingPersistSync();
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
|
|||||||
34
src/types/overlay.ts
Normal file
34
src/types/overlay.ts
Normal 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";
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2022",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
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;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
|
||||||
//
|
|
||||||
// 1. prevent Vite from obscuring rust errors
|
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? { protocol: "ws", host, port: 1421 }
|
||||||
protocol: "ws",
|
|
||||||
host,
|
|
||||||
port: 1421,
|
|
||||||
}
|
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: { ignored: ["**/src-tauri/**"] },
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
|
||||||
ignored: ["**/src-tauri/**"],
|
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user