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).
This commit is contained in:
NimBold
2026-07-15 21:48:00 +03:30
parent 9f333618fc
commit edeef0ac54
4 changed files with 90 additions and 12 deletions
+58 -7
View File
@@ -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<number | null>(null);
const startupResumeStarted = useRef(false);
const startupInputReady = useRef(false);
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
const pendingStartupInputs = useRef<Array<
| { type: 'extension'; payload: ExtensionDownloadRequest }
| { type: 'deep-link'; payload: string }
>>([]);
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<PostQueueAction, 'none'>) => {
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;
+3 -1
View File
@@ -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('');
});
+10 -4
View File
@@ -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}`
: '';
};
+19
View File
@@ -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/**",