Skip to content

Commit 148d2a8

Browse files
davidtorciviaclaude
andcommitted
release: v1.4.7 — opacity dim fix, audit hardening, UI consistency pass
Opacity: faded page content now dims toward a black backdrop (fv-dimmed class) instead of washing out to white — CSS opacity blends toward the backdrop, and under the layered window's uniform alpha only a black backdrop reads as desktop showing through. Security: strict CSP for app pages (landing script moved to src/main.js), IPC fallback capture validates Tauri internals shape, window titles strip control characters, startup click-through recovery logs failures. Bugs: bookmark/hotkey commands return bool so JS can distinguish success from IPC failure (local bookmark state no longer diverges); hotkey resume retries once before giving up; urls_match compares userinfo. Performance: media MutationObservers debounced to one full scan per 150ms; title/URL fallback polls 2s/3s → 10s with pushState/replaceState/hashchange hooks and title-observer re-attach. UI/UX: landing page restyled to the strip's warm-orange/dark-gray language; WCAG contrast bumps; focus-visible coverage for recent items, settings sliders, context menu; settings sliders mirror the strip slider; radius and shadow tokens; popup mutual exclusion includes the volume popup; URL bar selection/caret styling, select-all-on-click, text-height highlight. Maintainability: AppConfig gains config_version for future migrations; AGENTS.md/README/designdoc updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent da4f108 commit 148d2a8

15 files changed

Lines changed: 462 additions & 195 deletions

