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/