Skip to content

Commit c789b18

Browse files
committed
fix: audit-driven bug fixes and improvements
- Fix config save race condition by serializing writes through a background channel - Fix click-through startup trap by auto-disabling locked mode on launch - Fix tray quit to persist window geometry before exiting - Fix navigation double-load by returning Ok(true) from navigate/navigate_home - Fix media control NaN bug and track most-recently-interacted media element - Fix bookmark limit enforcement (max 50) and fuzzy matching in Rust - Fix opacity startup failure by deferring set_window_opacity 300ms - Fix fragile error-page detection heuristic - Fix tray fallback icon panic - Add real clear_site_data command using WebView2 native API - Add CropConfig persistence and set_crop/clear_crop commands - Expand hotkey parser to support full alphabet, digits, F-keys, and punctuation - Update AGENTS.md to reflect actual codebase structure
1 parent b0de742 commit c789b18

5 files changed

Lines changed: 390 additions & 88 deletions

File tree

AGENTS.md

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Guide for AI agents working on the FloatView codebase.
44

55
## Project Overview
66

7-
FloatView is a Tauri v2 application that provides a floating browser window for streaming media on secondary monitors. Key features include always-on-top, borderless resizable window, opacity control, click-through mode, persistent bookmarks, navigation controls, smart URL bar with DuckDuckGo search, window title tracking, crash recovery (config backup + periodic geometry auto-save), and clear site data.
7+
FloatView is a Tauri v2 application that provides a floating browser window for streaming media on secondary monitors. Key features include always-on-top, borderless resizable window, opacity control, click-through mode, persistent bookmarks, navigation controls, smart URL bar with DuckDuckGo search, window title tracking, crash recovery (config backup + periodic geometry auto-save), crop/zoom region, and clear site data.
88

99
## Tech Stack
1010

