fix(ui): align action controls and protect keychain consent

This commit is contained in:
NimBold
2026-07-17 18:37:50 +03:30
parent 566396e629
commit c949cbb9ee
11 changed files with 199 additions and 86 deletions
+17 -7
View File
@@ -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() {
<span>{actionLabel} in 10 seconds.</span>
<button
type="button"
className="app-button app-button-cancel px-2 py-1"
className="app-button px-2 py-1"
onClick={cancel}
>
Cancel
@@ -384,6 +388,7 @@ function App() {
try {
const settings = useSettingsStore.getState();
const isStartupActive = () => active;
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
portable: currentPlatform?.portable === true,
appVersion: currentKeychainConsentVersion,
@@ -398,15 +403,20 @@ function App() {
// This token is already owned by the backend and does not access
// the OS credential store. Render our explanation before any native
// Keychain/Credential Manager prompt can be user-triggered.
await settings.hydrateSessionPairingToken();
await settings.hydrateSessionPairingToken(isStartupActive);
if (!active) return;
if (showKeychainPrompt) {
settings.setShowKeychainModal(true);
}
} else {
changed = await settings.hydratePairingToken();
settings.setKeychainAccessReady(
currentPlatform?.portable !== true && useSettingsStore.getState().isPairingTokenPersistent
);
changed = await settings.hydratePairingToken(isStartupActive);
if (!active) return;
const currentSettings = useSettingsStore.getState();
settings.setKeychainAccessReady(getKeychainAccessReady({
portable: currentPlatform?.portable === true,
accessGranted: currentSettings.keychainAccessGranted,
persistent: currentSettings.isPairingTokenPersistent
}));
}
if (changed) {
addToast({
+1 -1
View File
@@ -1465,7 +1465,7 @@ export const AddDownloadsModal = () => {
{metadataSummaryMessage(parsedItems)}
</div>
<div className="flex gap-2.5">
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-cancel px-4 text-xs">
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-secondary px-4 text-xs">
Cancel
</button>
<div ref={actionMenuRef} className="relative flex gap-2.5">
+1 -1
View File
@@ -90,7 +90,7 @@ export const DeleteConfirmationModal: React.FC = () => {
<button
onClick={handleCancel}
disabled={isRemoving}
className="app-button app-button-cancel px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
Cancel
</button>
+1 -1
View File
@@ -68,7 +68,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
</div>
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
<button onClick={onCancel} className="app-button app-button-cancel px-4 text-xs">
<button onClick={onCancel} className="app-button px-4 text-xs">
Cancel
</button>
<button
+1 -1
View File
@@ -454,7 +454,7 @@ export const PropertiesModal = () => {
<div className="flex gap-2">
<button
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button app-button-cancel px-4 text-xs"
className="app-button px-4 text-xs"
>
Cancel
</button>
-60
View File
@@ -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));
+69
View File
@@ -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<PairingTokenHydration>(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', () => {
+24 -5
View File
@@ -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<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | 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 = <T>(task: () => Promise<T>): Promise<T> => {
return result;
};
const requestPairingTokenHydration = (): Promise<PairingTokenHydration> => {
if (!pairingTokenHydrationRequest) {
pairingTokenHydrationRequest = invoke('hydrate_extension_pairing_token')
.finally(() => {
pairingTokenHydrationRequest = null;
});
}
return pairingTokenHydrationRequest;
};
export const runSettingsPersistenceTransaction = <T>(
operation: () => Promise<T>
): Promise<T> => enqueueSettingsTask(operation);
@@ -240,11 +252,11 @@ export interface SettingsState {
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => Promise<void>;
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
hydratePairingToken: () => Promise<boolean>;
hydratePairingToken: (isCurrent?: () => boolean) => Promise<boolean>;
setShowKeychainModal: (show: boolean) => void;
setKeychainAccessReady: (ready: boolean) => void;
dismissKeychainPrompt: (version?: string) => void;
hydrateSessionPairingToken: () => Promise<void>;
hydrateSessionPairingToken: (isCurrent?: () => boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsState>()(
@@ -410,6 +422,11 @@ export const useSettingsStore = create<SettingsState>()(
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<SettingsState>()(
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<SettingsState>()(
});
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,
+26 -1
View File
@@ -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)$/
+13
View File
@@ -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,
+46 -9
View File
@@ -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/