Compare commits
26 Commits
a8255b90d5
...
934a3da32a
| 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">
|
||||
<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
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",
|
||||
"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
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_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",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
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/
|
||||
#[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 ®istration_result {
|
||||
Ok(()) => HotkeyStatus::Ok,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to register Ctrl+Alt+Space: {e}");
|
||||
HotkeyStatus::Failed {
|
||||
error: "Hotkey unavailable — click-through escape disabled".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
*state.hotkey_status.lock().expect("poisoned") = status;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::overlay::toggle_click_through,
|
||||
commands::overlay::sync_click_through_cache,
|
||||
commands::overlay::get_hotkey_status,
|
||||
commands::overlay::set_window_opacity,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
30
src/main.tsx
30
src/main.tsx
@@ -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
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": {
|
||||
"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" }]
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user