import { invoke } from '@tauri-apps/api/core' import { ask } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import { exit } from '@tauri-apps/plugin-process' import { LazyStore } from '@tauri-apps/plugin-store' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' import type { Host } from '../lib/hosts' import type { SERVE_TYPES } from '../lib/rclone/constants' import type { ConfigFile } from '../types/config' import type { Template } from '../types/template' import type { RemoteConfig as HostRemoteConfig } from './host' import { createTauriStateStorage } from './lib' const store = new LazyStore('store.json') interface RemoteConfigV1 { disabledActions?: ('tray' | 'tray-mount' | 'tray-browse' | 'tray-remove' | 'tray-cleanup')[] defaultRemotePath?: string defaultMountPoint?: string mountOnStart?: boolean mountDefaults?: Record vfsDefaults?: Record filterDefaults?: Record copyDefaults?: Record moveDefaults?: Record syncDefaults?: Record configDefaults?: Record serveDefaults?: Record<(typeof SERVE_TYPES)[number], Record> bisyncDefaults?: Record remoteDefaults?: Record } interface TemplateV1 { id: string name: string operation?: 'copy' | 'sync' | 'move' | 'delete' | 'purge' | 'serve' | 'mount' | 'bisync' options: Record } interface PersistedStateV1 { remoteConfigList: Record proxy: | { url: string ignoredHosts: string[] } | undefined favoritePaths: { remote: string; path: string; added: number }[] settingsPass: string | undefined licenseKey: string | undefined licenseValid: boolean startOnBoot: boolean // Legacy v1 field; the v1→v2 migration never reads it (host stores own scheduling data). scheduledTasks: unknown[] templates: TemplateV1[] configFiles: ConfigFile[] activeConfigFile: ConfigFile | null lastSkippedVersion: string | undefined hideStartup: boolean themeV2: { tray: 'light' | 'dark' | undefined } } interface PersistedStateV2 { settingsPass: string | undefined setSettingsPass: (pass: string | undefined) => void licenseKey: string | undefined setLicenseKey: (key: string | undefined) => void licenseValid: boolean setLicenseValid: (valid: boolean) => void startOnBoot: boolean setStartOnBoot: (startOnBoot: boolean) => void toolbarShortcut: string | undefined setToolbarShortcut: (shortcut: string | undefined) => Promise templates: Template[] // Notification targets are NOT here: they live in a Rust-owned store // (notifications/targets.json) so the headless scheduler runner can read AND write them. hosts: Host[] currentHostId: string | null setCurrentHost: (id: Host['id']) => void hideStartup: boolean acknowledgements: string[] appearance: { tray: 'light' | 'dark' | 'system' | 'color' app: 'light' | 'dark' | 'system' } // Absolute path of the rclone executable the app runs. Managed downloads live under // $APPLOCALDATA/rclone-versions/vX/, a system rclone is its PATH location, and a custom // binary is any other path. `undefined` triggers one-time adoption at startup. rclonePath: string | undefined setRclonePath: (path: string | undefined) => void // Download + switch to new stable rclone releases at startup (managed binaries only). // When off, the app still checks and notifies once per new version. autoUpdateRclone: boolean setAutoUpdateRclone: (enabled: boolean) => void lastNotifiedRcloneVersion: string | undefined } export const usePersistedStore = create()( persist( (set) => ({ settingsPass: undefined, setSettingsPass: (pass: string | undefined) => set((_) => ({ settingsPass: pass })), licenseKey: undefined, setLicenseKey: (key: string | undefined) => set((_) => ({ licenseKey: key })), licenseValid: false, setLicenseValid: (valid: boolean) => set((_) => ({ licenseValid: valid })), startOnBoot: false, setStartOnBoot: (startOnBoot: boolean) => set((_) => ({ startOnBoot })), toolbarShortcut: undefined, setToolbarShortcut: async (shortcut: string | undefined) => { await invoke('update_toolbar_shortcut', { shortcut }) set((_) => ({ toolbarShortcut: shortcut })) }, templates: [], hosts: [], currentHostId: null, setCurrentHost: (id: Host['id']) => set((state) => { if (!state.hosts.some((h) => h.id === id)) { return {} } return { currentHostId: id } }), hideStartup: false, acknowledgements: [], appearance: { tray: platform() === 'linux' ? 'color' : 'system', app: 'dark', }, rclonePath: undefined, setRclonePath: (path: string | undefined) => set((_) => ({ rclonePath: path })), autoUpdateRclone: true, setAutoUpdateRclone: (enabled: boolean) => set((_) => ({ autoUpdateRclone: enabled })), lastNotifiedRcloneVersion: undefined, }), { name: 'store', storage: createJSONStorage(() => createTauriStateStorage(() => store)), version: 3, migrate: async (persistedState, version) => { if (!persistedState) { return persistedState as PersistedStateV2 } if (version < 2) { const legacyState = persistedState as PersistedStateV1 console.log('[Migration] Migrating from V1 to V2') const localHostStore = new LazyStore('hosts/local/store.json') const newRemoteConfigs: Record = {} const newTemplates: Template[] = legacyState.templates ? [ ...legacyState.templates.map((template) => ({ ...template, tags: (template as unknown as TemplateV1).operation ? [(template as unknown as TemplateV1).operation!] : [], })), ] : [] if (legacyState.remoteConfigList) { for (const [key, config] of Object.entries(legacyState.remoteConfigList)) { newRemoteConfigs[key] = { mountOnStart: { enabled: config.mountOnStart || false, remotePath: config.defaultRemotePath || '', mountPoint: config.defaultMountPoint || '', mountOptions: config.mountDefaults || {}, vfsOptions: config.vfsDefaults || {}, filterOptions: config.filterDefaults || {}, configOptions: config.configDefaults || {}, }, } // migrate defaults to templates const mergedOptions: Record = { sources: [], dest: '', } if ( config.copyDefaults && Object.keys(config.copyDefaults).length > 0 ) { mergedOptions.copyOptions = config.copyDefaults } if (config.vfsDefaults && Object.keys(config.vfsDefaults).length > 0) { mergedOptions.vfsOptions = config.vfsDefaults } if ( config.filterDefaults && Object.keys(config.filterDefaults).length > 0 ) { mergedOptions.filterOptions = config.filterDefaults } if ( config.mountDefaults && Object.keys(config.mountDefaults).length > 0 ) { mergedOptions.mountOptions = config.mountDefaults } if ( config.configDefaults && Object.keys(config.configDefaults).length > 0 ) { mergedOptions.configOptions = config.configDefaults } if ( config.syncDefaults && Object.keys(config.syncDefaults).length > 0 ) { mergedOptions.syncOptions = config.syncDefaults } if ( config.moveDefaults && Object.keys(config.moveDefaults).length > 0 ) { mergedOptions.moveOptions = config.moveDefaults } if ( config.bisyncDefaults && Object.keys(config.bisyncDefaults).length > 0 ) { mergedOptions.bisyncOptions = config.bisyncDefaults } // check if any options have keys const hasOptions = Object.values(mergedOptions).some( (opt) => typeof opt === 'object' && opt !== null && Object.keys(opt).length > 0 ) if (hasOptions) { newTemplates.push({ id: crypto.randomUUID(), name: `${key} (Defaults)`, tags: [ 'copy', 'sync', 'move', 'delete', 'purge', 'serve', 'mount', 'bisync', ], options: mergedOptions, }) } } } const hostState = { state: { remoteConfigs: newRemoteConfigs, proxy: legacyState.proxy, favoritePaths: legacyState.favoritePaths || [], scheduledTasks: [], configFiles: legacyState.configFiles, activeConfigFile: legacyState.activeConfigFile, lastSkippedVersion: legacyState.lastSkippedVersion, }, version: 1, } try { await localHostStore.set('host-store', JSON.stringify(hostState)) await localHostStore.save() console.log( '[Migration] Moved host-specific state to hosts/local/store.json' ) } catch (e) { console.error('[Migration] Failed to save host store', e) await ask( 'Old data could not be migrated to V3. Please reinstall.\n\nYou can make a backup of the "store.json" file located in the app\'s directory before reinstalling.', { title: 'Fatal Error', kind: 'error', } ) await exit() } return { settingsPass: legacyState.settingsPass || undefined, licenseKey: legacyState.licenseKey || undefined, licenseValid: legacyState.licenseValid || false, startOnBoot: legacyState.startOnBoot || false, templates: newTemplates, hideStartup: legacyState.hideStartup || false, appearance: { tray: 'system', app: 'dark', }, } as unknown as PersistedStateV2 } if (version < 3) { // v2 stored the full current Host object; v3 stores just its id. const { currentHost, ...rest } = persistedState as PersistedStateV2 & { currentHost?: Host | null } return { ...rest, currentHostId: currentHost?.id ?? null, } as PersistedStateV2 } return persistedState as PersistedStateV2 }, } ) ) /** Resolves the current Host object from the stored id, or null if it no longer exists. */ export function selectCurrentHost(state: PersistedStateV2): Host | null { return state.hosts.find((h) => h.id === state.currentHostId) ?? null } export function useCurrentHost(): Host | null { return usePersistedStore(selectCurrentHost) } usePersistedStore.persist.onFinishHydration((state) => { if (state.toolbarShortcut) { invoke('update_toolbar_shortcut', { shortcut: state.toolbarShortcut }) } }) store.onKeyChange('store', async (_) => { await usePersistedStore.persist.rehydrate() })