377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
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<string, any>
|
|
vfsDefaults?: Record<string, any>
|
|
filterDefaults?: Record<string, any>
|
|
copyDefaults?: Record<string, any>
|
|
moveDefaults?: Record<string, any>
|
|
syncDefaults?: Record<string, any>
|
|
configDefaults?: Record<string, any>
|
|
serveDefaults?: Record<(typeof SERVE_TYPES)[number], Record<string, any>>
|
|
bisyncDefaults?: Record<string, any>
|
|
remoteDefaults?: Record<string, any>
|
|
}
|
|
|
|
interface TemplateV1 {
|
|
id: string
|
|
name: string
|
|
operation?: 'copy' | 'sync' | 'move' | 'delete' | 'purge' | 'serve' | 'mount' | 'bisync'
|
|
options: Record<string, any>
|
|
}
|
|
|
|
interface PersistedStateV1 {
|
|
remoteConfigList: Record<string, RemoteConfigV1>
|
|
|
|
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<void>
|
|
|
|
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<PersistedStateV2>()(
|
|
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<string, HostRemoteConfig> = {}
|
|
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<string, any> = {
|
|
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()
|
|
})
|