mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +00:00
fix(settings): harden audited state synchronization
This commit is contained in:
+4
-2
@@ -13,6 +13,7 @@ import { isPermissionGranted, requestPermission, sendNotification } from '@tauri
|
||||
import { WindowControls } from "./components/WindowControls";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { setLogStreamActive } from './utils/logger';
|
||||
import { updateDockBadge } from './utils/dockBadge';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
|
||||
import {
|
||||
@@ -207,6 +208,7 @@ function App() {
|
||||
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
||||
const showNotifications = useSettingsStore(state => state.showNotifications);
|
||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||
const dockBadgeSyncVersion = useSettingsStore(state => state.dockBadgeSyncVersion);
|
||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
@@ -689,9 +691,9 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (platform.os === 'macos') {
|
||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||
updateDockBadge(showDockBadge ? activeDownloadCount : 0).catch(() => {});
|
||||
}
|
||||
}, [platform.os, showDockBadge, activeDownloadCount]);
|
||||
}, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_prevent_sleep', {
|
||||
|
||||
@@ -92,6 +92,22 @@ const upsertEngineStatus = (items: EngineStatusItem[], item: EngineStatusItem) =
|
||||
return next;
|
||||
};
|
||||
|
||||
const commitBoundedIntegerInput = (
|
||||
raw: string,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
setValue: (value: number) => void,
|
||||
setDraft: (value: string) => void
|
||||
) => {
|
||||
const parsed = Number(raw);
|
||||
const next = Number.isFinite(parsed)
|
||||
? Math.min(max, Math.max(min, Math.trunc(parsed)))
|
||||
: fallback;
|
||||
setValue(next);
|
||||
setDraft(String(next));
|
||||
};
|
||||
|
||||
const USER_AGENT_SUGGESTIONS = [
|
||||
{
|
||||
label: 'Chrome (Windows)',
|
||||
@@ -293,6 +309,25 @@ const engineRunId = useRef(0);
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
|
||||
const [systemProxyStatus, setSystemProxyStatus] = useState<SystemProxyStatus>('idle');
|
||||
const [perServerConnectionsInput, setPerServerConnectionsInput] = useState(
|
||||
() => String(settings.perServerConnections)
|
||||
);
|
||||
const [maxConcurrentDownloadsInput, setMaxConcurrentDownloadsInput] = useState(
|
||||
() => String(settings.maxConcurrentDownloads)
|
||||
);
|
||||
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
|
||||
|
||||
useEffect(() => {
|
||||
setPerServerConnectionsInput(String(settings.perServerConnections));
|
||||
}, [settings.perServerConnections]);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxConcurrentDownloadsInput(String(settings.maxConcurrentDownloads));
|
||||
}, [settings.maxConcurrentDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
setProxyPortInput(String(settings.proxyPort));
|
||||
}, [settings.proxyPort]);
|
||||
|
||||
// Local state for adding site login
|
||||
const [loginPattern, setLoginPattern] = useState('');
|
||||
@@ -710,13 +745,22 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="16"
|
||||
value={settings.perServerConnections}
|
||||
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
|
||||
onBlur={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
if (val < 1) settings.setPerServerConnections(1);
|
||||
if (val > 16) settings.setPerServerConnections(16);
|
||||
value={perServerConnectionsInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setPerServerConnectionsInput(value);
|
||||
if (value !== '' && Number.isFinite(Number(value))) {
|
||||
settings.setPerServerConnections(Number(value));
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => commitBoundedIntegerInput(
|
||||
e.target.value,
|
||||
settings.perServerConnections,
|
||||
1,
|
||||
16,
|
||||
settings.setPerServerConnections,
|
||||
setPerServerConnectionsInput
|
||||
)}
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -727,13 +771,22 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="12"
|
||||
value={settings.maxConcurrentDownloads}
|
||||
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
|
||||
onBlur={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
if (val < 1) settings.setMaxConcurrentDownloads(1);
|
||||
if (val > 12) settings.setMaxConcurrentDownloads(12);
|
||||
value={maxConcurrentDownloadsInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setMaxConcurrentDownloadsInput(value);
|
||||
if (value !== '' && Number.isFinite(Number(value))) {
|
||||
settings.setMaxConcurrentDownloads(Number(value));
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => commitBoundedIntegerInput(
|
||||
e.target.value,
|
||||
settings.maxConcurrentDownloads,
|
||||
1,
|
||||
12,
|
||||
settings.setMaxConcurrentDownloads,
|
||||
setMaxConcurrentDownloadsInput
|
||||
)}
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -985,8 +1038,22 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="65535"
|
||||
value={settings.proxyPort}
|
||||
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
|
||||
value={proxyPortInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setProxyPortInput(value);
|
||||
if (value !== '' && Number.isFinite(Number(value))) {
|
||||
settings.setProxyPort(Number(value));
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => commitBoundedIntegerInput(
|
||||
e.target.value,
|
||||
settings.proxyPort,
|
||||
1,
|
||||
65535,
|
||||
settings.setProxyPort,
|
||||
setProxyPortInput
|
||||
)}
|
||||
className="app-control settings-port-input text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -40,7 +40,8 @@ type CommandMap = {
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
begin_dock_badge_session: { args: undefined; result: number };
|
||||
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
|
||||
get_platform_info: { args: undefined; result: PlatformInfo };
|
||||
approve_download_root: { args: { path: string }; result: string };
|
||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
import { updateDockBadge } from '../utils/dockBadge';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
@@ -487,7 +488,7 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
|
||||
const syncSystemIntegrations = () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => isTransferActiveStatus(d.status)).length;
|
||||
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
|
||||
updateDockBadge(settings.showDockBadge ? activeCount : 0).catch(() => {});
|
||||
};
|
||||
|
||||
const effectiveDestinationForItem = async (
|
||||
|
||||
@@ -37,6 +37,19 @@ describe('useSettingsStore global speed limit persistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore dock badge synchronization', () => {
|
||||
it('increments the badge sync version for every toggle without issuing out-of-band clears', () => {
|
||||
vi.clearAllMocks();
|
||||
const initialVersion = useSettingsStore.getState().dockBadgeSyncVersion;
|
||||
|
||||
useSettingsStore.getState().setShowDockBadge(false);
|
||||
useSettingsStore.getState().setShowDockBadge(true);
|
||||
|
||||
expect(useSettingsStore.getState().dockBadgeSyncVersion).toBe(initialVersion + 2);
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('update_dock_badge', { count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore credential-store startup flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -203,6 +203,8 @@ export interface SettingsState {
|
||||
appFontSize: AppFontSize;
|
||||
listRowDensity: ListRowDensity;
|
||||
showDockBadge: boolean;
|
||||
/** Forces the App-level badge effect to run for every toggle request. */
|
||||
dockBadgeSyncVersion: number;
|
||||
showMenuBarIcon: boolean;
|
||||
proxyMode: ProxyMode;
|
||||
proxyHost: string;
|
||||
@@ -320,6 +322,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
showDockBadge: true,
|
||||
dockBadgeSyncVersion: 0,
|
||||
showMenuBarIcon: true,
|
||||
proxyMode: 'none',
|
||||
proxyHost: '',
|
||||
@@ -392,8 +395,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||
setShowDockBadge: (showDockBadge) => {
|
||||
set({ showDockBadge });
|
||||
if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error);
|
||||
set(state => ({
|
||||
showDockBadge,
|
||||
dockBadgeSyncVersion: state.dockBadgeSyncVersion + 1
|
||||
}));
|
||||
},
|
||||
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
|
||||
setProxyMode: (proxyMode) => set({ proxyMode }),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as ipc from '../ipc';
|
||||
import { updateDockBadge } from './dockBadge';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn()
|
||||
}));
|
||||
|
||||
describe('dock badge synchronization', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => (
|
||||
command === 'begin_dock_badge_session' ? 1 : undefined
|
||||
));
|
||||
});
|
||||
|
||||
it('attaches increasing generations to concurrent updates', async () => {
|
||||
await Promise.all([updateDockBadge(3), updateDockBadge(0)]);
|
||||
|
||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]).toEqual(['begin_dock_badge_session']);
|
||||
const badgeCalls = calls.slice(1);
|
||||
expect(badgeCalls[0]).toEqual([
|
||||
'update_dock_badge',
|
||||
{ count: 3, generation: expect.any(Number), session: 1 }
|
||||
]);
|
||||
expect(badgeCalls[1]).toEqual([
|
||||
'update_dock_badge',
|
||||
{ count: 0, generation: expect.any(Number), session: 1 }
|
||||
]);
|
||||
const firstBadgeArgs = badgeCalls[0][1] as { generation: number };
|
||||
const secondBadgeArgs = badgeCalls[1][1] as { generation: number };
|
||||
expect(secondBadgeArgs.generation).toBe(firstBadgeArgs.generation + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { invokeCommand } from '../ipc';
|
||||
|
||||
let dockBadgeGeneration = 0;
|
||||
let dockBadgeSessionRequest: Promise<number> | null = null;
|
||||
|
||||
const getDockBadgeSession = (): Promise<number> => {
|
||||
if (!dockBadgeSessionRequest) {
|
||||
const request = invokeCommand('begin_dock_badge_session');
|
||||
dockBadgeSessionRequest = request.catch(error => {
|
||||
dockBadgeSessionRequest = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return dockBadgeSessionRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach the backend session and a per-session generation to every badge
|
||||
* update so stale main-thread callbacks cannot overwrite a newer session.
|
||||
*/
|
||||
export const updateDockBadge = async (count: number): Promise<void> => {
|
||||
const session = await getDockBadgeSession();
|
||||
const generation = ++dockBadgeGeneration;
|
||||
await invokeCommand('update_dock_badge', { count, generation, session });
|
||||
};
|
||||
Reference in New Issue
Block a user