Guide for AI agents working on the FloatView codebase.
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.
- Runtime: Tauri v2 (Rust backend)
- Rendering: WebView2 (system webview on Windows)
- Frontend: Vanilla HTML/JS (minimal, injected Shadow DOM)
- Plugins: single-instance, global-shortcut, shell, tray-icon, log
- Rust (install via rustup)
- Node.js 18+
- WebView2 Runtime (auto-installed via bootstrapper)
npm install # Install dependencies
npm run dev # Development mode with hot reload
npm run build # Build production release
cd src-tauri && cargo check # Type-check Rust only
cd src-tauri && cargo test # Run unit testsfloatview/
├── src/
│ ├── index.html # Landing page (URL input)
│ └── main.js # Landing page logic (external so the CSP can stay strict)
├── src-tauri/
│ ├── src/
│ │ ├── main.rs # Binary shim; just calls `floatview::run()`
│ │ ├── lib.rs # run(), RunEvent::Exit hook, lifecycle integration tests
│ │ ├── state.rs # AppState, auth, tray setter
│ │ ├── config.rs # serde config types, clamp_opacity
│ │ ├── config_io.rs # load/save/sanitize/shutdown pipeline
│ │ ├── urls.rs # normalize_url, urls_match
│ │ ├── logging.rs # tracing subscriber setup
│ │ ├── injection.rs # init-script builder + js_navigate + media JS + UA
│ │ ├── window_state.rs # geometry clamp, persist, startup restore
│ │ ├── ops.rs # strict toggle/opacity/navigate core
│ │ ├── actions.rs # do_* best-effort wrappers over ops
│ │ ├── hotkeys.rs # parse_hotkey + register_hotkeys
│ │ ├── tray.rs # setup_tray
│ │ ├── commands.rs # all #[tauri::command] handlers
│ │ ├── browsing_data.rs # WebView2 clear-all-data wrapper
│ │ ├── opacity.rs # Cross-platform opacity interop
│ │ └── injection.js # Shadow DOM control strip (embedded)
│ ├── capabilities/
│ │ └── default.json # Tauri v2 permissions
│ ├── icons/ # App icons (PNG, ICO)
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Tauri configuration
│ └── build.rs # Tauri build script
├── .github/workflows/
│ └── build.yml # CI: build + release installers
├── package.json # npm scripts
├── README.md # User documentation
└── designdoc.md # Technical design document
The application uses one WebviewWindow that navigates to user URLs. The control strip (URL bar, buttons) is injected into every page via WebView2's AddScriptToExecuteOnDocumentCreated, wrapped in a closed Shadow DOM for style isolation.
Critical pattern: The injected script runs inside external web pages where window.__TAURI__.window.getCurrentWindow() methods are unreliable. Always use invoke() to call Rust commands instead of calling JS-side Tauri window APIs directly.
// GOOD - reliable on all pages
await invoke('minimize_window');
await invoke('toggle_always_on_top');
// BAD - fails on external pages
window.__TAURI__.window.getCurrentWindow().minimize();Rust actions (hotkeys, tray menu) execute directly in Rust, then sync JS via two mechanisms:
- Events:
app.emit("event-name", payload)caught bywindow.__TAURI__.event.listen()in JS - Eval:
window.eval("__floatViewUpdate('key', value)")as reliable fallback
Important: Use window.__TAURI__.event.listen() (global), NOT getCurrentWindow().listen() (targeted only). Rust's app.emit() and window.emit() are global broadcasts.
Hotkey and tray menu actions are executed directly in Rust via helper functions (do_toggle_always_on_top, do_toggle_locked, do_opacity_change). This eliminates the fragile Rust->JS->Rust round-trip.
The drag bar uses -webkit-app-region: drag for native WebView2 drag handling. Do NOT rely on startDragging() JS calls -- they fail due to async IPC timing.
| Command | Purpose | File |
|---|---|---|
navigate |
Navigate to a URL (returns true) |
commands.rs |
navigate_home |
Navigate to home URL (returns true) |
commands.rs |
toggle_always_on_top |
Toggle pin state | commands.rs |
toggle_locked |
Toggle click-through | commands.rs |
set_opacity |
Set window opacity | commands.rs |
set_opacity_live |
Set opacity without persisting | commands.rs |
minimize_window |
Minimize window | commands.rs |
get_config |
Read current config | commands.rs |
update_config |
Update config fields | commands.rs |
set_url |
Set the last_url and recent list | commands.rs |
save_window_geometry |
Persist current geometry | commands.rs |
snap_window |
Snap window to corner/center, half, or third (resizes for halves/thirds) | commands.rs |
set_aspect_ratio |
Resize window to a "W:H" aspect ratio around its current center |
commands.rs |
pause_global_hotkeys |
Drop all global shortcut registrations (used during hotkey rebind) | commands.rs |
resume_global_hotkeys |
Re-register global shortcuts from current config | commands.rs |
open_settings |
Emit open-settings event | commands.rs |
close_window |
Close window | commands.rs |
maximize_toggle |
Maximize/unmaximize window | commands.rs |
get_version |
Get app version string | commands.rs |
check_for_updates |
Check for available updates | commands.rs |
install_update |
Download + install latest update, then restart | commands.rs |
set_window_title |
Set window title (truncated to 256 chars) | commands.rs |
add_bookmark |
Add URL to bookmarks (dedup, max 50) | commands.rs |
remove_bookmark |
Remove URL from bookmarks (fuzzy match) | commands.rs |
set_crop |
Persist crop region | commands.rs |
clear_crop |
Clear persisted crop region | commands.rs |
clear_site_data |
Clear all webview browsing data | commands.rs (→ browsing_data.rs) |
All commands require an auth token (token param) via authorize_command().
Config saves are performed by a dedicated background thread to avoid blocking the async runtime and to prevent races:
// In run():
let (save_tx, save_rx) = std::sync::mpsc::channel::<AppConfig>();
std::thread::spawn(move || {
while let Ok(cfg) = save_rx.recv() {
do_save_config(&path, &cfg);
}
});
// Commands mutate the Mutex, then call:
save_config(&state, &config); // sends clone to channelThis ensures that:
- The
Mutexis never held during disk I/O. - All writes are serialized (no race between geometry thread and user actions).
- The existing config file is never deleted on a failed rename.
pub struct AppConfig {
pub config_version: u32, // schema version; bump on rename/restructure, NOT additions
pub window: WindowConfig, // x, y, width, height, monitor, always_on_top, opacity, locked
pub last_url: Option<String>,
pub recent_urls: Option<Vec<String>>,
pub hotkeys: HotkeyConfig,
pub home_url: String,
pub first_run: bool,
pub auto_refresh_minutes: u32,
pub bookmarks: Vec<String>,
pub crop: Option<CropConfig>, // x, y, width, height (0-1 normalized)
}New fields only need #[serde(default)]. If you rename, retype, or restructure a field, bump CONFIG_VERSION in config.rs and branch on the stored config_version during load so old configs migrate instead of silently resetting.
The tray uses CheckMenuItem for Always on Top and Click-Through Mode, with check marks that mirror the current state, and a conditional Install Update vX.Y.Z item that's disabled until an update is available.
State changes reach the tray through a bundle of closures stored on AppState::tray, so the rest of the codebase never imports muda/CheckMenuItem types:
pub struct TraySetters {
pub set_always_on_top: TrayBoolSetter, // Box<dyn Fn(bool) + Send + Sync>
pub set_locked: TrayBoolSetter,
pub set_update_available: TrayUpdateSetter, // Box<dyn Fn(Option<&str>) + Send + Sync>
}state::update_tray_* helpers are the public façade; ops::* calls them after mutating config. Populated by tray::setup_tray; None in tests or during early startup, in which case updates silently no-op.
The click-through check item doubles as the escape hatch — a trapped user right-clicks the tray, unchecks the box, and is freed. No separate "Exit Click-Through Mode" item is needed.
- Add function with
#[tauri::command]attribute incommands.rs(declaredpub) - Add
commands::<name>togenerate_handler![ ... ]inlib.rs - Call from JS via
invoke('command_name', { args })
All commands must accept a token: String parameter and call authorize_command(&state, &token, "command_name")? from the state module.
- Add to
HotkeyConfiginconfig.rs - Add key code to the
hotkey_code_map()table inhotkeys.rs - Register in
register_hotkeys()-- call ado_*helper directly, don't emit events - Update
__floatViewUpdate()ininjection.jsif UI sync needed
Edit src-tauri/src/injection.js. The script:
- Creates a closed Shadow DOM on
<div id="floatview-root"> - Uses
MutationObserverto survive page DOM changes - Re-initializes on every navigation (guarded by
window.__floatViewInitialized)
Control strip layout:
┌──────────────────────────────────────────────────────────────────────┐
│ [←] [→] [⟳] [Pin] [Recent] [Home] [URL bar] [★] [Lock] [Snap] [Crop] | [Opacity] [⚙] [−] [✕] │
└──────────────────────────────────────────────────────────────────────┘
Buttons: back, forward, refresh, bookmark star (click toggle, right-click dropdown), always-on-top pin, lock, snap, crop, settings, minimize, close.
Key injection.js features:
- URL bar: DuckDuckGo search fallback for non-URL input
- Bookmarks: Star toggle + right-click dropdown, fuzzy URL matching via
urlsMatch() - Navigation:
history.back(),history.forward(),location.reload() - Title tracking: MutationObserver + 2s polling →
set_window_title - URL tracking:
popstate+ 3s polling for address bar sync - Config sync:
config-changedevent listener updates bookmarks in real-time - Dropdown mutual exclusion: recent/bookmarks/snap dropdowns dismiss each other
- Crop/Zoom: Select region, persist via
set_crop, restore on init and resize - Media hotkeys:
window.__floatViewLastMediatracks the most recently interacted<video>or<audio>element
-
Invoke, don't call window methods --
getCurrentWindow().method()is unreliable from injected scripts. Always create a Rust command and useinvoke(). -
Shadow DOM event boundaries --
e.relatedTargetin mouseleave can be null or retargeted. Check for null before comparing. -
Mutex lifetime in helpers -- When using
app.state::<AppState>()in block expressions, use.lock().unwrap()notif let Ok(...)to avoid lifetime issues with the State temporary. -
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 the tray's Click-Through check item (right-click tray → uncheck). The app auto-disables locked mode on startup for safety.
-
User Agent -- Set to Edge UA string for Direct Play support with Emby/Plex. See
inject_script_on_document_created(). -
Opacity on Windows -- Uses
SetLayeredWindowAttributeswithWS_EX_LAYERED. Thetransparent: trueTauri config is required for this to work. Opacity is applied with a 300ms startup delay to ensure the native HWND is ready. -
Single Instance --
tauri-plugin-single-instancebrings existing window to front if user launches again. -
Config backup --
do_save_config()creates a.bakcopy before writing. Used for crash recovery. It does NOT delete the existing config if the atomic rename fails. -
Periodic geometry auto-save -- Background thread saves window position/size every 30s (skips minimized/maximized). Prevents geometry loss on crash.
-
Bookmark limits -- Max 50 bookmarks, deduplication by normalized URL and fuzzy
urls_match, sanitized viasanitize_config(). -
Title truncation --
set_window_titletruncates titles >256 chars to prevent Win32 issues. -
Logging -- Uses
tracingcrate (warn!,error!) instead ofeprintln!for structured logging. -
Config save channel -- All config mutations are serialized through a background
std::sync::mpscchannel to eliminate races and keep the async runtime responsive. -
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.
-
Navigation via eval -- When Rust navigates the webview by URL, use
crate::injection::js_navigate(&url)(whichserde_json-encodes the URL as a string literal), not rawformat!("... = {:?}", url). The Debug format is close-but-not-guaranteed to match JS string syntax;js_navigateis the audited path. -
Placeholder substitution in
build_injection_script-- The home-URL placeholder ininjection.jsis substituted as a complete JSON string literal viaserde_json; the placeholder itself is"__FLOATVIEW_HOME_URL__"(wrapped in quotes) so the replacement swaps the whole literal. Don't wrap the placeholder in extra quotes on the JS side. -
Opacity clamping -- Always go through
config::clamp_opacity; it snaps near-opaque to 1.0 (lets the Windows backend dropWS_EX_LAYERED) and rejects non-finite inputs that would otherwise poison the saved config.
17b. Opacity dim backdrop -- Below full opacity, page content is faded with CSS (--fv-content-opacity) while the window alpha floors at WINDOW_ALPHA_FLOOR. While faded, applyContentOpacity puts an fv-dimmed class on <html> that forces a black html/body backdrop: CSS opacity blends content toward the backdrop, and under the layered window's uniform alpha a black backdrop reads as "desktop showing through" whereas the default white backdrop washes the page out to milky white. Don't remove the class toggle when touching the opacity path.
17c. Unit-returning commands look like failures in JS -- The JS invoke() wrapper returns null on IPC failure, and Tauri serializes Result<(), _> success as null too. Commands whose callers need to distinguish success (bookmarks, pause/resume hotkeys) return Ok(true) instead of Ok(()). Follow that pattern for new commands when the JS side gates state changes on success.
-
Title truncation --
set_window_titlecallstruncate_title, which strips control characters (page-supplied titles can embed newlines/NUL to garble or spoof the title bar) and respects UTF-8 char boundaries. Do NOT revert to&title[..N]slicing; it panics on multi-byte codepoints that any page can craft into a title. -
Capabilities -- The
global-shortcutplugin's JS register/unregister permissions are NOT granted. Hotkey management is Rust-only; don't add those permissions without a matching JS feature. -
CSP --
tauri.conf.jsonsets a strict CSP that applies to the app's own pages (the landing page) only — external sites bring their own. It allowsscript-src 'self'and no inline scripts, which is why the landing page logic lives insrc/main.jsinstead of an inline<script>. Keep it that way; Tauri appends its own nonces for the scripts it injects.
Run unit tests in src-tauri/ (via cargo test). Test manually:
npm run dev- Test URL navigation (landing page + control strip URL bar + DDG search fallback)
- Test always-on-top toggle (hotkey + tray + settings + strip button)
- Test opacity (hotkey + slider + settings)
- Test click-through mode (enter via hotkey, exit via hotkey AND tray menu)
- Test drag (from top bar), resize (from edges), minimize, close
- Test system tray (left-click show/hide, right-click menu items)
- Test persistence (close and reopen, verify state restored)
- Test on external pages (navigate to a real site, verify all controls still work)
- Test back/forward/refresh buttons work on navigated pages
- Test bookmark star (toggle on/off, right-click dropdown shows list)
- Test clear site data (Settings button, verify cookies/storage cleared)
- Test window title updates when navigating between pages
- Test crop/zoom (select region, verify persist/restore across restarts)
- Test tray quit preserves geometry
- Test media hotkeys target the most recently interacted player
- Test error-page redirect only fires on actual browser errors
- Test snap dropdown sub-sections: position corners, halves, thirds, and aspect ratios (16:9 / 4:3 / 21:9 / 1:1 / 9:16) — each section's buttons should fire correctly and the popup should stay inside the viewport
- Test hotkey rebinding from settings: click a binding, press a new combo, confirm it persists and the new combo fires immediately. Test Esc-cancel, "Modifier required" guard for unmodified non-F-keys, and Reset to defaults. While capturing, confirm pressing the current binding does not fire its action