@@ -26,7 +26,7 @@ npm install # Install dependencies
2626
npm run dev # Development mode with hot reload
2727
npm run build # Build production release
2828
cd src-tauri && cargo check # Type-check Rust only
29-
cd src-tauri && cargo test # Run unit tests (5 tests)
29+
cd src-tauri && cargo test # Run unit tests
3030
```
3131

3232
## Project Structure
@@ -39,7 +39,8 @@ floatview/
3939
│ ├── src/
4040
│ │ ├── main.rs # Main application logic
4141
│ │ ├── config.rs # Configuration types
42-
│ │ ├── click_through.rs # Win32 WS_EX_TRANSPARENT interop
42+
│ │ ├── opacity.rs # Cross-platform opacity interop
43+
│ │ ├── clear_site_data.rs # Browsing data clearing wrapper
4344
│ │ └── injection.js # Shadow DOM control strip (embedded)
4445
│ ├── capabilities/
4546
│ │ └── default.json # Tauri v2 permissions
@@ -96,24 +97,55 @@ The drag bar uses `-webkit-app-region: drag` for native WebView2 drag handling.
9697

9798
| Command | Purpose | File |
9899
|---------|---------|------|
99-
| `navigate_url` | Navigate to a URL | `main.rs:680` |
100-
| `navigate_home` | Navigate to home URL | `main.rs:751` |
100+
| `navigate` | Navigate to a URL (returns `true`) | `main.rs` |
101+
| `navigate_home` | Navigate to home URL (returns `true`) | `main.rs` |
101102
| `toggle_always_on_top` | Toggle pin state | `main.rs` |
102103
| `toggle_locked` | Toggle click-through | `main.rs` |
103104
| `set_opacity` | Set window opacity | `main.rs` |
105+
| `set_opacity_live` | Set opacity without persisting | `main.rs` |
104106
| `minimize_window` | Minimize window | `main.rs` |
105107
| `get_config` | Read current config | `main.rs` |
106108
| `update_config` | Update config fields | `main.rs` |
107-
| `set_home_url` | Set the home page URL | `main.rs` |
108-
| `clear_recent_urls` | Clear recent URL list | `main.rs` |
109-
| `clear_bookmarks` | Clear all bookmarks | `main.rs` |
110-
| `clear_site_data` | Clear cookies/storage then reload | `main.rs` |
111-
| `set_window_title` | Set window title (truncated to 256 chars) | `main.rs:702` |
112-
| `add_bookmark` | Add URL to bookmarks (dedup, max 50) | `main.rs:718` |
113-
| `remove_bookmark` | Remove URL from bookmarks | `main.rs:736` |
109+
| `set_url` | Set the last_url and recent list | `main.rs` |
110+
| `save_window_geometry` | Persist current geometry | `main.rs` |
111+
| `snap_window` | Snap window to corner/center | `main.rs` |
112+
| `open_settings` | Emit open-settings event | `main.rs` |
113+
| `exit_click_through` | Disable click-through mode | `main.rs` |
114+
| `close_window` | Close window | `main.rs` |
115+
| `maximize_toggle` | Maximize/unmaximize window | `main.rs` |
116+
| `get_version` | Get app version string | `main.rs` |
117+
| `check_for_updates` | Check for available updates | `main.rs` |
118+
| `set_window_title` | Set window title (truncated to 256 chars) | `main.rs` |
119+
| `add_bookmark` | Add URL to bookmarks (dedup, max 50) | `main.rs` |
120+
| `remove_bookmark` | Remove URL from bookmarks (fuzzy match) | `main.rs` |
121+
| `set_crop` | Persist crop region | `main.rs` |
122+
| `clear_crop` | Clear persisted crop region | `main.rs` |
123+
| `clear_site_data` | Clear all webview browsing data | `clear_site_data.rs` |
114124

115125
All commands require an auth token (`token` param) via `authorize_command()`.
116126

127+
### Config Serialization (Background Channel)
128+
129+
Config saves are performed by a dedicated background thread to avoid blocking the async runtime and to prevent races:
130+
131+
```rust
132+
// In run():
133+
let (save_tx, save_rx) = std::sync::mpsc::channel::<AppConfig>();
134+
std::thread::spawn(move || {
135+
while let Ok(cfg) = save_rx.recv() {
136+
do_save_config(&path, &cfg);
137+
}
138+
});
139+
140+
// Commands mutate the Mutex, then call:
141+
save_config(&state, &config); // sends clone to channel
142+
```
143+
144+
This ensures that:
145+
1. The `Mutex` is never held during disk I/O.
146+
2. All writes are serialized (no race between geometry thread and user actions).
147+
3. The existing config file is never deleted on a failed rename.
148+
117149
### Config Struct (`config.rs`)
118150

119151
```rust
@@ -125,16 +157,26 @@ pub struct AppConfig {
125157
pub home_url: String,
126158
pub first_run: bool,
127159
pub auto_refresh_minutes: u32,
128-
pub bookmarks: Vec<String>, // new in v1.1
160+
pub bookmarks: Vec<String>,
161+
pub crop: Option<CropConfig>, // x, y, width, height (0-1 normalized)
129162
}
130163
```
131164

165+
### Tray Menu Dynamic Updates
166+
167+
The tray's **Exit Click-Through Mode** item is dynamically enabled/disabled to match the actual locked state. A closure setter is stored in `AppState` because `MenuItem` is generic over `Runtime`:
168+
169+
```rust
170+
tray_exit_lock_setter: Mutex<Option<Box<dyn Fn(bool) + Send + Sync>>>
171+
```
172+
173+
It is disabled on startup (since locked mode is auto-cleared for safety) and updated whenever locked state changes.
174+
132175
### Adding a Tauri Command
133176

134177
1. Add function with `#[tauri::command]` attribute in `main.rs`
135178
2. Add to `invoke_handler!` macro
136-
3. Add required permissions in `capabilities/default.json`
137-
4. Call from JS via `invoke('command_name', { args })`
179+
3. Call from JS via `invoke('command_name', { args })`
138180

