Skip to content

Commit cbd24ff

Browse files
davidtorciviaclaude
andcommitted
release: v1.2.0
Modularize the Rust crate and apply a round of review-driven fixes. Rust crate split from one 1,899-line main.rs into 14 focused modules (main.rs: 1,899 -> 246 lines). New layout: state, config_io, urls, logging, injection, window_state, actions, hotkeys, tray, commands. Largest remaining file is commands.rs at ~460 lines. Fixes - Config save pipeline: proper shutdown with saver-thread join via RunEvent::Exit, no more race between quit and pending writes. - Startup safety: force-clear click-through on launch, write sync so it reaches disk before any crash can strand the user. - Tray quit persists geometry before exit; RunEvent::Exit flushes. - Media hotkeys target the most recently interacted <video>/<audio>. - Bookmark add: dedup checked before limit; fuzzy URL matching. - Crop region persists via new set_crop/clear_crop commands, clamped and NaN-guarded; f64::clamp panic on NaN max avoided. - Opacity startup reads fresh value at apply time (no stale capture). - Error-page detection: narrower title regex, avoids false-positives. - Tray exit_lock starts disabled, tracks locked state reliably. - Media tracker waits for document.body instead of silently no-oping. Improvements - Hotkey parser: 80-line match -> OnceLock<HashMap>. Full alphabet, digits, F1-F12, nav/arrow keys, punctuation. - Geometry auto-save: cooperative shutdown via AtomicBool (1s tick). - Dropped <R: Runtime> generics throughout -- app is Wry-only. - Tray exit_lock stores MenuItem<Wry> directly (no dyn Fn closure). - Selective warn!/error! logging in direct-action helpers so failed native calls are visible when debugging a stuck session. - Real clear_site_data command via WebView2 native API. Tests & hygiene - 17 tests (up from 5): URL matching/normalization, config sanitize including NaN/Inf crop, hotkey parsing, bookmark limits, geometry. - cargo clippy -D warnings clean. - injection.js gained a table of contents + 20 section banners. - AGENTS.md updated with the new module layout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c789b18 commit cbd24ff

18 files changed

Lines changed: 2210 additions & 1620 deletions

AGENTS.md

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,20 @@ floatview/
3737
│ └── index.html # Landing page (URL input)
3838
├── src-tauri/
3939
│ ├── src/
40-
│ │ ├── main.rs # Main application logic
41-
│ │ ├── config.rs # Configuration types
40+
│ │ ├── main.rs # Entry point, run(), RunEvent::Exit hook
41+
│ │ ├── state.rs # AppState, auth, tray item mutator
42+
│ │ ├── config.rs # serde config types
43+
│ │ ├── config_io.rs # load/save/sanitize/shutdown pipeline
44+
│ │ ├── urls.rs # normalize_url, urls_match
45+
│ │ ├── logging.rs # tracing subscriber setup
46+
│ │ ├── injection.rs # init-script builder + media JS + UA
47+
│ │ ├── window_state.rs # geometry clamp, persist, startup restore
48+
│ │ ├── actions.rs # do_* helpers (hotkeys / tray actions)
49+
│ │ ├── hotkeys.rs # parse_hotkey + register_hotkeys
50+
│ │ ├── tray.rs # setup_tray
51+
│ │ ├── commands.rs # all #[tauri::command] handlers
52+
│ │ ├── browsing_data.rs # WebView2 clear-all-data wrapper
4253
│ │ ├── opacity.rs # Cross-platform opacity interop
43-
│ │ ├── clear_site_data.rs # Browsing data clearing wrapper
4454
│ │ └── injection.js # Shadow DOM control strip (embedded)
4555
│ ├── capabilities/
4656
│ │ └── default.json # Tauri v2 permissions
@@ -97,30 +107,30 @@ The drag bar uses `-webkit-app-region: drag` for native WebView2 drag handling.
97107

