diff --git a/src/App.tsx b/src/App.tsx index 7eb1e4c..5ab51e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,11 @@ import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; import { openUrl } from '@tauri-apps/plugin-opener'; import { getPlatformInfo, usePlatformInfo } from './utils/platform'; -import { getKeychainConsentVersion, getKeychainStartupDecision } from './utils/keychainStartup'; +import { + getKeychainAccessReady, + getKeychainConsentVersion, + getKeychainStartupDecision +} from './utils/keychainStartup'; import { getVersion } from '@tauri-apps/api/app'; import type { PostQueueAction } from './bindings/PostQueueAction'; import { PanelLeft } from 'lucide-react'; @@ -198,7 +202,7 @@ function App() { {actionLabel} in 10 seconds.
diff --git a/src/components/DeleteConfirmationModal.tsx b/src/components/DeleteConfirmationModal.tsx index 21b132a..c0c74a6 100644 --- a/src/components/DeleteConfirmationModal.tsx +++ b/src/components/DeleteConfirmationModal.tsx @@ -90,7 +90,7 @@ export const DeleteConfirmationModal: React.FC = () => { diff --git a/src/components/DuplicateResolutionModal.tsx b/src/components/DuplicateResolutionModal.tsx index 7bd7ca1..da83c1d 100644 --- a/src/components/DuplicateResolutionModal.tsx +++ b/src/components/DuplicateResolutionModal.tsx @@ -68,7 +68,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
- diff --git a/src/index.css b/src/index.css index 1f1c361..658e3e8 100644 --- a/src/index.css +++ b/src/index.css @@ -464,28 +464,6 @@ html[data-list-density="relaxed"] { background: hsl(var(--accent-color) / 0.9); } - .app-button-cancel { - border-color: hsl(var(--border-modal)); - background: - linear-gradient(180deg, hsl(0 0% 100% / 0.055), transparent), - hsl(var(--bg-input) / 0.88); - color: hsl(var(--text-secondary)); - box-shadow: - inset 0 1px 0 hsl(0 0% 100% / 0.055), - 0 1px 2px hsl(var(--shadow-color)); - } - - .app-button-cancel:hover:not(:disabled) { - border-color: hsl(var(--text-muted) / 0.65); - background: - linear-gradient(hsl(var(--item-hover)), hsl(var(--item-hover))), - hsl(var(--bg-input)); - color: hsl(var(--text-primary)); - box-shadow: - inset 0 1px 0 hsl(0 0% 100% / 0.07), - 0 2px 5px hsl(var(--shadow-color)); - } - .app-icon-button { display: inline-flex; width: 28px; @@ -865,28 +843,6 @@ html[data-list-density="relaxed"] { hsl(var(--bg-input)); } - .add-download-button-cancel { - border-color: hsl(var(--border-modal)); - background: - linear-gradient(180deg, hsl(0 0% 100% / 0.055), transparent), - hsl(var(--bg-input) / 0.88); - color: hsl(var(--text-secondary)); - box-shadow: - inset 0 1px 0 hsl(0 0% 100% / 0.055), - 0 1px 2px hsl(var(--shadow-color)); - } - - .add-download-button-cancel:hover:not(:disabled) { - border-color: hsl(var(--text-muted) / 0.65); - background: - linear-gradient(hsl(var(--item-hover)), hsl(var(--item-hover))), - hsl(var(--bg-input)); - color: hsl(var(--text-primary)); - box-shadow: - inset 0 1px 0 hsl(0 0% 100% / 0.07), - 0 2px 5px hsl(var(--shadow-color)); - } - .add-download-button-primary { border-color: hsl(var(--accent-color) / 0.86); background: @@ -1017,27 +973,11 @@ html[data-list-density="relaxed"] { box-shadow: none; } - :is(.theme-dark, .theme-dracula, .theme-nord) .add-download-button-cancel { - border-color: hsl(var(--add-keyline)); - background: hsl(var(--add-section-bg)); - box-shadow: - inset 0 1px 0 hsl(0 0% 100% / 0.055), - 0 1px 2px hsl(var(--add-shadow)); - } - :is(.theme-dark, .theme-dracula, .theme-nord) .add-download-button-secondary:hover:not(:disabled) { border-color: hsl(var(--add-keyline-strong)); background: hsl(var(--item-hover)); } - :is(.theme-dark, .theme-dracula, .theme-nord) .add-download-button-cancel:hover:not(:disabled) { - border-color: hsl(var(--add-keyline-strong)); - background: hsl(var(--item-hover)); - box-shadow: - inset 0 1px 0 hsl(0 0% 100% / 0.07), - 0 2px 5px hsl(var(--add-shadow)); - } - :is(.theme-dark, .theme-dracula, .theme-nord) .add-download-button-primary { border-color: hsl(var(--accent-color)); background: hsl(var(--accent-color)); diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index b5a8bc8..43d33cf 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -5,6 +5,7 @@ import { useSettingsStore } from './useSettingsStore'; import * as ipc from '../ipc'; +import type { PairingTokenHydration } from '../bindings/PairingTokenHydration'; vi.mock('../ipc', () => ({ invokeCommand: vi.fn() @@ -69,6 +70,74 @@ describe('useSettingsStore credential-store startup flow', () => { expect(useSettingsStore.getState().keychainAccessVersion).toBe('1.0.5'); expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true); }); + + it('opens the consent modal instead of regenerating through the credential store', async () => { + await expect(useSettingsStore.getState().regeneratePairingToken()) + .rejects.toThrow('Grant credential-store access before regenerating the pairing token.'); + + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('regenerate_pairing_token'); + expect(useSettingsStore.getState().showKeychainModal).toBe(true); + }); + + it('does not apply pairing hydration after startup becomes inactive', async () => { + vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({ + token: 'stale-token', + tokenChanged: true, + persistent: true, + error: null + }); + + await expect(useSettingsStore.getState().hydratePairingToken(() => false)).resolves.toBe(false); + + expect(ipc.invokeCommand).toHaveBeenCalledWith('hydrate_extension_pairing_token'); + expect(useSettingsStore.getState().extensionPairingToken).toBe(''); + expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false); + }); + + it('does not apply session hydration after startup becomes inactive', async () => { + vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({ + token: 'stale-session-token', + tokenChanged: false, + persistent: false, + error: null + }); + + await useSettingsStore.getState().hydrateSessionPairingToken(() => false); + + expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token'); + expect(useSettingsStore.getState().extensionPairingToken).toBe(''); + expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false); + }); + + it('shares a concurrent pairing hydration request', async () => { + let resolveRequest!: (value: PairingTokenHydration) => void; + const request = new Promise(resolve => { + resolveRequest = resolve; + }); + let hydrationRequestCount = 0; + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'hydrate_extension_pairing_token') { + hydrationRequestCount += 1; + return request; + } + return undefined; + }); + + const first = useSettingsStore.getState().hydratePairingToken(); + const second = useSettingsStore.getState().hydratePairingToken(); + + expect(hydrationRequestCount).toBe(1); + resolveRequest({ + token: 'shared-token', + tokenChanged: false, + persistent: true, + error: null + }); + await Promise.all([first, second]); + + expect(useSettingsStore.getState().extensionPairingToken).toBe('shared-token'); + expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(true); + }); }); describe('useSettingsStore persistence failures', () => { diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index f7c87c3..19237bd 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -8,6 +8,7 @@ import type { ListRowDensity } from '../bindings/ListRowDensity'; import type { MediaCookieSource } from '../bindings/MediaCookieSource'; import type { PostQueueAction } from '../bindings/PostQueueAction'; import type { PersistedSettings } from '../bindings/PersistedSettings'; +import type { PairingTokenHydration } from '../bindings/PairingTokenHydration'; import type { ProxyMode } from '../bindings/ProxyMode'; import type { SchedulerSettings } from '../bindings/SchedulerSettings'; import type { SettingsTab } from '../bindings/SettingsTab'; @@ -20,6 +21,7 @@ import { import { normalizeSpeedLimitForBackend } from '../utils/downloads'; let settingsQueue: Promise = Promise.resolve(); +let pairingTokenHydrationRequest: Promise | null = null; const settingsPersistenceErrorListeners = new Set<() => void>(); let settingsPersistenceFailed = false; const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; @@ -37,6 +39,16 @@ const enqueueSettingsTask = (task: () => Promise): Promise => { return result; }; +const requestPairingTokenHydration = (): Promise => { + if (!pairingTokenHydrationRequest) { + pairingTokenHydrationRequest = invoke('hydrate_extension_pairing_token') + .finally(() => { + pairingTokenHydrationRequest = null; + }); + } + return pairingTokenHydrationRequest; +}; + export const runSettingsPersistenceTransaction = ( operation: () => Promise ): Promise => enqueueSettingsTask(operation); @@ -240,11 +252,11 @@ export interface SettingsState { removeSiteLogin: (id: string) => void; regeneratePairingToken: () => Promise; setAutoCheckUpdates: (autoCheckUpdates: boolean) => void; - hydratePairingToken: () => Promise; + hydratePairingToken: (isCurrent?: () => boolean) => Promise; setShowKeychainModal: (show: boolean) => void; setKeychainAccessReady: (ready: boolean) => void; dismissKeychainPrompt: (version?: string) => void; - hydrateSessionPairingToken: () => Promise; + hydrateSessionPairingToken: (isCurrent?: () => boolean) => Promise; } export const useSettingsStore = create()( @@ -410,6 +422,11 @@ export const useSettingsStore = create()( siteLogins: state.siteLogins.filter((login) => login.id !== id) })), regeneratePairingToken: async () => { + const current = get(); + if (!current.keychainAccessReady && !current.isPairingTokenPersistent) { + set({ showKeychainModal: true }); + throw new Error('Grant credential-store access before regenerating the pairing token.'); + } const result = await invoke('regenerate_pairing_token'); if (!result.persistent) { throw new Error(result.error || 'Credential store access is unavailable.'); @@ -420,11 +437,12 @@ export const useSettingsStore = create()( showKeychainModal: false }); }, - hydratePairingToken: async () => { + hydratePairingToken: async (isCurrent) => { // The backend migrates legacy settings copies and reads the token from // the credential store after the app state is ready to receive it. // Portable mode remains the explicit folder-contained exception. - const result = await invoke('hydrate_extension_pairing_token'); + const result = await requestPairingTokenHydration(); + if (isCurrent && !isCurrent()) return false; set({ extensionPairingToken: result.token, isPairingTokenPersistent: result.persistent, @@ -432,8 +450,9 @@ export const useSettingsStore = create()( }); return result.tokenChanged; }, - hydrateSessionPairingToken: async () => { + hydrateSessionPairingToken: async (isCurrent) => { const result = await invoke('get_session_pairing_token'); + if (isCurrent && !isCurrent()) return; set({ extensionPairingToken: result.token, isPairingTokenPersistent: false, diff --git a/src/utils/keychainStartup.test.ts b/src/utils/keychainStartup.test.ts index ed7dc47..f550604 100644 --- a/src/utils/keychainStartup.test.ts +++ b/src/utils/keychainStartup.test.ts @@ -1,7 +1,32 @@ import { describe, expect, it } from 'vitest'; -import { getKeychainConsentVersion, getKeychainStartupDecision } from './keychainStartup'; +import { + getKeychainAccessReady, + getKeychainConsentVersion, + getKeychainStartupDecision +} from './keychainStartup'; describe('getKeychainStartupDecision', () => { + it('keeps portable site credentials gated until system-store access is granted', () => { + expect(getKeychainAccessReady({ + portable: true, + accessGranted: false, + persistent: true + })).toBe(false); + expect(getKeychainAccessReady({ + portable: true, + accessGranted: true, + persistent: true + })).toBe(true); + }); + + it('uses persistent pairing state for standard-mode readiness', () => { + expect(getKeychainAccessReady({ + portable: false, + accessGranted: false, + persistent: true + })).toBe(true); + }); + it('changes the consent identity when the credential-access policy changes', () => { expect(getKeychainConsentVersion('1.1.0')).toMatch( /^1\.1\.0\|(build-.+|keychain-policy-2)$/ diff --git a/src/utils/keychainStartup.ts b/src/utils/keychainStartup.ts index 4117e0f..1b3fd9c 100644 --- a/src/utils/keychainStartup.ts +++ b/src/utils/keychainStartup.ts @@ -11,6 +11,12 @@ export type KeychainStartupDecision = { showKeychainPrompt: boolean; }; +export type KeychainAccessReadiness = { + portable: boolean; + accessGranted: boolean; + persistent: boolean; +}; + // The semantic app version can remain unchanged across release-candidate and // packaging rebuilds. Use the build identity so an updated binary cannot skip // Firelink's explanation and invoke the OS prompt directly. The policy epoch @@ -30,6 +36,13 @@ export const getKeychainConsentVersion = (appVersion: string): string => { : ''; }; +export const getKeychainAccessReady = ({ + portable, + accessGranted, + persistent +}: KeychainAccessReadiness): boolean => + portable ? accessGranted : persistent; + export const getKeychainStartupDecision = ({ portable, appVersion, diff --git a/vite.config.ts b/vite.config.ts index 263dbd1..7a402bb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from "vitest/config"; -import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { relative, resolve } from "node:path"; import react from "@vitejs/plugin-react"; import tailwindcss from '@tailwindcss/vite'; @@ -8,17 +10,52 @@ 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. + // consent identity must represent the actual input to this build, not only + // the last committed revision: local rebuilds, untracked source files, and + // packaged working trees can have different code-signing identities while + // sharing the same HEAD or having no Git metadata at all. 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'; + + const projectRoot = process.cwd(); + const inputRoots = [ + 'src', + 'src-tauri/src', + 'src-tauri/capabilities', + 'src-tauri/Cargo.toml', + 'src-tauri/Cargo.lock', + 'src-tauri/build.rs', + 'src-tauri/tauri.conf.json', + 'index.html', + 'package.json', + 'package-lock.json', + 'vite.config.ts' + ]; + const files: string[] = []; + const collectFiles = (path: string) => { + if (!existsSync(path) || lstatSync(path).isSymbolicLink()) return; + const stats = statSync(path); + if (stats.isFile()) { + files.push(path); + return; + } + if (!stats.isDirectory()) return; + for (const entry of readdirSync(path).sort()) { + collectFiles(resolve(path, entry)); + } + }; + + inputRoots.forEach(input => collectFiles(resolve(projectRoot, input))); + if (files.length === 0) return process.env.GITHUB_SHA?.trim() || 'unknown'; + + const fingerprint = createHash('sha256'); + for (const file of files.sort()) { + fingerprint.update(relative(projectRoot, file)); + fingerprint.update('\0'); + fingerprint.update(readFileSync(file)); + fingerprint.update('\0'); } + return fingerprint.digest('hex').slice(0, 24); })(); // https://vite.dev/config/