139181
All commands must accept a `token: String` parameter and call `authorize_command(&state, &token, "command_name")?`.
140182

@@ -152,14 +194,14 @@ Edit `src-tauri/src/injection.js`. The script:
152194
- Uses `MutationObserver` to survive page DOM changes
153195
- Re-initializes on every navigation (guarded by `window.__floatViewInitialized`)
154196

155-
Control strip layout (v1.1):
197+
Control strip layout:
156198
```
157-
┌─────────────────────────────────────────────────────┐
158-
← → ⟳ ★ [URL bar - DDG search fallback] 📌 🔒 ⚙ — ✕
159-
└─────────────────────────────────────────────────────┘
199+
┌──────────────────────────────────────────────────────────────────────
200+
[←] [→] [⟳] [Pin] [Recent] [Home] [URL bar] [★] [Lock] [Snap] [Crop] | [Opacity] [⚙] [−] [✕]
201+
└──────────────────────────────────────────────────────────────────────
160202
```
161203

162-
Buttons: back, forward, refresh, bookmark star (click toggle, right-click dropdown), always-on-top pin, lock, settings, minimize, close.
204+
Buttons: back, forward, refresh, bookmark star (click toggle, right-click dropdown), always-on-top pin, lock, snap, crop, settings, minimize, close.
163205

164206
Key injection.js features:
165207
- **URL bar**: DuckDuckGo search fallback for non-URL input
@@ -169,6 +211,8 @@ Key injection.js features:
169211
- **URL tracking**: `popstate` + 3s polling for address bar sync
170212
- **Config sync**: `config-changed` event listener updates bookmarks in real-time
171213
- **Dropdown mutual exclusion**: recent/bookmarks/snap dropdowns dismiss each other
214+
- **Crop/Zoom**: Select region, persist via `set_crop`, restore on init and resize
215+
- **Media hotkeys**: `window.__floatViewLastMedia` tracks the most recently interacted `<video>` or `<audio>` element
172216

173217
## Gotchas
174218

@@ -178,27 +222,31 @@ Key injection.js features:
178222

179223
3. **Mutex lifetime in helpers** -- When using `app.state::<AppState>()` in block expressions, use `.lock().unwrap()` not `if let Ok(...)` to avoid lifetime issues with the State temporary.
180224

181-
4. **Click-through mode is a trap** -- When locked, the control strip is hidden AND mouse events pass through. Users can only exit via global hotkey or tray menu. Always ensure these escape hatches work.
225+
4. **Click-through mode is a trap** -- When locked, the control strip is hidden AND mouse events pass through. Users can only exit via global hotkey or tray menu. Always ensure these escape hatches work. The app auto-disables locked mode on startup for safety.
182226

183227
5. **User Agent** -- Set to Edge UA string for Direct Play support with Emby/Plex. See `inject_script_on_document_created()`.
184228

185-
6. **Opacity on Windows** -- Uses `SetLayeredWindowAttributes` with `WS_EX_LAYERED`. The `transparent: true` Tauri config is required for this to work.
229+
6. **Opacity on Windows** -- Uses `SetLayeredWindowAttributes` with `WS_EX_LAYERED`. The `transparent: true` Tauri config is required for this to work. Opacity is applied with a 300ms startup delay to ensure the native HWND is ready.
186230

187231
7. **Single Instance** -- `tauri-plugin-single-instance` brings existing window to front if user launches again.
188232

189-
8. **Config backup** -- `save_config()` creates a `.bak` copy before writing. Used for crash recovery.
233+
8. **Config backup** -- `do_save_config()` creates a `.bak` copy before writing. Used for crash recovery. It does NOT delete the existing config if the atomic rename fails.
190234

191235
9. **Periodic geometry auto-save** -- Background thread saves window position/size every 30s (skips minimized/maximized). Prevents geometry loss on crash.
192236