AGENTS.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ cd src-tauri && cargo test # Run unit tests
3434
```
3535
floatview/
3636
├── src/
37-
│ └── index.html # Landing page (URL input)
37+
│ ├── index.html # Landing page (URL input)
38+
│ └── main.js # Landing page logic (external so the CSP can stay strict)
3839
├── src-tauri/
3940
│ ├── src/
4041
│ │ ├── main.rs # Binary shim; just calls `floatview::run()`
@@ -165,6 +166,7 @@ This ensures that:
165166

166167
```rust
167168
pub struct AppConfig {
169+
pub config_version: u32, // schema version; bump on rename/restructure, NOT additions
168170
pub window: WindowConfig, // x, y, width, height, monitor, always_on_top, opacity, locked
169171
pub last_url: Option<String>,
170172
pub recent_urls: Option<Vec<String>>,
@@ -177,6 +179,8 @@ pub struct AppConfig {
177179
}
178180
```
179181

182+
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.
183+
180184
### Tray Menu Dynamic Updates
181185

182186
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.
@@ -273,10 +277,16 @@ Key injection.js features:
273277

274278
17. **Opacity clamping** -- Always go through `config::clamp_opacity`; it snaps near-opaque to 1.0 (lets the Windows backend drop `WS_EX_LAYERED`) and rejects non-finite inputs that would otherwise poison the saved config.
275279

276-
18. **Title truncation** -- `set_window_title` calls `truncate_title`, which 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.
280+
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.
281+
282+
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.
283+
284+
18. **Title truncation** -- `set_window_title` calls `truncate_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.
277285

278286
19. **Capabilities** -- The `global-shortcut` plugin's JS register/unregister permissions are NOT granted. Hotkey management is Rust-only; don't add those permissions without a matching JS feature.
279287

288+
20. **CSP** -- `tauri.conf.json` sets a strict CSP that applies to the app's own pages (the landing page) only — external sites bring their own. It allows `script-src 'self'` and no inline scripts, which is why the landing page logic lives in `src/main.js` instead of an inline `<script>`. Keep it that way; Tauri appends its own nonces for the scripts it injects.
289+
280290
## Testing
281291

282292
Run unit tests in `src-tauri/` (via `cargo test`). Test manually:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ Configuration files are stored separately and not removed by default:
250250

251251
This program will not transfer any information to other networked systems unless specifically requested by the user. The only automated network request is the optional **Check for Updates** feature in Settings, which queries the [GitHub Releases API](https://github.com/davidtorcivia/floatview/releases) to check for new versions. No personal data, telemetry, or usage statistics are collected or transmitted.
252252

253+
Note that, like most desktop browsers, FloatView stores its settings — including bookmarks, recent URLs, and the last visited page — as plain-text JSON in your local app-data folder. Anyone with access to your user account (or disk) can read that file, so treat shared machines accordingly. **Clear Recent** / **Clear Bookmarks** in Settings remove those lists, and **Clear Site Data** wipes the webview's cookies and storage.
254+
253255
## License
254256

255257
[MIT](LICENSE)

designdoc.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# FloatView — Technical Design Document (v3, updated for v1.1.0)
1+
# FloatView — Technical Design Document (v4, updated for v1.4.7)
22

33
## Overview
44

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.4.6",
3+
"version": "1.4.7",
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.4.6"
3+
version = "1.4.7"
44
description = "A minimal floating browser window for streaming media"
55
authors = ["David Torcivia"]
66
license = "MIT"

src-tauri/src/commands.rs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,13 @@ pub async fn pause_global_hotkeys(
159159
app: AppHandle,
160160
state: tauri::State<'_, AppState>,
161161
token: String,
162-
) -> Result<(), String> {
162+
) -> Result<bool, String> {
163163
authorize_command(&state, &token, "pause_global_hotkeys")?;
164164
use tauri_plugin_global_shortcut::GlobalShortcutExt;
165165
let _ = app.global_shortcut().unregister_all();
166-
Ok(())
166+
// bool (not unit) so the JS invoke wrapper — which maps IPC failures
167+
// to null — can tell success apart from failure.
168+
Ok(true)
167169
}
168170

169171
/// Re-register global shortcuts from the current config. Pair with
@@ -174,10 +176,11 @@ pub async fn resume_global_hotkeys(
174176
app: AppHandle,
175177
state: tauri::State<'_, AppState>,
176178
token: String,
177-
) -> Result<(), String> {
179+
) -> Result<bool, String> {
178180
authorize_command(&state, &token, "resume_global_hotkeys")?;
179181
crate::hotkeys::register_hotkeys(&app);
180-
Ok(())
182+
// See pause_global_hotkeys for why this returns bool, not unit.
183+
Ok(true)
181184
}
182185

183186
#[tauri::command]
@@ -680,10 +683,15 @@ pub async fn set_window_title(
680683
/// Truncate a window title to a Win32-safe byte length, respecting UTF-8 char
681684
/// boundaries. A naive `&s[..253]` panics when byte 253 lands inside a
682685
/// multi-byte codepoint, which any page can trigger by crafting a title.
686+
///
687+
/// Control characters are stripped first: the title is page-supplied, and
688+
/// embedded control codes (newlines, NUL, C0/C1) can garble taskbar/title
689+
/// rendering or spoof multi-line text.
683690
fn truncate_title(title: &str) -> String {
684691
const MAX_BYTES: usize = 256;
692+
let title: String = title.chars().filter(|c| !c.is_control()).collect();
685693
if title.len() <= MAX_BYTES {
686-
return title.to_string();
694+
return title;
687695
}
688696
let ellipsis = "...";
689697
let budget = MAX_BYTES - ellipsis.len();
@@ -702,35 +710,39 @@ pub async fn add_bookmark(
702710
state: tauri::State<'_, AppState>,
703711
url: String,
704712
token: String,
705-
) -> Result<(), String> {
713+
) -> Result<bool, String> {
706714
authorize_command(&state, &token, "add_bookmark")?;
707715
let url = normalize_url(&url)?;
708716
let mut config = state.config.lock().map_err(|e| e.to_string())?;
709717
if config.bookmarks.iter().any(|b| urls_match(b, &url)) {
710-
return Ok(());
718+
return Ok(true);
711719
}
712720
if config.bookmarks.len() >= MAX_BOOKMARKS {
713721
return Err(format!("Bookmark limit reached (max {MAX_BOOKMARKS})"));
714722
}
715723
config.bookmarks.push(url);
716724
save_config(&state, &config);
717725
drop(config);
718-
Ok(())
726+
// Returns a value (not unit) so the JS invoke wrapper — which maps IPC
727+
// failures to null — can tell success apart from failure.
728+
Ok(true)
719729
}
720730

721731
#[tauri::command]
722732
pub async fn remove_bookmark(
723733
state: tauri::State<'_, AppState>,
724734
url: String,
725735
token: String,
726-
) -> Result<(), String> {
736+
) -> Result<bool, String> {
727737
authorize_command(&state, &token, "remove_bookmark")?;
728738
let url = normalize_url(&url)?;
729739
let mut config = state.config.lock().map_err(|e| e.to_string())?;
730740
config.bookmarks.retain(|u| !urls_match(u, &url));
731741
save_config(&state, &config);
732742
drop(config);
733-
Ok(())
743+
// Returns a value (not unit) so the JS invoke wrapper — which maps IPC
744+
// failures to null — can tell success apart from failure.
745+
Ok(true)
734746
}
735747

736748
#[tauri::command]
@@ -804,6 +816,14 @@ mod tests {
804816
assert_eq!(truncate_title(title), title);
805817
}
806818

819+
#[test]
820+
fn truncate_title_strips_control_characters() {
821+
assert_eq!(
822+
truncate_title("Line one\nLine two\r\0\u{1b}[31m"),
823+
"Line oneLine two[31m"
824+
);
825+
}
826+
807827
#[test]
808828
fn truncate_title_appends_ellipsis_past_limit() {
809829
let title = "a".repeat(300);

src-tauri/src/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,21 @@ pub struct CropConfig {
104104
pub height: f64,
105105
}
106106

107+
/// Current config schema version. Bump ONLY when a field is renamed,
108+
/// retyped, or restructured — plain additions are covered by
109+
/// `#[serde(default)]`. Load code can branch on the stored value to
110+
/// migrate old files instead of silently resetting user data.
111+
pub const CONFIG_VERSION: u32 = 1;
112+
113+
fn default_config_version() -> u32 {
114+
// Pre-1.4.7 configs have no version field; they are schema v1.
115+
CONFIG_VERSION
116+
}
117+
107118
#[derive(Debug, Clone, Serialize, Deserialize)]
108119
pub struct AppConfig {
120+
#[serde(default = "default_config_version")]
121+
pub config_version: u32,
109122
pub window: WindowConfig,
110123
pub last_url: Option<String>,
111124
pub recent_urls: Option<Vec<String>>,
@@ -133,6 +146,7 @@ fn default_true() -> bool {
133146
impl Default for AppConfig {
134147
fn default() -> Self {
135148
Self {
149+
config_version: CONFIG_VERSION,
136150
window: WindowConfig::default(),
137151
last_url: None,
138152
recent_urls: Some(Vec::new()),

0 commit comments

Comments
 (0)