mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 18:33:39 +00:00
fix(persistence): preserve user data across updates
This commit is contained in:
+51
-7
@@ -18,6 +18,7 @@ import DiagnosticsView from "./components/DiagnosticsView";
|
||||
|
||||
function App() {
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [pairingTokenChanged, setPairingTokenChanged] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
||||
@@ -39,6 +40,13 @@ function App() {
|
||||
const previousSpeedLimit = useRef<string | null>(null);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
|
||||
const acknowledgePairingTokenChange = () => {
|
||||
setPairingTokenChanged(false);
|
||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||
console.error('Failed to acknowledge pairing token migration notice:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
@@ -67,13 +75,11 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
useDownloadStore.getState().initDB();
|
||||
// Hydrate the browser-extension pairing token from the OS keychain before
|
||||
// the reactive push to the backend. If no token exists (fresh install or
|
||||
// upgrade from a plaintext-persisting version) a new one is minted and
|
||||
// stored, effectively rotating it away from any leaked plaintext.
|
||||
useSettingsStore.getState().hydratePairingToken().catch(error => {
|
||||
console.error('Failed to hydrate extension pairing token:', error);
|
||||
});
|
||||
useSettingsStore.getState().hydratePairingToken()
|
||||
.then(setPairingTokenChanged)
|
||||
.catch(error => {
|
||||
console.error('Failed to hydrate extension pairing token:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,6 +99,7 @@ function App() {
|
||||
}, [showMenuBarIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!extensionPairingToken) return;
|
||||
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
|
||||
console.error('Failed to configure browser extension pairing token:', error);
|
||||
});
|
||||
@@ -296,6 +303,43 @@ function App() {
|
||||
<AddDownloadsModal />
|
||||
<PropertiesModal />
|
||||
<DeleteConfirmationModal />
|
||||
{pairingTokenChanged && (
|
||||
<div
|
||||
className="app-toast fixed bottom-5 right-5 z-[80] max-w-[420px] p-4 text-[12px]"
|
||||
role="status"
|
||||
>
|
||||
<p className="font-medium">
|
||||
Browser extension disconnected because its pairing token changed.
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const token = useSettingsStore.getState().extensionPairingToken;
|
||||
if (token) {
|
||||
void navigator.clipboard.writeText(token);
|
||||
}
|
||||
acknowledgePairingTokenChange();
|
||||
}}
|
||||
>
|
||||
Copy new token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const settings = useSettingsStore.getState();
|
||||
settings.setActiveSettingsTab('integrations');
|
||||
settings.setActiveView('settings');
|
||||
acknowledgePairingTokenChange();
|
||||
}}
|
||||
>
|
||||
Open Integrations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PairingTokenHydration = { token: string, tokenChanged: boolean, };
|
||||
+6
-6
@@ -4,7 +4,6 @@ import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn
|
||||
import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { DownloadStatus } from './bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { MediaCookieSource } from './bindings/MediaCookieSource';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
@@ -13,6 +12,7 @@ import type { EngineStatusItem } from './bindings/EngineStatusItem';
|
||||
import type { EngineStatusResult } from './bindings/EngineStatusResult';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
||||
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
||||
|
||||
type StartDownloadArgs = {
|
||||
id: string;
|
||||
@@ -88,6 +88,8 @@ type CommandMap = {
|
||||
delete_file: { args: { path: string }; result: void };
|
||||
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||
get_system_proxy: { args: undefined; result: string | null };
|
||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||
@@ -96,11 +98,9 @@ type CommandMap = {
|
||||
db_save_settings: { args: { data: string }; result: void };
|
||||
db_load_settings: { args: undefined; result: string | null };
|
||||
db_get_all_downloads: { args: undefined; result: string[] };
|
||||
db_save_download: {
|
||||
args: { id: string; status: DownloadStatus; queueId: string; data: string };
|
||||
result: void;
|
||||
};
|
||||
db_delete_download: { args: { id: string }; result: void };
|
||||
db_replace_downloads: { args: { data: string }; result: void };
|
||||
db_get_all_queues: { args: undefined; result: string[] };
|
||||
db_replace_queues: { args: { data: string }; result: void };
|
||||
create_category_directories: { args: { paths: string[] }; result: void };
|
||||
export_logs: { args: { destPath: string }; result: string };
|
||||
get_pending_order: { args: undefined; result: string[] };
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
|
||||
import { info } from '@tauri-apps/plugin-log';
|
||||
|
||||
import { tauriStore } from './useDownloadStore';
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-engine-settings') {
|
||||
try {
|
||||
const data = await tauriStore.get<string>('engine_settings');
|
||||
return data || null;
|
||||
} catch (e) {
|
||||
console.error("Failed to load engine settings from DB", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-engine-settings') {
|
||||
try {
|
||||
await tauriStore.set('engine_settings', value);
|
||||
await tauriStore.save();
|
||||
} catch (e) {
|
||||
console.error("Failed to save engine settings to DB", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
removeItem: async (_name: string): Promise<void> => {
|
||||
// no-op for now
|
||||
},
|
||||
};
|
||||
|
||||
export interface SettingsState {
|
||||
defaultDownloadPath: string;
|
||||
globalSpeedLimit: number;
|
||||
concurrentDownloads: number;
|
||||
|
||||
setDefaultDownloadPath: (path: string) => void;
|
||||
setGlobalSpeedLimit: (limit: number) => void;
|
||||
setConcurrentDownloads: (count: number) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
defaultDownloadPath: '~/Downloads',
|
||||
globalSpeedLimit: 0,
|
||||
concurrentDownloads: 3,
|
||||
|
||||
setDefaultDownloadPath: (path) => {
|
||||
info(`Settings updated: defaultDownloadPath = ${path}`);
|
||||
set({ defaultDownloadPath: path });
|
||||
},
|
||||
setGlobalSpeedLimit: (limit) => {
|
||||
info(`Settings updated: globalSpeedLimit = ${limit}`);
|
||||
set({ globalSpeedLimit: limit });
|
||||
},
|
||||
setConcurrentDownloads: (count) => {
|
||||
info(`Settings updated: concurrentDownloads = ${count}`);
|
||||
set({ concurrentDownloads: count });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'firelink-engine-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
partialize: (state) => ({
|
||||
defaultDownloadPath: state.defaultDownloadPath,
|
||||
globalSpeedLimit: state.globalSpeedLimit,
|
||||
concurrentDownloads: state.concurrentDownloads,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -12,16 +12,6 @@ vi.mock('@tauri-apps/plugin-log', () => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/plugin-store', () => {
|
||||
return {
|
||||
LazyStore: class {
|
||||
get = vi.fn().mockResolvedValue([]);
|
||||
set = vi.fn();
|
||||
save = vi.fn();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./useSettingsStore', () => ({
|
||||
useSettingsStore: {
|
||||
getState: vi.fn(() => ({
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||
import { info } from '@tauri-apps/plugin-log';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
|
||||
export const tauriStore = new LazyStore('store.bin');
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
@@ -606,8 +604,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
const queues = await tauriStore.get<Queue[]>('queues') || [];
|
||||
const downloads = await tauriStore.get<DownloadItem[]>('download_queue') || [];
|
||||
const queues = (await invoke('db_get_all_queues')).map(value => JSON.parse(value) as Queue);
|
||||
const downloads = (await invoke('db_get_all_downloads')).map(
|
||||
value => JSON.parse(value) as DownloadItem
|
||||
);
|
||||
|
||||
set(state => ({
|
||||
queues: queues.length > 0 ? queues : state.queues,
|
||||
@@ -678,11 +678,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}));
|
||||
|
||||
let lastSavedDownloads = '';
|
||||
let downloadsSave = Promise.resolve();
|
||||
let queuesSave = Promise.resolve();
|
||||
|
||||
useDownloadStore.subscribe(async (state, prevState) => {
|
||||
if (state.queues !== prevState.queues) {
|
||||
await tauriStore.set('queues', state.queues);
|
||||
await tauriStore.save();
|
||||
const data = JSON.stringify(state.queues);
|
||||
queuesSave = queuesSave
|
||||
.then(() => invoke('db_replace_queues', { data }))
|
||||
.catch(error => {
|
||||
console.error('Failed to persist queues:', error);
|
||||
});
|
||||
await queuesSave;
|
||||
}
|
||||
|
||||
if (state.downloads !== prevState.downloads) {
|
||||
@@ -694,8 +701,12 @@ useDownloadStore.subscribe(async (state, prevState) => {
|
||||
const currentSerialized = JSON.stringify(staticDownloads);
|
||||
if (currentSerialized !== lastSavedDownloads) {
|
||||
lastSavedDownloads = currentSerialized;
|
||||
await tauriStore.set('download_queue', staticDownloads);
|
||||
await tauriStore.save();
|
||||
downloadsSave = downloadsSave
|
||||
.then(() => invoke('db_replace_downloads', { data: currentSerialized }))
|
||||
.catch(error => {
|
||||
console.error('Failed to persist downloads:', error);
|
||||
});
|
||||
await downloadsSave;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,14 +14,13 @@ import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
import type { SiteLogin } from '../bindings/SiteLogin';
|
||||
import type { Theme } from '../bindings/Theme';
|
||||
|
||||
import { tauriStore } from './useDownloadStore';
|
||||
let settingsSave = Promise.resolve();
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
const data = await tauriStore.get<string>('settings');
|
||||
return data || null;
|
||||
return await invoke('db_load_settings');
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings from DB", e);
|
||||
return null;
|
||||
@@ -31,12 +30,12 @@ const tauriStorage: StateStorage = {
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
await tauriStore.set('settings', value);
|
||||
await tauriStore.save();
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
}
|
||||
settingsSave = settingsSave
|
||||
.then(() => invoke('db_save_settings', { data: value }))
|
||||
.catch(e => {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
});
|
||||
await settingsSave;
|
||||
}
|
||||
},
|
||||
removeItem: async (_name: string): Promise<void> => {
|
||||
@@ -47,9 +46,8 @@ const tauriStorage: StateStorage = {
|
||||
/**
|
||||
* Keychain identifier for the browser-extension pairing token. The token is an
|
||||
* HMAC shared secret and is therefore persisted via the OS keychain rather
|
||||
* than the plaintext `store.bin` settings document. A fresh token is minted
|
||||
* when no prior entry exists (also covering upgrades from versions that
|
||||
* stored the token as plaintext, effectively rotating it on upgrade).
|
||||
* than the user-data database. Legacy plaintext values are migrated into the
|
||||
* Keychain before being removed from persisted settings.
|
||||
*/
|
||||
const PAIRING_TOKEN_KEYCHAIN_ID = 'extension-pairing-token';
|
||||
|
||||
@@ -135,7 +133,7 @@ export interface SettingsState {
|
||||
removeSiteLogin: (id: string) => void;
|
||||
regeneratePairingToken: () => void;
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||
hydratePairingToken: () => Promise<void>;
|
||||
hydratePairingToken: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const defaultDirectories = {
|
||||
@@ -240,7 +238,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
mediaCookieSource: 'none',
|
||||
downloadDirectories: { ...defaultDirectories },
|
||||
siteLogins: [],
|
||||
extensionPairingToken: generateSecureToken(),
|
||||
extensionPairingToken: '',
|
||||
autoCheckUpdates: true,
|
||||
|
||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||
@@ -305,30 +303,27 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
});
|
||||
},
|
||||
hydratePairingToken: async () => {
|
||||
const existing = useSettingsStore.getState().extensionPairingToken;
|
||||
try {
|
||||
const stored = await invoke('get_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID });
|
||||
if (stored) {
|
||||
set({ extensionPairingToken: stored });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No prior token in the keychain (fresh install or upgrade from a
|
||||
// version that stored plaintext). Fall through to mint + store.
|
||||
}
|
||||
const token = existing || generateSecureToken();
|
||||
set({ extensionPairingToken: token });
|
||||
try {
|
||||
await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token });
|
||||
} catch (e) {
|
||||
console.error('Failed to persist extension pairing token to keychain:', e);
|
||||
}
|
||||
const result = await invoke('hydrate_extension_pairing_token');
|
||||
set({ extensionPairingToken: result.token });
|
||||
return result.tokenChanged;
|
||||
},
|
||||
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
|
||||
}),
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
version: 1,
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState as SettingsState;
|
||||
}
|
||||
const persisted = persistedState as Partial<SettingsState>;
|
||||
return {
|
||||
...persisted,
|
||||
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
|
||||
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
|
||||
} as SettingsState;
|
||||
},
|
||||
partialize: (state): PersistedSettings => ({
|
||||
theme: state.theme,
|
||||
defaultDownloadPath: state.defaultDownloadPath,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
* Fields that may carry secrets and therefore must never reach the persisted
|
||||
* `download_queue` document. These are supplied in-memory for the active
|
||||
* session (see `enqueue_download` payloads) but are stripped at the
|
||||
* persistence boundary so `store.bin` contains no plaintext credentials.
|
||||
* persistence boundary so the user-data database contains no plaintext credentials.
|
||||
*/
|
||||
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user