From 2fe1683f4be0f88be9a2a406d8b01a2675bdfdc1 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 24 Jun 2026 21:06:20 +0330 Subject: [PATCH] fix(extension): harden desktop handoff --- Extensions/Firefox | 2 +- src-tauri/src/extension_server.rs | 23 +++++++--- src-tauri/src/lib.rs | 22 +++++---- src/App.tsx | 4 +- src/components/KeychainPermissionModal.tsx | 10 ++--- src/components/SettingsView.tsx | 10 ++--- src/store/useDownloadStore.test.ts | 52 ++++++++++++++++++++-- src/store/useDownloadStore.ts | 34 ++++++++++++-- 8 files changed, 122 insertions(+), 35 deletions(-) diff --git a/Extensions/Firefox b/Extensions/Firefox index c35c001..b08c765 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit c35c001d15585eee9d1aa4487b3a58942a2a8f96 +Subproject commit b08c76517aa6344fb0f7d1479199c623eeef069a diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index c8e59d6..6f03c1a 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -26,6 +26,8 @@ pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive = EXTENSION const MAX_URL_COUNT: usize = 200; const SIGNATURE_MAX_AGE_MS: u64 = 60_000; const SERVER_HEADER: &str = "x-firelink-server"; +const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version"; +const PROTOCOL_VERSION: &str = "2"; type HmacSha256 = Hmac; pub type SharedExtensionToken = Arc>; @@ -122,10 +124,13 @@ pub async fn start_server( async fn add_server_identity(request: Request, next: Next) -> Response { let mut response = next.run(request).await; - response - .headers_mut() - .insert(SERVER_HEADER, HeaderValue::from_static("1")); - response + response + .headers_mut() + .insert(SERVER_HEADER, HeaderValue::from_static("1")); + response + .headers_mut() + .insert(PROTOCOL_VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION)); + response } async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> { @@ -374,7 +379,7 @@ fn is_allowed_origin(origin: &str) -> bool { #[cfg(test)] mod tests { - use super::{add_server_identity, SERVER_HEADER}; + use super::{add_server_identity, PROTOCOL_VERSION_HEADER, SERVER_HEADER}; use axum::{http::StatusCode, middleware, routing::get, Router}; #[tokio::test] @@ -391,8 +396,12 @@ mod tests { }); let response = reqwest::get(format!("http://{address}/ping")).await.unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); + assert_eq!( + response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(), + "2" + ); server.abort(); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a33af5b..cbd5bdb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3069,14 +3069,18 @@ fn hydrate_extension_pairing_token( app_state: tauri::State<'_, AppState>, ) -> Result { let mut connection = database.lock()?; - // Frontend always skips keychain on regular hydration to avoid system prompts. - match crate::db::hydrate_pairing_token(&mut connection, true) { - Ok((token, token_changed)) => Ok(PairingTokenHydration { - token, - token_changed, - persistent: false, // Explicitly false since we skipped the keychain - error: None, - }), + match crate::db::hydrate_pairing_token(&mut connection, false) { + Ok((token, token_changed)) => { + if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { + *pairing_token = token.clone(); + } + Ok(PairingTokenHydration { + token, + token_changed, + persistent: true, + error: None, + }) + } Err(error) => { let token = app_state .extension_pairing_token @@ -3893,7 +3897,7 @@ pub fn run() { .map_err(|error| format!("failed to initialize persistence: {error}"))?; let initial_pairing_token = { let mut connection = database.lock()?; - match crate::db::hydrate_pairing_token(&mut connection, true) { + match crate::db::hydrate_pairing_token(&mut connection, false) { Ok((token, _)) => token, Err(error) => { log::warn!( diff --git a/src/App.tsx b/src/App.tsx index e4d5af1..3d0426e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -545,7 +545,9 @@ function App() { }); const unlistenExtension = listen('extension-add-download', (event) => { - useDownloadStore.getState().handleExtensionDownload(event.payload); + useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => { + console.error('Failed to handle browser extension download:', error); + }); }); const unlistenDeepLink = listen('deep-link-add-download', (event) => { useDownloadStore.getState().openAddModalWithUrls(event.payload); diff --git a/src/components/KeychainPermissionModal.tsx b/src/components/KeychainPermissionModal.tsx index 79e5ce2..6faebc8 100644 --- a/src/components/KeychainPermissionModal.tsx +++ b/src/components/KeychainPermissionModal.tsx @@ -46,17 +46,17 @@ export const KeychainPermissionModal: React.FC = () => {
-

Keychain Access Needed

+

Credential Storage Access Needed

- Firelink uses a browser extension to seamlessly capture downloads. - To securely store the pairing token that connects the app and the extension, - we need access to the macOS Keychain. + Firelink uses the browser extension to seamlessly capture downloads. + To securely store the pairing token that connects the app and the extension, + we need access to this system's credential store.

