mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(ui): align action controls and protect keychain consent
This commit is contained in:
+17
-7
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)$/
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user