fix(persistence): preserve user data across updates

This commit is contained in:
NimBold
2026-06-20 19:08:48 +03:30
parent 487e1fea43
commit 79b579790d
16 changed files with 1288 additions and 260 deletions
-75
View File
@@ -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,
}),
}
)
);
-10
View File
@@ -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(() => ({
+19 -8
View File
@@ -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;
}
}
});
+27 -32
View File
@@ -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,