- Note: Firelink only requests access to its own dedicated entry in the Keychain. It cannot and will not access any other passwords or Keychain items on your system. + Note: Firelink only requests access to its own dedicated credential entry. It cannot and will not access other saved passwords or credential items on your system.

{error && ( diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index dd3e4ce..1289fc2 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1061,9 +1061,9 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
-

Keychain Access Granted

+

Credential Storage Available

- Your pairing token is securely saved and will persist across restarts. + Your pairing token is securely saved in this system's credential store and will persist across restarts.

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

Keychain Access Needed

+

Credential Storage Needed

- Firelink needs macOS Keychain access to securely save your pairing token across app restarts. + 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/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 40d5601..889631c 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -55,6 +55,12 @@ describe('useDownloadStore', () => { downloads: [], backendRegisteredIds: new Set(), pendingOrder: [], + isAddModalOpen: false, + pendingAddUrls: '', + pendingAddReferer: '', + pendingAddFilename: '', + pendingAddHeaders: '', + pendingAddCookies: '', }); }); @@ -367,8 +373,8 @@ describe('useDownloadStore', () => { )?.[1] as any).item.queue_id).toBe('queue-a'); }); - it('preserves extension request headers and cookies for the Add modal', () => { - useDownloadStore.getState().handleExtensionDownload({ + it('preserves extension request headers and cookies for the Add modal', async () => { + await useDownloadStore.getState().handleExtensionDownload({ urls: ['https://example.com/file.bin'], referer: 'https://example.com/page', silent: false, @@ -382,7 +388,45 @@ describe('useDownloadStore', () => { expect(state.pendingAddUrls).toBe('https://example.com/file.bin'); expect(state.pendingAddReferer).toBe('https://example.com/page'); expect(state.pendingAddFilename).toBe('file.bin'); - expect(state.pendingAddHeaders).toBe('X-Test: value'); - expect(state.pendingAddCookies).toBe('session=secret'); + expect(state.pendingAddHeaders).toBe('X-Test: value'); + expect(state.pendingAddCookies).toBe('session=secret'); + }); + + it('starts silent extension captures through backend queue', async () => { + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'get_pending_order') return ['silent-id']; + return undefined; }); + vi.spyOn(crypto, 'randomUUID').mockReturnValueOnce('silent-id' as `${string}-${string}-${string}-${string}-${string}`); + + await useDownloadStore.getState().handleExtensionDownload({ + urls: ['https://example.com/downloads/report.pdf'], + referer: 'https://example.com/page', + silent: true, + filename: 'report.pdf', + headers: 'User-Agent: Test', + cookies: 'session=secret' + }); + + const state = useDownloadStore.getState(); + expect(state.isAddModalOpen).toBe(false); + expect(state.downloads[0]).toEqual(expect.objectContaining({ + id: 'silent-id', + url: 'https://example.com/downloads/report.pdf', + fileName: 'report.pdf', + category: 'Documents', + queueId: '00000000-0000-0000-0000-000000000001', + hasBeenDispatched: true + })); + expect(ipc.invokeCommand).toHaveBeenCalledWith( + 'enqueue_download', + expect.objectContaining({ + item: expect.objectContaining({ + id: 'silent-id', + headers: 'User-Agent: Test', + cookies: 'session=secret' + }) + }) + ); + }); }); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 656fca2..04c124f 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -7,7 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus'; import type { ExtensionDownload } from '../bindings/ExtensionDownload'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; -import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -125,6 +125,16 @@ export const getSiteLogin = (url: string, settings: ReturnType { + try { + const url = new URL(rawUrl); + const lastSegment = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() || ''); + return lastSegment || 'download'; + } catch { + return 'download'; + } +}; + const syncSystemIntegrations = () => { const settings = useSettingsStore.getState(); const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length; @@ -192,7 +202,7 @@ interface DownloadState { headers?: string | null, cookies?: string | null ) => void; - handleExtensionDownload: (request: ExtensionDownloadRequest) => void; + handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise; deleteModalState: DeleteModalState; openDeleteModal: (downloadIds?: string | string[]) => void; closeDeleteModal: () => void; @@ -302,10 +312,28 @@ export const useDownloadStore = create((set, get) => ({ pendingAddCookies: cookies?.trim() || state.pendingAddCookies || '' }; }), - handleExtensionDownload: (request) => { + handleExtensionDownload: async (request) => { const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))]; if (urls.length === 0) return; + if (request.silent) { + for (const url of urls) { + const filename = canonicalizeDownloadFileName( + urls.length === 1 && request.filename ? request.filename : suggestedFileNameFromUrl(url) + ); + await get().addDownload({ + id: crypto.randomUUID(), + url, + fileName: filename, + category: categoryForFileName(filename), + dateAdded: new Date().toISOString(), + headers: request.headers?.trim() || undefined, + cookies: urls.length === 1 ? request.cookies?.trim() || undefined : undefined, + }, { type: 'start-now' }); + } + return; + } + get().openAddModalWithUrls( urls.join('\n'), request.referer,