193-
10. **Bookmark limits** -- Max 50 bookmarks, deduplication by normalized URL, sanitized via `sanitize_config()`.
237+
10. **Bookmark limits** -- Max 50 bookmarks, deduplication by normalized URL and fuzzy `urls_match`, sanitized via `sanitize_config()`.
194238

195239
11. **Title truncation** -- `set_window_title` truncates titles >256 chars to prevent Win32 issues.
196240

197241
12. **Logging** -- Uses `tracing` crate (`warn!`, `error!`) instead of `eprintln!` for structured logging.
198242

243+
13. **Config save channel** -- All config mutations are serialized through a background `std::sync::mpsc` channel to eliminate races and keep the async runtime responsive.
244+
245+
14. **Error-page detection** -- The injected script detects browser error pages using multiple heuristics (requires at least 2 indicators or a definitive error title) to avoid false positives on tech blogs.
246+
199247
## Testing
200248

201-
5 unit tests in `src-tauri/` (run via `cargo test`). Test manually:
249+
Run unit tests in `src-tauri/` (via `cargo test`). Test manually:
202250

203251
1. `npm run dev`
204252
2. Test URL navigation (landing page + control strip URL bar + DDG search fallback)
@@ -213,3 +261,7 @@ Key injection.js features:
213261
11. Test bookmark star (toggle on/off, right-click dropdown shows list)
214262
12. Test clear site data (Settings button, verify cookies/storage cleared)
215263
13. Test window title updates when navigating between pages
264+
14. Test crop/zoom (select region, verify persist/restore across restarts)
265+
15. Test tray quit preserves geometry
266+
16. Test media hotkeys target the most recently interacted player
267+
17. Test error-page redirect only fires on actual browser errors

src-tauri/src/clear_site_data.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
use tauri::{Runtime, WebviewWindow};
2+
3+
pub fn clear_all_browsing_data<R: Runtime>(window: &WebviewWindow<R>) -> Result<(), String> {
4+
window.clear_all_browsing_data().map_err(|e| e.to_string())
5+
}

src-tauri/src/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ impl Default for HotkeyConfig {
5454
}
5555
}
5656

57+
#[derive(Debug, Clone, Serialize, Deserialize)]
58+
pub struct CropConfig {
59+
pub x: f64,
60+
pub y: f64,
61+
pub width: f64,
62+
pub height: f64,
63+
}
64+
5765
#[derive(Debug, Clone, Serialize, Deserialize)]
5866
pub struct AppConfig {
5967
pub window: WindowConfig,
@@ -68,6 +76,8 @@ pub struct AppConfig {
6876
pub auto_refresh_minutes: u32,
6977
#[serde(default)]
7078
pub bookmarks: Vec<String>,
79+
#[serde(default)]
80+
pub crop: Option<CropConfig>,
7181
}
7282

7383
fn default_home_url() -> String {
@@ -89,6 +99,7 @@ impl Default for AppConfig {
8999
first_run: true,
90100
auto_refresh_minutes: 0,
91101
bookmarks: Vec::new(),
102+
crop: None,
92103
}
93104
}
94105
}

src-tauri/src/injection.js

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@
2626
let hideTimer = null;
2727
let config = null;
2828

