Skip to content

Commit 2cd8cc7

Browse files
committed
release: v1.5.0 — audit hardening, JS test harness, CI lint gate
1 parent 37ba46e commit 2cd8cc7

21 files changed

Lines changed: 892 additions & 497 deletions

.github/workflows/build.yml

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,49 @@ jobs:
2727
run: |
2828
gh release delete nightly --repo "${{ github.repository }}" --cleanup-tag --yes || echo "no nightly release to delete"
2929
30-
build:
30+
# Fast fail gate: runs the Rust + JS test/lint suites on a cheap Linux
31+
# runner in parallel with the reset-nightly gate, and the build matrix
32+
# waits on it so a formatting slip, clippy warning, or failing test
33+
# blocks the release artifacts rather than being discovered mid-build.
34+
lint:
3135
needs: reset-nightly
36+
runs-on: ubuntu-latest
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@v5
40+
41+
- name: Install Rust stable (with fmt + clippy)
42+
uses: dtolnay/rust-toolchain@stable
43+
with:
44+
components: rustfmt, clippy
45+
46+
- name: Rust cache
47+
uses: swatinem/rust-cache@v2
48+
with:
49+
workspaces: src-tauri -> target
50+
51+
- name: cargo fmt --check
52+
working-directory: src-tauri
53+
run: cargo fmt --check
54+
55+
- name: cargo clippy (-D warnings)
56+
working-directory: src-tauri
57+
run: cargo clippy --all-targets -- -D warnings
58+
59+
- name: cargo test
60+
working-directory: src-tauri
61+
run: cargo test
62+
63+
- name: Install Node.js
64+
uses: actions/setup-node@v5
65+
with:
66+
node-version: 22
67+
68+
- name: node --test (JS url helpers)
69+
run: node --test "js/**/*.test.mjs"
70+
71+
build:
72+
needs: [reset-nightly, lint]
3273
permissions:
3374
contents: write
3475
strategy:

js/urltest.mjs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Pure-function copies of the URL helpers that live inside FloatView's
2+
// injected control strip (src-tauri/src/injection.js). Extracted here so
3+
// they can be exercised under `node --test` without booting the webview.
4+
//
5+
// KEEP IN SYNC with:
6+
// - injection.js `urlsMatch` (~line 3346)
7+
// - injection.js URL-bar normalization logic (~line 3087)
8+
// - src-tauri/src/urls.rs `urls_match` / `normalize_url` (the Rust
9+
// authoritative copy)
10+
//
11+
// The shared Rust/JS truth table lives in src-tauri/src/url_fixtures.rs
12+
// (`URL_MATCH_CASES`) and is duplicated (with a cross-reference comment)
13+
// in urltest.test.mjs. Drift between the two is exactly what these tests
14+
// exist to catch.
15+
16+
/// Compare two URLs for bookmark-style equivalence.
17+
///
18+
/// Equal if identical, or if origin + userinfo + path
19+
/// (trailing-slash-insensitive) + query match. Fragments are ignored.
20+
///
21+
/// Userinfo (username/password) is compared explicitly because
22+
/// `URL.origin` excludes it (RFC 6454): without the check,
23+
/// `https://user:pass@host/` would silently dedup against plain
24+
/// `https://host/`. Mirrors Rust `urls::urls_match`.
25+
export function urlsMatch(a, b) {
26+
if (a === b) return true;
27+
try {
28+
const ua = new URL(a);
29+
const ub = new URL(b);
30+
return (
31+
ua.origin === ub.origin &&
32+
ua.username === ub.username &&
33+
ua.password === ub.password &&
34+
ua.pathname.replace(/\/+$/, '') === ub.pathname.replace(/\/+$/, '') &&
35+
ua.search === ub.search
36+
);
37+
} catch {
38+
return false;
39+
}
40+
}
41+
42+
/// Normalize a URL-bar input the way the injected strip does: prepend
43+
/// `https://` when no scheme is present, and send anything that isn't a
44+
/// clean http(s) URL (spaces, no dots, an explicit non-http scheme) to
45+
/// DuckDuckGo as a search. Returns the final href string.
46+
///
47+
/// Mirrors the URL-bar handler in injection.js and the normalize+search
48+
/// fallback in src/main.js.
49+
export function normalizeUrlInput(raw) {
50+
const trimmed = String(raw).trim();
51+
if (!trimmed) return null;
52+
53+
let url = trimmed;
54+
if (!/^https?:\/\//.test(url)) {
55+
if (url.includes(' ') || (!url.includes('.') && !url.includes(':'))) {
56+
return 'https://duckduckgo.com/?q=' + encodeURIComponent(trimmed);
57+
}
58+
url = 'https://' + url;
59+
}
60+
61+
let parsed;
62+
try {
63+
parsed = new URL(url);
64+
} catch {
65+
parsed = null;
66+
}
67+
if (!parsed || !/^https?:$/.test(parsed.protocol)) {
68+
return 'https://duckduckgo.com/?q=' + encodeURIComponent(trimmed);
69+
}
70+
return parsed.toString();
71+
}