98108
| Command | Purpose | File |
99109
|---------|---------|------|
100-
| `navigate` | Navigate to a URL (returns `true`) | `main.rs` |
101-
| `navigate_home` | Navigate to home URL (returns `true`) | `main.rs` |
102-
| `toggle_always_on_top` | Toggle pin state | `main.rs` |
103-
| `toggle_locked` | Toggle click-through | `main.rs` |
104-
| `set_opacity` | Set window opacity | `main.rs` |
105-
| `set_opacity_live` | Set opacity without persisting | `main.rs` |
106-
| `minimize_window` | Minimize window | `main.rs` |
107-
| `get_config` | Read current config | `main.rs` |
108-
| `update_config` | Update config fields | `main.rs` |
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` |
110+
| `navigate` | Navigate to a URL (returns `true`) | `commands.rs` |
111+
| `navigate_home` | Navigate to home URL (returns `true`) | `commands.rs` |
112+
| `toggle_always_on_top` | Toggle pin state | `commands.rs` |
113+
| `toggle_locked` | Toggle click-through | `commands.rs` |
114+
| `set_opacity` | Set window opacity | `commands.rs` |
115+
| `set_opacity_live` | Set opacity without persisting | `commands.rs` |
116+
| `minimize_window` | Minimize window | `commands.rs` |
117+
| `get_config` | Read current config | `commands.rs` |
118+
| `update_config` | Update config fields | `commands.rs` |
119+
| `set_url` | Set the last_url and recent list | `commands.rs` |
120+
| `save_window_geometry` | Persist current geometry | `commands.rs` |
121+
| `snap_window` | Snap window to corner/center | `commands.rs` |
122+
| `open_settings` | Emit open-settings event | `commands.rs` |
123+
| `exit_click_through` | Disable click-through mode | `commands.rs` |
124+
| `close_window` | Close window | `commands.rs` |
125+
| `maximize_toggle` | Maximize/unmaximize window | `commands.rs` |
126+
| `get_version` | Get app version string | `commands.rs` |
127+
| `check_for_updates` | Check for available updates | `commands.rs` |
128+
| `set_window_title` | Set window title (truncated to 256 chars) | `commands.rs` |
129+
| `add_bookmark` | Add URL to bookmarks (dedup, max 50) | `commands.rs` |
130+
| `remove_bookmark` | Remove URL from bookmarks (fuzzy match) | `commands.rs` |
131+
| `set_crop` | Persist crop region | `commands.rs` |
132+
| `clear_crop` | Clear persisted crop region | `commands.rs` |
133+
| `clear_site_data` | Clear all webview browsing data | `commands.rs` (→ `browsing_data.rs`) |
124134

125135
All commands require an auth token (`token` param) via `authorize_command()`.
126136

@@ -174,16 +184,16 @@ It is disabled on startup (since locked mode is auto-cleared for safety) and upd
174184

175185
### Adding a Tauri Command
176186

177-
1. Add function with `#[tauri::command]` attribute in `main.rs`
178-
2. Add to `invoke_handler!` macro
187+
1. Add function with `#[tauri::command]` attribute in `commands.rs` (declared `pub`)
188+
2. Add `commands::<name>` to `generate_handler![ ... ]` in `main.rs`
179189
3. Call from JS via `invoke('command_name', { args })`
180190

181-
All commands must accept a `token: String` parameter and call `authorize_command(&state, &token, "command_name")?`.
191+
All commands must accept a `token: String` parameter and call `authorize_command(&state, &token, "command_name")?` from the `state` module.
182192

183193
### Adding a Global Hotkey
184194