29+
// Track most recently interacted media element for global hotkeys
30+
window.__floatViewLastMedia = null;
31+
(function trackMediaInteractions() {
32+
const updateLast = (e) => { window.__floatViewLastMedia = e.target; };
33+
const attach = (m) => {
34+
if (!m.dataset.floatviewTracked) {
35+
m.dataset.floatviewTracked = '1';
36+
m.addEventListener('play', updateLast, { passive: true });
37+
m.addEventListener('pause', updateLast, { passive: true });
38+
m.addEventListener('volumechange', updateLast, { passive: true });
39+
m.addEventListener('click', updateLast, { passive: true });
40+
}
41+
};
42+
document.querySelectorAll('video, audio').forEach(attach);
43+
const observer = new MutationObserver((mutations) => {
44+
mutations.forEach((m) => {
45+
m.addedNodes.forEach((node) => {
46+
if (node.nodeType === 1) {
47+
if (node.matches && (node.matches('video') || node.matches('audio'))) attach(node);
48+
if (node.querySelectorAll) node.querySelectorAll('video, audio').forEach(attach);
49+
}
50+
});
51+
});
52+
});
53+
if (document.body) observer.observe(document.body, { childList: true, subtree: true });
54+
})();
55+
2956
const container = document.createElement('div');
3057
container.id = 'floatview-root';
3158
container.style.cssText = 'position:fixed;top:0;left:0;right:0;height:0;z-index:2147483647;pointer-events:none;';
@@ -1285,6 +1312,9 @@
12851312
}
12861313
cropActive = true;
12871314
btnCrop.classList.add('active');
1315+
if (animate) {
1316+
invoke('set_crop', { x, y, width: w, height: h });
1317+
}
12881318
}
12891319

12901320
function removeCrop() {
@@ -1300,6 +1330,7 @@
13001330
}, 320);
13011331
cropActive = false;
13021332
btnCrop.classList.remove('active');
1333+
invoke('clear_crop');
13031334
}
13041335

13051336
window.addEventListener('resize', () => {
@@ -1928,7 +1959,7 @@
19281959
}
19291960
});
19301961

1931-
btnClearSiteData.addEventListener('click', () => {
1962+
btnClearSiteData.addEventListener('click', async () => {
19321963
try {
19331964
localStorage.clear();
19341965
sessionStorage.clear();
@@ -1937,6 +1968,7 @@
19371968
document.cookie = name + '=;expires=' + new Date().toUTCString() + ';path=/';
19381969
});
19391970
} catch {}
1971+
await invoke('clear_site_data');
19401972
window.location.reload();
19411973
});
19421974

@@ -2096,8 +2128,21 @@
20962128
function isErrorPage() {
20972129
try {
20982130
const text = (document.body?.innerText || '').substring(0, 2000);
2099-
return /ERR_(?:SSL_|CONNECTION_|NAME_NOT_RESOLVED|CERT_|TIMED_OUT|EMPTY_RESPONSE|FAILED|BLOCKED|TUNNEL_|NETWORK_|INTERNET_|ABORTED|ADDRESS_|INVALID)/.test(text)
2100-
|| /can[\u2019']t reach this page|this site can[\u2019']t be reached|no internet/i.test(text);
2131+
const title = document.title || '';
2132+
const patterns = [
2133+
/ERR_(?:SSL_|CONNECTION_|NAME_NOT_RESOLVED|CERT_|TIMED_OUT|EMPTY_RESPONSE|FAILED|BLOCKED|TUNNEL_|NETWORK_|INTERNET_|ABORTED|ADDRESS_|INVALID)/,
2134+
/can[\u2019']t reach this page/i,
2135+
/this site can[\u2019']t be reached/i,
2136+
/no internet/i,
2137+
/refused to connect/i,
2138+
/dns_probe_finished/i
2139+
];
2140+
let matches = 0;
2141+
for (const p of patterns) {
2142+
if (p.test(text) || p.test(title)) matches++;
2143+
}
2144+
// Require at least 2 matches, or a definitive error title plus 1 match
2145+
return matches >= 2 || (/^(This site can.t be reached|Can.t reach this page|Error|Http Error)/i.test(title) && matches >= 1);
21012146
} catch { return false; }
21022147
}
21032148

@@ -2153,6 +2198,10 @@
21532198
updateBookmarkIcon();
21542199
observeTitle();
21552200
updateWindowTitle();
2201+
2202+
if (config && config.crop) {
2203+
applyCrop(config.crop.x, config.crop.y, config.crop.width, config.crop.height);
2204+
}
21562205
}
21572206

21582207
// Global callback for Rust to update UI reliably via eval()

0 commit comments

Comments
 (0)