js/urltest.test.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Test suite for the JS URL helpers, run via `node --test`
2+
// (or `npm test`, which the package.json "test" script wires to
3+
// `node --test js/`).
4+
//
5+
// The URL_MATCH_CASES array below is a MANUAL DUPLICATE of
6+
// src-tauri/src/url_fixtures.rs::URL_MATCH_CASES. The Rust file is the
7+
// source of truth — when you change a case there, change it here too.
8+
// A shared JSON file would avoid the duplication but would add a build
9+
// step; a duplicated array with this cross-reference is the pragmatic
10+
// zero-dependency choice. The Rust side independently asserts its own
11+
// table in urls::tests::urls_match_matches_shared_truth_table.
12+
13+
import { test } from 'node:test';
14+
import assert from 'node:assert/strict';
15+
import { urlsMatch, normalizeUrlInput } from './urltest.mjs';
16+
17+
const URL_MATCH_CASES = [
18+
['https://example.com/', 'https://example.com/', true],
19+
['https://example.com/', 'https://example.com', true],
20+
['https://example.com/path', 'https://example.com/path/', true],
21+
['https://example.com/?q=1', 'https://example.com?q=1', true],
22+
['https://example.com/a', 'https://example.com/b', false],
23+
['https://example.com/', 'http://example.com/', false],
24+
['https://example.com/?q=1', 'https://example.com/?q=2', false],
25+
['https://example.com/a#frag', 'https://example.com/a', true],
26+
['https://a.example.com/', 'https://b.example.com/', false],
27+
['https://user:pass@example.com/', 'https://example.com/', false],
28+
['https://alice@example.com/', 'https://bob@example.com/', false],
29+
['https://user:pass@example.com/path', 'https://user:pass@example.com/path/', true],
30+
['https://u@example.com/', 'https://example.com/', false],
31+
['not a url', 'not a url', true],
32+
['not a url', 'https://example.com/', false],
33+
];
34+
35+
test('urlsMatch agrees with the shared Rust/JS truth table', () => {
36+
URL_MATCH_CASES.forEach(([a, b, expected], i) => {
37+
assert.equal(
38+
urlsMatch(a, b),
39+
expected,
40+
`case #${i}: urlsMatch(${JSON.stringify(a)}, ${JSON.stringify(b)}) expected ${expected}`,
41+
);
42+
});
43+
});
44+
45+
test('normalizeUrlInput adds https scheme to bare hosts', () => {
46+
assert.equal(normalizeUrlInput('example.com'), 'https://example.com/');
47+
});
48+
49+
test('normalizeUrlInput sends multi-word input to DuckDuckGo', () => {
50+
assert.equal(
51+
normalizeUrlInput('rust web framework'),
52+
'https://duckduckgo.com/?q=rust%20web%20framework',
53+
);
54+
});
55+
56+
test('normalizeUrlInput sends dotless non-port input to DuckDuckGo', () => {
57+
assert.equal(
58+
normalizeUrlInput('localhost search term'),
59+
'https://duckduckgo.com/?q=localhost%20search%20term',
60+
);
61+
});
62+
63+
test('normalizeUrlInput keeps an explicit https URL canonical', () => {
64+
assert.equal(normalizeUrlInput('https://example.com/foo?q=1'), 'https://example.com/foo?q=1');
65+
});
66+
67+
test('normalizeUrlInput prepends https to a dotted non-http scheme (matches injection.js URL bar)', () => {
68+
// The injected URL bar's heuristic only special-cases spaces and
69+
// dotless/portless input; anything else with a `.` gets `https://`
70+
// prepended. So `ftp://example.com` (which contains a dot) becomes
71+
// `https://ftp//example.com` — odd, but it's the documented URL-bar
72+
// behavior, NOT a search. (The landing page `src/main.js` is stricter
73+
// and would send this to search; the two paths genuinely differ.)
74+
// Pinning the URL-bar semantics here so a future "cleanup" doesn't
75+
// silently change what the strip does.
76+
assert.equal(normalizeUrlInput('ftp://example.com'), 'https://ftp//example.com');
77+
});
78+
79+
test('normalizeUrlInput trims whitespace', () => {
80+
assert.equal(normalizeUrlInput(' example.com '), 'https://example.com/');
81+
});
82+
83+
test('normalizeUrlInput rejects empty input', () => {
84+
assert.equal(normalizeUrlInput(''), null);
85+
assert.equal(normalizeUrlInput(' '), null);
86+
});

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
{
22
"name": "floatview",
3-
"version": "1.4.7",
3+
"version": "1.5.0",
44
"description": "A minimal floating browser window for streaming media on a secondary monitor",
55
"scripts": {
66
"tauri": "tauri",
77
"dev": "tauri dev",
8-
"build": "tauri build"
8+
"build": "tauri build",
9+
"test": "node --test \"js/**/*.test.mjs\""
910
},
1011
"devDependencies": {
1112
"@tauri-apps/cli": "^2"

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

src-tauri/Entitlements.plist

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,30 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<!--
6+
Hardened-runtime entitlements. Kept as narrow as possible:
7+
8+
- allow-jit: required for the WKWebView/JavaScriptCore JIT that the
9+
Tauri shell relies on for page execution. Dropping this would
10+
force JIT-LESS mode and tank page performance.
11+
12+
Deliberately NOT granted (previously cargo-culted from Tauri
13+
templates; nothing in this app uses them):
14+
- com.apple.security.cs.allow-unsigned-executable-memory
15+
Only needed for runtimes that map writable+executable memory
16+
themselves (some game engines, older JIT runtimes). WKWebView
17+
handles this internally under allow-jit.
18+
- com.apple.security.cs.allow-dyld-environment-variables
19+
Only needed for dynamic-linker injection (DYLD_INSERT_LIBRARIES),
20+
which this app never uses; granting it widens the attack
21+
surface for library injection.
22+
23+
If a future macOS build regression appears right after a change
24+
here, the fastest revert is to re-add the dropped keys above. The
25+
macOS build is gated in CI (.github/workflows/build.yml) so a
26+
breakage surfaces on the next push, not at release.
27+
-->
528
<key>com.apple.security.cs.allow-jit</key>
629
<true/>
7-
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
8-
<true/>
9-
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
10-
<true/>
1130
</dict>
1231
</plist>

src-tauri/src/actions.rs

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,36 @@ pub fn do_install_update(app: &AppHandle) {
7474
});
7575
}
7676

