fix(startup): enforce keychain consent before credential access

Keep credential-store operations behind a per-process consent gate, prevent duplicate native grant requests, and defer legacy token migration until explicit consent. Harden the RTL/sidebar, custom window controls, bidi copy, and queue editor fixes.

Refs #17
This commit is contained in:
NimBold
2026-07-19 07:07:29 +03:30
parent f47eb7507f
commit 6e85c0842f
17 changed files with 221 additions and 79 deletions
+6
View File
@@ -450,6 +450,12 @@ function App() {
settings.setShowKeychainModal(true);
}
} else {
// The backend keeps credential-store access disabled for every new
// process. Arm it only after the persisted startup decision has
// confirmed that this build was already approved; the hydrate call
// below is then the first operation allowed to touch the OS store.
await invoke('authorize_keychain_access');
if (!active) return;
changed = await settings.hydratePairingToken(isStartupActive);
if (!active) return;
const currentSettings = useSettingsStore.getState();
+5 -2
View File
@@ -25,7 +25,7 @@ import { getPlatformInfo } from '../utils/platform';
import { isTransferLocked } from '../utils/downloadActions';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
import { localePluralVariant } from '../i18n/locales';
import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/locales';
import {
canSubmitMetadataRows,
appendRequestUrlsAfterVersion,
@@ -116,6 +116,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
export const AddDownloadsModal = () => {
const { t, i18n } = useTranslation();
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const { addToast } = useToast();
const {
isAddModalOpen,
@@ -1247,7 +1248,9 @@ export const AddDownloadsModal = () => {
</div>
</div>
<textarea
className="add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none"
className={`add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none ${
isRtl ? 'add-download-links-input--rtl' : ''
}`}
placeholder={t($ => $.addDownloads.pastePlaceholder)}
value={urls}
onChange={(e) => setUrls(e.target.value)}
+41 -10
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useSettingsStore } from '../store/useSettingsStore';
import { invokeCommand as invoke } from '../ipc';
import { KeyRound, ShieldAlert } from 'lucide-react';
@@ -20,16 +20,23 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
const platform = usePlatformInfo();
const [isGranting, setIsGranting] = useState(false);
const [grantRequestPending, setGrantRequestPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
useEffect(() => {
if (!showKeychainModal || isGranting) return;
if (!showKeychainModal || isGranting || grantRequestPending) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
if (event.key !== 'Escape') return;
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
useSettingsStore.getState().setShowKeychainModal(false);
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
if (!showKeychainModal) {
return null;
@@ -56,6 +63,10 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
: t($ => $.keychain.grantLabelDefault);
const handleGrant = async () => {
// A native credential-store call cannot be cancelled from the webview.
// Keep the request identity until it settles so a UI timeout cannot
// launch a second OS prompt while the first one is still outstanding.
if (grantRequestRef.current) return;
setIsGranting(true);
setError(null);
@@ -79,9 +90,21 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
return true;
};
const grantRequest = invoke('grant_keychain_access');
grantRequestRef.current = grantRequest;
setGrantRequestPending(true);
void grantRequest.then(
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
},
() => {
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
setGrantRequestPending(false);
}
);
// A native credential-store call cannot be cancelled by the webview. Keep
// a late successful result useful even if the UI timeout has already
// restored the Later/retry controls.
// returned control to the explanation.
grantRequest.then(applyPersistentGrant).catch(() => undefined);
try {
@@ -106,14 +129,22 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
};
const handleLater = () => {
dismissKeychainPrompt(consentVersion);
if (consentVersion.trim()) {
dismissKeychainPrompt(consentVersion);
} else {
// A modal opened by an early user action can render before the async
// app-version lookup completes. Do not persist a dismissal for an
// unknown build; startup must make the final consent decision once the
// identity is known.
useSettingsStore.getState().setShowKeychainModal(false);
}
};
return (
<div
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={(event) => {
if (event.target === event.currentTarget && !isGranting) handleLater();
if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater();
}}
role="dialog"
aria-modal="true"
@@ -168,17 +199,17 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
<button
onClick={handleLater}
disabled={isGranting}
disabled={isGranting || grantRequestPending}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
>
{t($ => $.keychain.later)}
</button>
<button
onClick={handleGrant}
disabled={isGranting}
disabled={isGranting || grantRequestPending}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
>
{isGranting ? t($ => $.keychain.enabling) : grantLabel}
{isGranting || grantRequestPending ? t($ => $.keychain.enabling) : grantLabel}
</button>
</div>
</div>
+8 -2
View File
@@ -33,6 +33,7 @@ import { usePlatformInfo } from '../utils/platform';
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
import { normalizeCustomProxy } from '../store/useDownloadStore';
import { useTranslation } from 'react-i18next';
import { localeDirection, resolveAppLocale } from '../i18n';
const settingsTabs: { type: SettingsTab; icon: typeof Download }[] = [
{ type: 'downloads', icon: Download },
@@ -252,9 +253,12 @@ const CategoryFolderInput = ({
};
export default function SettingsView() {
const { t } = useTranslation();
const { i18n, t } = useTranslation();
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const isSidebarOnRight = settings.sidebarPosition === 'right'
|| (settings.sidebarPosition === 'auto' && isRtl);
const platform = usePlatformInfo();
const platformName =
platform.os === 'macos'
@@ -680,7 +684,9 @@ runEngineChecks(false);
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
<div className="settings-toolbar">
<div className="settings-tab-strip flex items-stretch gap-1">
<div className={`settings-tab-strip flex items-stretch gap-1 ${
isSidebarOnRight ? 'settings-tab-strip--sidebar-right' : ''
}`}>
{settingsTabs.map(tab => (
<TabButton key={tab.type} {...tab} label={tabLabels[tab.type]} />
))}
+31 -1
View File
@@ -46,6 +46,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const renameQueueCancelRef = useRef<string | null>(null);
const renamingQueueIdRef = useRef<string | null>(null);
const editingQueueNameRef = useRef('');
const rejectedAddQueueNameRef = useRef<string | null>(null);
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
@@ -174,9 +176,17 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
return;
}
if (!addQueue(normalizedName)) {
if (trigger === 'blur' && rejectedAddQueueNameRef.current === normalizedName) {
rejectedAddQueueNameRef.current = null;
setNewQueueName('');
setIsAddingQueue(false);
return;
}
rejectedAddQueueNameRef.current = normalizedName;
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
return;
}
rejectedAddQueueNameRef.current = null;
addQueueSubmitRef.current = true;
setNewQueueName('');
setIsAddingQueue(false);
@@ -202,9 +212,23 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
return;
}
if (!renameQueue(queueId, normalizedName)) {
if (
trigger === 'blur'
&& rejectedRenameRef.current?.queueId === queueId
&& rejectedRenameRef.current.name === normalizedName
) {
rejectedRenameRef.current = null;
renamingQueueIdRef.current = null;
editingQueueNameRef.current = '';
setEditingQueueName('');
setRenamingQueueId(null);
return;
}
rejectedRenameRef.current = { queueId, name: normalizedName };
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
return;
}
rejectedRenameRef.current = null;
renameQueueSubmitRef.current = true;
renamingQueueIdRef.current = null;
setRenamingQueueId(null);
@@ -226,6 +250,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
value={editingQueueName}
onChange={e => {
editingQueueNameRef.current = e.target.value;
rejectedRenameRef.current = null;
setEditingQueueName(e.target.value);
}}
onKeyDown={e => {
@@ -352,7 +377,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
placeholder={t($ => $.actions.queueName)}
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={newQueueName}
onChange={e => setNewQueueName(e.target.value)}
onChange={e => {
rejectedAddQueueNameRef.current = null;
setNewQueueName(e.target.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') handleAddQueueSubmit();
if (e.key === 'Escape') {
@@ -371,6 +399,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
onClick={() => {
addQueueSubmitRef.current = false;
addQueueCancelRef.current = false;
rejectedAddQueueNameRef.current = null;
setIsAddingQueue(true);
setNewQueueName('');
}}
@@ -454,6 +483,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
if (q) {
renameQueueSubmitRef.current = false;
renameQueueCancelRef.current = null;
rejectedRenameRef.current = null;
renamingQueueIdRef.current = q.id;
editingQueueNameRef.current = q.name;
setEditingQueueName(q.name);
+2 -2
View File
@@ -1,5 +1,5 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Minus, Square, X } from 'lucide-react';
import { Maximize2, Minus, X } from 'lucide-react';
import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
@@ -58,7 +58,7 @@ export function WindowControls({ side }: WindowControlsProps) {
void appWindow.toggleMaximize();
}}
>
<Square size={8} strokeWidth={3} />
<Maximize2 size={9} strokeWidth={3} />
</button>
</div>
);
+2 -2
View File
@@ -401,7 +401,7 @@ const fa = {
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
cannotReplace: 'نمی‌توان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
downloadLinks: 'پیوندهای دانلود',
pastePlaceholder: 'URLهای HTTP، HTTPS، FTP یا SFTP را در اینجا جای‌گذاری کنید...\n\nبرای دانلود رسانه، پیوندهایی از YouTube، X، TikTok، Instagram، Reddit و غیره جای‌گذاری کنید.',
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جای‌گذاری کنید...\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.',
playlistSummary: 'لیست پخش "{{title}}": {{loaded}}{{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
safeEntryLimit: ' (به حد نصاب ایمن ورودی‌ها رسیدیم)',
selectedSummary: '{{ready}} انتخاب‌شده آماده، {{fallback}} بازگشتی، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
@@ -704,7 +704,7 @@ const fa = {
firelinkIcon: 'آیکون Firelink',
unknown: 'نامشخص',
version: 'نسخه {{version}}',
description: 'مدیریت دانلود بین پلتفرمی برای macOS، Windows و Linux.',
description: 'مدیریت دانلود چند پلتفرمی برای macOS، Windows و Linux.',
sourceCode: 'کد منبع',
source: 'منبع',
upToDate: 'Firelink {{version}} به‌روز است.',
+1 -1
View File
@@ -401,7 +401,7 @@ const he = {
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
downloadLinks: 'קישורי הורדה',
pastePlaceholder: 'הדבק כתובות HTTP, HTTPS, FTP או SFTP כאן...\n\nעבור הורדות מדיה, הדבק קישורים מ-YouTube, X, TikTok, Instagram, Reddit וכו\'.',
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן...\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
playlistSummary: 'פלייליסט "{{title}}": {{loaded}} מתוך {{total}} רשומות נטענו{{truncated}}{{skipped}}',
safeEntryLimit: ' (הגיע למגבלת רשומות בטוחה)',
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
+23 -2
View File
@@ -621,7 +621,12 @@ html[data-list-density="relaxed"] {
border-radius: 10px;
line-height: 1.55;
direction: ltr;
text-align: start;
text-align: left;
}
.add-download-links-input--rtl:placeholder-shown {
direction: rtl;
text-align: right;
}
.add-download-link-button {
@@ -1004,6 +1009,9 @@ html[data-list-density="relaxed"] {
.app-shell {
direction: ltr;
background: hsl(var(--main-bg));
border: 1px solid hsl(var(--border-color));
border-radius: 14px;
overflow: hidden;
}
.app-sidebar-shell {
@@ -1332,6 +1340,10 @@ html[data-list-density="relaxed"] {
direction: ltr;
}
.settings-tab-strip--sidebar-right {
flex-direction: row-reverse;
}
.settings-tab-button {
min-height: 64px;
border-radius: 8px;
@@ -1630,13 +1642,18 @@ html[data-list-density="relaxed"] {
.mac-switch:checked::after {
transform: translateX(16px);
}
/* Switch thumbs use physical LTR transforms in every interface direction. */
button[role="switch"] {
direction: ltr;
}
.downloads-view {
background: hsl(var(--main-bg));
}
.window-controls {
position: fixed;
top: 15px;
top: 20px;
left: 22px;
right: auto;
z-index: 60;
@@ -1693,6 +1710,10 @@ html[data-list-density="relaxed"] {
filter: saturate(1.14) brightness(1.05);
}
.window-controls:hover .window-control {
color: hsl(0 0% 8% / 0.68);
}
.window-control:active {
transform: scale(0.92);
filter: brightness(0.9);
+1
View File
@@ -66,6 +66,7 @@ type CommandMap = {
get_extension_server_port: { args: undefined; result: number | null };
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
authorize_keychain_access: { args: undefined; result: void };
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
acknowledge_pairing_token_change: { args: undefined; result: void };
+5 -3
View File
@@ -7,14 +7,16 @@ describe('shouldUseCustomWindowControls', () => {
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
});
it('does not render custom controls for macOS', () => {
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(false);
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(false);
it('keeps custom controls present for macOS while platform detection is unresolved', () => {
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
});
it('only opts into the supported desktop platforms', () => {
expect(shouldUseCustomWindowControls('windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(true);
expect(shouldUseCustomWindowControls('linux', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true);
expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(true);
expect(shouldUseCustomWindowControls('android', 'Mozilla/5.0 (Linux; Android 14)')).toBe(false);
expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Linux; Android 14; Mobile)')).toBe(false);
});
});
+9 -2
View File
@@ -9,8 +9,15 @@ const fallback: PlatformInfo = {
portable: false
};
export const shouldUseCustomWindowControls = (os: string, userAgent: string): boolean =>
!userAgent.includes('Mac') && (os === 'windows' || os === 'linux' || os === 'unknown');
export const shouldUseCustomWindowControls = (os: string, userAgent: string): boolean => {
if (os === 'windows' || os === 'linux' || os === 'macos') return true;
if (os !== 'unknown') return false;
// Keep the custom titlebar visible while the native platform query is
// resolving. Mobile user agents are the only unknown targets that must not
// receive desktop window controls.
return !/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent);
};
let cached: PlatformInfo | null = null;
let pending: Promise<PlatformInfo> | null = null;