mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 07:36:17 +00:00
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:
+58
-7
@@ -10,7 +10,7 @@ import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
|||||||
import { extractValidDownloadUrls } from './utils/url';
|
import { extractValidDownloadUrls } from './utils/url';
|
||||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
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 { initDownloadListener } from './store/downloadStore';
|
||||||
import { useSettingsStore } from "./store/useSettingsStore";
|
import { useSettingsStore } from "./store/useSettingsStore";
|
||||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||||
@@ -127,6 +127,12 @@ function App() {
|
|||||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||||
const pendingPostActionTimer = useRef<number | null>(null);
|
const pendingPostActionTimer = useRef<number | null>(null);
|
||||||
const startupResumeStarted = useRef(false);
|
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 maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
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'>) => {
|
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||||
clearPendingPostActionTimer();
|
clearPendingPostActionTimer();
|
||||||
|
|
||||||
@@ -257,7 +271,7 @@ function App() {
|
|||||||
let unlistenExtension: (() => void) | null = null;
|
let unlistenExtension: (() => void) | null = null;
|
||||||
let unlistenDeepLink: (() => void) | null = null;
|
let unlistenDeepLink: (() => void) | null = null;
|
||||||
const disposeListeners = () => {
|
const disposeListeners = () => {
|
||||||
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
|
void queueFrontendReadyUpdate(false).catch(() => {});
|
||||||
unlistenTerminalState?.();
|
unlistenTerminalState?.();
|
||||||
unlistenTerminalState = null;
|
unlistenTerminalState = null;
|
||||||
unlistenExtension?.();
|
unlistenExtension?.();
|
||||||
@@ -303,11 +317,19 @@ function App() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlistenExtension = await listen('extension-add-download', (event) => {
|
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 => {
|
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
||||||
console.error('Failed to handle browser extension download:', error);
|
console.error('Failed to handle browser extension download:', error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
unlistenDeepLink = await listen('deep-link-add-download', (event) => {
|
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);
|
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,17 +446,45 @@ function App() {
|
|||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setCoreReady(true);
|
setCoreReady(true);
|
||||||
invoke('set_extension_frontend_ready', { ready: true }).catch(error => {
|
|
||||||
console.error('Failed to activate browser extension integration:', error);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
void initialize();
|
void initialize();
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
startupInputReady.current = false;
|
||||||
|
pendingStartupInputs.current = [];
|
||||||
cleanupListeners?.();
|
cleanupListeners?.();
|
||||||
cleanupListeners = null;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
||||||
@@ -678,6 +728,7 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
if (!coreReady || useSettingsStore.getState().showKeychainModal) return;
|
||||||
if (
|
if (
|
||||||
e.target instanceof HTMLInputElement ||
|
e.target instanceof HTMLInputElement ||
|
||||||
e.target instanceof HTMLTextAreaElement ||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
@@ -696,7 +747,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('paste', handlePaste);
|
window.addEventListener('paste', handlePaste);
|
||||||
return () => window.removeEventListener('paste', handlePaste);
|
return () => window.removeEventListener('paste', handlePaste);
|
||||||
}, []);
|
}, [coreReady, showKeychainModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return;
|
if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { getKeychainConsentVersion, getKeychainStartupDecision } from './keychai
|
|||||||
|
|
||||||
describe('getKeychainStartupDecision', () => {
|
describe('getKeychainStartupDecision', () => {
|
||||||
it('changes the consent identity when the credential-access policy changes', () => {
|
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('');
|
expect(getKeychainConsentVersion('')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ export type KeychainStartupDecision = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The semantic app version can remain unchanged across release-candidate and
|
// The semantic app version can remain unchanged across release-candidate and
|
||||||
// packaging rebuilds. Bump this contract version whenever a build can require
|
// packaging rebuilds. Use the build identity so an updated binary cannot skip
|
||||||
// a fresh credential-store authorization, so an updated binary cannot skip
|
// Firelink's explanation and invoke the OS prompt directly. The policy epoch
|
||||||
// Firelink's explanation and invoke the OS prompt directly.
|
// remains only as a safe fallback for builds created outside the Git checkout.
|
||||||
const KEYCHAIN_CONSENT_POLICY_VERSION = '2';
|
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 => {
|
export const getKeychainConsentVersion = (appVersion: string): string => {
|
||||||
const normalizedVersion = appVersion.trim();
|
const normalizedVersion = appVersion.trim();
|
||||||
|
const consentIdentity = buildId && buildId !== 'unknown'
|
||||||
|
? `build-${buildId}`
|
||||||
|
: `keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}`;
|
||||||
return normalizedVersion
|
return normalizedVersion
|
||||||
? `${normalizedVersion}|keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}`
|
? `${normalizedVersion}|${consentIdentity}`
|
||||||
: '';
|
: '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
// @ts-expect-error process is a nodejs global
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'import.meta.env.VITE_BUILD_ID': JSON.stringify(buildId)
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
exclude: [
|
exclude: [
|
||||||
"Extensions/**",
|
"Extensions/**",
|
||||||
|
|||||||
Reference in New Issue
Block a user