77-
pub async fn install_update(app: AppHandle) -> Result<bool, String> {
77+
/// Check for an update and, if one exists, download + install it.
78+
///
79+
/// `on_chunk` receives `(chunk_bytes, total_bytes)` deltas during the
80+
/// download so callers can drive a progress UI; pass a no-op for the
81+
/// silent tray path. Returns `Ok(true)` if an update was installed,
82+
/// `Ok(false)` if none was available, `Err(_)` on failure. The caller
83+
/// owns restart-on-success and status-event emission — those differ
84+
/// between the tray (best-effort, restarts from `do_install_update`) and
85+
/// the JS command (emits granular status + restarts itself), so they
86+
/// stay at the call sites rather than being baked in here.
87+
pub async fn install_update_with_progress<F>(
88+
app: AppHandle,
89+
mut on_chunk: F,
90+
) -> Result<bool, String>
91+
where
92+
F: FnMut(u64, Option<u64>) + Send + 'static,
93+
{
7894
let updater = app.updater().map_err(|e| e.to_string())?;
79-
if let Some(update) = updater.check().await.map_err(|e| e.to_string())? {
80-
info!(version = %update.version, "Installing update from native action");
81-
update
82-
.download_and_install(|_, _| {}, || {})
83-
.await
84-
.map_err(|e| e.to_string())?;
85-
return Ok(true);
86-
}
87-
Ok(false)
95+
let Some(update) = updater.check().await.map_err(|e| e.to_string())? else {
96+
return Ok(false);
97+
};
98+
info!(version = %update.version, "Installing update from native action");
99+
update
100+
.download_and_install(move |chunk, total| on_chunk(chunk as u64, total), || {})
101+
.await
102+
.map_err(|e| e.to_string())?;
103+
Ok(true)
104+
}
105+
106+
pub async fn install_update(app: AppHandle) -> Result<bool, String> {
107+
// Silent tray path: no per-chunk progress reporting.
108+
install_update_with_progress(app, |_, _| {}).await
88109
}

0 commit comments

Comments
 (0)