From a0f44b79adfc699a89cb299828db5b991db79010 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sun, 12 Jul 2026 23:08:48 +0330 Subject: [PATCH] feat(portable): add secure Windows portable release (#15) Implement marker-based portable storage, portable WebView and log paths, secure queue and migration sanitization, and Windows portable ZIP validation while preserving the NSIS installer path. Refs #15 --- .github/workflows/release.yml | 54 +++ README.md | 5 +- RELEASE.md | 1 + scripts/smoke-packaged-app.js | 99 +++++- src-tauri/src/db.rs | 396 ++++++++++++++++++++- src-tauri/src/extension_server.rs | 8 + src-tauri/src/ipc.rs | 1 + src-tauri/src/lib.rs | 147 ++++++-- src-tauri/src/settings.rs | 78 +++- src-tauri/src/storage.rs | 130 +++++++ src-tauri/tauri.conf.json | 1 + src-tauri/tauri.linux.conf.json | 1 + src-tauri/tauri.macos.conf.json | 1 + src-tauri/tauri.windows.conf.json | 1 + src/bindings/PlatformInfo.ts | 2 +- src/components/KeychainPermissionModal.tsx | 27 +- src/components/SettingsView.tsx | 19 +- src/store/useSettingsStore.ts | 4 +- src/utils/downloads.ts | 9 +- src/utils/platform.ts | 3 +- 20 files changed, 905 insertions(+), 82 deletions(-) create mode 100644 src-tauri/src/storage.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fe1b5f..0d573e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,52 @@ jobs: Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue 7z x $installer.FullName "-o$extractRoot" -y node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }} + + $portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload" + $portableArtifactDir = "$env:RUNNER_TEMP/firelink-portable" + Remove-Item -Recurse -Force $portableRoot -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $portableArtifactDir -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $portableRoot -Force | Out-Null + New-Item -ItemType Directory -Path $portableArtifactDir -Force | Out-Null + $payloadExe = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ieq "firelink.exe" } | + Sort-Object FullName | + Select-Object -First 1 + if (-not $payloadExe) { throw "firelink.exe was not found in the extracted installer payload." } + Copy-Item (Join-Path $payloadExe.Directory.FullName '*') $portableRoot -Recurse -Force + Set-Content -Path (Join-Path $portableRoot 'portable.flag') -Value 'portable' -NoNewline + @" + Firelink portable + + Extract this folder to a writable location and launch firelink.exe. + Close Firelink before copying or moving this folder. + Settings, queues, logs, and WebView data are stored under data\. + Only one Firelink instance can run at a time; close the installed app before launching this copy. + Credentials, browser cookies, and URL query/fragment data are not persisted in portable queue records. + Saved site passwords remain in the Windows credential store and are not portable. + The portable folder contains the extension pairing credential; treat it as sensitive and do not share it. + Saved absolute download locations may need to be selected again after moving to another drive. + The portable archive does not register the firelink:// protocol; use the installer for browser launch integration. + "@ | Set-Content -Path (Join-Path $portableRoot 'PORTABLE_README.txt') + New-Item -ItemType Directory -Path (Join-Path $portableRoot 'data') -Force | Out-Null + $portableExe = Join-Path $portableRoot 'firelink.exe' + node scripts/verify-binaries.js --search-root "$portableRoot" --target ${{ matrix.target }} + node scripts/smoke-packaged-app.js --executable $portableExe --assert-no-visible-child-windows --assert-portable-data + Get-ChildItem $portableRoot -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^(unins|Uninstall).*\.exe$' } | + Remove-Item -Force -ErrorAction SilentlyContinue + $portableDataDir = Join-Path $portableRoot 'data' + for ($attempt = 1; $attempt -le 10; $attempt++) { + Remove-Item -Recurse -Force $portableDataDir -ErrorAction SilentlyContinue + if (-not (Test-Path $portableDataDir)) { break } + Start-Sleep -Milliseconds (200 * $attempt) + } + if (Test-Path $portableDataDir) { + throw "Portable test data could not be removed after smoke; refusing to package a ZIP containing runtime data." + } + $portableZip = "$portableArtifactDir/Firelink_${{ github.ref_name }}_Windows-x64-portable.zip" + 7z a -tzip $portableZip "$portableRoot\*" -y + $installRoot = "$env:RUNNER_TEMP\FirelinkSmoke" Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue $install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru @@ -151,6 +197,13 @@ jobs: path: ${{ matrix.artifact }} if-no-files-found: error retention-days: 3 + - uses: actions/upload-artifact@v7 + if: runner.os == 'Windows' + with: + name: Firelink-Windows-x64-portable-${{ github.ref_name }} + path: ${{ runner.temp }}/firelink-portable/*.zip + if-no-files-found: error + retention-days: 3 - uses: actions/upload-artifact@v7 if: runner.os == 'Linux' with: @@ -209,6 +262,7 @@ jobs: rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb" rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm" rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe" + rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip" - name: Generate checksums run: | cd release-assets diff --git a/README.md b/README.md index df9912a..d5be7c5 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,14 @@ Download desktop builds from [GitHub Releases](https://github.com/nimbold/Fireli | --- | --- | --- | | **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. | | **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. | +| **Windows x64 portable** | `.zip` archive | Extract to a writable folder and launch `firelink.exe`. App data stays under the archive's `data/` directory. | | **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the portable fallback. AppImage may need executable permission. | Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager. -The native packages use the distribution's normal desktop runtime dependencies, while AppImage remains the portable installation option. +The native packages use the distribution's normal desktop runtime dependencies. AppImage and the Windows portable ZIP are the portable distribution options for their respective platforms. + +The Windows portable archive is an opt-in secondary distribution. Keep the extracted folder writable; `Program Files`, read-only media, and some network or synchronized folders can prevent SQLite and WebView data from being saved. Close Firelink before copying or moving the folder. Only one Firelink instance can run at a time, so close the installed app before launching the portable copy. Portable mode keeps application settings, queues, logs, and WebView data beside the executable. Credentials, browser cookies, and URL query/fragment data are not persisted in portable queue records; active downloads that depend on those URL components are marked failed and must be added again after restart. Saved site passwords remain in the Windows credential store and are intentionally not copied into the archive. The portable folder contains the extension pairing credential needed to preserve extension integration, so treat the folder as sensitive and do not share it. Saved absolute download locations may need to be selected again if the folder is moved to a different drive. The installer remains the supported path for `firelink://` browser launch registration. ## Browser Extension diff --git a/RELEASE.md b/RELEASE.md index b813b18..dc97c2c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,6 +4,7 @@ Targets: - macOS arm64 DMG - Windows x64 NSIS installer +- Windows x64 portable ZIP - Linux x64 AppImage - Linux x64 Debian package - Linux x64 RPM package diff --git a/scripts/smoke-packaged-app.js b/scripts/smoke-packaged-app.js index bb76ad6..89a1a2e 100755 --- a/scripts/smoke-packaged-app.js +++ b/scripts/smoke-packaged-app.js @@ -1,5 +1,6 @@ #!/usr/bin/env node import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; function argValue(name) { @@ -15,6 +16,7 @@ if (!executableArg) { const executable = path.resolve(executableArg); const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows'); +const assertPortableData = process.argv.includes('--assert-portable-data'); const child = spawn(executable, [], { cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(), detached: process.platform !== 'win32', @@ -30,12 +32,14 @@ const child = spawn(executable, [], { let stderr = ''; let spawnError = null; let readyPort = null; +let childExit = null; child.on('error', error => { spawnError = error; }); child.on('exit', (code, signal) => { + childExit = { code, signal }; if (readyPort === null) { console.error(`Child exited prematurely with code ${code} signal ${signal}`); } @@ -44,6 +48,7 @@ child.on('exit', (code, signal) => { child.stderr.on('data', data => { stderr += data.toString(); }); +child.stdout.on('data', () => {}); function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); @@ -51,14 +56,17 @@ function sleep(ms) { async function findReadyPort() { for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) { - if (spawnError) { + if (spawnError || childExit) { break; } for (let port = 6412; port <= 6422; port += 1) { try { const response = await fetch(`http://127.0.0.1:${port}/ping`); - if (response.headers.get('x-firelink-server') === '1') { + if ( + response.headers.get('x-firelink-server') === '1' + && response.headers.get('x-firelink-smoke-process-id') === String(child.pid) + ) { readyPort = port; break; } @@ -120,17 +128,35 @@ if ($visible.Count -gt 0) { } } -function terminateChild() { - if (!child.pid) { - return; +function waitForChildExit(timeoutMs) { + if (childExit) { + return Promise.resolve(true); + } + + return new Promise(resolve => { + const timer = setTimeout(() => { + resolve(false); + }, timeoutMs); + child.once('exit', () => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +async function terminateChild() { + if (!child.pid || childExit) { + return true; } if (process.platform === 'win32') { - spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true, - }); - return; + try { + execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }); + } catch {} + return waitForChildExit(10000); } try { @@ -138,25 +164,68 @@ function terminateChild() { } catch { child.kill('SIGTERM'); } + if (await waitForChildExit(5000)) { + return true; + } + + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + return waitForChildExit(5000); } -await findReadyPort(); +function assertPortableStorage() { + const portableRoot = path.dirname(executable); + const marker = path.join(portableRoot, 'portable.flag'); + const database = path.join(portableRoot, 'data', 'firelink.sqlite'); + const webviewData = path.join(portableRoot, 'data', 'webview'); + + if (!fs.statSync(marker, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`Portable marker was not found at ${marker}`); + } + if (!fs.statSync(database, { throwIfNoEntry: false })?.isFile()) { + throw new Error(`Portable database was not created at ${database}`); + } + if (!fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`Portable WebView data directory was not created at ${webviewData}`); + } +} try { + await findReadyPort(); + if (readyPort === null) { if (spawnError) { - console.error(`Packaged Firelink failed to start: ${spawnError.message}`); + throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`); + } else if (childExit) { + throw new Error( + `Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`, + ); } else { - console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`); + throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`); } - process.exit(1); } if (assertNoVisibleChildWindows) { assertNoVisibleWindows(child.pid); } + if (assertPortableData) { + assertPortableStorage(); + } + + if (childExit) { + throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`); + } console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; } finally { - terminateChild(); + if (!await terminateChild()) { + console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.'); + process.exitCode = 1; + } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 7d2442e..bbada75 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -3,7 +3,6 @@ use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use tauri::Manager; const DATABASE_NAME: &str = "firelink.sqlite"; const LEGACY_STORE_NAME: &str = "store.bin"; @@ -16,6 +15,7 @@ static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(()); pub struct DbState { conn: Mutex, + portable: bool, } impl DbState { @@ -49,20 +49,16 @@ struct LegacyData { pairing_token: Option, } -pub fn init(app_handle: &tauri::AppHandle) -> Result { - let app_data_dir = app_handle - .path() - .app_data_dir() - .map_err(|error| format!("failed to resolve app data directory: {error}"))?; - init_at_path_internal(&app_data_dir) +pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result { + init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable()) } #[cfg(test)] fn init_at_path(app_data_dir: &Path) -> Result { - init_at_path_internal(app_data_dir) + init_at_path_internal(app_data_dir, false) } -fn init_at_path_internal(app_data_dir: &Path) -> Result { +fn init_at_path_internal(app_data_dir: &Path, portable: bool) -> Result { fs::create_dir_all(app_data_dir) .map_err(|error| format!("failed to create app data directory: {error}"))?; let database_path = app_data_dir.join(DATABASE_NAME); @@ -73,7 +69,12 @@ fn init_at_path_internal(app_data_dir: &Path) -> Result { let version = connection .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .map_err(|error| format!("failed to read database schema version: {error}"))?; - if existed && version < CURRENT_SCHEMA_VERSION { + // Portable mode intentionally does not create raw migration backups: + // those backups would duplicate any legacy transfer secrets beside the + // executable. The imported data is sanitized before the portable DB is + // used, and any legacy source is sanitized in place after a successful + // import so it cannot remain as an unsanitized sidecar. + if existed && version < CURRENT_SCHEMA_VERSION && !portable { backup_database(&connection, &database_path, &format!("schema-v{version}"))?; } migrate_schema(&mut connection, version)?; @@ -81,13 +82,23 @@ fn init_at_path_internal(app_data_dir: &Path) -> Result { // We no longer touch the keychain on backend startup. // Legacy imports will safely preserve any pairing token in the JSON payload. // The frontend will manually trigger migration to the keychain via IPC if access is granted. - import_legacy_data(&mut connection, app_data_dir, false)?; + import_legacy_data(&mut connection, app_data_dir, false, portable)?; + if portable { + sanitize_persisted_downloads(&mut connection)?; + } Ok(DbState { conn: Mutex::new(connection), + portable, }) } +impl DbState { + pub fn is_portable(&self) -> bool { + self.portable + } +} + fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), String> { if from_version > CURRENT_SCHEMA_VERSION { return Err(format!( @@ -176,6 +187,7 @@ fn import_legacy_data( connection: &mut Connection, app_data_dir: &Path, migrate_keychain: bool, + portable: bool, ) -> Result<(), String> { let legacy_app_dir = app_data_dir .parent() @@ -194,9 +206,14 @@ fn import_legacy_data( } let marker = format!("legacy-import:{}", candidate.to_string_lossy()); if metadata_exists(connection, &marker)? { + if portable { + sanitize_legacy_source(&candidate)?; + } continue; } - backup_file(&candidate, "legacy-import")?; + if !portable { + backup_file(&candidate, "legacy-import")?; + } let mut legacy = if candidate .file_name() .is_some_and(|name| name == DATABASE_NAME) @@ -230,11 +247,85 @@ fn import_legacy_data( params![marker], ) .map_err(|error| format!("failed to record legacy import: {error}"))?; + if portable { + sanitize_legacy_source(&candidate)?; + } } } Ok(()) } +fn sanitize_legacy_source(path: &Path) -> Result<(), String> { + if path + .file_name() + .is_some_and(|name| name == DATABASE_NAME) + { + let mut connection = Connection::open(path).map_err(|error| { + format!( + "failed to open legacy database '{}' for portable sanitization: {error}", + path.display() + ) + })?; + if table_exists(&connection, "downloads")? { + return sanitize_persisted_downloads(&mut connection); + } + return Ok(()); + } + + let text = fs::read_to_string(path).map_err(|error| { + format!( + "failed to read legacy store '{}' for portable sanitization: {error}", + path.display() + ) + })?; + let mut document: Value = serde_json::from_str(&text).map_err(|error| { + format!( + "failed to decode legacy store '{}' for portable sanitization: {error}", + path.display() + ) + })?; + let Some(downloads) = document + .get_mut("download_queue") + .and_then(Value::as_array_mut) + else { + return Ok(()); + }; + for download in downloads { + remove_persisted_transfer_secrets(download); + } + let sanitized = serde_json::to_string(&document).map_err(|error| { + format!( + "failed to encode legacy store '{}' for portable sanitization: {error}", + path.display() + ) + })?; + if sanitized == text { + return Ok(()); + } + + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("invalid legacy store path '{}'", path.display()))?; + let temporary = path.with_file_name(format!(".{file_name}.portable-sanitized.tmp")); + fs::write(&temporary, sanitized).map_err(|error| { + format!( + "failed to write temporary sanitized legacy store '{}': {error}", + temporary.display() + ) + })?; + if let Err(rename_error) = fs::rename(&temporary, path) { + let _ = fs::remove_file(path); + fs::rename(&temporary, path).map_err(|error| { + format!( + "failed to replace legacy store '{}' after rename error ({rename_error}): {error}", + path.display() + ) + })?; + } + Ok(()) +} + fn merge_legacy_data(connection: &mut Connection, legacy: LegacyData) -> Result<(), String> { let transaction = connection .transaction() @@ -595,13 +686,20 @@ pub fn load_downloads(connection: &Connection) -> Result, String> { query_string_column(connection, "SELECT data FROM downloads ORDER BY rowid") } -pub fn replace_downloads(connection: &mut Connection, data: &str) -> Result<(), String> { +pub fn replace_downloads( + connection: &mut Connection, + data: &str, + portable: bool, +) -> Result<(), String> { let values: Vec = serde_json::from_str(data) .map_err(|error| format!("failed to decode downloads: {error}"))?; let strings = values - .iter() - .map(|value| { - serde_json::to_string(value) + .into_iter() + .map(|mut value| { + if portable { + remove_persisted_transfer_secrets(&mut value); + } + serde_json::to_string(&value) .map_err(|error| format!("failed to encode download: {error}")) }) .collect::, _>>()?; @@ -614,6 +712,106 @@ pub fn replace_downloads(connection: &mut Connection, data: &str) -> Result<(), .map_err(|error| format!("failed to commit download save: {error}")) } +fn remove_persisted_transfer_secrets(value: &mut Value) { + let Some(object) = value.as_object_mut() else { + return; + }; + + // These values are accepted from users, browser extensions, or URLs and + // may contain credentials or bearer tokens. Portable queues keep their + // useful metadata, but never persist these values beside the executable. + for key in ["password", "cookies", "headers", "mirrors", "proxy"] { + object.remove(key); + } + + if let Some(url) = object.get("url").and_then(Value::as_str) { + if let Ok(mut parsed) = url::Url::parse(url) { + let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some(); + let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some(); + if had_userinfo || had_query_or_fragment { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.set_query(None); + parsed.set_fragment(None); + object.insert("url".to_string(), Value::String(parsed.to_string())); + + // A queued transfer whose URL depended on query/fragment + // credentials must not silently auto-resume with a truncated + // URL after a portable restart. + if had_userinfo || had_query_or_fragment { + mark_portable_download_unresumable(object); + } + } + } else { + object.insert("url".to_string(), Value::String(String::new())); + mark_portable_download_unresumable(object); + } + } +} + +fn mark_portable_download_unresumable(object: &mut serde_json::Map) { + if object + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status != "completed") + { + object.insert("status".to_string(), Value::String("failed".to_string())); + object.insert("resumable".to_string(), Value::Bool(false)); + object.insert( + "lastError".to_string(), + Value::String( + "Portable mode removed credentials from this persisted download; add it again to resume." + .to_string(), + ), + ); + } +} + +fn sanitize_persisted_downloads(connection: &mut Connection) -> Result<(), String> { + let transaction = connection + .transaction() + .map_err(|error| format!("failed to begin portable download sanitization: {error}"))?; + let records = { + let mut statement = transaction + .prepare("SELECT id, data FROM downloads") + .map_err(|error| { + format!("failed to prepare portable download sanitization: {error}") + })?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| { + format!("failed to read downloads for portable sanitization: {error}") + })?; + rows.collect::, _>>().map_err(|error| { + format!("failed to read download for portable sanitization: {error}") + })? + }; + + for (id, data) in records { + let mut value: Value = serde_json::from_str(&data).map_err(|error| { + format!("failed to decode download '{id}' for portable sanitization: {error}") + })?; + remove_persisted_transfer_secrets(&mut value); + let sanitized = serde_json::to_string(&value).map_err(|error| { + format!("failed to encode download '{id}' for portable sanitization: {error}") + })?; + if sanitized != data { + transaction + .execute( + "UPDATE downloads SET data = ?1 WHERE id = ?2", + params![sanitized, id], + ) + .map_err(|error| format!("failed to sanitize download '{id}': {error}"))?; + } + } + + transaction + .commit() + .map_err(|error| format!("failed to commit portable download sanitization: {error}")) +} + fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> { transaction .execute("DELETE FROM downloads", []) @@ -844,10 +1042,22 @@ pub fn load_pairing_token_from_settings(connection: &Connection) -> Result Result<(), String> { let Some(settings_json) = load_settings(connection)? else { - // Settings haven't been persisted yet — nothing to update. - return Ok(()); + if !initialize_if_missing { + // Settings have not been persisted yet. Standard mode keeps the + // first-run token session-only until the user grants credential + // store access; portable mode opts into initialization explicitly. + return Ok(()); + } + let initial = serde_json::json!({ + "state": { "extensionPairingToken": token }, + "version": 3 + }); + let serialized = serde_json::to_string(&initial) + .map_err(|error| format!("failed to encode initial settings: {error}"))?; + return save_settings(connection, &serialized); }; let mut value: serde_json::Value = serde_json::from_str(&settings_json) .map_err(|error| format!("failed to decode settings: {error}"))?; @@ -1077,6 +1287,43 @@ mod tests { })); } + #[test] + fn portable_migration_does_not_create_raw_schema_backup() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join(DATABASE_NAME); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch( + " + CREATE TABLE downloads ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + queue_id TEXT NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL); + INSERT INTO downloads VALUES ( + 'one', 'queued', 'main', + '{\"id\":\"one\",\"status\":\"queued\",\"password\":\"secret\"}' + ); + ", + ) + .unwrap(); + drop(connection); + + let state = init_at_path_internal(temp.path(), true).unwrap(); + let connection = state.lock().unwrap(); + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert!(saved.get("password").is_none()); + assert!(!fs::read_dir(temp.path()).unwrap().flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("firelink.sqlite.backup-schema-v0-") + })); + } + #[test] fn imports_legacy_bundle_store_and_preserves_token() { let root = TempDir::new().unwrap(); @@ -1126,6 +1373,33 @@ mod tests { })); } + #[test] + fn portable_import_sanitizes_legacy_source_after_success() { + let root = TempDir::new().unwrap(); + let current = root.path().join("com.nimbold.firelink"); + let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER); + fs::create_dir_all(&legacy).unwrap(); + let store_path = legacy.join(LEGACY_STORE_NAME); + let store = json!({ + "settings": json!({"state": {"theme": "dark"}}).to_string(), + "download_queue": [{ + "id": "download-1", + "status": "queued", + "url": "https://example.com/file", + "password": "legacy-secret" + }], + "queues": [] + }); + fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap(); + + let state = init_at_path_internal(¤t, true).unwrap(); + let connection = state.lock().unwrap(); + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert!(saved.get("password").is_none()); + let sanitized_store = fs::read_to_string(&store_path).unwrap(); + assert!(!sanitized_store.contains("legacy-secret")); + } + #[test] fn imports_legacy_bundle_sqlite_database() { let root = TempDir::new().unwrap(); @@ -1178,6 +1452,92 @@ mod tests { })); } + #[test] + fn portable_download_persistence_removes_transfer_secrets() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-1", + "status": "queued", + "queueId": "main", + "url": "https://user:secret@example.com/file?token=secret#fragment", + "password": "secret", + "cookies": "session=secret", + "headers": "Authorization: Bearer secret", + "mirrors": "https://user:secret@example.com/mirror", + "proxy": "http://user:secret@example.com:8080" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["url"], "https://example.com/file"); + assert_eq!(saved["status"], "failed"); + assert_eq!(saved["resumable"], false); + assert!(!saved.to_string().contains("secret")); + for key in ["password", "cookies", "headers", "mirrors", "proxy"] { + assert!(saved.get(key).is_none(), "portable data retained {key}"); + } + } + + #[test] + fn portable_persistence_redacts_unparseable_download_urls() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-1", + "status": "queued", + "url": "not a URL secret=secret" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["url"], ""); + assert_eq!(saved["status"], "failed"); + assert!(!saved.to_string().contains("secret")); + } + + #[test] + fn portable_initialization_sanitizes_existing_downloads() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-1", + "status": "queued", + "url": "https://example.com/file", + "password": "secret" + }]) + .to_string(); + replace_downloads(&mut connection, &data, false).unwrap(); + drop(connection); + drop(state); + + let state = init_at_path_internal(temp.path(), true).unwrap(); + let connection = state.lock().unwrap(); + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert!(saved.get("password").is_none()); + } + + #[test] + fn pairing_token_is_persisted_before_frontend_settings_exist() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let connection = state.lock().unwrap(); + + save_pairing_token_to_settings(&connection, "initial-token", true).unwrap(); + + assert_eq!( + load_pairing_token_from_settings(&connection).unwrap().as_deref(), + Some("initial-token") + ); + } + #[test] fn token_decision_preserves_keychain_and_legacy_values() { let keychain = decide_pairing_token(Some("keychain"), Some("legacy"), true, "generated"); diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 3c3ea5a..29cd2ac 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -30,6 +30,7 @@ const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version"; const CLIENT_NONCE_HEADER: &str = "x-firelink-client-nonce"; const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof"; const SERVER_PORT_HEADER: &str = "x-firelink-server-port"; +const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id"; const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n"; const PROTOCOL_VERSION: &str = "4"; @@ -140,6 +141,13 @@ async fn add_server_identity(request: Request, next: Next) -> Response { PROTOCOL_VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION), ); + if std::env::var_os("FIRELINK_SMOKE_TEST").is_some() { + if let Ok(process_id) = HeaderValue::from_str(&std::process::id().to_string()) { + response + .headers_mut() + .insert(SMOKE_PROCESS_ID_HEADER, process_id); + } + } response } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index a260eba..c3c9d3a 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -297,6 +297,7 @@ pub struct PlatformInfo { pub os: String, pub arch: String, pub target_triple: String, + pub portable: bool, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 57f4ba0..934425c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1959,6 +1959,7 @@ pub mod queue; pub mod process; pub mod retry; mod settings; +mod storage; pub use error::AppError; // Retained only for compatibility with the optional aria2 diagnostic monitor. @@ -1970,6 +1971,7 @@ pub enum TaskHandle { pub struct AppState { pub download_coordinator: download::DownloadCoordinator, + pub storage_layout: crate::storage::StorageLayout, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, pub extension_server_port: extension_server::SharedServerPort, @@ -3818,11 +3820,12 @@ fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) { } #[tauri::command] -fn get_platform_info() -> crate::ipc::PlatformInfo { +fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformInfo { crate::ipc::PlatformInfo { os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), target_triple: crate::platform::target_triple(), + portable: state.storage_layout.is_portable(), } } @@ -4346,17 +4349,42 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result Result<(), String> { +fn set_keychain_password( + state: tauri::State<'_, AppState>, + id: String, + password: String, +) -> Result<(), String> { + if state.storage_layout.is_portable() + && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID + { + return Ok(()); + } crate::db::set_keychain_password(&id, &password) } #[tauri::command] -fn get_keychain_password(id: String) -> Result { +fn get_keychain_password( + state: tauri::State<'_, AppState>, + id: String, +) -> Result { + if state.storage_layout.is_portable() + && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID + { + return Err("portable pairing token is stored in portable settings".to_string()); + } crate::db::get_keychain_password(&id) } #[tauri::command] -fn delete_keychain_password(id: String) -> Result<(), String> { +fn delete_keychain_password( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + if state.storage_layout.is_portable() + && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID + { + return Ok(()); + } crate::db::delete_keychain_password(&id) } @@ -4399,17 +4427,23 @@ fn hydrate_extension_pairing_token( }); } - // No token in the DB yet — generate one and save it so future launches - // find it without prompting. + // No token in the DB yet — generate one. Portable mode initializes the + // settings row immediately so the pairing secret travels with the folder; + // standard mode remains session-only until the user grants credential-store + // access when settings have not been persisted yet. let generated = crate::db::generate_pairing_token(); - crate::db::save_pairing_token_to_settings(&connection, &generated)?; + crate::db::save_pairing_token_to_settings( + &connection, + &generated, + app_state.storage_layout.is_portable(), + )?; if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = generated.clone(); } Ok(PairingTokenHydration { token: generated, token_changed: false, - persistent: false, + persistent: app_state.storage_layout.is_portable(), error: None, }) } @@ -4421,6 +4455,26 @@ fn grant_keychain_access( ) -> Result { let mut connection = database.lock()?; + if app_state.storage_layout.is_portable() { + let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)? + { + existing + } else { + let generated = crate::db::generate_pairing_token(); + crate::db::save_pairing_token_to_settings(&connection, &generated, true)?; + generated + }; + if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { + *pairing_token = token.clone(); + } + return Ok(PairingTokenHydration { + token, + token_changed: false, + persistent: true, + error: None, + }); + } + // Explicitly force migration of any legacy token to the keychain. // This is the ONLY code path that touches the OS keychain and it is // reached exclusively through the frontend's "Grant Access" button, @@ -4431,7 +4485,7 @@ fn grant_keychain_access( Ok((token, token_changed)) => { // Persist the token to the settings DB so future startups // can read it without touching the keychain at all. - let _ = crate::db::save_pairing_token_to_settings(&connection, &token); + let _ = crate::db::save_pairing_token_to_settings(&connection, &token, false); if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = token.clone(); } @@ -4475,6 +4529,11 @@ fn db_save_settings( let connection = state.lock()?; let existing = crate::db::load_settings(&connection)?; let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?; + let merged = if state.is_portable() { + crate::settings::preserve_portable_pairing_token(existing.as_deref(), &merged)? + } else { + merged + }; crate::db::save_settings(&connection, &merged)?; let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?; if let Ok(mut cached) = app_state.scheduler_settings.write() { @@ -4502,8 +4561,9 @@ fn db_replace_downloads( state: tauri::State<'_, crate::db::DbState>, data: String, ) -> Result<(), String> { + let portable = state.is_portable(); let mut connection = state.lock()?; - crate::db::replace_downloads(&mut connection, &data) + crate::db::replace_downloads(&mut connection, &data, portable) } #[tauri::command] @@ -4531,8 +4591,11 @@ fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { } async fn log_files(app_handle: &tauri::AppHandle) -> Result, String> { - use tauri::Manager; - let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?; + let log_dir = app_handle + .state::() + .storage_layout + .log_dir() + .to_path_buf(); let mut files = Vec::new(); if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { @@ -5563,6 +5626,20 @@ fn set_log_stream_active(active: bool) { pub fn run() { ensure_reqwest_crypto_provider(); + let storage_mode = crate::storage::StorageMode::detect(); + let setup_storage_mode = storage_mode.clone(); + let log_target = match &storage_mode { + crate::storage::StorageMode::Standard => tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::LogDir { file_name: None }, + ), + crate::storage::StorageMode::Portable { root } => tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Folder { + path: root.join("data").join("logs"), + file_name: None, + }, + ), + }; + let extension_pairing_token = Arc::new(RwLock::new(String::new())); let server_pairing_token = extension_pairing_token.clone(); let extension_frontend_ready = Arc::new(AtomicBool::new(false)); @@ -5587,11 +5664,26 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .manage(Aria2DaemonGuard::new()) .setup(move |app| { - #[cfg(target_os = "windows")] - if let Some(window) = app.get_webview_window("main") { - window - .set_decorations(false) - .map_err(|error| format!("failed to disable Windows native frame: {error}"))?; + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + setup_storage_mode.clone(), + )?; + let main_window_config = app + .config() + .app + .windows + .iter() + .find(|window| window.label == "main") + .cloned() + .ok_or_else(|| "main window configuration is missing".to_string())?; + let mut main_window_builder = tauri::WebviewWindowBuilder::from_config( + app.handle(), + &main_window_config, + ) + .map_err(|error| format!("failed to prepare main window: {error}"))?; + if storage_layout.is_portable() { + main_window_builder = + main_window_builder.data_directory(storage_layout.webview_dir().to_path_buf()); } let mut sys = sysinfo::System::new_all(); @@ -5607,7 +5699,7 @@ pub fn run() { build_main_tray(app.handle()) .map_err(|error| format!("failed to create tray menu: {error}"))?; - let database = crate::db::init(app.handle()) + let database = crate::db::init(&storage_layout) .map_err(|error| format!("failed to initialize persistence: {error}"))?; let initial_pairing_token = { // Generate a temporary session token for the extension server on startup. @@ -5671,6 +5763,7 @@ pub fn run() { app.manage(AppState { download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()), + storage_layout, extension_pairing_token, extension_frontend_ready, extension_server_port, @@ -5683,6 +5776,20 @@ pub fn run() { queue_manager, }); + // Build the window only after all command state is registered. This + // prevents the frontend from racing startup and invoking IPC before + // the database and portable storage layout are available. + main_window_builder + .build() + .map_err(|error| format!("failed to create main window: {error}"))?; + + #[cfg(target_os = "windows")] + if let Some(window) = app.get_webview_window("main") { + window + .set_decorations(false) + .map_err(|error| format!("failed to disable Windows native frame: {error}"))?; + } + let deep_link_app = app.handle().clone(); #[cfg(target_os = "linux")] if let Err(error) = app.deep_link().register_all() { @@ -5970,9 +6077,7 @@ pub fn run() { tauri_plugin_log::Builder::new() .targets([ tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { - file_name: None, - }), + log_target, tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview) .filter(|_| { LOG_STREAM_ACTIVE.load(std::sync::atomic::Ordering::Acquire) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d66f27d..d3fbd9f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -77,6 +77,41 @@ pub fn preserve_scheduler_runtime_keys( .map_err(|error| format!("failed to encode persisted settings: {error}")) } +pub fn preserve_portable_pairing_token( + existing: Option<&str>, + incoming: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(incoming.to_string()); + }; + + let existing_document = decode_document(&Value::String(existing.to_string()))?; + let existing_state = settings_state(&existing_document)?; + let Some(token) = existing_state + .get("extensionPairingToken") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + else { + return Ok(incoming.to_string()); + }; + + let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?; + let incoming_state = settings_state_mut(&mut incoming_document)?; + let incoming_token_present = incoming_state + .get("extensionPairingToken") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + if !incoming_token_present { + incoming_state.insert( + "extensionPairingToken".to_string(), + Value::String(token.to_string()), + ); + } + + serde_json::to_string(&incoming_document) + .map_err(|error| format!("failed to encode portable settings: {error}")) +} + fn decode_document(stored: &Value) -> Result { match stored { Value::String(text) => serde_json::from_str(text) @@ -320,7 +355,9 @@ fn default_settings() -> PersistedSettings { #[cfg(test)] mod tests { - use super::{decode_stored_settings, preserve_scheduler_runtime_keys}; + use super::{ + decode_stored_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys, + }; use serde_json::{json, Value}; #[test] @@ -510,11 +547,12 @@ mod tests { #[test] fn ignores_legacy_extension_pairing_token_field() { - // Older versions persisted `extensionPairingToken` as plaintext inside - // the settings document. It now lives in the OS keychain and is no - // longer part of PersistedSettings. serde ignores the unknown field so - // existing installs decode without error; the plaintext value is - // simply dropped and a fresh token is minted by the frontend. + // Older standard installs persisted `extensionPairingToken` as + // plaintext inside the settings document. It now lives in the OS + // keychain and is no longer part of PersistedSettings; portable mode + // deliberately keeps its token in the portable settings document. + // serde ignores the unknown field so existing installs decode without + // error; standard-mode migration drops the plaintext value. let stored = json!({ "state": { "extensionPairingToken": "plaintext-leaked-secret", @@ -527,4 +565,32 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 5); } + + #[test] + fn preserves_portable_pairing_token_when_startup_save_races() { + let existing = json!({ + "state": { + "extensionPairingToken": "portable-token", + "maxConcurrentDownloads": 3 + }, + "version": 3 + }) + .to_string(); + let incoming = json!({ + "state": { + "extensionPairingToken": "", + "maxConcurrentDownloads": 5 + }, + "version": 3 + }) + .to_string(); + + let merged = preserve_portable_pairing_token(Some(&existing), &incoming).unwrap(); + let document: Value = serde_json::from_str(&merged).unwrap(); + assert_eq!( + document["state"]["extensionPairingToken"], + "portable-token" + ); + assert_eq!(document["state"]["maxConcurrentDownloads"], 5); + } } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..d41f40b --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,130 @@ +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager, Runtime}; + +pub const PORTABLE_MARKER: &str = "portable.flag"; +const PORTABLE_DATA_DIR: &str = "data"; +const PORTABLE_LOG_DIR: &str = "logs"; +const PORTABLE_WEBVIEW_DIR: &str = "webview"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StorageMode { + Standard, + Portable { root: PathBuf }, +} + +impl StorageMode { + pub fn detect() -> Self { + let Some(executable) = std::env::current_exe().ok() else { + return Self::Standard; + }; + let Some(root) = executable.parent() else { + return Self::Standard; + }; + + if root.join(PORTABLE_MARKER).is_file() { + Self::Portable { + root: root.to_path_buf(), + } + } else { + Self::Standard + } + } + + #[cfg(test)] + fn detect_from_root(root: &Path) -> Self { + if root.join(PORTABLE_MARKER).is_file() { + Self::Portable { + root: root.to_path_buf(), + } + } else { + Self::Standard + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageLayout { + mode: StorageMode, + data_dir: PathBuf, + log_dir: PathBuf, + webview_dir: PathBuf, +} + +impl StorageLayout { + pub fn resolve( + app_handle: &AppHandle, + mode: StorageMode, + ) -> Result { + match mode { + StorageMode::Standard => Ok(Self { + mode: StorageMode::Standard, + data_dir: app_handle + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data directory: {error}"))?, + log_dir: app_handle + .path() + .app_log_dir() + .map_err(|error| format!("failed to resolve app log directory: {error}"))?, + webview_dir: app_handle.path().app_local_data_dir().map_err(|error| { + format!("failed to resolve app local data directory: {error}") + })?, + }), + StorageMode::Portable { root } => { + let data_dir = root.join(PORTABLE_DATA_DIR); + Ok(Self { + mode: StorageMode::Portable { root }, + log_dir: data_dir.join(PORTABLE_LOG_DIR), + webview_dir: data_dir.join(PORTABLE_WEBVIEW_DIR), + data_dir, + }) + } + } + } + + pub fn is_portable(&self) -> bool { + matches!(self.mode, StorageMode::Portable { .. }) + } + + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + pub fn log_dir(&self) -> &Path { + &self.log_dir + } + + pub fn webview_dir(&self) -> &Path { + &self.webview_dir + } +} + +#[cfg(test)] +mod tests { + use super::{StorageMode, PORTABLE_MARKER}; + use std::fs; + use tempfile::TempDir; + + #[test] + fn marker_selects_portable_mode() { + let root = TempDir::new().unwrap(); + fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap(); + + assert_eq!( + StorageMode::detect_from_root(root.path()), + StorageMode::Portable { + root: root.path().to_path_buf() + } + ); + } + + #[test] + fn missing_marker_keeps_standard_mode() { + let root = TempDir::new().unwrap(); + + assert_eq!( + StorageMode::detect_from_root(root.path()), + StorageMode::Standard + ); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e93b5b9..53ad707 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,6 +13,7 @@ "macOSPrivateApi": true, "windows": [ { + "create": false, "title": "Firelink", "width": 1280, "height": 760, diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index e39aaaf..7b4a26b 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -2,6 +2,7 @@ "app": { "windows": [ { + "create": false, "title": "Firelink", "width": 1280, "height": 760, diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 7f38f42..73c52f3 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -2,6 +2,7 @@ "app": { "windows": [ { + "create": false, "title": "Firelink", "width": 1280, "height": 760, diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index f4d74c9..94f7e8d 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -2,6 +2,7 @@ "app": { "windows": [ { + "create": false, "title": "Firelink", "width": 1280, "height": 760, diff --git a/src/bindings/PlatformInfo.ts b/src/bindings/PlatformInfo.ts index bcfe96f..0580109 100644 --- a/src/bindings/PlatformInfo.ts +++ b/src/bindings/PlatformInfo.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PlatformInfo = { os: string, arch: string, targetTriple: string, }; +export type PlatformInfo = { os: string, arch: string, targetTriple: string, portable: boolean, }; diff --git a/src/components/KeychainPermissionModal.tsx b/src/components/KeychainPermissionModal.tsx index c68208b..44cd688 100644 --- a/src/components/KeychainPermissionModal.tsx +++ b/src/components/KeychainPermissionModal.tsx @@ -17,14 +17,20 @@ export const KeychainPermissionModal: React.FC = () => { const isMac = platform.os === 'macos'; const storeName = - platform.os === 'windows' + platform.portable + ? 'the portable Firelink data folder' + : platform.os === 'windows' ? 'Windows Credential Manager' : platform.os === 'linux' ? 'your Linux credential store' : platform.os === 'macos' ? 'macOS Keychain' : "this system's credential store"; - const grantLabel = isMac ? 'Grant Access' : 'Enable Secure Storage'; + const grantLabel = platform.portable + ? 'Enable Portable Pairing' + : isMac + ? 'Grant Access' + : 'Enable Secure Storage'; const handleGrant = async () => { setIsGranting(true); @@ -75,14 +81,18 @@ export const KeychainPermissionModal: React.FC = () => {

- {isMac + {platform.portable + ? 'The pairing token is portable with this folder. Treat the folder as sensitive and do not share it.' + : isMac ? 'macOS may show a Keychain prompt after you grant access.' : 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.'}

- Note: Firelink only writes its own dedicated credential entry. It cannot access other - saved passwords or credential items on your system. + Note:{' '} + {platform.portable + ? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.' + : 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}

{error && ( @@ -93,8 +103,11 @@ export const KeychainPermissionModal: React.FC = () => { )}
- Hint: If you select Later, the extension will only work for this session. - You can enable secure storage anytime from Settings > Integrations. + Hint:{' '} + {platform.portable + ? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.' + : 'If you select Later, the extension will only work for this session.'} + You can enable storage anytime from Settings > Integrations.
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index d4cafdb..7fcee54 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1214,9 +1214,13 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
-

Credential Storage Available

+

+ {platform.portable ? 'Portable Pairing Enabled' : 'Credential Storage Available'} +

- Your pairing token is securely saved in this system's credential store and will persist across restarts. + {platform.portable + ? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.' + : "Your pairing token is securely saved in this system's credential store and will persist across restarts."}

@@ -1224,16 +1228,19 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
-

Credential Storage Needed

+

+ {platform.portable ? 'Portable Pairing Available' : 'Credential Storage Needed'} +

- Firelink needs access to this system's credential store to securely save your pairing token across app restarts. - Currently, your extension will only stay connected for this session. + {platform.portable + ? 'Your pairing token is stored with this portable Firelink folder and will persist across restarts. Enable it here to review the portable-storage warning.' + : "Firelink needs access to this system's credential store to securely save your pairing token across app restarts. Currently, your extension will only stay connected for this session."}

diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 77bcd71..ca2d0d5 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -53,7 +53,9 @@ const tauriStorage: StateStorage = { * Keychain identifier for the browser-extension pairing token. The token is an * HMAC shared secret and is therefore persisted via the OS keychain rather * than the user-data database. Legacy plaintext values are migrated into the - * Keychain before being removed from persisted settings. + * Keychain before being removed from persisted settings. Portable mode is the + * explicit exception: its pairing token is persisted with the portable folder + * so extension pairing follows that folder. */ const PAIRING_TOKEN_KEYCHAIN_ID = 'extension-pairing-token'; diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index a76300e..dfb9902 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -121,11 +121,10 @@ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const; * progress fields (`fraction`, `speed`, `eta`) are also dropped as in the * existing persistence path. * - * Note: `url` is intentionally retained even though it may contain signed - * query parameters — redacting it would break resume/retry since the URL is - * the download source. Ad-hoc credentials entered in the Add Downloads modal - * are therefore session-scoped; site-login passwords (Keychain-backed) are - * unaffected by this redaction. + * Note: standard persistence intentionally retains `url` because it is the + * download source. The backend applies a stricter portable-mode policy: URL + * userinfo, query, and fragment components are removed before portable data + * is written, and affected active records are not auto-resumed. */ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => { const copy: DownloadItem = { ...item }; diff --git a/src/utils/platform.ts b/src/utils/platform.ts index dc958b3..9fe0806 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -5,7 +5,8 @@ import { invokeCommand as invoke } from '../ipc'; const fallback: PlatformInfo = { os: 'unknown', arch: 'unknown', - targetTriple: 'unknown' + targetTriple: 'unknown', + portable: false }; let cached: PlatformInfo | null = null;