From edeef0ac54dffcb3968c70a6d47fec6ecaaca066 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 15 Jul 2026 21:48:00 +0330 Subject: [PATCH] fix(startup): enforce consent before download handoffs Gate clipboard, extension, and deep-link inputs until startup consent is resolved, and serialize extension readiness transitions so native prompts cannot race the app explanation. Identify consent by the build revision so same-version updates re-enter the consent boundary. Requested by [this X post](https://x.com/ixabolfazl/status/2077356127763804450?s=20). --- src/App.tsx | 65 +++++++++++++++++++++++++++---- src/utils/keychainStartup.test.ts | 4 +- src/utils/keychainStartup.ts | 14 +++++-- vite.config.ts | 19 +++++++++ 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b134d44..e4e9be6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal"; import { extractValidDownloadUrls } from './utils/url'; import { readClipboardDownloadUrls } from './utils/clipboard'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; -import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore'; +import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore'; import { initDownloadListener } from './store/downloadStore'; import { useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; @@ -127,6 +127,12 @@ function App() { const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds); const pendingPostActionTimer = useRef(null); const startupResumeStarted = useRef(false); + const startupInputReady = useRef(false); + const frontendReadyUpdate = useRef>(Promise.resolve()); + const pendingStartupInputs = useRef>([]); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; @@ -151,6 +157,14 @@ function App() { } }, []); + const queueFrontendReadyUpdate = useCallback((ready: boolean) => { + const update = frontendReadyUpdate.current + .catch(() => undefined) + .then(() => invoke('set_extension_frontend_ready', { ready })); + frontendReadyUpdate.current = update; + return update; + }, []); + const schedulePostQueueAction = useCallback((action: Exclude) => { clearPendingPostActionTimer(); @@ -257,7 +271,7 @@ function App() { let unlistenExtension: (() => void) | null = null; let unlistenDeepLink: (() => void) | null = null; const disposeListeners = () => { - invoke('set_extension_frontend_ready', { ready: false }).catch(() => {}); + void queueFrontendReadyUpdate(false).catch(() => {}); unlistenTerminalState?.(); unlistenTerminalState = null; unlistenExtension?.(); @@ -303,11 +317,19 @@ function App() { } }); unlistenExtension = await listen('extension-add-download', (event) => { + if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) { + pendingStartupInputs.current.push({ type: 'extension', payload: event.payload }); + return; + } useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => { console.error('Failed to handle browser extension download:', error); }); }); unlistenDeepLink = await listen('deep-link-add-download', (event) => { + if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) { + pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload }); + return; + } useDownloadStore.getState().openAddModalWithUrls(event.payload); }); @@ -424,17 +446,45 @@ function App() { if (!active) return; setCoreReady(true); - invoke('set_extension_frontend_ready', { ready: true }).catch(error => { - console.error('Failed to activate browser extension integration:', error); - }); }; void initialize(); return () => { active = false; + startupInputReady.current = false; + pendingStartupInputs.current = []; cleanupListeners?.(); cleanupListeners = null; }; - }, [addToast]); + }, [addToast, queueFrontendReadyUpdate]); + + useEffect(() => { + if (!coreReady) return; + // The backend must not emit extension/deep-link handoffs while the + // explanatory Keychain dialog is active. Deep links are buffered by the + // coordinator, and extension callers receive a retryable 503 instead. + void queueFrontendReadyUpdate(!showKeychainModal).catch(error => { + console.error('Failed to update browser extension readiness:', error); + }); + }, [coreReady, queueFrontendReadyUpdate, showKeychainModal]); + + useEffect(() => { + if (!coreReady || showKeychainModal) { + startupInputReady.current = false; + return; + } + if (startupInputReady.current) return; + startupInputReady.current = true; + const pendingInputs = pendingStartupInputs.current.splice(0); + for (const input of pendingInputs) { + if (input.type === 'extension') { + useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => { + console.error('Failed to handle queued browser extension download:', error); + }); + } else { + useDownloadStore.getState().openAddModalWithUrls(input.payload); + } + } + }, [coreReady, showKeychainModal]); useEffect(() => { if (!coreReady || showKeychainModal || startupResumeStarted.current) return; @@ -678,6 +728,7 @@ function App() { useEffect(() => { const handlePaste = (e: ClipboardEvent) => { + if (!coreReady || useSettingsStore.getState().showKeychainModal) return; if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || @@ -696,7 +747,7 @@ function App() { }; window.addEventListener('paste', handlePaste); return () => window.removeEventListener('paste', handlePaste); - }, []); + }, [coreReady, showKeychainModal]); useEffect(() => { if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return; diff --git a/src/utils/keychainStartup.test.ts b/src/utils/keychainStartup.test.ts index a5df1d8..ed7dc47 100644 --- a/src/utils/keychainStartup.test.ts +++ b/src/utils/keychainStartup.test.ts @@ -3,7 +3,9 @@ import { getKeychainConsentVersion, getKeychainStartupDecision } from './keychai describe('getKeychainStartupDecision', () => { it('changes the consent identity when the credential-access policy changes', () => { - expect(getKeychainConsentVersion('1.1.0')).toBe('1.1.0|keychain-policy-2'); + expect(getKeychainConsentVersion('1.1.0')).toMatch( + /^1\.1\.0\|(build-.+|keychain-policy-2)$/ + ); expect(getKeychainConsentVersion('')).toBe(''); }); diff --git a/src/utils/keychainStartup.ts b/src/utils/keychainStartup.ts index 117c5ce..4117e0f 100644 --- a/src/utils/keychainStartup.ts +++ b/src/utils/keychainStartup.ts @@ -12,15 +12,21 @@ export type KeychainStartupDecision = { }; // The semantic app version can remain unchanged across release-candidate and -// packaging rebuilds. Bump this contract version whenever a build can require -// a fresh credential-store authorization, so an updated binary cannot skip -// Firelink's explanation and invoke the OS prompt directly. +// packaging rebuilds. Use the build identity so an updated binary cannot skip +// Firelink's explanation and invoke the OS prompt directly. The policy epoch +// remains only as a safe fallback for builds created outside the Git checkout. const KEYCHAIN_CONSENT_POLICY_VERSION = '2'; +const buildId = typeof import.meta.env.VITE_BUILD_ID === 'string' + ? import.meta.env.VITE_BUILD_ID.trim() + : ''; export const getKeychainConsentVersion = (appVersion: string): string => { const normalizedVersion = appVersion.trim(); + const consentIdentity = buildId && buildId !== 'unknown' + ? `build-${buildId}` + : `keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}`; return normalizedVersion - ? `${normalizedVersion}|keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}` + ? `${normalizedVersion}|${consentIdentity}` : ''; }; diff --git a/vite.config.ts b/vite.config.ts index b6eb30a..263dbd1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,32 @@ import { defineConfig } from "vitest/config"; +import { execFileSync } from "node:child_process"; import react from "@vitejs/plugin-react"; import tailwindcss from '@tailwindcss/vite'; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; +const buildId = (() => { + // Release-candidate builds can keep the same semantic app version. The + // source revision is the stable identity needed for consent migrations. + const configured = process.env.VITE_BUILD_ID?.trim(); + if (configured) return configured; + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() || 'unknown'; + } catch { + return 'unknown'; + } +})(); + // https://vite.dev/config/ export default defineConfig(async () => ({ plugins: [react(), tailwindcss()], + define: { + 'import.meta.env.VITE_BUILD_ID': JSON.stringify(buildId) + }, test: { exclude: [ "Extensions/**",