fix(extension): harden desktop handoff

This commit is contained in:
NimBold
2026-06-24 21:06:20 +03:30
parent 6f426c1a18
commit 2fe1683f4b
8 changed files with 122 additions and 35 deletions
+16 -7
View File
@@ -26,6 +26,8 @@ pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION
const MAX_URL_COUNT: usize = 200; const MAX_URL_COUNT: usize = 200;
const SIGNATURE_MAX_AGE_MS: u64 = 60_000; const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
const SERVER_HEADER: &str = "x-firelink-server"; const SERVER_HEADER: &str = "x-firelink-server";
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
const PROTOCOL_VERSION: &str = "2";
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>; pub type SharedExtensionToken = Arc<RwLock<String>>;
@@ -122,10 +124,13 @@ pub async fn start_server(
async fn add_server_identity(request: Request<Body>, next: Next) -> Response { async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await; let mut response = next.run(request).await;
response response
.headers_mut() .headers_mut()
.insert(SERVER_HEADER, HeaderValue::from_static("1")); .insert(SERVER_HEADER, HeaderValue::from_static("1"));
response response
.headers_mut()
.insert(PROTOCOL_VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
response
} }
async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> { async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> {
@@ -374,7 +379,7 @@ fn is_allowed_origin(origin: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { 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}; use axum::{http::StatusCode, middleware, routing::get, Router};
#[tokio::test] #[tokio::test]
@@ -391,8 +396,12 @@ mod tests {
}); });
let response = reqwest::get(format!("http://{address}/ping")).await.unwrap(); let response = reqwest::get(format!("http://{address}/ping")).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
"2"
);
server.abort(); server.abort();
} }
+13 -9
View File
@@ -3069,14 +3069,18 @@ fn hydrate_extension_pairing_token(
app_state: tauri::State<'_, AppState>, app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> { ) -> Result<PairingTokenHydration, String> {
let mut connection = database.lock()?; let mut connection = database.lock()?;
// Frontend always skips keychain on regular hydration to avoid system prompts. match crate::db::hydrate_pairing_token(&mut connection, false) {
match crate::db::hydrate_pairing_token(&mut connection, true) { Ok((token, token_changed)) => {
Ok((token, token_changed)) => Ok(PairingTokenHydration { if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
token, *pairing_token = token.clone();
token_changed, }
persistent: false, // Explicitly false since we skipped the keychain Ok(PairingTokenHydration {
error: None, token,
}), token_changed,
persistent: true,
error: None,
})
}
Err(error) => { Err(error) => {
let token = app_state let token = app_state
.extension_pairing_token .extension_pairing_token
@@ -3893,7 +3897,7 @@ pub fn run() {
.map_err(|error| format!("failed to initialize persistence: {error}"))?; .map_err(|error| format!("failed to initialize persistence: {error}"))?;
let initial_pairing_token = { let initial_pairing_token = {
let mut connection = database.lock()?; 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, Ok((token, _)) => token,
Err(error) => { Err(error) => {
log::warn!( log::warn!(
+3 -1
View File
@@ -545,7 +545,9 @@ function App() {
}); });
const unlistenExtension = listen('extension-add-download', (event) => { 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) => { const unlistenDeepLink = listen('deep-link-add-download', (event) => {
useDownloadStore.getState().openAddModalWithUrls(event.payload); useDownloadStore.getState().openAddModalWithUrls(event.payload);
+5 -5
View File
@@ -46,17 +46,17 @@ export const KeychainPermissionModal: React.FC = () => {
<div className="p-2 bg-blue-500/10 rounded-full flex items-center justify-center"> <div className="p-2 bg-blue-500/10 rounded-full flex items-center justify-center">
<KeyRound size={20} className="text-blue-500" /> <KeyRound size={20} className="text-blue-500" />
</div> </div>
<h2 className="text-lg font-semibold text-text-primary m-0">Keychain Access Needed</h2> <h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
</div> </div>
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4"> <div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4">
<p> <p>
Firelink uses a browser extension to seamlessly capture downloads. Firelink uses the browser extension to seamlessly capture downloads.
To securely store the pairing token that connects the app and the extension, To securely store the pairing token that connects the app and the extension,
we need access to the macOS Keychain. we need access to this system's credential store.
</p> </p>
<p> <p>
<strong>Note:</strong> 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. <strong>Note:</strong> 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.
</p> </p>
{error && ( {error && (
+5 -5
View File
@@ -1061,9 +1061,9 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
<Check size={16} strokeWidth={2.5} /> <Check size={16} strokeWidth={2.5} />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h4 className="text-sm font-semibold text-green-500 m-0">Keychain Access Granted</h4> <h4 className="text-sm font-semibold text-green-500 m-0">Credential Storage Available</h4>
<p className="text-xs text-text-secondary m-0 mt-0.5"> <p className="text-xs text-text-secondary m-0 mt-0.5">
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.
</p> </p>
</div> </div>
</div> </div>
@@ -1071,16 +1071,16 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 flex items-start gap-3"> <div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 flex items-start gap-3">
<ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" /> <ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<h4 className="text-sm font-semibold text-text-primary mb-1">Keychain Access Needed</h4> <h4 className="text-sm font-semibold text-text-primary mb-1">Credential Storage Needed</h4>
<p className="text-xs text-text-secondary mb-3"> <p className="text-xs text-text-secondary mb-3">
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. Currently, your extension will only stay connected for this session.
</p> </p>
<button <button
onClick={() => settings.setShowKeychainModal(true)} onClick={() => settings.setShowKeychainModal(true)}
className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-white hover:bg-accent/90 shadow-sm" className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-white hover:bg-accent/90 shadow-sm"
> >
Grant Keychain Access Grant Credential Access
</button> </button>
</div> </div>
</div> </div>
+48 -4
View File
@@ -55,6 +55,12 @@ describe('useDownloadStore', () => {
downloads: [], downloads: [],
backendRegisteredIds: new Set(), backendRegisteredIds: new Set(),
pendingOrder: [], pendingOrder: [],
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
pendingAddFilename: '',
pendingAddHeaders: '',
pendingAddCookies: '',
}); });
}); });
@@ -367,8 +373,8 @@ describe('useDownloadStore', () => {
)?.[1] as any).item.queue_id).toBe('queue-a'); )?.[1] as any).item.queue_id).toBe('queue-a');
}); });
it('preserves extension request headers and cookies for the Add modal', () => { it('preserves extension request headers and cookies for the Add modal', async () => {
useDownloadStore.getState().handleExtensionDownload({ await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/file.bin'], urls: ['https://example.com/file.bin'],
referer: 'https://example.com/page', referer: 'https://example.com/page',
silent: false, silent: false,
@@ -382,7 +388,45 @@ describe('useDownloadStore', () => {
expect(state.pendingAddUrls).toBe('https://example.com/file.bin'); expect(state.pendingAddUrls).toBe('https://example.com/file.bin');
expect(state.pendingAddReferer).toBe('https://example.com/page'); expect(state.pendingAddReferer).toBe('https://example.com/page');
expect(state.pendingAddFilename).toBe('file.bin'); expect(state.pendingAddFilename).toBe('file.bin');
expect(state.pendingAddHeaders).toBe('X-Test: value'); expect(state.pendingAddHeaders).toBe('X-Test: value');
expect(state.pendingAddCookies).toBe('session=secret'); 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'
})
})
);
});
}); });
+31 -3
View File
@@ -7,7 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload'; import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue'; import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { import {
resolveCategoryDestination resolveCategoryDestination
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
@@ -125,6 +125,16 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
return null; return null;
}; };
const suggestedFileNameFromUrl = (rawUrl: string): string => {
try {
const url = new URL(rawUrl);
const lastSegment = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() || '');
return lastSegment || 'download';
} catch {
return 'download';
}
};
const syncSystemIntegrations = () => { const syncSystemIntegrations = () => {
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length; const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
@@ -192,7 +202,7 @@ interface DownloadState {
headers?: string | null, headers?: string | null,
cookies?: string | null cookies?: string | null
) => void; ) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
deleteModalState: DeleteModalState; deleteModalState: DeleteModalState;
openDeleteModal: (downloadIds?: string | string[]) => void; openDeleteModal: (downloadIds?: string | string[]) => void;
closeDeleteModal: () => void; closeDeleteModal: () => void;
@@ -302,10 +312,28 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddCookies: cookies?.trim() || state.pendingAddCookies || '' pendingAddCookies: cookies?.trim() || state.pendingAddCookies || ''
}; };
}), }),
handleExtensionDownload: (request) => { handleExtensionDownload: async (request) => {
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))]; const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
if (urls.length === 0) return; 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( get().openAddModalWithUrls(
urls.join('\n'), urls.join('\n'),
request.referer, request.referer,