185195
1. Add to `HotkeyConfig` in `config.rs`
186-
2. Add key code to `parse_hotkey()` in `main.rs`
196+
2. Add key code to the `hotkey_code_map()` table in `hotkeys.rs`
187197
3. Register in `register_hotkeys()` -- call a `do_*` helper directly, don't emit events
188198
4. Update `__floatViewUpdate()` in `injection.js` if UI sync needed
189199

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "floatview",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "A minimal floating browser window for streaming media on a secondary monitor",
55
"scripts": {
66
"tauri": "tauri",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "floatview"
3-
version = "1.1.0"
3+
version = "1.2.0"
44
description = "A minimal floating browser window for streaming media"
55
authors = ["David Torcivia"]
66
license = "MIT"

src-tauri/src/actions.rs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
//! Direct-action helpers invoked from the tray menu and global hotkeys.
2+
//!
3+
//! These bypass the JS command bus — they mutate state in Rust, call the
4+
//! native window API directly, and emit events to the webview so the
5+
//! control strip stays in sync. They match the behavior of their
6+
//! `#[tauri::command]` siblings but are callable without a token.
7+
8+
use tauri::{AppHandle, Emitter, Manager};
9+
use tracing::{error, info, warn};
10+
use tauri_plugin_updater::UpdaterExt;
11+
12+
use crate::config_io::save_config;
13+
use crate::opacity;
14+
use crate::state::{update_tray_exit_lock_enabled, AppState};
15+
use crate::urls::{normalize_url, DEFAULT_HOME_URL};
16+
17+
pub fn do_navigate_home(app: &AppHandle) {
18+
if let Some(window) = app.get_webview_window("main") {
19+
let state = app.state::<AppState>();
20+
let home_url = if let Ok(mut config) = state.config.lock() {
21+
config.last_url = None;
22+
save_config(&state, &config);
23+
normalize_url(&config.home_url).unwrap_or_else(|_| DEFAULT_HOME_URL.to_string())
24+
} else {
25+
DEFAULT_HOME_URL.to_string()
26+
};
27+
let _ = window.eval("window.stop()");
28+
let _ = window.eval(format!("window.location.href = {:?}", home_url));
29+
}
30+
}
31+
32+
pub fn do_toggle_always_on_top(app: &AppHandle) {
33+
let Some(window) = app.get_webview_window("main") else {
34+
warn!("do_toggle_always_on_top: main window not found");
35+
return;
36+
};
37+
let current = window.is_always_on_top().unwrap_or(false);
38+
let new_value = !current;
39+
if let Err(e) = window.set_always_on_top(new_value) {
40+
warn!(error = %e, new_value, "set_always_on_top failed");
41+
}
42+
43+
let state = app.state::<AppState>();
44+
if let Ok(mut config) = state.config.lock() {
45+
config.window.always_on_top = new_value;
46+
save_config(&state, &config);
47+
}
48+
49+
if let Err(e) = app.emit("always-on-top-changed", new_value) {
50+
warn!(error = %e, "failed to emit always-on-top-changed");
51+
}
52+
let _ = window.eval(format!(
53+
"if(window.__floatViewUpdate) window.__floatViewUpdate('always_on_top', {})",
54+
new_value
55+
));
56+
}
57+
58+
pub fn do_toggle_locked(app: &AppHandle) {
59+
let Some(window) = app.get_webview_window("main") else {
60+
warn!("do_toggle_locked: main window not found");
61+
return;
62+
};
63+
let new_value = {
64+
let state = app.state::<AppState>();
65+
let mut config = match state.config.lock() {
66+
Ok(config) => config,
67+
Err(e) => {
68+
error!("Failed to lock config in do_toggle_locked: {}", e);
69+
return;
70+
}
71+
};
72+
let nv = !config.window.locked;
73+
config.window.locked = nv;
74+
save_config(&state, &config);
75+
nv
76+
};
77+
78+
if let Err(e) = window.set_ignore_cursor_events(new_value) {
79+
// If this fails while enabling lock, the user's click-through request
80+
// silently no-ops, which is confusing but not dangerous. If it fails
81+
// while disabling lock, the user is trapped behind an invisible window
82+
// — that's a significant UX failure worth a loud log line.
83+
error!(error = %e, new_value, "set_ignore_cursor_events failed");
84+
}
85+
86+
update_tray_exit_lock_enabled(app, new_value);
87+
88+
if let Err(e) = app.emit("locked-changed", new_value) {
89+
warn!(error = %e, "failed to emit locked-changed");
90+
}
91+
let _ = window.eval(format!(
92+
"if(window.__floatViewUpdate) window.__floatViewUpdate('locked', {})",
93+
new_value
94+
));
95+
}
96+
97+
pub fn do_exit_click_through(app: &AppHandle) {
98+
let is_locked = {
99+
let state = app.state::<AppState>();
100+
let config = match state.config.lock() {
101+
Ok(config) => config,
102+
Err(e) => {
103+
error!("Failed to lock config in do_exit_click_through: {}", e);
104+
return;
105+
}
106+
};
107+
config.window.locked
108+
};
109+
if !is_locked {
110+
return;
111+
}
112+
113+
let Some(window) = app.get_webview_window("main") else {
114+
warn!("do_exit_click_through: main window not found");
115+
return;
116+
};
117+
{
118+
let state = app.state::<AppState>();
119+
let mut config = match state.config.lock() {
120+
Ok(config) => config,
121+
Err(e) => {
122+
error!("Failed to lock config in do_exit_click_through: {}", e);
123+
return;
124+
}
125+
};
126+
config.window.locked = false;
127+
save_config(&state, &config);
128+
}
129+
130+
// Safety-critical: this releases the user from click-through mode. If it
131+
// fails, the invisible window still eats all cursor input. Log at error
132+
// level so it's impossible to miss when debugging a stuck session.
133+
if let Err(e) = window.set_ignore_cursor_events(false) {
134+
error!(error = %e, "failed to disable click-through from exit hotkey");
135+
}
136+
update_tray_exit_lock_enabled(app, false);
137+
138+
if let Err(e) = app.emit("locked-changed", false) {
139+
warn!(error = %e, "failed to emit locked-changed");
140+
}
141+
let _ = window.eval("if(window.__floatViewUpdate) window.__floatViewUpdate('locked', false)");
142+
}
143+
144+
pub fn do_opacity_change(app: &AppHandle, delta: f64) {
145+
if let Some(window) = app.get_webview_window("main") {
146+
let new_opacity = {
147+
let state = app.state::<AppState>();
148+
let mut config = match state.config.lock() {
149+
Ok(config) => config,
150+
Err(e) => {
151+
error!("Failed to lock config in do_opacity_change: {}", e);
152+
return;
153+
}
154+
};
155+
let op = (config.window.opacity + delta).clamp(0.1, 1.0);
156+
config.window.opacity = op;
157+
save_config(&state, &config);
158+
op
159+
};
160+
161+
opacity::set_window_opacity(&window, new_opacity);
162+
163+
let _ = app.emit("opacity-changed", new_opacity);
164+
let _ = window.eval(format!(
165+
"if(window.__floatViewUpdate) window.__floatViewUpdate('opacity', {})",
166+
new_opacity
167+
));
168+
}
169+
}
170+
171+
pub fn do_media_action(app: &AppHandle, script: &'static str) {
172+
if let Some(window) = app.get_webview_window("main") {
173+
let _ = window.eval(script);
174+
}
175+
}
176+
177+
pub fn do_install_update(app: &AppHandle) {
178+
let app_handle = app.clone();
179+
tauri::async_runtime::spawn(async move {
180+
match install_update(app_handle.clone()).await {
181+
Ok(true) => {
182+
let _ = app_handle.emit(
183+
"update-install-status",
184+
"Installing update and restarting...",
185+
);
186+
app_handle.restart();
187+
}
188+
Ok(false) => {
189+
let _ = app_handle.emit("update-install-status", "No update available to install");
190+
info!("Install update requested but no update was available");
191+
}
192+
Err(e) => {
193+
let _ = app_handle.emit("update-install-status", format!("Install failed: {}", e));
194+
error!("Install update failed: {}", e);
195+
}
196+
}
197+
});
198+
}
199+
200+
pub async fn install_update(app: AppHandle) -> Result<bool, String> {
201+
let updater = app.updater().map_err(|e| e.to_string())?;
202+
if let Some(update) = updater.check().await.map_err(|e| e.to_string())? {
203+
info!(version = %update.version, "Installing update from native action");
204+
update
205+
.download_and_install(|_, _| {}, || {})
206+
.await
207+
.map_err(|e| e.to_string())?;
208+
return Ok(true);
209+
}
210+
Ok(false)
211+
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use tauri::{Runtime, WebviewWindow};
22

3-
pub fn clear_all_browsing_data<R: Runtime>(window: &WebviewWindow<R>) -> Result<(), String> {
3+
pub fn clear_all<R: Runtime>(window: &WebviewWindow<R>) -> Result<(), String> {
44
window.clear_all_browsing_data().map_err(|e| e.to_string())
55
}

0 commit comments

Comments
 (0)