From 02bfdb8ddf98058d0a66465f19db361ec04b1aec Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:41:22 +0300 Subject: [PATCH] zookeeper + cleanup --- biome.json | 2 +- lib/errors.ts | 46 + lib/events.ts | 33 + lib/hooks.ts | 34 + lib/hosts.ts | 22 +- lib/license.ts | 106 +- lib/query.ts | 1 - lib/rclone/api.ts | 38 +- lib/rclone/cli.ts | 284 ++-- lib/rclone/client.ts | 110 +- lib/rclone/common.ts | 290 ++-- lib/rclone/constants.ts | 72 +- lib/rclone/init.ts | 682 ++++----- lib/rclone/versions.ts | 200 +++ lib/tray.ts | 3 +- lib/window.ts | 2 +- main.ts | 428 +++--- package-lock.json | 10 - package.json | 1 - src-tauri/Cargo.lock | 133 +- src-tauri/Cargo.toml | 6 +- src-tauri/capabilities/default.json | 40 - src-tauri/common/window.rs | 5 - src-tauri/src/lib.rs | 203 +-- src-tauri/src/zookeeper.rs | 1248 +++++++++++++++++ src/components/ConfigCreateDrawer.tsx | 14 +- src/components/ConfigEditDrawer.tsx | 14 +- src/components/ConfigSyncDrawer.tsx | 14 +- src/components/HostAddDrawer.tsx | 12 +- src/components/OptionsSection.tsx | 8 +- src/components/RemoteAutoMountDrawer.tsx | 12 +- src/components/RemoteCreateDrawer.tsx | 1 + src/components/RemoteEditDrawer.tsx | 31 +- src/components/RemoteOptionsSection.tsx | 148 +- src/components/ScheduleEditDrawer.tsx | 11 +- src/components/TemplateAddDrawer.tsx | 5 +- src/components/TemplateEditDrawer.tsx | 5 +- src/components/navigator/FilePanel.tsx | 14 +- src/components/navigator/PathBreadcrumb.tsx | 14 +- src/components/navigator/PreviewDrawer.tsx | 4 +- src/components/navigator/RemoteSidebar.tsx | 14 +- src/components/navigator/useCreateFolder.ts | 24 +- src/components/navigator/useFileNavigation.ts | 37 +- src/components/operation/OperationFooter.tsx | 213 +++ src/components/operation/OptionsAccordion.tsx | 114 ++ .../operation/useOperationDryRun.ts | 34 + src/components/operation/useOptionGroups.ts | 344 +++++ src/components/operation/useScheduleTask.ts | 68 + src/main.tsx | 6 +- src/pages/Bisync.tsx | 783 ++++------- src/pages/Commander.tsx | 46 +- src/pages/Copy.tsx | 921 ++++-------- src/pages/Delete.tsx | 754 ++++------ src/pages/Download.tsx | 14 +- src/pages/Mount.tsx | 65 +- src/pages/Move.tsx | 938 ++++--------- src/pages/Purge.tsx | 537 +++---- src/pages/Schedules.tsx | 20 +- src/pages/Serve.tsx | 64 +- src/pages/Settings/AboutSection.tsx | 8 +- src/pages/Settings/BinarySection.tsx | 532 +++++++ src/pages/Settings/ConfigSection.tsx | 169 +-- src/pages/Settings/GeneralSection.tsx | 10 +- src/pages/Settings/HostsSection.tsx | 13 +- src/pages/Settings/RemotesSection.tsx | 70 +- src/pages/Settings/index.tsx | 40 +- src/pages/Sync.tsx | 923 ++++-------- store/host.ts | 83 +- store/lib.ts | 40 + store/memory.ts | 14 - store/persisted.ts | 123 +- toolbar/actions.ts | 119 +- 72 files changed, 6111 insertions(+), 5335 deletions(-) create mode 100644 lib/errors.ts create mode 100644 lib/events.ts create mode 100644 lib/rclone/versions.ts create mode 100644 src-tauri/src/zookeeper.rs create mode 100644 src/components/operation/OperationFooter.tsx create mode 100644 src/components/operation/OptionsAccordion.tsx create mode 100644 src/components/operation/useOperationDryRun.ts create mode 100644 src/components/operation/useOptionGroups.ts create mode 100644 src/components/operation/useScheduleTask.ts create mode 100644 src/pages/Settings/BinarySection.tsx create mode 100644 store/lib.ts diff --git a/biome.json b/biome.json index 861e0bb..7e00166 100644 --- a/biome.json +++ b/biome.json @@ -55,7 +55,7 @@ "formatter": { "quoteStyle": "single", "quoteProperties": "preserve", - "trailingComma": "es5", + "trailingCommas": "es5", "semicolons": "asNeeded" }, "globals": ["it", "describe", "expect", "test"] diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..d8d62f8 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/browser' +import { message } from '@tauri-apps/plugin-dialog' + +// Coerce an unknown thrown value into a user-facing string. Mirrors the +// `error instanceof Error ? error.message : ` idiom hand-written across the app. +// Pass `String(error)` as the fallback to preserve sites that surfaced the raw value. +export function formatErrorMessage(error: unknown, fallback = 'An unknown error occurred'): string { + return error instanceof Error ? error.message : fallback +} + +interface ReportErrorOptions { + title: string + fallback?: string + okLabel?: string + // Defaults to capturing. Pass `false` for sites that did not call Sentry.captureException. + capture?: boolean + // When provided, forwarded to console.error before the dialog, with the error appended + // (so `['[switchConfig] failed']` -> console.error('[switchConfig] failed', error)). Omit to + // suppress console.error entirely for sites that never logged. + log?: unknown[] +} + +// console.error (optional) + Sentry.captureException (unless capture === false) + error dialog. +export async function reportError(error: unknown, options: ReportErrorOptions): Promise { + const { title, fallback, okLabel, capture, log } = options + if (log) { + console.error(...log, error) + } + if (capture !== false) { + Sentry.captureException(error) + } + await message(formatErrorMessage(error, fallback), { + title, + kind: 'error', + ...(okLabel ? { okLabel } : {}), + }) +} + +// A ready-made TanStack Query `onError` handler that reports through reportError. +export function onErrorDialog( + title: string, + fallback?: string, + options?: Omit +): (error: unknown) => Promise { + return (error: unknown) => reportError(error, { title, fallback, ...options }) +} diff --git a/lib/events.ts b/lib/events.ts new file mode 100644 index 0000000..1742f86 --- /dev/null +++ b/lib/events.ts @@ -0,0 +1,33 @@ +import { getCurrentWindow } from '@tauri-apps/api/window' +import type { ConfigFile } from '../types/config' + +// Wire names for the cross-window app-lifecycle events. These strings cross the Tauri event bus +// and are listened for in main.ts (loaded only by the hidden 'main' window) — keep them stable. +export const CLOSE_APP = 'close-app' +export const RELAUNCH_APP = 'relaunch-app' +export const RESTART_RCLONE = 'restart-rclone' + +// Full lifecycle snapshot carried on a restart request. The main window may not have rehydrated +// the initiating webview's store writes yet, so the intended values ride along in the payload. +export interface RestartRclonePayload { + rclonePath?: string + defaultConfigPath?: string + configFiles?: ConfigFile[] + activeConfigId?: string | null + proxy?: { url: string; ignoredHosts: string[] } | undefined +} + +export type AppEventPayload = { + [CLOSE_APP]: undefined + [RELAUNCH_APP]: undefined + [RESTART_RCLONE]: RestartRclonePayload +} + +// Emit an app-lifecycle event. Tauri's window.emit broadcasts globally, so the single main-window +// listener receives it regardless of which webview calls this. +export async function emitToMain( + event: E, + payload?: AppEventPayload[E] +): Promise { + await getCurrentWindow().emit(event, payload) +} diff --git a/lib/hooks.ts b/lib/hooks.ts index 694cdac..90ca08e 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -1,8 +1,42 @@ import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' import { sortByName } from './flags' import rclone from './rclone/client' import { SERVE_TYPES } from './rclone/constants' +// Wall-clock tick for values derived from "now" (relative timestamps, next cron occurrences). +// Memoizing such values without a time dep freezes them at their last dep change. Pass null to +// pause (e.g. while a drawer is closed); re-arming refreshes immediately. +export function useNow(intervalMs: number | null = 30_000): number { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (intervalMs === null) { + return + } + setNow(Date.now()) + const id = setInterval(() => setNow(Date.now()), intervalMs) + return () => clearInterval(id) + }, [intervalMs]) + + return now +} + +// Shared query options for a remote's `/config/get`. No default staleTime: most consumers rely on +// staleTime-0 refetch-on-mount for cross-window freshness (each webview has its own QueryClient); +// the handful that want caching spread `staleTime` per-site. +export function remoteConfigQueryOptions(remote: string | undefined | null) { + return { + queryKey: ['remote', remote, 'config'] as const, + queryFn: () => rclone('/config/get', { params: { query: { name: remote! } } }), + enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES', + } +} + +export function useRemoteConfig(remote: string | undefined | null) { + return useQuery(remoteConfigQueryOptions(remote)) +} + export function useFlags() { const allFlagsQuery = useQuery({ queryKey: ['options', 'all'], diff --git a/lib/hosts.ts b/lib/hosts.ts index 526760d..5b65093 100644 --- a/lib/hosts.ts +++ b/lib/hosts.ts @@ -1,4 +1,5 @@ import { fetch as tauriFetch } from '@tauri-apps/plugin-http' +import { platform } from '@tauri-apps/plugin-os' import pRetry from 'p-retry' import createRCDClient from 'rclone-sdk' @@ -14,6 +15,25 @@ export interface Host { export const LOCAL_HOST_ID = 'local' as const +// The rclone RC daemon port. Keep in sync with the hardcoded port in the Rust +// start_cloudflared_tunnel command. +export const RC_PORT = 5572 +export const RC_LOCAL_URL = `http://localhost:${RC_PORT}` + +/** The canonical local-machine host, used as the fallback whenever no reachable host is selected. */ +export function makeLocalHost(): Host { + const os = platform() + return { + id: LOCAL_HOST_ID, + name: 'Local Machine', + url: RC_LOCAL_URL, + // platform() is wider than Host['os'] (ios/android/freebsd/...); desktop builds only see + // these three — anything else falls back to linux, mirroring getHostInfo's normalization. + os: os === 'windows' || os === 'macos' ? os : 'linux', + cliVersion: 'unknown', + } +} + export const LABEL_FOR_OS = { windows: 'Windows', macos: 'macOS', @@ -43,8 +63,6 @@ export async function getHostInfo({ authHeader = `Basic ${btoa(`${authUser}:${authPassword}`)}` } - console.log('[getHostInfo] authHeader', authHeader) - const rcloneClient = createRCDClient({ baseUrl: url, headers: authHeader diff --git a/lib/license.ts b/lib/license.ts index 88d1d19..5b13ad7 100644 --- a/lib/license.ts +++ b/lib/license.ts @@ -3,44 +3,78 @@ import { fetch } from '@tauri-apps/plugin-http' import { platform } from '@tauri-apps/plugin-os' import { usePersistedStore } from '../store/persisted' -export async function validateLicense(licenseKey: string) { - console.log('[validateLicense]') +interface LicenseCallLogs { + start: string + uidFail: string + uidMissing: string + fetchFail: string + errorResponse: string +} + +// Shared scaffold for the license API calls: builds the machine id, POSTs to rcloneui.com, and +// runs the error triage. Per-branch log strings are passed in so each caller's logs stay identical. +async function licenseCall( + endpoint: string, + licenseKey: string, + extraBody: Record, + failVerb: string, + logs: LicenseCallLogs +): Promise { + console.log(logs.start) let id try { id = await invoke('get_uid') } catch (e) { - console.error('[validateLicense] failed to build unique identifier') + console.error(logs.uidFail) console.error(JSON.stringify(e)) throw new Error('Failed to build unique identifier. Please try again later.') } if (!id) { - console.error('[validateLicense] missing unique identifier') + console.error(logs.uidMissing) throw new Error('Failed to build unique identifier. Please try again later.') } - const validationResponse = await fetch('https://rcloneui.com/api/v2/validate', { + const response = await fetch(`https://rcloneui.com${endpoint}`, { method: 'POST', body: JSON.stringify({ licenseKey, id, - platform: platform(), + ...extraBody, }), }) - .then((r) => r.json() as Promise<{ error: string; valid: boolean }>) + .then((r) => r.json() as Promise) .catch((e) => { - console.error('[validateLicense] failed to validate license') + console.error(logs.fetchFail) console.error(JSON.stringify(e)) - throw new Error('Failed to validate license. Are you connected to the internet?') + throw new Error(`Failed to ${failVerb} license. Are you connected to the internet?`) }) - if (validationResponse.error) { - console.error('[validateLicense] failed to validate license') - throw new Error(validationResponse.error) + if (response.error) { + console.error(logs.errorResponse) + throw new Error(response.error) } + return response +} + +export async function validateLicense(licenseKey: string) { + const validationResponse = await licenseCall<{ error: string; valid: boolean }>( + '/api/v2/validate', + licenseKey, + { platform: platform() }, + 'validate', + { + start: '[validateLicense]', + uidFail: '[validateLicense] failed to build unique identifier', + uidMissing: '[validateLicense] missing unique identifier', + fetchFail: '[validateLicense] failed to validate license', + errorResponse: '[validateLicense] failed to validate license', + } + ) + if (!validationResponse.valid) { console.error('[validateLicense] invalid license key') throw new Error('Invalid license key. Please check your license key and try again.') @@ -52,41 +86,19 @@ export async function validateLicense(licenseKey: string) { } export async function revokeMachineLicense(licenseKey: string) { - console.log('[revokeMachineLicense]') - - let id - - try { - id = await invoke('get_uid') - } catch (e) { - console.error('[revokeMachineLicense] failed to build unique identifier') - console.error(JSON.stringify(e)) - throw new Error('Failed to build unique identifier. Please try again later.') - } - - if (!id) { - console.error('[revokeMachineLicense] missing unique identifier') - throw new Error('Failed to build unique identifier. Please try again later.') - } - - const revocationResponse = await fetch('https://rcloneui.com/api/v1/revoke', { - method: 'POST', - body: JSON.stringify({ - licenseKey, - id, - }), - }) - .then((r) => r.json() as Promise<{ error: string; revoked: boolean }>) - .catch((e) => { - console.error('[revokeMachineLicense] failed to revoke license, fetch failed') - console.error(JSON.stringify(e)) - throw new Error('Failed to revoke license. Are you connected to the internet?') - }) - - if (revocationResponse.error) { - console.error('[revokeMachineLicense] failed to revoke license, has error response') - throw new Error(revocationResponse.error) - } + const revocationResponse = await licenseCall<{ error: string; revoked: boolean }>( + '/api/v1/revoke', + licenseKey, + {}, + 'revoke', + { + start: '[revokeMachineLicense]', + uidFail: '[revokeMachineLicense] failed to build unique identifier', + uidMissing: '[revokeMachineLicense] missing unique identifier', + fetchFail: '[revokeMachineLicense] failed to revoke license, fetch failed', + errorResponse: '[revokeMachineLicense] failed to revoke license, has error response', + } + ) if (!revocationResponse.revoked) { console.error('[revokeMachineLicense] failed to revoke license, missing revoked response') diff --git a/lib/query.ts b/lib/query.ts index 8735ec9..dd30921 100644 --- a/lib/query.ts +++ b/lib/query.ts @@ -1,4 +1,3 @@ -'use no memo' import { persistQueryClient } from '@tanstack/query-persist-client-core' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' import { QueryClient } from '@tanstack/react-query' diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 860fadd..10e6a4c 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/browser' import { message } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import pRetry from 'p-retry' -import { useHostStore } from '../../store/host' +import { selectActiveConfigFile, useHostStore } from '../../store/host' import { useStore } from '../../store/memory' import type { JobItem } from '../../types/jobs' import type { FlagValue } from '../../types/rclone' @@ -108,23 +108,19 @@ function serializeOptions( } async function hasStat(path: string) { - try { - const { root, filePath } = getFsInfo(path) - const r = await rclone('/operations/stat', { - params: { - query: { - fs: root === ':local:' ? ':local:/' : root, - remote: filePath, - }, + // No try/catch: a transport failure must propagate as the real error instead of being + // masked as "Source does not exist". A genuinely missing path returns a response with no + // item, which still yields false. + const { root, filePath } = getFsInfo(path) + const r = await rclone('/operations/stat', { + params: { + query: { + fs: root === ':local:' ? ':local:/' : root, + remote: filePath, }, - }) - if (!r || !r.item) { - return false - } - return true - } catch { - return false - } + }, + }) + return !!r?.item } export async function startCopy({ @@ -1280,7 +1276,7 @@ export async function removeConfigPassword() { console.log('[removeConfigPassword]') const state = useHostStore.getState() - const activeConfig = state.activeConfigFile + const activeConfig = selectActiveConfigFile(state) if (!activeConfig || !activeConfig.id) { throw new Error('No active configuration selected.') @@ -1317,16 +1313,12 @@ export async function setConfigPassword(options: { console.log('[setConfigPassword]') const state = useHostStore.getState() - const activeConfig = state.activeConfigFile + const activeConfig = selectActiveConfigFile(state) if (!activeConfig || !activeConfig.id) { throw new Error('No active configuration selected.') } - // if (!activeConfig.isEncrypted) { - // throw new Error('Configuration is not encrypted.') - // } - const password = options.password if (!password) { diff --git a/lib/rclone/cli.ts b/lib/rclone/cli.ts index afa6acc..569e996 100644 --- a/lib/rclone/cli.ts +++ b/lib/rclone/cli.ts @@ -1,21 +1,27 @@ import * as Sentry from '@sentry/browser' import { invoke } from '@tauri-apps/api/core' import { sep } from '@tauri-apps/api/path' -import { getAllWindows } from '@tauri-apps/api/window' import { message } from '@tauri-apps/plugin-dialog' -import { Command } from '@tauri-apps/plugin-shell' -import { useHostStore } from '../../store/host' +import { selectActiveConfigFile, useHostStore } from '../../store/host' +import { usePersistedStore } from '../../store/persisted' import type { ConfigFile } from '../../types/config' +import { RESTART_RCLONE, emitToMain } from '../events' import { getConfigParentFolder } from '../format' -import { getConfigPath, isInternalRcloneInstalled, isSystemRcloneInstalled } from './common' +import { getConfigPath } from './common' + +interface ExecResult { + code: number | null + stdout: string + stderr: string +} export interface RcloneCliCommandContext { - command: Command + rclonePath: string + args: string[] activeConfig: ConfigFile configPath: string configDirectory: string env: Record - flavour: 'system' | 'internal' } export async function promptForConfigPassword(message: string) { @@ -42,19 +48,35 @@ export async function promptForConfigPassword(message: string) { } } +/** Returns the active rclone binary path (set during startup adoption). */ +export function getActiveRclonePath(): string { + const path = usePersistedStore.getState().rclonePath + if (!path) { + throw new Error('No rclone binary is configured.') + } + return path +} + async function validateConfigAccess( - commandName: 'rclone-system' | 'rclone-internal', - env: Record + rclonePath: string, + env: Record, + timeoutMs: number | null = 15000 ): Promise<{ success: boolean + timedOut?: boolean code?: number | null stderr?: string error?: Error }> { - console.log('[validateConfigAccess] command:', commandName) + console.log('[validateConfigAccess] rclone:', rclonePath) try { - const command = Command.create(commandName, ['config', 'dump'], { env }) - const result = await command.execute() + const result = await invoke('exec_rclone', { + path: rclonePath, + args: ['config', 'dump'], + env, + stdinLines: null, + timeoutMs, + }) console.log('[validateConfigAccess] exit code:', result.code) if (result.code === 0) { @@ -63,6 +85,9 @@ async function validateConfigAccess( return { success: false, + // A null code means the probe was killed at the deadline — rclone never ruled on the + // credentials, so callers must not treat this as a wrong password. + timedOut: result.code === null, code: result.code ?? null, stderr: result.stderr, } @@ -79,7 +104,7 @@ export async function ensureEncryptedConfigEnv( activeConfig: ConfigFile, env: Record, autoPromptForPassword: boolean, - commandName: 'rclone-system' | 'rclone-internal', + rclonePath: string, promptMessage: string ) { console.log('[ensureEncryptedConfigEnv] ensuring encrypted config env for:', activeConfig.id) @@ -93,7 +118,9 @@ export async function ensureEncryptedConfigEnv( RCLONE_CONFIG_PASS_COMMAND: activeConfig.passCommand, } - const validation = await validateConfigAccess(commandName, validationEnv) + // Password commands can block on user interaction (biometric prompt, pinentry), so this + // probe must not have a deadline. + const validation = await validateConfigAccess(rclonePath, validationEnv, null) if (validation.success) { console.log('[ensureEncryptedConfigEnv] passCommand validation succeeded') env.RCLONE_CONFIG_PASS_COMMAND = activeConfig.passCommand @@ -146,7 +173,7 @@ export async function ensureEncryptedConfigEnv( RCLONE_CONFIG_PASS: password, } - const validation = await validateConfigAccess(commandName, validationEnv) + const validation = await validateConfigAccess(rclonePath, validationEnv) if (validation.success) { console.log('[ensureEncryptedConfigEnv] password validation succeeded') env.RCLONE_CONFIG_PASS = password @@ -155,6 +182,16 @@ export async function ensureEncryptedConfigEnv( console.error('[ensureEncryptedConfigEnv] password validation failed', validation.code) + if (validation.timedOut || validation.error) { + // Indeterminate result (probe killed at its deadline, or rclone failed to launch) — + // the password may well be correct, so never clear a stored one or reprompt over it. + throw new Error( + validation.error + ? `Could not verify the configuration password: ${validation.error.message}` + : 'Timed out while verifying the configuration password. Please try again.' + ) + } + if (passwordSource === 'stored') { console.log('[ensureEncryptedConfigEnv] clearing invalid stored password') if (activeConfigId && updateConfigFile) { @@ -207,21 +244,68 @@ export async function ensureEncryptedConfigEnv( } } +/** + * Builds the environment map for running rclone: proxy vars, config location (always set to the + * resolved config path), and encrypted-config credentials. Shared by the daemon and one-off CLI. + */ +export async function buildRcloneEnv(opts: { + activeConfig: ConfigFile + configDirectory: string + configPath: string + proxy?: { url: string; ignoredHosts: string[] } | undefined + rclonePath: string + autoPromptForPassword?: boolean + additionalEnv?: Record +}): Promise> { + const env: Record = {} + + if (opts.proxy?.url) { + env.http_proxy = opts.proxy.url + env.https_proxy = opts.proxy.url + env.HTTP_PROXY = opts.proxy.url + env.HTTPS_PROXY = opts.proxy.url + env.no_proxy = opts.proxy.ignoredHosts.join(',') + env.NO_PROXY = opts.proxy.ignoredHosts.join(',') + } + + // Always pin the config location. For a system + default-config user this equals rclone's own + // default (explicit = default), and for managed/custom it prevents falling back to a wrong path. + env.RCLONE_CONFIG_DIR = opts.configDirectory + env.RCLONE_CONFIG = opts.configPath.endsWith('rclone.conf') + ? opts.configPath + : `${opts.configDirectory}${sep()}rclone.conf` + + if (opts.activeConfig.isEncrypted) { + await ensureEncryptedConfigEnv( + opts.activeConfig, + env, + opts.autoPromptForPassword ?? true, + opts.rclonePath, + `Please enter the current password for "${opts.activeConfig.label}"` + ) + } + + if (opts.additionalEnv) { + Object.assign(env, opts.additionalEnv) + } + + return env +} + async function createRcloneCliCommand( args: string[], additionalEnv?: Record, autoPromptForPassword = true ): Promise { console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args) - const env: Record = {} const hostStore = useHostStore.getState() - const activeConfig = hostStore.activeConfigFile + const activeConfig = selectActiveConfigFile(hostStore) if (!activeConfig || !activeConfig.id) { throw new Error('No active configuration selected.') } - console.log('[createRcloneCliCommand] active config:', activeConfig) + const rclonePath = getActiveRclonePath() let configPath: string try { @@ -231,150 +315,72 @@ async function createRcloneCliCommand( throw error } - console.log('[createRcloneCliCommand] config path:', configPath) - const configDirectory = getConfigParentFolder(configPath) console.log('[createRcloneCliCommand] config directory:', configDirectory) - const proxy = hostStore.proxy - console.log('[createRcloneCliCommand] proxy:', proxy) - if (proxy?.url) { - env.http_proxy = proxy.url - env.https_proxy = proxy.url - env.HTTP_PROXY = proxy.url - env.HTTPS_PROXY = proxy.url - env.no_proxy = proxy.ignoredHosts.join(',') - env.NO_PROXY = proxy.ignoredHosts.join(',') - } - - console.log('[createRcloneCliCommand] checking for system rclone installation') - const hasSystem = await isSystemRcloneInstalled() - console.log('[createRcloneCliCommand] checking for internal rclone installation') - const hasInternal = await isInternalRcloneInstalled() - - console.log('[createRcloneCliCommand] has system:', hasSystem) - console.log('[createRcloneCliCommand] has internal:', hasInternal) - - if (!hasSystem && !hasInternal) { - console.log('[createRcloneCliCommand] no rclone installation found') - const error = new Error('Unable to locate an rclone installation.') - Sentry.captureException(error) - throw error - } - - const flavour = hasSystem ? 'system' : 'internal' - const commandName = flavour === 'system' ? 'rclone-system' : 'rclone-internal' - - if (!hasSystem || activeConfig.id !== 'default') { - console.log('[createRcloneCliCommand] setting config directory and path') - env.RCLONE_CONFIG_DIR = configDirectory - env.RCLONE_CONFIG = configPath.endsWith('rclone.conf') - ? configPath - : `${configDirectory}${sep()}rclone.conf` - } - - if (activeConfig.isEncrypted) { - console.log('[createRcloneCliCommand] ensuring encrypted configuration access') - await ensureEncryptedConfigEnv( - activeConfig, - env, - autoPromptForPassword, - commandName, - `Please enter the current password for "${activeConfig.label}"` - ) - } - - if (additionalEnv) { - console.log('[createRcloneCliCommand] setting additional environment') - Object.assign(env, additionalEnv) - } - - console.log( - '[createRcloneCliCommand] creating command, name:', - commandName, - 'args:', - args, - 'env:', - env - ) - - const command = Command.create(commandName, args, { env }) - - console.log('[createRcloneCliCommand] command created') + const env = await buildRcloneEnv({ + activeConfig, + configDirectory, + configPath, + proxy: hostStore.proxy, + rclonePath, + autoPromptForPassword, + additionalEnv, + }) return { - command, + rclonePath, + args, activeConfig, configPath, configDirectory, env, - flavour, } } export async function runRcloneCli(args: string[], input: string[] = []) { - const { command } = await createRcloneCliCommand(args, undefined, true) - - let stdout = '' - let stderr = '' + const { rclonePath, env } = await createRcloneCliCommand(args, undefined, true) console.log('[runRcloneCli] running command', 'args:', args, 'input:', input) - return await new Promise((resolve, reject) => { - command.stdout.on('data', (line) => { - console.log('[runRcloneCli] stdout:', line) - stdout += line + let result: ExecResult + try { + result = await invoke('exec_rclone', { + path: rclonePath, + args, + env, + stdinLines: input.length > 0 ? input : null, + // Config-writing operations must not be interrupted by a timeout. + timeoutMs: null, }) - command.stderr.on('data', (line) => { - console.log('[runRcloneCli] stderr:', line) - stderr += line - }) - command.addListener('error', (event) => { - console.log('[runRcloneCli] error:', event) - const error = typeof event === 'string' ? new Error(event) : event - Sentry.captureException(error) - reject(error instanceof Error ? error : new Error('Unknown rclone CLI error.')) - }) - command.addListener('close', (event) => { - console.log('[runRcloneCli] close:', event) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + Sentry.captureException(err) + throw err + } - if (event.code === 0) { - resolve() - return - } - - const error = new Error( - `rclone command failed (code ${event.code ?? 'unknown'}): ${stderr || stdout}` - ) - Sentry.captureException(error) - reject(error) - }) - - command - .spawn() - .then(async (child) => { - console.log('[runRcloneCli] child:', child) - for (const line of input) { - console.log('[runRcloneCli] writing input:', line) - await child.write(`${line}\n`) - await new Promise((resolve) => setTimeout(resolve, 100)) - console.log('[runRcloneCli] input written') - } - }) - .catch((error) => { - console.log('[runRcloneCli] error:', error) - Sentry.captureException(error) - reject(error) - }) - }) + if (result.code !== 0) { + const error = new Error( + `rclone command failed (code ${result.code ?? 'unknown'}): ${result.stderr || result.stdout}` + ) + Sentry.captureException(error) + throw error + } } export async function restartActiveRclone() { try { - ;(await getAllWindows()) - .filter((window) => window.label === 'main')[0] - .emit('restart-rclone') - // await getCurrentWindow().emit('restart-rclone') + // The main window's store may not have rehydrated this webview's writes before the restart + // runs — carry a full lifecycle snapshot from THIS webview's fresh stores in the payload. + const host = useHostStore.getState() + const persisted = usePersistedStore.getState() + await emitToMain(RESTART_RCLONE, { + rclonePath: persisted.rclonePath, + defaultConfigPath: host.defaultConfigPath, + configFiles: host.configFiles, + activeConfigId: host.activeConfigId, + proxy: host.proxy, + }) } catch (error) { Sentry.captureException(error) console.error('[restartActiveRclone] failed to emit restart event', error) diff --git a/lib/rclone/client.ts b/lib/rclone/client.ts index 5f87244..ee16615 100644 --- a/lib/rclone/client.ts +++ b/lib/rclone/client.ts @@ -10,7 +10,7 @@ import createRCDClient, { type OpenApiRequiredKeysOf, type RCDClient, } from 'rclone-sdk' -import { usePersistedStore } from '../../store/persisted' +import { selectCurrentHost, usePersistedStore } from '../../store/persisted' const RE_RECONNECT = /rclone config reconnect (\S+?):/ @@ -52,7 +52,7 @@ let client: RCDClient | null = null function getClient() { if (!client) { - const currentHost = usePersistedStore.getState().currentHost + const currentHost = selectCurrentHost(usePersistedStore.getState()) if (!currentHost) { console.error('[rclone] No current host') throw new Error('No current host') @@ -86,17 +86,19 @@ type InitParam = OpenApiRequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?] : [Init & { [key: string]: unknown }] -export default async function rclone< - Path extends OpenApiClientPathsWithMethod, - Init extends OpenApiMaybeOptionalInit = OpenApiMaybeOptionalInit< - Paths[Path], - 'post' - >, ->( - path: Path, - ...init: InitParam -): Promise> { - console.log('[rclone] REQUEST', path, { +type RequestResult = { + error?: unknown + data?: unknown + response: Response +} + +// Shared transport core for the sync (POST) and async (ASYNC) RC calls. The two exported wrappers +// differ only in the client method, the log prefix, and the return cast; everything else — client +// acquisition and the 3-branch error triage — is identical and has always been patched in both. +async function request(mode: 'sync' | 'async', path: string, init: any[]): Promise { + const label = mode === 'async' ? 'ASYNC ' : '' + + console.log(`[rclone] ${label}REQUEST`, path, { params: init[0]?.params, body: init[0]?.body, }) @@ -110,10 +112,11 @@ export default async function rclone< throw new Error('Failed to get client after retries') } - const result = await client.POST( - path, - ...(init as InitParam>) - ) + const result = ( + mode === 'async' + ? await client.ASYNC(path as any, ...(init as [any])) + : await client.POST(path as any, ...(init as [any])) + ) as RequestResult if (result?.error) { console.error('[rclone] ERROR', path, { error: result.error }) @@ -127,8 +130,7 @@ export default async function rclone< const data = result.data as { error?: unknown } | undefined if (data?.error) { console.error('[rclone] DATA ERROR', path, { error: data.error }) - const errMsg = - typeof data.error === 'string' ? data.error : JSON.stringify(data.error) + const errMsg = typeof data.error === 'string' ? data.error : JSON.stringify(data.error) await handleReconnectIfNeeded(errMsg) throw new Error(errMsg) @@ -142,12 +144,12 @@ export default async function rclone< throw new Error(`${result.response.status} ${result.response.statusText}`) } - console.log('[rclone] RESPONSE', path, { hasData: !!result.data }) + console.log(`[rclone] ${label}RESPONSE`, path, { hasData: !!result.data }) - return result.data as OpenApiMethodResponse + return result.data } -export async function rcloneAsync< +export default async function rclone< Path extends OpenApiClientPathsWithMethod, Init extends OpenApiMaybeOptionalInit = OpenApiMaybeOptionalInit< Paths[Path], @@ -156,51 +158,21 @@ export async function rcloneAsync< >( path: Path, ...init: InitParam -): Promise { - console.log('[rclone] ASYNC REQUEST', path, { - params: init[0]?.params, - body: init[0]?.body, - }) - - const client = await pRetry(() => getClient(), { - 'maxTimeout': 500, - }) - - if (!client) { - console.error('[rclone] ERROR: Failed to get client after retries', path) - throw new Error('Failed to get client after retries') - } - - const result = await client.ASYNC(path, ...(init as [any])) - - if (result?.error) { - console.error('[rclone] ERROR', path, { error: result.error }) - const errMsg = - typeof result.error === 'string' ? result.error : JSON.stringify(result.error) - - await handleReconnectIfNeeded(errMsg) - throw new Error(errMsg) - } - - const data = result.data as { error?: unknown } | undefined - if (data?.error) { - console.error('[rclone] DATA ERROR', path, { error: data.error }) - const errMsg = - typeof data.error === 'string' ? data.error : JSON.stringify(data.error) - - await handleReconnectIfNeeded(errMsg) - throw new Error(errMsg) - } - - if (!result.response.ok) { - console.error('[rclone] HTTP ERROR', path, { - status: result.response.status, - statusText: result.response.statusText, - }) - throw new Error(`${result.response.status} ${result.response.statusText}`) - } - - console.log('[rclone] ASYNC RESPONSE', path, { hasData: !!result.data }) - - return result.data as AsyncJobResponse +): Promise> { + return (await request('sync', path, init)) as OpenApiMethodResponse< + RCDClient, + 'post', + Path, + Init + > +} + +export async function rcloneAsync< + Path extends OpenApiClientPathsWithMethod, + Init extends OpenApiMaybeOptionalInit = OpenApiMaybeOptionalInit< + Paths[Path], + 'post' + >, +>(path: Path, ...init: InitParam): Promise { + return (await request('async', path, init)) as AsyncJobResponse } diff --git a/lib/rclone/common.ts b/lib/rclone/common.ts index b8d4e56..52c66c9 100644 --- a/lib/rclone/common.ts +++ b/lib/rclone/common.ts @@ -1,8 +1,6 @@ +import { invoke } from '@tauri-apps/api/core' import { appLocalDataDir, sep } from '@tauri-apps/api/path' -import { exists, mkdir, writeTextFile } from '@tauri-apps/plugin-fs' -import { fetch as tauriFetch } from '@tauri-apps/plugin-http' -import { Command } from '@tauri-apps/plugin-shell' -import createRCDClient from 'rclone-sdk' +import { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' import { useHostStore } from '../../store/host' import type { FlagValue } from '../../types/rclone' import { getConfigParentFolder } from '../format' @@ -25,71 +23,30 @@ export async function getDefaultPaths() { } } -export async function getSystemConfigPath() { - console.log('[getSystemConfigPath] running system rclone') - - const instance = Command.create('rclone-system', [ - 'rcd', - '--rc-no-auth', - '--rc-serve', - // '-rc-addr', - // ':5572', - ]) - - if (!instance) { - console.error('[getSystemConfigPath] failed to create rclone instance') - throw new Error('Failed to create rclone instance, please try again later.') - } - - const output = await instance.spawn() - - console.log('[getSystemConfigPath] spawned rclone') - - await new Promise((resolve) => setTimeout(resolve, 200)) - - try { - // no host store at this point - const client = createRCDClient({ - baseUrl: 'http://localhost:5572', - fetch: (request: Request) => tauriFetch(request), - }) - - const defaultPaths = await client.POST('/config/paths', {}) - - const configPath = defaultPaths.data?.config - if (!configPath) { - throw new Error('Failed to fetch config path') - } - - return configPath.replace(DOUBLE_BACKSLASH_REGEX, '\\') - } catch (error) { - console.error('[getSystemConfigPath] error', error) - if (error instanceof Error) { - throw error - } - throw new Error('Failed to get default path, please try again later.') - } finally { - await output.kill() - } +/** App-private location of the default config, used when there is no system rclone to defer to. */ +export async function appPrivateDefaultConfigPath() { + const appLocalDataDirPath = await appLocalDataDir() + return appLocalDataDirPath + sep() + 'configs' + sep() + 'default' + sep() + 'rclone.conf' } export async function getConfigPath({ id, validate = true }: { id: string; validate?: boolean }) { console.log('[getConfigPath]', id, validate) const appLocalDataDirPath = await appLocalDataDir() - console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath) let configPath = appLocalDataDirPath + sep() + 'configs' + sep() + id + sep() + 'rclone.conf' - console.log('[getConfigPath] configPath', configPath) - - if (id == 'default' && (await isSystemRcloneInstalled())) { - const defaultPath = await getSystemConfigPath() - - configPath = defaultPath - console.log('[getConfigPath] configPath', configPath) + // The "default" config lives at a location resolved once at adoption (native for a system + // rclone, app-private otherwise) and persisted, so switching binaries never moves remotes. + if (id === 'default') { + const persistedDefault = useHostStore.getState().defaultConfigPath + if (persistedDefault) { + configPath = persistedDefault + } } + console.log('[getConfigPath] configPath', configPath) + if (validate) { const configExists = await exists(configPath) if (!configExists) { @@ -104,68 +61,102 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid export async function createConfigFile(path: string) { console.log('[createConfigFile] path', path) - const hasConfig = await exists(path).catch(() => false) - console.log('[createConfigFile] hasConfig', hasConfig) - if (!hasConfig) { - console.log('[createConfigFile] writing space character to default path (1)', path) + if (await exists(path).catch(() => false)) { + return + } + + try { + await writeTextFile(path, '# Empty config file\n') + } catch { + // Write-first, then create the parent dir on failure and retry. Do NOT mkdir first: + // getConfigParentFolder returns the path UNCHANGED for non-rclone.conf filenames, so an + // unconditional mkdir could create a directory at the config file path. + await mkdir(getConfigParentFolder(path), { recursive: true }) + await writeTextFile(path, '# Empty config file\n') + } +} + +/** + * Locates a genuine system rclone on PATH (excluding the app's own PATH-integration pointer). + * Returns null under Flatpak, where the host PATH is unreachable. + */ +export async function findSystemRclone(): Promise { + try { + if (await invoke('is_flatpak')) { + return null + } + return (await invoke('find_system_rclone')) ?? null + } catch (error) { + console.error('[findSystemRclone] error', error) + return null + } +} + +/** Runs ` version` and returns the parsed version string; throws the detailed Rust error + * (including the macOS Gatekeeper `xattr` hint) when the binary is unusable. */ +export async function probeRcloneBinaryOrThrow(path: string): Promise { + return await invoke('validate_rclone_binary', { path }) +} + +/** Like probeRcloneBinaryOrThrow, but returns null instead of throwing. */ +export async function validateRcloneBinary(path: string): Promise { + try { + return await probeRcloneBinaryOrThrow(path) + } catch (error) { + console.error('[validateRcloneBinary] error', error) + return null + } +} + +export interface RcloneClassification { + kind: 'system' | 'managed' | 'custom' + version: string | null +} + +/** Classifies a path as system / managed / custom using canonical comparisons in Rust. */ +export async function classifyRclonePath(path: string): Promise { + try { + return await invoke('classify_rclone_path', { path }) + } catch (error) { + console.error('[classifyRclonePath] error', error) + return { kind: 'custom', version: null } + } +} + +/** + * Resolves where the default config should live, driven by what the user already uses: + * an app-private config that already holds remotes wins; otherwise a system rclone's native + * config; otherwise the app-private default. Called once, then persisted. + */ +export async function resolveDefaultConfigPath(): Promise { + const appPrivate = await appPrivateDefaultConfigPath() + + try { + if (await exists(appPrivate)) { + const content = await readTextFile(appPrivate) + // A section header — or an encrypted body, which has no headers — means the user + // has real remotes here; keep them. + if (/^\s*\[/m.test(content) || content.includes('RCLONE_ENCRYPT_V0:')) { + return appPrivate + } + } + } catch (error) { + console.error('[resolveDefaultConfigPath] failed reading app-private config', error) + } + + const system = await findSystemRclone() + if (system) { try { - await writeTextFile(path, '# Empty config file\n') + const native = await invoke('rclone_config_path', { path: system }) + if (native) { + return native.replace(DOUBLE_BACKSLASH_REGEX, '\\') + } } catch (error) { - console.error('[createConfigFile] error', error) - } - - if (!(await exists(path).catch(() => false))) { - console.log( - '[createConfigFile] failed to write space character to default path (1)', - path - ) - const folderPath = getConfigParentFolder(path) - console.log('[createConfigFile] creating folder', folderPath) - await mkdir(folderPath, { recursive: true }) - console.log('[createConfigFile] created folder', folderPath) - console.log('[createConfigFile] writing space character to default path (2)', path) - await writeTextFile(path, '# Empty config file\n') - const existsFinally = await exists(path).catch(() => false) - console.log('[createConfigFile] existsFinally', existsFinally) + console.error('[resolveDefaultConfigPath] failed reading native config path', error) } } -} -/** - * Checks if rclone is installed and accessible from the system PATH - * @returns {Promise} True if rclone is installed and working - */ -export async function isSystemRcloneInstalled() { - console.log('[isSystemRcloneInstalled]') - - try { - const output = await Command.create('rclone-system').execute() - return ( - output.stdout.includes('Available commands') || - output.stderr.includes('Available commands') - ) - } catch { - return false - } -} - -/** - * Checks if rclone is downloaded by the application in the app's local data directory - * @returns {Promise} True if downloaded rclone is present and working - */ -export async function isInternalRcloneInstalled() { - console.log('[isInternalRcloneInstalled]') - - try { - const output = await Command.create('rclone-internal').execute() - // console.log('[isInternalRcloneInstalled] output', output) - return ( - output.stdout.includes('Available commands') || - output.stderr.includes('Available commands') - ) - } catch { - return false - } + return appPrivate } export function parseRcloneOptions(options: Record) { @@ -176,7 +167,10 @@ export function parseRcloneOptions(options: Record) { export function compareVersions(version1: string, version2: string): number { const parseVersion = (version: string) => { - const parts = version.split('.').map((num) => Number.parseInt(num, 10)) + // Strip a leading 'v' and any pre-release suffix (e.g. "1.74.0-beta.x") before comparing; + // otherwise parseInt('v1') is NaN → coerced to 0, silently mis-ordering versions. + const core = version.trim().replace(/^v/, '').split('-')[0] + const parts = core.split('.').map((num) => Number.parseInt(num, 10)) return { major: parts[0] || 0, minor: parts[1] || 0, @@ -198,67 +192,3 @@ export function compareVersions(version1: string, version2: string): number { } return 0 } - -const YOURS_VERSION_REGEX = /yours:\s+([^\s]+)/ -const LATEST_VERSION_REGEX = /latest:\s+([^\s]+)/ - -export async function getRcloneVersion(type?: 'system' | 'internal') { - let instanceType = type - if (!instanceType) { - instanceType = (await isSystemRcloneInstalled()) ? 'system' : 'internal' - } - - const result = await Command.create( - instanceType === 'system' ? 'rclone-system' : 'rclone-internal', - ['selfupdate', '--check'] - ).execute() - const output = result.stdout.trim() - return parseRcloneVersion(output) -} - -export function parseRcloneVersion(output: string) { - const yoursMatch = output.match(YOURS_VERSION_REGEX) - const latestMatch = output.match(LATEST_VERSION_REGEX) - - if (!yoursMatch || !latestMatch) { - return null - } - - return { - yours: yoursMatch[1], - latest: latestMatch[1], - } -} - -export function shouldUpdateRclone(versionData: { yours: string; latest: string } | null) { - if (!versionData) { - console.warn('[shouldUpdateRclone] received no version data:', versionData) - return false - } - - const currentVersion = versionData?.yours - const latestVersion = versionData?.latest - - if (!currentVersion || !latestVersion) { - console.warn('[shouldUpdateRclone] could not parse version output:', versionData) - return false - } - - console.log('[shouldUpdateRclone] current version:', currentVersion) - console.log('[shouldUpdateRclone] latest version:', latestVersion) - - if (useHostStore.getState().lastSkippedVersion === latestVersion) { - console.log('[shouldUpdateRclone] latest version is in the lastSkippedVersion') - return false - } - - // Compare versions using the existing compareVersions function - const versionComparison = compareVersions(currentVersion, latestVersion) - if (versionComparison < 0) { - console.log('[shouldUpdateRclone] internal rclone needs update') - return true - } - - console.log('[shouldUpdateRclone] internal rclone is up to date') - return false -} diff --git a/lib/rclone/constants.ts b/lib/rclone/constants.ts index 0dd6e6c..772e56f 100644 --- a/lib/rclone/constants.ts +++ b/lib/rclone/constants.ts @@ -20,6 +20,12 @@ export const RCLONE_CONFIG_DEFAULTS = { export const RCLONE_CONF_REGEX = /[\/\\]rclone\.conf$/ export const DOUBLE_BACKSLASH_REGEX = /\\\\/g +// Minimum rclone version the app's RC surface requires. The Serve feature calls +// /serve/start|list|stop|stopall, which rclone added in 1.70. +export const MIN_RCLONE_VERSION = '1.70.0' +export const RCLONE_RELEASES_API = 'https://api.github.com/repos/rclone/rclone/releases?per_page=30' +export const RCLONE_RELEASES_SHOWN = 20 + export const SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const export const SUPPORTS_CLEANUP = [ @@ -161,69 +167,3 @@ export function supportsPersistentEmptyFolders(backendType?: string | null) { if (!backendType) return true return !CANNOT_PERSIST_EMPTY_FOLDERS.includes(backendType.toLowerCase()) } - -// export const SUPPORTED_OPERATIONS = [ -// { -// id: 'uncategorized', -// name: 'Uncategorized', -// icon: , -// titleColor: 'text-foreground', -// indicatorColor: 'text-foreground-500', -// }, -// { -// id: 'copy', -// name: 'Copy', -// icon: , -// titleColor: 'text-primary-400', -// indicatorColor: 'text-primary-300', -// }, -// { -// id: 'move', -// name: 'Move', -// icon: , -// titleColor: 'text-primary-400', -// indicatorColor: 'text-primary-300', -// }, -// { -// id: 'delete', -// name: 'Delete', -// icon: , -// titleColor: 'text-danger-400', -// indicatorColor: 'text-danger-300', -// }, -// { -// id: 'sync', -// name: 'Sync', -// icon: , -// titleColor: 'text-success-300', -// indicatorColor: 'text-success-300', -// }, -// { -// id: 'bisync', -// name: 'Bisync', -// icon: , -// titleColor: 'text-primary-400', -// indicatorColor: 'text-primary-300', -// }, -// { -// id: 'mount', -// name: 'Mount', -// icon: , -// titleColor: 'text-secondary-500', -// indicatorColor: 'text-secondary-400', -// }, -// { -// id: 'purge', -// name: 'Purge', -// icon: , -// titleColor: 'text-warning-300', -// indicatorColor: 'text-warning-300', -// }, -// { -// id: 'serve', -// name: 'Serve', -// icon: , -// titleColor: 'text-cyan-500', -// indicatorColor: 'text-cyan-300', -// }, -// ] as const diff --git a/lib/rclone/init.ts b/lib/rclone/init.ts index 32851db..2ec7967 100644 --- a/lib/rclone/init.ts +++ b/lib/rclone/init.ts @@ -1,144 +1,76 @@ import * as Sentry from '@sentry/browser' import { invoke } from '@tauri-apps/api/core' -import { BaseDirectory, appLocalDataDir, appLogDir, sep } from '@tauri-apps/api/path' -import { tempDir } from '@tauri-apps/api/path' +import { appLogDir, sep } from '@tauri-apps/api/path' import { ask, message } from '@tauri-apps/plugin-dialog' -import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugin-fs' -import { writeFile } from '@tauri-apps/plugin-fs' +import { exists, readTextFile } from '@tauri-apps/plugin-fs' import { fetch } from '@tauri-apps/plugin-http' import { platform } from '@tauri-apps/plugin-os' import { exit, relaunch } from '@tauri-apps/plugin-process' -import { Command } from '@tauri-apps/plugin-shell' -import { useHostStore } from '../../store/host' +import { selectActiveConfigFile, useHostStore } from '../../store/host' import { useStore } from '../../store/memory' import { usePersistedStore } from '../../store/persisted' import { getConfigParentFolder } from '../format' +import notify from '../notify' import { openSmallWindow } from '../window' -import { ensureEncryptedConfigEnv } from './cli' +import { buildRcloneEnv } from './cli' import { + classifyRclonePath, + compareVersions, createConfigFile, + findSystemRclone, getConfigPath, - getRcloneVersion, - getSystemConfigPath, - isInternalRcloneInstalled, - isSystemRcloneInstalled, - shouldUpdateRclone, + resolveDefaultConfigPath, + validateRcloneBinary, } from './common' +import { downloadVersion, listDownloadedVersions } from './versions' export async function initRclone(args: string[]) { console.log('[initRclone] starting with args:', args) - const system = !(await invoke('is_flatpak')) && (await isSystemRcloneInstalled()) - console.log('[initRclone] system rclone installed:', system) - let internal = await isInternalRcloneInstalled() - console.log('[initRclone] internal rclone installed:', internal) + // Resolve which rclone binary to run (adopting a system/legacy binary on first launch). + let rclonePath = await resolveActiveRclone() - // rclone not available, let's download it - if (!system && !internal) { - console.log('[initRclone] no rclone installation found, provisioning...') + // Nothing installed anywhere — download the latest and adopt it. + if (!rclonePath) { + console.log('[initRclone] no rclone available, provisioning...') useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) await openSmallWindow({ name: 'Startup', url: '/startup', }) - const success = await provisionRclone() - console.log('[initRclone] provision rclone result:', success) - if (!success) { + + const provisionedPath = await provisionRclone() + console.log('[initRclone] provision rclone result:', provisionedPath) + if (!provisionedPath) { console.error('[initRclone] provision failed, setting fatal status') useStore.setState({ startupStatus: 'fatal' }) return } - console.log('[initRclone] provision succeeded') + usePersistedStore.getState().setRclonePath(provisionedPath) + rclonePath = provisionedPath useStore.setState({ startupStatus: 'initialized' }) if (!['windows', 'macos'].includes(platform())) { usePersistedStore.setState({ hideStartup: true }) } - - internal = true } - const rcloneVersion = await getRcloneVersion(system ? 'system' : 'internal') - console.log('[initRclone] rclone version:', rcloneVersion) + // Check for a newer stable release of a managed binary: auto-update or notify. + rclonePath = await maybeAutoUpdateRclone(rclonePath) - if (shouldUpdateRclone(rcloneVersion)) { - console.log('[initRclone] needs update') + // Keep the PATH-integration pointer aimed at the active binary (best-effort). + invoke('update_path_pointer', { targetPath: rclonePath }).catch((error) => { + console.warn('[initRclone] update_path_pointer failed', error) + }) - useStore.setState({ startupStatus: 'updating' }) - - await openSmallWindow({ - name: 'Startup', - url: '/startup', - }) - - try { - if (system) { - console.log('[initRclone] updating system rclone') - const code = (await invoke('update_system_rclone')) as number - console.log('[initRclone] update_rclone code', code) - if (code !== 0) { - console.log( - '[initRclone] system rclone update failed or was cancelled by user, code:', - code - ) - useStore.setState({ startupStatus: 'error' }) - const skipping = await ask( - 'You are running an outdated version of the CLI that could not be updated.\n\nPlease update manually and restart Rclone UI.', - { - title: 'Error', - kind: 'error', - okLabel: 'Skip version', - cancelLabel: 'Exit', - } - ) - console.log('[initRclone] user skipping version:', skipping) - if (skipping) { - console.log('[initRclone] saving skipped version:', rcloneVersion!.yours) - useHostStore.setState({ lastSkippedVersion: rcloneVersion!.yours }) - } - } else { - console.log('[initRclone] system rclone updated successfully') - useStore.setState({ startupStatus: 'updated' }) - } - } - if (internal) { - console.log('[initRclone] updating internal rclone') - const instance = Command.create('rclone-internal', ['selfupdate']) - const updateResult = await instance.execute() - console.log('[initRclone] updateResult', JSON.stringify(updateResult, null, 2)) - if (updateResult.code !== 0) { - console.log( - '[initRclone] internal rclone update failed, code:', - updateResult.code - ) - useStore.setState({ startupStatus: 'error' }) - } else { - console.log('[initRclone] internal rclone updated successfully') - useStore.setState({ startupStatus: 'updated' }) - } - } - } catch (error) { - console.error('[initRclone] failed to update rclone', error) - useStore.setState({ startupStatus: 'error' }) - } - - await new Promise((resolve) => setTimeout(resolve, 1000)) - } + // Resolve + materialize the default config location once, independent of the binary, + // so switching binaries never relocates the user's remotes. + await ensureDefaultConfig() const hostState = useHostStore.getState() let configFiles = hostState.configFiles || [] console.log('[initRclone] loaded config files count:', configFiles.length) - let activeConfigFile = hostState.activeConfigFile - console.log('[initRclone] active config file:', activeConfigFile?.id) - - if (system) { - const defaultPath = await getSystemConfigPath() - console.log('[initRclone] defaultPath', defaultPath) - - await createConfigFile(defaultPath) - console.log('[initRclone] created system config file') - } const existingDefaultConfig = configFiles.find((config) => config.id === 'default') configFiles = configFiles.filter((config) => config.id !== 'default') @@ -159,6 +91,10 @@ export async function initRclone(args: string[]) { console.log('[initRclone] added default config to list') useHostStore.setState({ configFiles }) + // Resolve the active config against the REBUILT list so a persisted id of 'default' resolves. + let activeConfigFile = selectActiveConfigFile(useHostStore.getState()) + console.log('[initRclone] active config file:', activeConfigFile?.id) + if (!activeConfigFile) { console.log('[initRclone] no active config file, setting default') activeConfigFile = configFiles[0] @@ -168,13 +104,7 @@ export async function initRclone(args: string[]) { } console.log('[initRclone] set active config file to:', activeConfigFile.id) - useHostStore.setState({ activeConfigFile }) - } - - if (internal && activeConfigFile.id === 'default') { - console.log('[initRclone] creating internal default config file') - const defaultInternalPath = await getConfigPath({ id: 'default', validate: false }) - await createConfigFile(defaultInternalPath) + useHostStore.getState().setActiveConfigFile(activeConfigFile.id!) } let configFolderPath = activeConfigFile.sync @@ -203,11 +133,12 @@ export async function initRclone(args: string[]) { okLabel: 'OK', }) activeConfigFile = configFiles[0] - configFolderPath = getConfigParentFolder( - await getConfigPath({ id: 'default', validate: true }) - ) + // Rebind configPath too (not just configFolderPath): otherwise the readTextFile below + // reads the stale, known-missing synced path and the fallback dead-ends in an exit. + configPath = await getConfigPath({ id: 'default', validate: true }) + configFolderPath = getConfigParentFolder(configPath) console.log('[initRclone] switched to default config') - useHostStore.setState({ activeConfigFile: configFiles[0] }) + useHostStore.getState().setActiveConfigFile(configFiles[0].id!) } } @@ -227,36 +158,14 @@ export async function initRclone(args: string[]) { } else { console.log('[initRclone] no stored password configured') } + } - if (!activeConfigFile.isEncrypted) { - console.log('[initRclone] updating config file encryption flag') - const updatedConfigFile = { ...activeConfigFile, isEncrypted: true } - const updatedConfigFiles = configFiles.map((config) => - config.id === activeConfigFile!.id ? updatedConfigFile : config - ) - useHostStore.setState({ - configFiles: updatedConfigFiles, - activeConfigFile: updatedConfigFile, - }) - console.log('[initRclone] saved updated encryption flag') - - // Update activeConfigFile reference for the rest of the function - activeConfigFile = updatedConfigFile - } - } else if (activeConfigFile.isEncrypted) { - console.log('[initRclone] config file is not encrypted, clearing encryption flag') - const updatedConfigFile = { ...activeConfigFile, isEncrypted: false } - const updatedConfigFiles = configFiles.map((config) => - config.id === activeConfigFile!.id ? updatedConfigFile : config - ) - useHostStore.setState({ - configFiles: updatedConfigFiles, - activeConfigFile: updatedConfigFile, - }) - console.log('[initRclone] cleared encryption flag') - - // Update activeConfigFile reference for the rest of the function - activeConfigFile = updatedConfigFile + // Reconcile the stored encryption flag with the file's actual contents. The local rebind + // is load-bearing: buildRcloneEnv below reads activeConfigFile to build the password env. + if (activeConfigFile.isEncrypted !== isEncrypted) { + console.log('[initRclone] reconciling encryption flag to', isEncrypted) + useHostStore.getState().updateConfigFile(activeConfigFile.id!, { isEncrypted }) + activeConfigFile = { ...activeConfigFile, isEncrypted } } } catch (error) { console.log('[initRclone] could not read config file', error) @@ -273,10 +182,7 @@ export async function initRclone(args: string[]) { return } - const extraParams: { env: Record } = { - env: {}, - } - + // Proxy connectivity check (informational; the env vars themselves are set by buildRcloneEnv). if (hostState.proxy) { console.log('[initRclone] proxy configured:', hostState.proxy.url) try { @@ -302,301 +208,255 @@ export async function initRclone(args: string[]) { return } } - console.log('[initRclone] setting proxy environment variables') - extraParams.env.http_proxy = hostState.proxy.url - extraParams.env.https_proxy = hostState.proxy.url - extraParams.env.HTTP_PROXY = hostState.proxy.url - extraParams.env.HTTPS_PROXY = hostState.proxy.url - extraParams.env.no_proxy = hostState.proxy.ignoredHosts.join(',') - extraParams.env.NO_PROXY = hostState.proxy.ignoredHosts.join(',') - console.log( - '[initRclone] proxy env vars set, ignored hosts:', - hostState.proxy.ignoredHosts.length - ) } - if (internal || activeConfigFile.id !== 'default') { - console.log('[initRclone] setting custom config path:', configFolderPath) - extraParams.env.RCLONE_CONFIG_DIR = configFolderPath - extraParams.env.RCLONE_CONFIG = `${configFolderPath}${sep()}rclone.conf` - } - - const commandName = system ? 'rclone-system' : internal ? 'rclone-internal' : null - - if (activeConfigFile.isEncrypted && commandName) { - console.log('[initRclone] ensuring encrypted configuration access') - try { - await ensureEncryptedConfigEnv( - activeConfigFile, - extraParams.env, - true, - commandName, - `Please enter the current password for "${activeConfigFile.label}"` - ) - } catch (error) { - if (error instanceof Error && error.message === 'Password prompt cancelled by user.') { - console.error('[initRclone] password prompt cancelled by user') - const response = await message( - 'Password is required for encrypted configurations.', - { - title: 'Password Required', - kind: 'error', - buttons: { - cancel: 'Close', - ok: 'Try Again', - }, - } - ) - console.log('[initRclone] message response:', response) - if (response === 'Try Again') { - await relaunch() - return - } - await exit(0) + let env: Record + try { + env = await buildRcloneEnv({ + activeConfig: activeConfigFile, + configDirectory: configFolderPath, + configPath, + proxy: hostState.proxy, + rclonePath, + autoPromptForPassword: true, + }) + } catch (error) { + if (error instanceof Error && error.message === 'Password prompt cancelled by user.') { + console.error('[initRclone] password prompt cancelled by user') + const response = await message('Password is required for encrypted configurations.', { + title: 'Password Required', + kind: 'error', + buttons: { + cancel: 'Close', + ok: 'Try Again', + }, + }) + console.log('[initRclone] message response:', response) + if (response === 'Try Again') { + await relaunch() return } - throw error + await exit(0) + return } + throw error } - console.log('[initRclone] extraParams', extraParams) - - if (system) { - console.log('[initRclone] creating system rclone command instance') - const instance = Command.create('rclone-system', args, extraParams) - console.log('[initRclone] returning system rclone instance') - return { system: instance } - } - if (internal) { - console.log('[initRclone] creating internal rclone command instance') - const instance = Command.create('rclone-internal', args, extraParams) - console.log('[initRclone] returning internal rclone instance') - return { internal: instance } - } - - console.error('[initRclone] no rclone installation available') - throw new Error('Failed to initialize rclone, please try again later.') + console.log('[initRclone] returning rclone command', { path: rclonePath, args }) + return { path: rclonePath, args, env } } /** - * Downloads and provisions the latest version of rclone for the current platform - * @throws {Error} If architecture detection fails or installation is unsuccessful - * @returns {Promise} + * Resolves the active rclone binary path: validates the persisted selection (self-healing a + * managed version whose absolute path moved), otherwise adopts a system / legacy / downloaded + * binary. Returns null when nothing is available so the caller can provision. */ -export async function provisionRclone() { - console.log('[provisionRclone] starting provisioning process') +async function resolveActiveRclone(): Promise { + const persisted = usePersistedStore.getState() + const stored = persisted.rclonePath - console.log('[provisionRclone] fetching latest version info') - const currentVersionString = await fetch('https://downloads.rclone.org/version.txt').then( - (res) => res.text() - ) - console.log('[provisionRclone] currentVersionString', currentVersionString) + if (stored) { + const version = await validateRcloneBinary(stored) + if (version) { + console.log('[resolveActiveRclone] using stored rclone', stored, version) + return stored + } + console.warn('[resolveActiveRclone] stored rclone path is unusable:', stored) - const currentVersion = currentVersionString.split('v')?.[1]?.trim() - - if (!currentVersion) { - console.error('[provisionRclone] failed to get latest version from string') - await message('Failed to get latest rclone version, please try again later.') - return false - } - console.log('[provisionRclone] currentVersion', currentVersion) - - const currentPlatform = platform() - console.log('[provisionRclone] currentPlatform', currentPlatform) - - const currentOs = currentPlatform === 'macos' ? 'osx' : currentPlatform - console.log('[provisionRclone] currentOs', currentOs) - - console.log('[provisionRclone] getting temp directory path') - let tempDirPath = await tempDir() - if (tempDirPath.endsWith(sep())) { - tempDirPath = tempDirPath.slice(0, -1) - } - console.log('[provisionRclone] tempDirPath', tempDirPath) - - console.log('[provisionRclone] detecting system architecture') - const arch = (await invoke('get_arch')) as 'arm64' | 'amd64' | '386' | 'unknown' - console.log('[provisionRclone] arch', arch) - - if (arch === 'unknown') { - console.error('[provisionRclone] failed to get architecture') - await message('Failed to get current arch, please try again later.') - return false - } - - const downloadUrl = `https://downloads.rclone.org/v${currentVersion}/rclone-v${currentVersion}-${currentOs}-${arch}.zip` - console.log('[provisionRclone] downloadUrl', downloadUrl) - - console.log('[provisionRclone] downloading rclone binary') - const downloadedFile = await fetch(downloadUrl).then((res) => res.arrayBuffer()) - console.log('[provisionRclone] download complete, size:', downloadedFile.byteLength) - - console.log('[provisionRclone] checking if temp rclone directory exists') - let tempDirExists = false - try { - tempDirExists = await exists('rclone', { - baseDir: BaseDirectory.Temp, - }) - console.log('[provisionRclone] tempDirExists', tempDirExists) - } catch (error) { - Sentry.captureException(error) - console.error('[provisionRclone] failed to check if rclone temp dir exists', error) - } - - if (tempDirExists) { - console.log('[provisionRclone] removing existing temp directory') - try { - await remove('rclone', { - recursive: true, - baseDir: BaseDirectory.Temp, + // Self-heal a managed version whose absolute path moved (e.g. home-dir rename). + const match = stored.match(/rclone-versions[/\\]v([^/\\]+)/) + if (match) { + const healed = await invoke('managed_version_path', { + version: match[1], }) - console.log('[provisionRclone] removed rclone temp dir') - } catch (error) { - Sentry.captureException(error) - console.error('[provisionRclone] failed to remove rclone temp dir', error) - await message('Failed to provision rclone.') - return false + if (healed && (await validateRcloneBinary(healed))) { + console.log('[resolveActiveRclone] self-healed managed path ->', healed) + persisted.setRclonePath(healed) + return healed + } } + // fall through to the adoption ladder } - console.log('[provisionRclone] creating temp directory') + // Fold any legacy single-slot binary into the versioned library first (idempotent), so it + // remains visible even when a system rclone ends up active. + let legacyAdopted: { version: string; path: string } | null = null try { - await mkdir('rclone', { - baseDir: BaseDirectory.Temp, - }) - console.log('[provisionRclone] created rclone temp dir') + legacyAdopted = await invoke<{ version: string; path: string } | null>( + 'adopt_legacy_rclone' + ) } catch (error) { - Sentry.captureException(error) - console.error('[provisionRclone] failed to create rclone temp dir', error) - await message('Failed to provision rclone.') - return false + console.error('[resolveActiveRclone] adopt_legacy_rclone failed', error) } - const zipPath = [ - tempDirPath, - 'rclone', - `rclone-v${currentVersion}-${currentOs}-${arch}.zip`, - ].join(sep()) - console.log('[provisionRclone] zipPath', zipPath) - - console.log('[provisionRclone] writing zip file to disk') - try { - await writeFile(zipPath, new Uint8Array(downloadedFile)) - console.log('[provisionRclone] wrote zip file successfully') - } catch (error) { - Sentry.captureException(error) - console.error('[provisionRclone] failed to write zip file', error) - await message('Failed to provision rclone.') - return false - } - - const extractPath = `${tempDirPath}${sep()}rclone${sep()}extracted` - console.log('[provisionRclone] extracting zip file to:', extractPath) - try { - await invoke('unzip_file', { - zipPath, - outputFolder: extractPath, - }) - console.log('[provisionRclone] successfully unzipped file') - } catch (error) { - Sentry.captureException(error) - console.error('[provisionRclone] failed to unzip file', error) - await message('Failed to provision rclone.') - return false - } - - const unarchivedPath = [ - tempDirPath, - 'rclone', - 'extracted', - `rclone-v${currentVersion}-${currentOs}-${arch}`, - ].join(sep()) - console.log('[provisionRclone] unarchivedPath', unarchivedPath) - - const binaryName = currentPlatform === 'windows' ? 'rclone.exe' : 'rclone' - console.log('[provisionRclone] binaryName', binaryName) - - const rcloneBinaryPath = unarchivedPath + sep() + binaryName - console.log('[provisionRclone] rcloneBinaryPath', rcloneBinaryPath) - - console.log('[provisionRclone] verifying extracted binary exists') - try { - const binaryExists = await exists(rcloneBinaryPath) - console.log('[provisionRclone] rcloneBinaryPathExists', binaryExists) - if (!binaryExists) { - console.error('[provisionRclone] binary not found in expected location') - throw new Error('Could not find rclone binary in zip') - } - } catch (error) { - Sentry.captureException(error) - console.error('[provisionRclone] failed to check if rclone binary exists', error) - await message('Failed to provision rclone.') - return false - } - - console.log('[provisionRclone] getting app local data directory') - const appLocalDataDirPath = await appLocalDataDir() - console.log('[provisionRclone] appLocalDataDirPath', appLocalDataDirPath) - - console.log('[provisionRclone] checking if app local data directory exists') - const appLocalDataDirPathExists = await exists(appLocalDataDirPath) - console.log('[provisionRclone] appLocalDataDirPathExists', appLocalDataDirPathExists) - - if (!appLocalDataDirPathExists) { - console.log('[provisionRclone] creating app local data directory') - await mkdir(appLocalDataDirPath, { - recursive: true, - }) - console.log('[provisionRclone] appLocalDataDirPath created') - } - - const targetBinaryPath = `${appLocalDataDirPath}${sep()}${binaryName}` - console.log('[provisionRclone] targetBinaryPath', targetBinaryPath) - - console.log('[provisionRclone] copying binary to final location') - const maxCopyRetries = 3 - for (let attempt = 1; attempt <= maxCopyRetries; attempt++) { - console.log(`[provisionRclone] copy attempt ${attempt}/${maxCopyRetries}`) - try { - await copyFile(rcloneBinaryPath, targetBinaryPath) - console.log('[provisionRclone] copied rclone binary successfully') - break - } catch (copyError) { - console.log( - `[provisionRclone] attempt ${attempt}/${maxCopyRetries} failed to copy:`, - copyError + // 1. Genuine system rclone — offered, not silently adopted, so the user decides whether the + // app tracks their system install or manages its own copy. Answering persists a path, so + // the question fires only while no usable path is stored. + const system = await findSystemRclone() + if (system) { + const systemVersion = await validateRcloneBinary(system) + if (systemVersion) { + const useSystem = await ask( + `Found rclone v${systemVersion} at:\n${system}\n\nUse it as the app's rclone? Otherwise the app will manage its own copy. You can switch anytime in Settings.`, + { + title: 'System rclone detected', + kind: 'info', + okLabel: 'Use system rclone', + cancelLabel: 'Manage separately', + } ) - - if (attempt < maxCopyRetries) { - const waitTime = attempt * 1000 - console.log(`[provisionRclone] waiting ${waitTime}ms before retry`) - // Wait a bit before retrying - await new Promise((resolve) => setTimeout(resolve, waitTime)) - } else { - console.error('[provisionRclone] all copy attempts failed', copyError) - Sentry.captureException(copyError, { - extra: { - rcloneBinaryPath, - targetBinaryPath, - }, - }) - throw new Error( - 'Failed to provision rclone, file is busy. Install cli manually or try again later.' - ) + if (useSystem) { + persisted.setRclonePath(system) + return system } } } - console.log('[provisionRclone] verifying installation') - const hasInstalled = await isInternalRcloneInstalled() - console.log('[provisionRclone] installation verified:', hasInstalled) - - if (!hasInstalled) { - console.error('[provisionRclone] installation verification failed') - throw new Error('Failed to install rclone') + // 2. The just-adopted legacy binary. Re-probe it: when the version already existed in the + // library, adopt_legacy_rclone returns that pre-existing binary without validating it. + if (legacyAdopted?.path && (await validateRcloneBinary(legacyAdopted.path))) { + persisted.setRclonePath(legacyAdopted.path) + return legacyAdopted.path } - console.log('[provisionRclone] rclone has been installed successfully') + // 3. Newest already-downloaded managed version that still runs — a broken binary must fall + // through to provisioning instead of being re-adopted. + try { + const downloaded = await listDownloadedVersions() + for (const candidate of downloaded) { + if (await validateRcloneBinary(candidate.path)) { + persisted.setRclonePath(candidate.path) + return candidate.path + } + console.warn( + '[resolveActiveRclone] skipping unusable downloaded version:', + candidate.path + ) + } + } catch (error) { + console.error('[resolveActiveRclone] list_downloaded_rclone_versions failed', error) + } - return true + // 4. Nothing available — caller provisions. + return null +} + +let rcloneUpdateChecked = false + +/** + * For a managed binary: checks downloads.rclone.org for a newer stable release, once per app + * session (so switching versions in Settings doesn't immediately undo a pin). Downloads and + * adopts it when auto-update is on; otherwise notifies once per version that an update can be + * run from Settings. Never blocks startup on failure. + */ +async function maybeAutoUpdateRclone(currentPath: string): Promise { + if (rcloneUpdateChecked) { + return currentPath + } + rcloneUpdateChecked = true + + try { + const active = await classifyRclonePath(currentPath) + if (active.kind !== 'managed' || !active.version) { + return currentPath + } + + const versionString = await fetch('https://downloads.rclone.org/version.txt', { + connectTimeout: 5000, + }).then((res) => res.text()) + const latest = versionString.split('v')?.[1]?.trim() + if (!latest || compareVersions(latest, active.version) <= 0) { + return currentPath + } + + const persisted = usePersistedStore.getState() + + if (!persisted.autoUpdateRclone) { + if (persisted.lastNotifiedRcloneVersion !== latest) { + usePersistedStore.setState({ lastNotifiedRcloneVersion: latest }) + await notify({ + title: 'Rclone update available', + body: `rclone v${latest} is available. You can update from Settings → Binary.`, + }) + } + return currentPath + } + + console.log('[maybeAutoUpdateRclone] updating', active.version, '->', latest) + // Startup-window status only (never startupDisplayed): showStartup opens the window and, + // finding 'updated', shows the update message; a failed update is restored below so the + // window can't stick on 'updating' with no TAP TO START. + useStore.setState({ startupStatus: 'updating' }) + const newPath = await downloadVersion(latest) + persisted.setRclonePath(newPath) + useStore.setState({ startupStatus: 'updated' }) + return newPath + } catch (error) { + console.log('[maybeAutoUpdateRclone] update check skipped', error) + // Restore so 'updating' can't stick — but only if we set it: a failure before the + // download (classify, version fetch) must not downgrade a status another path already + // promoted (provisioning sets 'initialized' before this runs). When the Startup window + // is already open (provisioning path), restore 'initialized' — 'initializing' renders + // no TAP TO START and showStartup early-returns on startupDisplayed, stranding the + // window. Do NOT set 'error' — this path is offline-safe and silently continues on the + // existing binary. + const store = useStore.getState() + if (store.startupStatus === 'updating') { + useStore.setState({ + startupStatus: store.startupDisplayed ? 'initialized' : 'initializing', + }) + } + return currentPath + } +} + +/** Resolves (once) and materializes the default config location for the active host. */ +async function ensureDefaultConfig() { + const host = useHostStore.getState() + let defaultConfigPath = host.defaultConfigPath + if (!defaultConfigPath) { + defaultConfigPath = await resolveDefaultConfigPath() + console.log('[ensureDefaultConfig] resolved default config path', defaultConfigPath) + host.setDefaultConfigPath(defaultConfigPath) + } + await createConfigFile(defaultConfigPath) +} + +/** + * Downloads the latest rclone release into the versioned library and returns its absolute path, + * or false on failure. The download/extract/verify pipeline lives in Rust. + */ +export async function provisionRclone(): Promise { + console.log('[provisionRclone] starting') + + let version: string | undefined + try { + const versionString = await fetch('https://downloads.rclone.org/version.txt').then((res) => + res.text() + ) + version = versionString.split('v')?.[1]?.trim() + } catch (error) { + console.error('[provisionRclone] failed to fetch latest version', error) + } + + if (!version) { + await message('Failed to get latest rclone version, please try again later.') + return false + } + console.log('[provisionRclone] latest version', version) + + let path: string + try { + path = await downloadVersion(version) + } catch (error) { + console.error('[provisionRclone] download failed', error) + Sentry.captureException(error) + await message( + `Failed to download rclone: ${error instanceof Error ? error.message : String(error)}` + ) + return false + } + + console.log('[provisionRclone] installed at', path) + return path } diff --git a/lib/rclone/versions.ts b/lib/rclone/versions.ts new file mode 100644 index 0000000..fc3c400 --- /dev/null +++ b/lib/rclone/versions.ts @@ -0,0 +1,200 @@ +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import { ask } from '@tauri-apps/plugin-dialog' +import { fetch } from '@tauri-apps/plugin-http' +import { useHostStore } from '../../store/host' +import { usePersistedStore } from '../../store/persisted' +import { restartActiveRclone } from './cli' +import rcloneClient from './client' +import { appPrivateDefaultConfigPath, compareVersions } from './common' +import { MIN_RCLONE_VERSION, RCLONE_RELEASES_API, RCLONE_RELEASES_SHOWN } from './constants' + +export interface DownloadedVersion { + version: string + path: string + sizeBytes: number +} + +export interface AvailableRelease { + version: string + publishedAt: string +} + +export interface PathStatus { + enabled: boolean + target: string | null + warning: string | null +} + +export interface DownloadProgress { + version: string + downloaded: number + total: number | null +} + +export async function listDownloadedVersions(): Promise { + return await invoke('list_downloaded_rclone_versions') +} + +/** Fetches stable rclone releases at or above the minimum supported version (best-effort). */ +export async function fetchAvailableVersions(): Promise { + const res = await fetch(RCLONE_RELEASES_API, { + headers: { Accept: 'application/vnd.github+json' }, + }) + if (!res.ok) { + throw new Error(`GitHub API responded ${res.status}`) + } + const releases = (await res.json()) as { + tag_name: string + prerelease: boolean + draft: boolean + published_at: string + }[] + + return releases + .filter((r) => !r.prerelease && !r.draft) + .map((r) => ({ version: r.tag_name.replace(/^v/, ''), publishedAt: r.published_at })) + .filter((r) => compareVersions(r.version, MIN_RCLONE_VERSION) >= 0) + .sort((a, b) => compareVersions(b.version, a.version)) + .slice(0, RCLONE_RELEASES_SHOWN) +} + +/** + * Downloads a version into the managed library, forwarding progress events for the given version. + * Returns the absolute path of the installed binary. + */ +export async function downloadVersion( + version: string, + onProgress?: (progress: DownloadProgress) => void +): Promise { + const unlisten = await listen('rclone-download-progress', (event) => { + if (event.payload.version === version) { + onProgress?.(event.payload) + } + }) + try { + const proxyUrl = useHostStore.getState().proxy?.url ?? null + return await invoke('download_rclone_version', { version, proxyUrl }) + } finally { + unlisten() + } +} + +export async function deleteVersion(version: string): Promise { + const activePath = usePersistedStore.getState().rclonePath ?? null + await invoke('delete_rclone_version', { version, activePath }) +} + +/** True if rclone currently has active transfers/checks or mounts. */ +async function isRcloneBusy(): Promise { + try { + const stats = (await rcloneClient('/core/stats')) as { + transferring?: unknown[] + checking?: unknown[] + } + if ((stats?.transferring?.length ?? 0) > 0 || (stats?.checking?.length ?? 0) > 0) { + return true + } + } catch (error) { + console.warn('[isRcloneBusy] core/stats failed', error) + } + try { + const mounts = (await rcloneClient('/mount/listmounts')) as { mountPoints?: unknown[] } + if ((mounts?.mountPoints?.length ?? 0) > 0) { + return true + } + } catch (error) { + console.warn('[isRcloneBusy] mount/listmounts failed', error) + } + return false +} + +/** + * Points the app at `path` and restarts the daemon on it. Confirms first when transfers/mounts + * are active. Returns false if the user cancelled. With `offerSystemConfig`, offers adopting the + * binary's native config — after the busy confirm, so cancelling leaves no state behind. + */ +export async function activateRclonePath( + path: string, + opts?: { offerSystemConfig?: boolean } +): Promise { + if (await isRcloneBusy()) { + const proceed = await ask( + 'Transfers or mounts are in progress and will be interrupted by switching rclone. Continue?', + { + title: 'Rclone is busy', + kind: 'warning', + okLabel: 'Switch anyway', + cancelLabel: 'Cancel', + } + ) + if (!proceed) { + return false + } + } + + usePersistedStore.getState().setRclonePath(path) + + // Called for its side effect: it persists the adopted default config path, which the restart + // snapshot below then reads back from the store. + if (opts?.offerSystemConfig) { + await maybeOfferSystemConfig(path) + } + + try { + await invoke('update_path_pointer', { targetPath: path }) + } catch (error) { + console.warn('[activateRclonePath] update_path_pointer failed', error) + } + await restartActiveRclone() + return true +} + +/** + * When switching to the system rclone while the app's config is app-private, offer to adopt the + * system rclone's native config so the shell and app share remotes. Persists the adopted default + * config path (the zero-arg restart snapshot reads it back from the store); returns it, or null. + */ +async function maybeOfferSystemConfig(systemPath: string): Promise { + const host = useHostStore.getState() + const appPrivate = await appPrivateDefaultConfigPath() + const current = host.defaultConfigPath + + if (current && current !== appPrivate) { + return null // already using a non-app-private (likely native) config + } + + try { + const native = await invoke('rclone_config_path', { path: systemPath }) + if (!native || native === current) { + return null + } + const useNative = await ask( + `Your app remotes are stored at:\n${current ?? appPrivate}\n\nThe system rclone uses:\n${native}\n\nWhich config should the app use?`, + { + title: 'Config location', + kind: 'info', + okLabel: 'Use system config', + cancelLabel: 'Keep app config', + } + ) + if (useNative) { + host.setDefaultConfigPath(native) + return native + } + } catch (error) { + console.warn('[maybeOfferSystemConfig] failed', error) + } + return null +} + +export async function getPathIntegration(): Promise { + return await invoke('get_rclone_path_integration') +} + +export async function setPathIntegration(enable: boolean, targetPath: string): Promise { + return await invoke('set_rclone_path_integration', { + enable, + targetPath, + }) +} diff --git a/lib/tray.ts b/lib/tray.ts index 713cacf..1380ff8 100644 --- a/lib/tray.ts +++ b/lib/tray.ts @@ -9,6 +9,7 @@ import { openUrl } from '@tauri-apps/plugin-opener' import { platform } from '@tauri-apps/plugin-os' import { exit } from '@tauri-apps/plugin-process' import { usePersistedStore } from '../store/persisted' +import { CLOSE_APP, emitToMain } from './events' import { openWindow } from './window' async function buildMenu() { @@ -136,7 +137,7 @@ async function buildMenu() { id: 'quit', text: 'Quit', action: async () => { - await getCurrentWindow().emit('close-app') + await emitToMain(CLOSE_APP) }, }) menuItems.push(quitItem) diff --git a/lib/window.ts b/lib/window.ts index c645545..801a3f8 100644 --- a/lib/window.ts +++ b/lib/window.ts @@ -11,7 +11,7 @@ export async function openFullWindow({ url: string hideTitleBar?: boolean }) { - console.log('[openFullWindow] ', name, url) + console.log('[openFullWindow]', name) await invoke('open_full_window', { name, url, hideTitleBar }) return WebviewWindow.getByLabel(name) } diff --git a/main.ts b/main.ts index 2518fed..ec0beea 100644 --- a/main.ts +++ b/main.ts @@ -1,19 +1,19 @@ import * as Sentry from '@sentry/browser' import { getVersion as getUiVersion } from '@tauri-apps/api/app' -import { invoke } from '@tauri-apps/api/core' +import { Channel, invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' +import { writeText } from '@tauri-apps/plugin-clipboard-manager' import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link' import { ask, message } from '@tauri-apps/plugin-dialog' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import { platform } from '@tauri-apps/plugin-os' -import { writeText } from '@tauri-apps/plugin-clipboard-manager' import { exit, relaunch } from '@tauri-apps/plugin-process' -import type { Child } from '@tauri-apps/plugin-shell' -import { check } from '@tauri-apps/plugin-updater' +import { type Update, check } from '@tauri-apps/plugin-updater' import { CronExpressionParser } from 'cron-parser' import { defaultOptions } from 'tauri-plugin-sentry-api' import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep' -import { LOCAL_HOST_ID, getHostInfo } from './lib/hosts' +import { CLOSE_APP, RELAUNCH_APP, RESTART_RCLONE, type RestartRclonePayload } from './lib/events' +import { LOCAL_HOST_ID, RC_PORT, getHostInfo, makeLocalHost } from './lib/hosts' import { validateLicense } from './lib/license' import notify from './lib/notify' import queryClient from './lib/query' @@ -33,13 +33,35 @@ import { initRclone } from './lib/rclone/init' import { initTray } from './lib/tray' import { openSmallWindow } from './lib/window' import { initHostStore, useHostStore } from './store/host' +import { waitForStoreHydration } from './store/lib' import { useStore } from './store/memory' -import { usePersistedStore } from './store/persisted' +import { selectCurrentHost, usePersistedStore } from './store/persisted' import type { ScheduledTask } from './types/schedules' -let currentRcloneChild: Child | null = null let rcloneListenersRegistered = false +// Mirrors zookeeper.rs RcloneEvent — only 'close' is emitted. +type RcloneDaemonEvent = { + kind: 'close' + code: number | null + intentional: boolean +} + +async function killRcloneDaemon() { + // Rust daemon state is authoritative: the command no-ops (returns false) when nothing is + // tracked, so we must not gate on a local mirror that could defeat its reload-orphan guard. + let killed = false + try { + killed = await invoke('kill_rclone_daemon', {}) + } catch (error) { + console.error('[killRcloneDaemon] failed to kill rclone daemon', error) + Sentry.captureException(error) + } + if (killed) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } +} + try { Sentry.init({ ...defaultOptions, @@ -98,19 +120,14 @@ async function checkFlatpakPermissions() { async function waitForHydration() { console.log('[waitForHydration] waiting for store hydration') - - await new Promise((resolve) => setTimeout(resolve, 50)) - if (!usePersistedStore.persist.hasHydrated()) { - await waitForHydration() - } + await waitForStoreHydration(() => usePersistedStore.persist.hasHydrated()) console.log('[waitForHydration] store hydrated') } async function initializeHostStore() { console.log('[initializeHostStore] initializing') - const currentHost = usePersistedStore.getState().currentHost // Default to 'local' if fresh install/no host selected - const hostId = currentHost?.id || 'local' + const hostId = usePersistedStore.getState().currentHostId || LOCAL_HOST_ID await initHostStore(hostId) @@ -120,7 +137,7 @@ async function initializeHostStore() { async function checkHostReachability(): Promise { console.log('[checkHostReachability] checking host reachability') - const currentHost = usePersistedStore.getState().currentHost + const currentHost = selectCurrentHost(usePersistedStore.getState()) // If no host selected or local host, skip check (local rclone hasn't started yet) if (!currentHost || currentHost.id === LOCAL_HOST_ID) { @@ -164,13 +181,15 @@ async function checkHostReachability(): Promise { console.log('[checkHostReachability] retrying connection') isReachable = await checkReachability() } else { - // User chose to use local host + // User chose to use local host. Upsert the local host and point at it in one write so + // currentHostId never dangles. console.log('[checkHostReachability] switching to local host') - const hosts = usePersistedStore.getState().hosts - const localHost = hosts.find((h) => h.id === LOCAL_HOST_ID) - if (localHost) { - usePersistedStore.setState({ currentHost: localHost }) - } + usePersistedStore.setState((prev) => ({ + hosts: prev.hosts.some((h) => h.id === LOCAL_HOST_ID) + ? prev.hosts + : [...prev.hosts, makeLocalHost()], + currentHostId: LOCAL_HOST_ID, + })) // Re-initialize host store for local await initHostStore(LOCAL_HOST_ID) return @@ -226,8 +245,7 @@ async function checkAlreadyRunning() { console.log('[checkAlreadyRunning]') try { - const rcPort = 5572 - const running = await invoke('is_rclone_running', { port: rcPort }) + const running = await invoke('is_rclone_running', { port: RC_PORT }) console.log('[checkAlreadyRunning] running', running) if (running) { @@ -278,24 +296,26 @@ async function registerRcloneWindowListeners() { const window = getCurrentWindow() - await window.listen('close-app', async () => { - console.log('[registerRcloneWindowListeners] close-app requested') - - const transfers = await queryClient.ensureQueryData({ - queryKey: ['transfers', 'list', 'all'], - queryFn: async () => await listTransfers(), - staleTime: 10_000, // 10 seconds - gcTime: 60_000, // 1 minute - }) + // Kill the daemon BEFORE exit/relaunch — this ordering is the entire point of these listeners. + const shutdown = async (mode: 'quit' | 'relaunch') => { + // A dead daemon means "no active transfers": don't let a listTransfers throw make quit a + // silent no-op. + const transfers = await queryClient + .ensureQueryData({ + queryKey: ['transfers', 'list', 'all'], + queryFn: async () => await listTransfers(), + staleTime: 10_000, // 10 seconds + gcTime: 60_000, // 1 minute + }) + .catch(() => null) if (transfers?.active && transfers.active.length > 0) { const answer = await ask('All active transfers will be stopped.', { title: 'Exit', kind: 'info', - okLabel: 'Quit', + okLabel: mode === 'relaunch' ? 'Relaunch' : 'Quit', cancelLabel: 'Cancel', }) - if (!answer) { return } @@ -304,84 +324,61 @@ async function registerRcloneWindowListeners() { const cloudflaredTunnel = useStore.getState().cloudflaredTunnel if (cloudflaredTunnel) { try { - console.log('[close-app] stopping cloudflared tunnel') + console.log('[shutdown] stopping cloudflared tunnel') await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid }) useStore.setState({ cloudflaredTunnel: null }) } catch (error) { - console.error('[close-app] failed to stop cloudflared tunnel', error) + console.error('[shutdown] failed to stop cloudflared tunnel', error) } } - const child = currentRcloneChild + await killRcloneDaemon() - if (child) { - try { - await child.kill() - } catch (error) { - console.error('[close-app] failed to kill rclone child', error) - Sentry.captureException(error) - } - currentRcloneChild = null - await new Promise((resolve) => setTimeout(resolve, 1000)) + if (mode === 'relaunch') { + await relaunch() + } else { + await exit(0) } + } - await exit(0) + await window.listen(CLOSE_APP, async () => { + console.log('[registerRcloneWindowListeners] close-app requested') + await shutdown('quit') }) console.log('[registerRcloneWindowListeners] close-app listener registered') - await window.listen('relaunch-app', async () => { + await window.listen(RELAUNCH_APP, async () => { console.log('[registerRcloneWindowListeners] relaunch-app requested') - - const transfers = await queryClient.ensureQueryData({ - queryKey: ['transfers', 'list', 'all'], - queryFn: async () => await listTransfers(), - staleTime: 10_000, // 10 seconds - gcTime: 60_000, // 1 minute - }) - - if (transfers?.active && transfers.active.length > 0) { - const answer = await ask('All active transfers will be stopped.', { - title: 'Exit', - kind: 'info', - okLabel: 'Relaunch', - cancelLabel: 'Cancel', - }) - if (!answer) { - return - } - } - - const cloudflaredTunnel = useStore.getState().cloudflaredTunnel - if (cloudflaredTunnel) { - try { - console.log('[close-app] stopping cloudflared tunnel') - await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid }) - useStore.setState({ cloudflaredTunnel: null }) - } catch (error) { - console.error('[close-app] failed to stop cloudflared tunnel', error) - } - } - - const child = currentRcloneChild - - if (child) { - try { - await child.kill() - } catch (error) { - console.error('[relaunch-app] failed to kill rclone child', error) - Sentry.captureException(error) - } - currentRcloneChild = null - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - - await relaunch() + await shutdown('relaunch') }) console.log('[registerRcloneWindowListeners] relaunch-app listener registered') - await window.listen('restart-rclone', async () => { + await window.listen(RESTART_RCLONE, async (event) => { console.log('[registerRcloneWindowListeners] restart-rclone requested') + // Trust the payload: the initiating webview's store writes may not have reached the main + // window yet. Apply BEFORE the in-flight guard so state isn't lost on a skipped restart. + // configFiles BEFORE activeConfigId (setActiveConfigFile resolves against state.configFiles + // and nulls on a miss). NEVER log the raw payload — it carries config `pass`. + const payload = event.payload + if (payload) { + if (payload.rclonePath) { + usePersistedStore.getState().setRclonePath(payload.rclonePath) + } + if (payload.defaultConfigPath) { + useHostStore.getState().setDefaultConfigPath(payload.defaultConfigPath) + } + if (payload.configFiles) { + useHostStore.setState({ configFiles: payload.configFiles }) + } + if (payload.activeConfigId) { + useHostStore.getState().setActiveConfigFile(payload.activeConfigId) + } + if (payload.proxy !== undefined) { + useHostStore.setState({ proxy: payload.proxy }) + } + } + if (useStore.getState().isRestartingRclone) { console.log('[restart-rclone] restart already in progress, ignoring request') return @@ -390,18 +387,7 @@ async function registerRcloneWindowListeners() { useStore.setState({ isRestartingRclone: true }) try { - const child = currentRcloneChild - - if (child) { - try { - await child.kill() - } catch (error) { - console.error('[restart-rclone] failed to exit rclone process', error) - Sentry.captureException(error) - } - currentRcloneChild = null - await new Promise((resolve) => setTimeout(resolve, 1000)) - } + await killRcloneDaemon() await startRclone() } catch (error) { @@ -454,25 +440,28 @@ async function startRclone() { return await exit(0) } - const command = rclone?.system || rclone?.internal - - if (!command) { + if (!rclone) { console.error('[startRclone] initRclone returned without a runnable command') Sentry.captureException(new Error('initRclone returned without a runnable command.')) return } - command.addListener('close', async (event) => { - console.log('close', event) - currentRcloneChild = null + const { path, args: rcloneArgs, env } = rclone + + const channel = new Channel() + channel.onmessage = async (payload) => { + console.log('[startRclone] daemon close', payload) + + // Killed intentionally (restart / quit) — the initiator handles what happens next. + if (payload.intentional) { + return + } if (platform() === 'windows') { return await exit(0) } - console.log('event.code', event.code) - - if (event.code === 143 || event.code === 1) { + if (payload.code === 143 || payload.code === 1) { Sentry.captureException(new Error('Rclone has crashed')) const confirmed = await ask('Rclone has crashed', { title: 'Error', @@ -485,16 +474,37 @@ async function startRclone() { } await relaunch() } - }) + } - command.addListener('error', (event) => { - console.log('error', event) - }) - - console.log('[startRclone] starting rclone') - const childProcess = await command.spawn() - currentRcloneChild = childProcess - console.log('[startRclone] running rclone') + console.log('[startRclone] spawning rclone daemon') + let pid: number + try { + pid = await invoke('spawn_rclone', { + path, + args: rcloneArgs, + env, + onEvent: channel, + }) + } catch (error) { + console.error('[startRclone] failed to spawn rclone daemon', error) + Sentry.captureException(error) + // A relaunch re-runs the resolution ladder, which can heal a broken binary. + const confirmed = await ask( + `Rclone failed to start: ${error instanceof Error ? error.message : String(error)}`, + { + title: 'Error', + kind: 'error', + okLabel: 'Relaunch', + cancelLabel: 'Exit', + } + ) + if (confirmed) { + await relaunch() + return + } + return await exit(0) + } + console.log('[startRclone] running rclone, pid', pid) await new Promise((resolve) => setTimeout(resolve, 500)) } @@ -593,7 +603,14 @@ async function showStartup() { } console.log('[showStartup] startup not displayed, setting displayed and status') - useStore.setState({ startupDisplayed: true, startupStatus: 'initialized' }) + // Upgrade-only: a successful auto-update's 'updated' status must survive so its message shows; + // everything else (normal launch with null status, or a failed update restored to + // 'initializing') becomes 'initialized'. Never unconditionally clobber, or 'updated' is lost. + const currentStartupStatus = useStore.getState().startupStatus + useStore.setState({ + startupDisplayed: true, + startupStatus: currentStartupStatus === 'updated' ? 'updated' : 'initialized', + }) console.log('[showStartup] store updated with startup displayed and status set') await openSmallWindow({ name: 'Startup', @@ -617,7 +634,7 @@ async function resumeTasks() { } const scheduledTasks = useHostStore.getState().scheduledTasks - const activeConfigId = useHostStore.getState().activeConfigFile?.id + const activeConfigId = useHostStore.getState().activeConfigId console.log('[resumeTasks] found', scheduledTasks.length, 'scheduled tasks') console.log('[resumeTasks] activeConfigId:', activeConfigId) @@ -892,6 +909,45 @@ async function handleTask(task: ScheduledTask) { } } +async function installUpdate(update: Update, required: boolean) { + const confirmed = await ask( + 'You are running an outdated version of Rclone UI. Please update to the latest version.', + { + title: required ? 'Update Required' : 'Update Available', + kind: 'info', + okLabel: 'Update', + cancelLabel: required ? 'Exit' : 'Cancel', + } + ) + + if (!confirmed) { + console.log('[checkVersion] user cancelled update') + if (required) { + return await exit(0) + } + return + } + + console.log('[checkVersion] downloading and installing update') + + await update.downloadAndInstall() + + console.log('[checkVersion] update downloaded and installed') + + await message('Rclone UI has been updated. Please restart the application.', { + title: 'Update Complete', + kind: 'info', + okLabel: 'Restart', + }) + + console.log('[checkVersion] relaunching app') + + // Direct relaunch: at checkVersion time no daemon exists yet (before startRclone), so the + // shutdown path is unnecessary; the old emit fired before any listener existed and was + // dropped, leaving the app running the old version. + await relaunch() +} + async function checkVersion() { console.log('[checkVersion]') @@ -939,70 +995,10 @@ async function checkVersion() { if (compareVersions(currentVersion, minimumVersion) < 0) { console.log('[checkVersion] currentVersion is outdated') - - const confirmed = await ask( - 'You are running an outdated version of Rclone UI. Please update to the latest version.', - { - title: 'Update Required', - kind: 'info', - okLabel: 'Update', - cancelLabel: 'Exit', - } - ) - - if (!confirmed) { - console.log('[checkVersion] user cancelled update') - return await exit(0) - } - - console.log('[checkVersion] downloading and installing update') - - await receivedUpdate.downloadAndInstall() - - console.log('[checkVersion] update downloaded and installed') - - await message('Rclone UI has been updated. Please restart the application.', { - title: 'Update Complete', - kind: 'info', - okLabel: 'Restart', - }) - - console.log('[checkVersion] relaunching app') - - await getCurrentWindow().emit('relaunch-app') + await installUpdate(receivedUpdate, true) } else if (compareVersions(currentVersion, okVersion) < 0) { console.log('[checkVersion] checking for update') - - const confirmed = await ask( - 'You are running an outdated version of Rclone UI. Please update to the latest version.', - { - title: 'Update Available', - kind: 'info', - okLabel: 'Update', - cancelLabel: 'Cancel', - } - ) - - if (!confirmed) { - console.log('[checkVersion] user cancelled update') - return - } - - console.log('[checkVersion] downloading and installing update') - - await receivedUpdate.downloadAndInstall() - - console.log('[checkVersion] update downloaded and installed') - - await message('Rclone UI has been updated. Please restart the application.', { - title: 'Update Complete', - kind: 'info', - okLabel: 'Restart', - }) - - console.log('[checkVersion] relaunching app') - - await getCurrentWindow().emit('relaunch-app') + await installUpdate(receivedUpdate, false) } } catch (error) { console.error('[checkVersion] error', error) @@ -1011,17 +1007,7 @@ async function checkVersion() { } async function checkRclone() { - let currentHost = usePersistedStore.getState().currentHost - - if (!currentHost) { - currentHost = { - id: 'local', - name: 'Local Machine', - url: 'http://localhost:5572', - os: 'linux', - cliVersion: 'unknown', - } - } + let currentHost = selectCurrentHost(usePersistedStore.getState()) ?? makeLocalHost() let hostInfo = await getHostInfo({ url: currentHost.url, @@ -1037,13 +1023,7 @@ async function checkRclone() { kind: 'error', } ) - currentHost = { - id: 'local', - name: 'Local Machine', - url: 'http://localhost:5572', - os: 'linux', - cliVersion: 'unknown', - } + currentHost = makeLocalHost() hostInfo = await getHostInfo({ url: currentHost.url, @@ -1078,9 +1058,9 @@ async function checkRclone() { console.log('[checkRclone] setting currentHost', currentHost) - usePersistedStore.setState({ currentHost }) usePersistedStore.setState((prev) => ({ - hosts: [...prev.hosts.filter((h) => h.id !== currentHost!.id), currentHost], + hosts: [...prev.hosts.filter((h) => h.id !== currentHost.id), currentHost], + currentHostId: currentHost.id, })) } @@ -1089,18 +1069,16 @@ getCurrentWindow().listen('tauri://close-requested', async () => { await getCurrentWindow().destroy() }) -// maybe place this inside handleDeepLink? +function processDeepLink(url: string) { + const deepLinkUrl = getDeepLinkUrl(url) + console.log('deep link url', deepLinkUrl) + handleDeepLinkUrl(deepLinkUrl) + useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) +} + onOpenUrl((urls) => { console.log('deep links while running', urls) - const receivedUrl = urls[0] - - const deepLinkUrl = getDeepLinkUrl(receivedUrl) - - console.log('deep link url', deepLinkUrl) - - handleDeepLinkUrl(deepLinkUrl) - - useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) + processDeepLink(urls[0]) }) async function handleDeepLink() { @@ -1110,24 +1088,14 @@ async function handleDeepLink() { console.log('[handleDeepLink] no deep links found') return } - - console.log('[handleDeepLink] getting deep link url') - const deepLinkUrl = getDeepLinkUrl(urls[0]) - console.log('[handleDeepLink] deep link url', deepLinkUrl) - - console.log('[handleDeepLink] handling deep link url') - handleDeepLinkUrl(deepLinkUrl) - console.log('[handleDeepLink] deep link url handled') - - console.log('[handleDeepLink] setting startup displayed and status') - useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) - console.log('[handleDeepLink] startup displayed and status set') + processDeepLink(urls[0]) } waitForHydration() .then(() => checkFlatpakPermissions()) .then(() => initializeHostStore()) .then(() => checkHostReachability()) + .then(() => registerRcloneWindowListeners()) .then(() => checkVersion()) .then(() => validateInstance()) .then(() => checkAlreadyRunning()) diff --git a/package-lock.json b/package-lock.json index a199e7b..7f22c50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", @@ -5984,15 +5983,6 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", - "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.10.1" - } - }, "node_modules/@tauri-apps/plugin-store": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz", diff --git a/package.json b/package.json index 541d12b..ca28e3c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a8c9d4c..09a80a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -258,6 +258,7 @@ dependencies = [ "sentry", "serde", "serde_json", + "sha2", "sysinfo", "tar", "tauri", @@ -276,11 +277,12 @@ dependencies = [ "tauri-plugin-prevent-default", "tauri-plugin-process", "tauri-plugin-sentry", - "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", "tinyfiledialogs-rs", + "windows-sys 0.59.0", + "winreg 0.52.0", "x11rb", "zbus", "zip 0.6.6", @@ -5294,44 +5296,12 @@ dependencies = [ "digest", ] -[[package]] -name = "shared_child" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" -dependencies = [ - "libc", - "sigchld", - "windows-sys 0.60.2", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6101,27 +6071,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "tauri-plugin-shell" -version = "2.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" -dependencies = [ - "encoding_rs", - "log", - "open", - "os_pipe", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "shared_child", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "tokio", -] - [[package]] name = "tauri-plugin-single-instance" version = "2.4.2" @@ -7599,6 +7548,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7650,6 +7608,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7707,6 +7680,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7725,6 +7704,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7743,6 +7728,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7773,6 +7764,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7791,6 +7788,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7809,6 +7812,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7827,6 +7836,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7872,6 +7887,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index be33bf0..aa9bb72 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,7 +24,6 @@ log = "0.4" tauri = { version = "2.11.1", features = [ "tray-icon", "image-ico", "image-png", "config-json5" ] } tauri-plugin-log = "2.8.0" -tauri-plugin-shell = "2.3.5" tauri-plugin-dialog = "2.7.1" tauri-plugin-fs = "2.5.1" tauri-plugin-opener = "2.5.4" @@ -50,6 +49,7 @@ tinyfiledialogs = { package = "tinyfiledialogs-rs", version = "3.21.3", features tauri-plugin-deep-link = "2.4.9" flate2 = "1.1.9" tar = "0.4.45" +sha2 = "0.10" [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.26" @@ -59,3 +59,7 @@ objc = "0.2" zbus = { version = "5" } x11rb = "0.13" gtk = "0.18" + +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52" +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d9485d9..c30435e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -168,46 +168,6 @@ "core:webview:allow-internal-toggle-devtools", "core:webview:allow-get-all-webviews", "clipboard-manager:allow-write-text", - "shell:default", - "shell:allow-kill", - "shell:allow-spawn", - "shell:allow-stdin-write", - "shell:allow-open", - { - "identifier": "shell:allow-execute", - "allow": [ - { - "name": "rclone-system", - "cmd": "rclone", - "args": true - }, - { - "name": "rclone-internal", - "cmd": "$APPLOCALDATA/rclone", - "args": true - }, - { - "name": "umount", - "cmd": "umount", - "args": true - } - ] - }, - { - "identifier": "shell:allow-spawn", - "allow": [ - { - "name": "rclone-system", - "cmd": "rclone", - "args": true - }, - { - "name": "rclone-internal", - "cmd": "$APPLOCALDATA/rclone", - "args": true - } - ] - }, "log:default", "dialog:default", { diff --git a/src-tauri/common/window.rs b/src-tauri/common/window.rs index ecdbd95..436647b 100644 --- a/src-tauri/common/window.rs +++ b/src-tauri/common/window.rs @@ -18,7 +18,6 @@ pub fn make_transparent(window: &WebviewWindow) -> Result<(), tauri::Error> { let ns_window: id = msg_send![webview_obj, window]; let bg_color = NSColor::colorWithSRGBRed_green_blue_alpha_(nil, 0.0, 0.0, 0.0, 0.0); let _: id = msg_send![ns_window, setBackgroundColor: bg_color]; - // let _: () = msg_send![ns_window, setIgnoresMouseEvents:true]; })?; Ok(()) @@ -155,8 +154,6 @@ pub async fn open_window( ) -> Result<(), String> { if let Some(existing) = app_handle.get_webview_window(&name) { existing.set_focus().map_err(|e| e.to_string())?; - #[cfg(target_os = "linux")] - focus_window_linux(&app_handle, &existing); return Ok(()); } @@ -286,8 +283,6 @@ pub async fn open_small_window( return Ok(()); } - let os = std::env::consts::OS; - let mut builder = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into())) .title(&name) .inner_size(800.0, 500.0) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02391fa..2bed408 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,10 @@ use machine_uid; use sentry; -use std::fs::{self, File}; -use std::path::Path; +use std::fs; use sysinfo::System; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Manager}; use tauri_plugin_sentry; use tinyfiledialogs as tfd; -use zip::ZipArchive; #[path = "../common/shortcut.rs"] mod shortcut; @@ -14,6 +12,8 @@ mod shortcut; #[path = "../common/window.rs"] mod window; +mod zookeeper; + use shortcut::{ ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT, }; @@ -127,48 +127,7 @@ fn has_flatpak_permissions() -> bool { false } -#[tauri::command] -fn unzip_file(zip_path: &str, output_folder: &str) -> Result<(), String> { - // Open the zip file - let file = File::open(zip_path).map_err(|e| e.to_string())?; - - // Create output directory if it doesn't exist - fs::create_dir_all(output_folder).map_err(|e| e.to_string())?; - - // Create ZIP archive reader - let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?; - - // Extract everything - for i in 0..archive.len() { - let mut file = archive.by_index(i).map_err(|e| e.to_string())?; - let outpath = Path::new(output_folder).join(file.name()); - - if file.name().ends_with('/') || file.name().ends_with('\\') { - fs::create_dir_all(&outpath).map_err(|e| e.to_string())?; - } else { - if let Some(p) = outpath.parent() { - fs::create_dir_all(p).map_err(|e| e.to_string())?; - } - let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?; - std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?; - } - - // Get and set permissions (Unix only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Some(mode) = file.unix_mode() { - fs::set_permissions(&outpath, fs::Permissions::from_mode(mode)) - .map_err(|e| e.to_string())?; - } - } - } - - Ok(()) -} - -#[tauri::command] -async fn stop_pid(pid: u32, timeout_ms: Option) -> Result<(), String> { +pub(crate) async fn kill_pid(pid: u32, timeout_ms: Option) -> Result<(), String> { let timeout = timeout_ms.unwrap_or(5000); #[cfg(any( @@ -325,7 +284,7 @@ async fn stop_rclone_processes(timeout_ms: Option) -> Result { let mut stopped: u32 = 0; for pid in pids { - match stop_pid(pid, Some(timeout)).await { + match kill_pid(pid, Some(timeout)).await { Ok(()) => stopped += 1, Err(_e) => {} } @@ -334,10 +293,6 @@ async fn stop_rclone_processes(timeout_ms: Option) -> Result { Ok(stopped) } -async fn prompt_password(title: String, message: String) -> Result, String> { - prompt_text(title, message, None, Some(true)).await -} - async fn prompt_text( title: String, message: String, @@ -559,6 +514,7 @@ async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String) // Start cloudflared tunnel let mut child = SysCommand::new(&cloudflared_path) + // keep in sync with RC_PORT in lib/hosts.ts .args(&["tunnel", "--url", "http://localhost:5572"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -602,7 +558,7 @@ async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String) } // If we didn't get a URL, kill the process and return error - let _ = stop_pid(pid, Some(2000)).await; + let _ = kill_pid(pid, Some(2000)).await; Err("Failed to get tunnel URL from cloudflared".to_string()) } @@ -611,7 +567,7 @@ async fn stop_cloudflared_tunnel(pid: u32) -> Result<(), String> { use std::time::Duration; // Cloudflared takes ~5s to gracefully shut down, so give it enough time - match stop_pid(pid, Some(6000)).await { + match kill_pid(pid, Some(6000)).await { Ok(()) => Ok(()), Err(e) => { // Wait a bit for the process to fully terminate @@ -752,119 +708,6 @@ async fn test_proxy_connection(proxy_url: String) -> Result { Err(last_error.unwrap_or_else(|| "All proxy tests failed".to_string())) } -#[tauri::command] -async fn update_system_rclone() -> Result { - #[cfg(target_os = "macos")] - { - use std::process::Command as SysCommand; - - fn quote_posix(value: &str) -> String { - let escaped = value.replace("'", "'\\''"); - format!("'{}'", escaped) - } - - let mut cmdline = - String::from("PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; "); - cmdline.push_str("e_posix("rclone")); - cmdline.push(' '); - cmdline.push_str("e_posix("selfupdate")); - - // Escape for embedding inside an AppleScript string literal - let applescript_cmd = cmdline.replace('\\', "\\\\").replace('"', "\\\""); - let prompt = "Rclone UI needs permission to run rclone selfupdate."; - let script = format!( - "do shell script \"{}\" with administrator privileges with prompt \"{}\"", - applescript_cmd, - prompt.replace('"', "\\\"") - ); - - let status = SysCommand::new("osascript") - .arg("-e") - .arg(script) - .status() - .map_err(|e| e.to_string())?; - return Ok(status.code().unwrap_or(0)); - } - - #[cfg(target_os = "linux")] - { - use std::process::Command as SysCommand; - - let path_env = - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"; - - // Try PolicyKit first (graphical auth prompt on most desktops) - let mut pkexec_args: Vec = Vec::new(); - pkexec_args.push("--description".to_string()); - pkexec_args.push("Rclone UI needs to run rclone selfupdate".to_string()); - pkexec_args.push("env".to_string()); - pkexec_args.push(path_env.to_string()); - pkexec_args.push("rclone".to_string()); - pkexec_args.push("selfupdate".to_string()); - - match SysCommand::new("pkexec").args(&pkexec_args).status() { - Ok(status) => return Ok(status.code().unwrap_or(0)), - Err(_e) => { - // Fallback to sudo with custom prompt (works if the user has NOPASSWD or cached credentials) - let mut sudo_env = std::collections::HashMap::new(); - sudo_env.insert("SUDO_PROMPT", "Rclone UI needs permission to run rclone selfupdate. Please enter your password: "); - - let mut sudo_args: Vec = Vec::new(); - sudo_args.push("-n".to_string()); - sudo_args.push("env".to_string()); - sudo_args.push(path_env.to_string()); - sudo_args.push("rclone".to_string()); - sudo_args.push("selfupdate".to_string()); - - let status = SysCommand::new("sudo") - .envs(&sudo_env) - .args(&sudo_args) - .status() - .map_err(|e| e.to_string())?; - return Ok(status.code().unwrap_or(0)); - } - } - } - - #[cfg(target_os = "windows")] - { - use std::process::Command as SysCommand; - - fn quote_ps(value: &str) -> String { - // PowerShell single-quote escaping: ' -> '' - format!("'{}'", value.replace('\'', "''")) - } - - let file_path = quote_ps("rclone"); - let arg_list = String::from("@('selfupdate')"); - - let ps_script = format!( - "$p = Start-Process -Verb RunAs -WindowStyle Hidden -PassThru -FilePath {file} -ArgumentList {args}; \n\ - $p.WaitForExit();\n\ - exit $p.ExitCode", - file = file_path, - args = arg_list - ); - - let status = SysCommand::new("powershell") - .args([ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - &ps_script, - ]) - .status() - .map_err(|e| e.to_string())?; - return Ok(status.code().unwrap_or(0)); - } - - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { - Err("Unsupported platform".to_string()) - } -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let client = sentry::init(( @@ -887,6 +730,9 @@ pub fn run() { } let mut app = builder + .manage::(std::sync::Mutex::new( + zookeeper::DaemonState::default(), + )) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_sentry::init_with_no_injection(&client)) .plugin(tauri_plugin_clipboard_manager::init()) @@ -903,21 +749,17 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_log::Builder::new().build()) - .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_prevent_default::debug()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .invoke_handler(tauri::generate_handler![ - unzip_file, get_arch, get_uid, is_rclone_running, stop_rclone_processes, prompt, - stop_pid, update_toolbar_shortcut, show_toolbar, - update_system_rclone, test_proxy_connection, is_flatpak, is_linux_mint, @@ -929,7 +771,22 @@ pub fn run() { unlock_windows, start_cloudflared_tunnel, stop_cloudflared_tunnel, - extract_tgz + extract_tgz, + zookeeper::exec_rclone, + zookeeper::spawn_rclone, + zookeeper::kill_rclone_daemon, + zookeeper::validate_rclone_binary, + zookeeper::rclone_config_path, + zookeeper::find_system_rclone, + zookeeper::classify_rclone_path, + zookeeper::list_downloaded_rclone_versions, + zookeeper::delete_rclone_version, + zookeeper::adopt_legacy_rclone, + zookeeper::managed_version_path, + zookeeper::download_rclone_version, + zookeeper::update_path_pointer, + zookeeper::get_rclone_path_integration, + zookeeper::set_rclone_path_integration ]) .setup(|app| { #[cfg(target_os = "linux")] @@ -962,6 +819,10 @@ pub fn run() { } } + // Reclaim leftover .tmp-* download staging dirs from an interrupted download. Runs + // once here (before any webview) so it can never race a live download. + zookeeper::sweep_versions_tmp(app.handle()); + if let Err(err) = ensure_toolbar_window(&app.handle()) { log::warn!("failed to prepare toolbar window: {}", err); } diff --git a/src-tauri/src/zookeeper.rs b/src-tauri/src/zookeeper.rs new file mode 100644 index 0000000..b50871f --- /dev/null +++ b/src-tauri/src/zookeeper.rs @@ -0,0 +1,1248 @@ +//! rclone binary manager: spawning, versioned downloads, and PATH integration. +//! +//! rclone is executed by absolute path from here (via `std::process`), replacing the +//! old `tauri-plugin-shell` named-command approach that could only run two fixed paths. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; + +// --------------------------------------------------------------------------- +// Shared types & state +// --------------------------------------------------------------------------- + +/// Result of a one-shot rclone invocation. +#[derive(Serialize)] +pub struct ExecResult { + pub code: Option, + pub stdout: String, + pub stderr: String, +} + +/// Event streamed from the long-lived daemon to the frontend over a Channel. +/// Only `close` is emitted — stdout/stderr are discarded (nothing consumes them; +/// readiness is RC-port polling on the JS side). +#[derive(Serialize, Clone)] +pub struct RcloneEvent { + pub kind: String, // "close" + pub code: Option, + pub intentional: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadedVersion { + pub version: String, + pub path: String, + pub size_bytes: u64, +} + +#[derive(Serialize)] +pub struct RcloneClassification { + pub kind: String, // "system" | "managed" | "custom" + pub version: Option, +} + +#[derive(Serialize)] +pub struct PathStatus { + pub enabled: bool, + pub target: Option, + pub warning: Option, +} + +/// Tracks the currently-running daemon so kills can be marked intentional (suppressing +/// the crash dialog) and so a webview reload cannot orphan the process. +#[derive(Default)] +pub struct DaemonState { + pid: Option, + intentional: Option>, +} + +pub type SharedDaemonState = Mutex; + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +fn bin_name() -> &'static str { + if cfg!(target_os = "windows") { + "rclone.exe" + } else { + "rclone" + } +} + +fn app_local_data(app: &AppHandle) -> Result { + app.path() + .app_local_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {}", e)) +} + +fn versions_dir(app: &AppHandle) -> Result { + Ok(app_local_data(app)?.join("rclone-versions")) +} + +/// Legacy single-slot binary path used before the versioned layout. +fn legacy_slot(app: &AppHandle) -> Result { + Ok(app_local_data(app)?.join(bin_name())) +} + +/// Stable pointer used for PATH integration (independent of the active version). +fn path_pointer(app: &AppHandle) -> Result { + Ok(app_local_data(app)?.join("bin").join(bin_name())) +} + +fn canonical(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +fn rclone_os() -> &'static str { + match std::env::consts::OS { + "macos" => "osx", + other => other, + } +} + +fn rclone_arch() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + "x86_64" => "amd64", + "i386" | "x86" => "386", + _ => "unknown", + } +} + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +// --------------------------------------------------------------------------- +// One-shot execution +// --------------------------------------------------------------------------- + +fn exec_blocking( + path: String, + args: Vec, + env: HashMap, + stdin_lines: Option>, + timeout_ms: Option, +) -> Result { + use std::io::{Read, Write}; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + let mut cmd = Command::new(&path); + cmd.args(&args); + for (k, v) in &env { + cmd.env(k, v); + } + cmd.stdin(if stdin_lines.is_some() { + Stdio::piped() + } else { + Stdio::null() + }); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to run {}: {}", path, e))?; + + // Feed stdin lines (paced) then close stdin. + if let Some(lines) = stdin_lines { + if let Some(mut stdin) = child.stdin.take() { + for line in lines { + let _ = stdin.write_all(line.as_bytes()); + let _ = stdin.write_all(b"\n"); + let _ = stdin.flush(); + std::thread::sleep(Duration::from_millis(100)); + } + // stdin dropped here -> EOF + } + } + + // Drain stdout/stderr on threads so the pipes can't fill and deadlock the wait. + let mut out = child.stdout.take(); + let mut err = child.stderr.take(); + let out_handle = std::thread::spawn(move || { + let mut s = String::new(); + if let Some(ref mut o) = out { + let _ = o.read_to_string(&mut s); + } + s + }); + let err_handle = std::thread::spawn(move || { + let mut s = String::new(); + if let Some(ref mut e) = err { + let _ = e.read_to_string(&mut s); + } + s + }); + + let code = if let Some(t) = timeout_ms { + let deadline = Instant::now() + Duration::from_millis(t); + loop { + match child.try_wait().map_err(|e| e.to_string())? { + Some(status) => break status.code(), + None => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break None; // timed out + } + std::thread::sleep(Duration::from_millis(40)); + } + } + } + } else { + child.wait().map_err(|e| e.to_string())?.code() + }; + + let stdout = out_handle.join().unwrap_or_default(); + let stderr = err_handle.join().unwrap_or_default(); + + Ok(ExecResult { + code, + stdout, + stderr, + }) +} + +#[tauri::command] +pub async fn exec_rclone( + path: String, + args: Vec, + env: HashMap, + stdin_lines: Option>, + timeout_ms: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + exec_blocking(path, args, env, stdin_lines, timeout_ms) + }) + .await + .map_err(|e| e.to_string())? +} + +fn parse_rclone_version(stdout: &str) -> Option { + let first = stdout.lines().next()?; + // e.g. "rclone v1.74.3" or "rclone v1.74.0-beta.9673.d0c469c3c" + let token = first.split_whitespace().nth(1)?; + Some(token.trim_start_matches('v').to_string()) +} + +/// Runs ` version`, returning the parsed version. Adds a Gatekeeper-specific hint on macOS. +#[tauri::command] +pub async fn validate_rclone_binary(path: String) -> Result { + let result = exec_blocking( + path.clone(), + vec!["version".to_string()], + HashMap::new(), + None, + Some(5000), + ); + + match result { + Ok(res) if res.code == Some(0) => parse_rclone_version(&res.stdout) + .ok_or_else(|| "Could not parse rclone version output".to_string()), + Ok(res) => { + #[cfg(target_os = "macos")] + { + // Detect quarantine (Gatekeeper) which SIGKILLs unsigned binaries. + let quarantined = std::process::Command::new("xattr") + .args(["-p", "com.apple.quarantine", &path]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if quarantined { + return Err(format!( + "macOS blocked this binary (Gatekeeper/quarantine). Run: xattr -d com.apple.quarantine \"{}\"", + path + )); + } + } + let msg = res.stderr.trim(); + Err(if msg.is_empty() { + format!("rclone exited with code {:?}", res.code) + } else { + msg.to_string() + }) + } + Err(e) => Err(e), + } +} + +/// Runs ` config paths` and returns the native config-file path. +#[tauri::command] +pub async fn rclone_config_path(path: String) -> Result { + let res = exec_blocking( + path, + vec!["config".to_string(), "paths".to_string()], + HashMap::new(), + None, + Some(8000), + )?; + if res.code != Some(0) { + return Err(format!("rclone config paths failed: {}", res.stderr.trim())); + } + for line in res.stdout.lines() { + if let Some(rest) = line.strip_prefix("Config file:") { + return Ok(rest.trim().to_string()); + } + } + Err("Could not find config file path in output".to_string()) +} + +// --------------------------------------------------------------------------- +// Daemon lifecycle +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn spawn_rclone( + app: AppHandle, + path: String, + args: Vec, + env: HashMap, + on_event: tauri::ipc::Channel, +) -> Result { + use std::process::{Command, Stdio}; + + // Reject a second daemon instead of orphaning the first. A restart that reaches here after a + // swallowed kill failure gets the clean spawn-failure dialog rather than a crash dialog. + { + let state = app.state::(); + let s = state.lock().unwrap(); + if s.pid.is_some() { + return Err("an rclone daemon is already running".to_string()); + } + } + + let mut cmd = Command::new(&path); + cmd.args(&args); + for (k, v) in &env { + cmd.env(k, v); + } + // Nothing consumes daemon stdio; null it to avoid pipe-fill and extra threads. + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn rclone daemon: {}", e))?; + let pid = child.id(); + + let intentional = Arc::new(AtomicBool::new(false)); + { + let state = app.state::(); + let mut s = state.lock().unwrap(); + s.pid = Some(pid); + s.intentional = Some(intentional.clone()); + } + + let app_thread = app.clone(); + std::thread::spawn(move || { + let status = child.wait(); + let was_intentional = intentional.load(Ordering::SeqCst); + let code = status.ok().and_then(|s| s.code()); + + // Clear state only if we are still the current daemon. + { + let state = app_thread.state::(); + let mut s = state.lock().unwrap(); + if s.pid == Some(pid) { + s.pid = None; + s.intentional = None; + } + } + + let _ = on_event.send(RcloneEvent { + kind: "close".to_string(), + code, + intentional: was_intentional, + }); + }); + + Ok(pid) +} + +/// Terminates the running daemon. Marks it intentional so its close event is ignored by the UI. +/// Returns whether a daemon was actually killed (false when nothing was tracked). Rust state is +/// authoritative — no caller-supplied pid to SIGKILL a possibly-reused OS pid. +#[tauri::command] +pub async fn kill_rclone_daemon( + app: AppHandle, + timeout_ms: Option, +) -> Result { + let target = { + let state = app.state::(); + let s = state.lock().unwrap(); + if s.pid.is_some() { + if let Some(flag) = &s.intentional { + flag.store(true, Ordering::SeqCst); + } + } + s.pid + }; + + if let Some(pid) = target { + crate::kill_pid(pid, Some(timeout_ms.unwrap_or(5000))).await?; + Ok(true) + } else { + Ok(false) + } +} + +// --------------------------------------------------------------------------- +// System-rclone discovery & classification +// --------------------------------------------------------------------------- + +/// Walks PATH for an rclone executable, skipping any candidate under the app data dir +/// (so our own PATH-integration pointer is never mistaken for a "system" install). +#[tauri::command] +pub fn find_system_rclone(app: AppHandle) -> Option { + let exe = bin_name(); + let path_var = std::env::var_os("PATH")?; + let app_data = app_local_data(&app).ok().map(|p| canonical(&p)); + + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(exe); + if !candidate.is_file() { + continue; + } + let canon = canonical(&candidate); + if let Some(ad) = &app_data { + if canon.starts_with(ad) { + continue; + } + } + return Some(candidate.to_string_lossy().to_string()); + } + None +} + +/// Classifies a path as system / managed / custom (with canonical comparisons, so case-insensitive +/// filesystems and symlinked PATH entries don't misclassify). +#[tauri::command] +pub fn classify_rclone_path(app: AppHandle, path: String) -> RcloneClassification { + let canon = canonical(Path::new(&path)); + + if let Ok(vdir) = versions_dir(&app) { + let vdir_canon = canonical(&vdir); + if canon.starts_with(&vdir_canon) { + // .../rclone-versions/v1.74.3/rclone -> "1.74.3" + let version = canon + .strip_prefix(&vdir_canon) + .ok() + .and_then(|rest| rest.components().next()) + .map(|c| c.as_os_str().to_string_lossy().trim_start_matches('v').to_string()); + return RcloneClassification { + kind: "managed".to_string(), + version, + }; + } + } + + if let Some(sys) = find_system_rclone(app) { + if canonical(Path::new(&sys)) == canon { + return RcloneClassification { + kind: "system".to_string(), + version: None, + }; + } + } + + RcloneClassification { + kind: "custom".to_string(), + version: None, + } +} + +// --------------------------------------------------------------------------- +// Versioned library: list / delete / adopt / self-heal +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn list_downloaded_rclone_versions(app: AppHandle) -> Result, String> { + let base = versions_dir(&app)?; + let mut out = Vec::new(); + if !base.exists() { + return Ok(out); + } + let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with('v') || name.starts_with(".tmp") { + continue; + } + let bin = entry.path().join(bin_name()); + if !bin.is_file() { + continue; + } + let size = std::fs::metadata(&bin).map(|m| m.len()).unwrap_or(0); + out.push(DownloadedVersion { + version: name.trim_start_matches('v').to_string(), + path: bin.to_string_lossy().to_string(), + size_bytes: size, + }); + } + // Newest first. + out.sort_by(|a, b| compare_versions(&b.version, &a.version)); + Ok(out) +} + +/// Refuses to delete the version whose binary is the currently active one. +#[tauri::command] +pub fn delete_rclone_version( + app: AppHandle, + version: String, + active_path: Option, +) -> Result<(), String> { + let dir = versions_dir(&app)?.join(format!("v{}", version)); + if !dir.exists() { + return Ok(()); + } + if let Some(active) = active_path { + let active_canon = canonical(Path::new(&active)); + if active_canon.starts_with(canonical(&dir)) { + return Err("Cannot delete the active rclone version".to_string()); + } + } + std::fs::remove_dir_all(&dir).map_err(|e| e.to_string()) +} + +/// Moves a pre-existing single-slot `$APPLOCALDATA/rclone` binary into the versioned library. +#[tauri::command] +pub async fn adopt_legacy_rclone(app: AppHandle) -> Result, String> { + let legacy = legacy_slot(&app)?; + if !legacy.is_file() { + return Ok(None); + } + + let version = validate_rclone_binary(legacy.to_string_lossy().to_string()) + .await + .map_err(|e| format!("Failed to probe legacy rclone: {}", e))?; + + let dest_dir = versions_dir(&app)?.join(format!("v{}", version)); + let dest = dest_dir.join(bin_name()); + if !dest.exists() { + std::fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; + // Same volume -> rename; fall back to copy+remove. + if std::fs::rename(&legacy, &dest).is_err() { + std::fs::copy(&legacy, &dest).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(&legacy); + } + set_executable(&dest); + } + + let size = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); + Ok(Some(DownloadedVersion { + version, + path: dest.to_string_lossy().to_string(), + size_bytes: size, + })) +} + +/// Returns the on-disk path for a managed version if present (used to self-heal a stale +/// absolute `rclonePath` after a home-dir move/rename before falling down the ladder). +#[tauri::command] +pub fn managed_version_path(app: AppHandle, version: String) -> Option { + let bin = versions_dir(&app) + .ok()? + .join(format!("v{}", version)) + .join(bin_name()); + if bin.is_file() { + Some(bin.to_string_lossy().to_string()) + } else { + None + } +} + +/// Removes stale `.tmp-*` staging dirs from an interrupted download. Called once at startup +/// (before any webview) so it can never race a live download; failures are non-fatal. +pub fn sweep_versions_tmp(app: &AppHandle) { + let Ok(base) = versions_dir(app) else { + return; + }; + if !base.exists() { + return; + } + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().starts_with(".tmp") { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } +} + +fn set_executable(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)); + } + #[cfg(not(unix))] + { + let _ = path; + } +} + +fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { + fn parts(v: &str) -> (u64, u64, u64) { + // Strip any leading 'v' and drop a pre-release suffix (e.g. "1.74.0-beta.x"). + let core = v.trim_start_matches('v'); + let core = core.split('-').next().unwrap_or(core); + let mut it = core.split('.').map(|n| n.parse::().unwrap_or(0)); + ( + it.next().unwrap_or(0), + it.next().unwrap_or(0), + it.next().unwrap_or(0), + ) + } + parts(a).cmp(&parts(b)) +} + +// --------------------------------------------------------------------------- +// Download +// --------------------------------------------------------------------------- + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct DownloadProgress { + version: String, + downloaded: u64, + total: Option, +} + +fn build_http_client(proxy_url: Option) -> Result { + use std::time::Duration; + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(600)); + if let Some(p) = proxy_url { + let p = p.trim().to_string(); + if !p.is_empty() { + let proxy = reqwest::Proxy::all(&p).map_err(|e| format!("Invalid proxy: {}", e))?; + builder = builder.proxy(proxy); + } + } + builder.build().map_err(|e| e.to_string()) +} + +/// Parses a (PGP-signed) SHA256SUMS body for the expected hash of `file_name`. +fn expected_sha256(sums: &str, file_name: &str) -> Option { + // SHA256SUMS is PGP-signed: skip the header/footer and blank lines, match " ". + for line in sums.lines() { + let mut it = line.split_whitespace(); + let Some(hash) = it.next() else { + continue; + }; + let name = it.last().unwrap_or(""); + if name == file_name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(hash.to_ascii_lowercase()); + } + } + None +} + +#[tauri::command] +pub async fn download_rclone_version( + app: AppHandle, + version: String, + proxy_url: Option, +) -> Result { + let arch = rclone_arch(); + if arch == "unknown" { + return Err("Unsupported architecture".to_string()); + } + let os = rclone_os(); + let zip_name = format!("rclone-v{}-{}-{}.zip", version, os, arch); + let zip_url = format!("https://downloads.rclone.org/v{}/{}", version, zip_name); + let sums_url = format!("https://downloads.rclone.org/v{}/SHA256SUMS", version); + + let base = versions_dir(&app)?; + std::fs::create_dir_all(&base).map_err(|e| e.to_string())?; + let tmp = base.join(format!(".tmp-{}", version)); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?; + + // Wrap the work so we always clean up the tmp dir on failure. + let result = + download_and_install(&app, &version, &zip_name, &zip_url, &sums_url, proxy_url, &tmp, &base) + .await; + let _ = std::fs::remove_dir_all(&tmp); + + let installed_path = result?; + + let _ = app.emit( + "rclone-download-finished", + DownloadProgress { + version, + downloaded: 0, + total: None, + }, + ); + Ok(installed_path) +} + +async fn download_and_install( + app: &AppHandle, + version: &str, + zip_name: &str, + zip_url: &str, + sums_url: &str, + proxy_url: Option, + tmp: &Path, + base: &Path, +) -> Result { + use sha2::{Digest, Sha256}; + use std::io::Write; + + let client = build_http_client(proxy_url)?; + + // 1. Expected checksum (hard requirement). + let sums = client + .get(sums_url) + .send() + .await + .map_err(|e| format!("Failed to fetch checksums: {}", e))?; + if !sums.status().is_success() { + return Err(format!("Checksums unavailable (HTTP {})", sums.status())); + } + let sums_body = sums.text().await.map_err(|e| e.to_string())?; + let expected = expected_sha256(&sums_body, zip_name) + .ok_or_else(|| format!("No checksum found for {}", zip_name))?; + + // 2. Stream the zip to disk while hashing + reporting progress. + let zip_path = tmp.join("dl.zip"); + let mut file = std::fs::File::create(&zip_path).map_err(|e| e.to_string())?; + let mut hasher = Sha256::new(); + + let mut resp = client + .get(zip_url) + .send() + .await + .map_err(|e| format!("Download failed: {}", e))?; + if !resp.status().is_success() { + return Err(format!("Download failed (HTTP {})", resp.status())); + } + let total = resp.content_length(); + let mut downloaded: u64 = 0; + let mut since_emit: u64 = 0; + while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? { + file.write_all(&chunk).map_err(|e| e.to_string())?; + hasher.update(&chunk); + downloaded += chunk.len() as u64; + since_emit += chunk.len() as u64; + if since_emit >= 262_144 { + since_emit = 0; + let _ = app.emit( + "rclone-download-progress", + DownloadProgress { + version: version.to_string(), + downloaded, + total, + }, + ); + } + } + file.flush().map_err(|e| e.to_string())?; + drop(file); + + let actual = format!("{:x}", hasher.finalize()); + if actual != expected { + return Err(format!( + "Checksum mismatch for {} (expected {}, got {})", + zip_name, expected, actual + )); + } + + // 3. Extract (zip-slip hardened) and locate the binary. + let extract_dir = tmp.join("x"); + std::fs::create_dir_all(&extract_dir).map_err(|e| e.to_string())?; + unzip_hardened(&zip_path, &extract_dir)?; + + let binary = find_binary(&extract_dir) + .ok_or_else(|| "rclone binary not found in archive".to_string())?; + set_executable(&binary); + + // 4. Atomically publish into rclone-versions/v{version}/. + let staging = tmp.join(format!("v{}", version)); + std::fs::create_dir_all(&staging).map_err(|e| e.to_string())?; + let staged_bin = staging.join(bin_name()); + std::fs::rename(&binary, &staged_bin) + .or_else(|_| std::fs::copy(&binary, &staged_bin).map(|_| ())) + .map_err(|e| e.to_string())?; + set_executable(&staged_bin); + + let dest = base.join(format!("v{}", version)); + let _ = std::fs::remove_dir_all(&dest); + std::fs::rename(&staging, &dest).map_err(|e| e.to_string())?; + + Ok(dest.join(bin_name()).to_string_lossy().to_string()) +} + +fn find_binary(dir: &Path) -> Option { + let target = bin_name(); + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_binary(&path) { + return Some(found); + } + } else if entry.file_name().to_string_lossy() == target { + return Some(path); + } + } + None +} + +fn unzip_hardened(zip_path: &Path, out_dir: &Path) -> Result<(), String> { + let file = std::fs::File::open(zip_path).map_err(|e| e.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?; + let out_canon = canonical(out_dir); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; + // Reject absolute paths / traversal. + let name = entry + .enclosed_name() + .ok_or_else(|| "Unsafe path in archive".to_string())?; + let outpath = out_dir.join(&name); + + if entry.name().ends_with('/') || entry.name().ends_with('\\') { + std::fs::create_dir_all(&outpath).map_err(|e| e.to_string())?; + continue; + } + if let Some(parent) = outpath.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + // Defence in depth: ensure the resolved parent stays inside out_dir. + if let Some(parent) = outpath.parent() { + if !canonical(parent).starts_with(&out_canon) { + return Err("Archive entry escapes output directory".to_string()); + } + } + let mut outfile = std::fs::File::create(&outpath).map_err(|e| e.to_string())?; + std::io::copy(&mut entry, &mut outfile).map_err(|e| e.to_string())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = entry.unix_mode() { + let _ = std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode)); + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// PATH integration +// --------------------------------------------------------------------------- + +/// Refreshes the stable PATH pointer to aim at `target_path` (symlink on unix, copy on windows). +#[tauri::command] +pub fn update_path_pointer(app: AppHandle, target_path: String) -> Result<(), String> { + let pointer = path_pointer(&app)?; + if let Some(parent) = pointer.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let _ = std::fs::remove_file(&pointer); + symlink(Path::new(&target_path), &pointer).map_err(|e| e.to_string())?; + } + + #[cfg(windows)] + { + // Copy (Windows can't reliably symlink without privilege). Retry for transient locks. + let mut last_err = None; + for _ in 0..3 { + match std::fs::copy(&target_path, &pointer) { + Ok(_) => { + last_err = None; + break; + } + Err(e) => { + last_err = Some(e.to_string()); + std::thread::sleep(std::time::Duration::from_millis(200)); + } + } + } + if let Some(e) = last_err { + return Err(format!( + "Failed to update PATH pointer (close terminals using rclone and retry): {}", + e + )); + } + } + + Ok(()) +} + +/// True if the effective PATH resolves `rclone` to something other than our pointer. +fn path_shadow_warning(app: &AppHandle) -> Option { + let pointer_canon = path_pointer(app).ok().map(|p| canonical(&p))?; + let exe = bin_name(); + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(exe); + if candidate.is_file() { + let canon = canonical(&candidate); + if canon == pointer_canon { + return None; // ours wins + } + return Some(format!( + "Another rclone at {} takes precedence in your shell; the app's binary won't be used there.", + candidate.to_string_lossy() + )); + } + } + None +} + +#[cfg(target_os = "macos")] +const MACOS_LINK: &str = "/usr/local/bin/rclone"; + +#[tauri::command] +pub fn get_rclone_path_integration(app: AppHandle) -> Result { + let pointer = path_pointer(&app)?; + let pointer_canon = canonical(&pointer); + + #[cfg(target_os = "macos")] + { + let link = Path::new(MACOS_LINK); + let enabled = std::fs::read_link(link) + .map(|t| canonical(&t) == pointer_canon) + .unwrap_or(false); + return Ok(PathStatus { + enabled, + target: Some(MACOS_LINK.to_string()), + warning: if enabled { path_shadow_warning(&app) } else { None }, + }); + } + + #[cfg(target_os = "linux")] + { + let link = linux_link()?; + let enabled = std::fs::read_link(&link) + .map(|t| canonical(&t) == pointer_canon) + .unwrap_or(false); + let mut warning = if enabled { path_shadow_warning(&app) } else { None }; + if enabled && warning.is_none() && !dir_on_path(link.parent()) { + warning = Some(format!( + "{} is not on your PATH; add it or open a new login shell.", + link.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default() + )); + } + return Ok(PathStatus { + enabled, + target: Some(link.to_string_lossy().to_string()), + warning, + }); + } + + #[cfg(target_os = "windows")] + { + let bin_dir = pointer.parent().map(|p| p.to_string_lossy().to_string()); + let enabled = bin_dir + .as_ref() + .map(|d| windows_path_contains(d)) + .unwrap_or(false); + return Ok(PathStatus { + enabled, + target: bin_dir, + warning: if enabled { path_shadow_warning(&app) } else { None }, + }); + } + + #[allow(unreachable_code)] + Ok(PathStatus { + enabled: false, + target: None, + warning: None, + }) +} + +#[tauri::command] +pub fn set_rclone_path_integration( + app: AppHandle, + enable: bool, + target_path: String, +) -> Result { + // Keep the pointer fresh before wiring anything to it. + update_path_pointer(app.clone(), target_path)?; + let pointer = path_pointer(&app)?; + let pointer_str = pointer.to_string_lossy().to_string(); + + #[cfg(target_os = "macos")] + { + macos_set_link(enable, &pointer_str)?; + } + + #[cfg(target_os = "linux")] + { + linux_set_link(enable, &pointer)?; + } + + #[cfg(target_os = "windows")] + { + let bin_dir = pointer + .parent() + .map(|p| p.to_string_lossy().to_string()) + .ok_or_else(|| "Invalid pointer path".to_string())?; + windows_set_path(enable, &bin_dir)?; + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = (enable, pointer_str); + return Err("PATH integration not supported on this platform".to_string()); + } + + get_rclone_path_integration(app) +} + +// ---- macOS PATH helpers ---- + +#[cfg(target_os = "macos")] +fn macos_set_link(enable: bool, pointer: &str) -> Result<(), String> { + let link = Path::new(MACOS_LINK); + + if enable { + // Never clobber a foreign rclone (e.g. Homebrew). + if link.exists() { + let ours = std::fs::read_link(link) + .map(|t| canonical(&t) == canonical(Path::new(pointer))) + .unwrap_or(false); + if !ours { + return Err(format!( + "An rclone already exists at {}. Remove it first to let Rclone UI manage it.", + MACOS_LINK + )); + } + return Ok(()); // already ours + } + let cmd = format!("mkdir -p /usr/local/bin && ln -sfn {} {}", sh_quote(pointer), sh_quote(MACOS_LINK)); + run_osascript_admin(&cmd, "Rclone UI wants to add rclone to your PATH.") + } else { + // Only remove if it is our symlink. + let ours = std::fs::read_link(link) + .map(|t| canonical(&t) == canonical(Path::new(pointer))) + .unwrap_or(false); + if !ours { + return Ok(()); + } + let cmd = format!("rm -f {}", sh_quote(MACOS_LINK)); + run_osascript_admin(&cmd, "Rclone UI wants to remove rclone from your PATH.") + } +} + +/// POSIX single-quote a value for embedding in a shell command. +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn sh_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(target_os = "macos")] +fn run_osascript_admin(shell_cmd: &str, prompt: &str) -> Result<(), String> { + // Escape for embedding inside an AppleScript string literal. + let applescript_cmd = shell_cmd.replace('\\', "\\\\").replace('"', "\\\""); + let script = format!( + "do shell script \"{}\" with administrator privileges with prompt \"{}\"", + applescript_cmd, + prompt.replace('"', "\\\"") + ); + let status = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .status() + .map_err(|e| e.to_string())?; + if status.success() { + Ok(()) + } else { + Err("Authorization was cancelled or failed.".to_string()) + } +} + +// ---- Linux PATH helpers ---- + +#[cfg(target_os = "linux")] +fn linux_link() -> Result { + let home = std::env::var_os("HOME").ok_or_else(|| "HOME not set".to_string())?; + Ok(PathBuf::from(home).join(".local").join("bin").join("rclone")) +} + +#[cfg(target_os = "linux")] +fn linux_set_link(enable: bool, pointer: &Path) -> Result<(), String> { + use std::os::unix::fs::symlink; + let link = linux_link()?; + if enable { + if let Some(parent) = link.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + // symlink_metadata (unlike exists()) is also true for a broken symlink, so a dead link + // no longer falls through to symlink() and fails EEXIST ("File exists") forever. + if std::fs::symlink_metadata(&link).is_ok() { + let target = std::fs::read_link(&link).ok(); + let ours = target + .as_ref() + .map(|t| canonical(t) == canonical(pointer)) + .unwrap_or(false); + if ours { + return Ok(()); + } + // A dead symlink (its target no longer exists) is safe to replace; a live foreign + // entry or a real file is not. + let dead = target + .as_ref() + .map(|t| { + let resolved = if t.is_absolute() { + t.clone() + } else { + link.parent().map(|p| p.join(t)).unwrap_or_else(|| t.clone()) + }; + !resolved.exists() + }) + .unwrap_or(false); + if !dead { + return Err(format!( + "An rclone already exists at {}. Remove it first.", + link.to_string_lossy() + )); + } + let _ = std::fs::remove_file(&link); + } + symlink(pointer, &link).map_err(|e| e.to_string()) + } else { + let ours = std::fs::read_link(&link) + .map(|t| canonical(&t) == canonical(pointer)) + .unwrap_or(false); + // Also clear a dangling symlink regardless of ownership — otherwise it silently blocks + // re-enabling PATH integration until a manual rm. + let broken = std::fs::symlink_metadata(&link).is_ok() && !link.exists(); + if ours || broken { + let _ = std::fs::remove_file(&link); + } + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn dir_on_path(dir: Option<&Path>) -> bool { + let Some(dir) = dir else { return false }; + let Some(path_var) = std::env::var_os("PATH") else { + return false; + }; + let dir_canon = canonical(dir); + std::env::split_paths(&path_var).any(|p| canonical(&p) == dir_canon) +} + +// ---- Windows PATH helpers ---- + +#[cfg(target_os = "windows")] +fn windows_path_contains(dir: &str) -> bool { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let env = match hkcu.open_subkey("Environment") { + Ok(k) => k, + Err(_) => return false, + }; + let current: String = env.get_value("Path").unwrap_or_default(); + let dir_lc = dir.to_ascii_lowercase(); + current + .split(';') + .any(|seg| seg.trim().trim_end_matches('\\').to_ascii_lowercase() == dir_lc.trim_end_matches('\\')) +} + +#[cfg(target_os = "windows")] +fn windows_set_path(enable: bool, dir: &str) -> Result<(), String> { + use winreg::enums::{RegType, HKEY_CURRENT_USER}; + use winreg::{RegKey, RegValue}; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (env, _) = hkcu + .create_subkey("Environment") + .map_err(|e| e.to_string())?; + let current: String = env.get_value("Path").unwrap_or_default(); + + let dir_norm = dir.trim_end_matches('\\').to_ascii_lowercase(); + let mut segments: Vec = current + .split(';') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + let already = segments + .iter() + .any(|s| s.trim_end_matches('\\').to_ascii_lowercase() == dir_norm); + + if enable { + if !already { + segments.push(dir.to_string()); + } + } else { + segments.retain(|s| s.trim_end_matches('\\').to_ascii_lowercase() != dir_norm); + } + + let new_value = segments.join(";"); + // Preserve REG_EXPAND_SZ (PATH commonly contains %USERPROFILE% etc.). + let bytes: Vec = new_value + .encode_utf16() + .chain(std::iter::once(0u16)) + .flat_map(|u| u.to_le_bytes()) + .collect(); + env.set_raw_value( + "Path", + &RegValue { + bytes, + vtype: RegType::REG_EXPAND_SZ, + }, + ) + .map_err(|e| e.to_string())?; + + windows_broadcast_env_change(); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn windows_broadcast_env_change() { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE, + }; + let param: Vec = std::ffi::OsStr::new("Environment") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + unsafe { + let mut result: usize = 0; + SendMessageTimeoutW( + HWND_BROADCAST, + WM_SETTINGCHANGE, + 0, + param.as_ptr() as isize, + SMTO_ABORTIFHUNG, + 5000, + &mut result, + ); + } +} diff --git a/src/components/ConfigCreateDrawer.tsx b/src/components/ConfigCreateDrawer.tsx index 3e13936..b3ec800 100644 --- a/src/components/ConfigCreateDrawer.tsx +++ b/src/components/ConfigCreateDrawer.tsx @@ -18,6 +18,7 @@ import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' import { platform } from '@tauri-apps/plugin-os' import { UploadIcon } from 'lucide-react' import { useMemo, useState } from 'react' +import { onErrorDialog } from '../../lib/errors' import { getConfigPath } from '../../lib/rclone/common' import { useHostStore } from '../../store/host' import type { ConfigFile } from '../../types/config' @@ -85,14 +86,11 @@ export default function ConfigCreateDrawer({ onSuccess: () => { onClose() }, - onError: async (error) => { - console.error('[createConfig] failed to save config', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Failed to save config', undefined, { + okLabel: 'OK', + capture: false, + log: ['[createConfig] failed to save config'], + }), }) return ( diff --git a/src/components/ConfigEditDrawer.tsx b/src/components/ConfigEditDrawer.tsx index e34d3d3..1960443 100644 --- a/src/components/ConfigEditDrawer.tsx +++ b/src/components/ConfigEditDrawer.tsx @@ -15,6 +15,7 @@ import { message } from '@tauri-apps/plugin-dialog' import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' import { platform } from '@tauri-apps/plugin-os' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' +import { onErrorDialog } from '../../lib/errors' import { getConfigPath } from '../../lib/rclone/common' import { useHostStore } from '../../store/host' @@ -78,14 +79,11 @@ export default function ConfigEditDrawer({ onSuccess: () => { onClose() }, - onError: async (error) => { - console.error('[updateConfig] failed to save config', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Failed to save config', undefined, { + okLabel: 'OK', + capture: false, + log: ['[updateConfig] failed to save config'], + }), }) const initializeConfig = useCallback(async () => { diff --git a/src/components/ConfigSyncDrawer.tsx b/src/components/ConfigSyncDrawer.tsx index 1f01f50..9750bc6 100644 --- a/src/components/ConfigSyncDrawer.tsx +++ b/src/components/ConfigSyncDrawer.tsx @@ -17,6 +17,7 @@ import { exists, readTextFile } from '@tauri-apps/plugin-fs' import { platform } from '@tauri-apps/plugin-os' import { UploadIcon } from 'lucide-react' import { useState } from 'react' +import { onErrorDialog } from '../../lib/errors' import { useHostStore } from '../../store/host' import type { ConfigFile } from '../../types/config' @@ -70,14 +71,11 @@ export default function ConfigSyncDrawer({ onSuccess: () => { onClose() }, - onError: async (error) => { - console.error('[createSyncConfig] failed to save config', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Failed to save config', undefined, { + okLabel: 'OK', + capture: false, + log: ['[createSyncConfig] failed to save config'], + }), }) return ( diff --git a/src/components/HostAddDrawer.tsx b/src/components/HostAddDrawer.tsx index 7a1fac1..3f1989d 100644 --- a/src/components/HostAddDrawer.tsx +++ b/src/components/HostAddDrawer.tsx @@ -12,6 +12,7 @@ import { useMutation } from '@tanstack/react-query' import { message } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import { useState } from 'react' +import { onErrorDialog } from '../../lib/errors' import { getHostInfo } from '../../lib/hosts' import { usePersistedStore } from '../../store/persisted' @@ -83,13 +84,10 @@ export default function HostAddDrawer({ setForm(INITIAL_FORM_STATE) onClose() }, - onError: async (error) => { - console.error('[addHost] failed', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Connection failed', - kind: 'error', - }) - }, + onError: onErrorDialog('Connection failed', undefined, { + capture: false, + log: ['[addHost] failed'], + }), }) return ( diff --git a/src/components/OptionsSection.tsx b/src/components/OptionsSection.tsx index 24bae12..1916e30 100644 --- a/src/components/OptionsSection.tsx +++ b/src/components/OptionsSection.tsx @@ -541,6 +541,9 @@ export default function OptionsSection({ lineHeight: 20, paddingTop: 0, textareaOffsetTop: 0, + // Tracked so textModel (which measures line wrapping) recomputes on horizontal resize — + // the ResizeObserver refreshes this state, giving the memo a dep that follows the DOM. + clientWidth: 0, }) const [activeDecoration, setActiveDecoration] = useState(null) const [isSelectorOpen, setIsSelectorOpen] = useState(false) @@ -576,12 +579,14 @@ export default function OptionsSection({ const baseRect = textareaBaseRef.current?.getBoundingClientRect() const textareaRect = textarea.getBoundingClientRect() const textareaOffsetTop = baseRect ? textareaRect.top - baseRect.top : 0 + const clientWidth = textarea.clientWidth setTextareaLayout((previous) => { if ( previous.lineHeight === lineHeight && previous.paddingTop === paddingTop && - previous.textareaOffsetTop === textareaOffsetTop + previous.textareaOffsetTop === textareaOffsetTop && + previous.clientWidth === clientWidth ) { return previous } @@ -590,6 +595,7 @@ export default function OptionsSection({ lineHeight, paddingTop, textareaOffsetTop, + clientWidth, } }) diff --git a/src/components/RemoteAutoMountDrawer.tsx b/src/components/RemoteAutoMountDrawer.tsx index b743d3f..a5d1d9b 100644 --- a/src/components/RemoteAutoMountDrawer.tsx +++ b/src/components/RemoteAutoMountDrawer.tsx @@ -8,6 +8,7 @@ import { message, open } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import { FolderOpen } from 'lucide-react' import { startTransition, useCallback, useEffect, useState } from 'react' +import { onErrorDialog } from '../../lib/errors' import { useFlags } from '../../lib/hooks' import { lockWindows, unlockWindows } from '../../lib/window' import { type RemoteConfig, useHostStore } from '../../store/host' @@ -165,13 +166,10 @@ export default function RemoteAutoMountDrawer({ setButtonText('Save Changes') }, 1200) }, - onError: async (error) => { - console.error('Failed to update remote:', error) - await message(error instanceof Error ? error.message : 'Unknown error occurred', { - title: 'Could not update remote', - kind: 'error', - }) - }, + onError: onErrorDialog('Could not update remote', 'Unknown error occurred', { + capture: false, + log: ['Failed to update remote:'], + }), }) const setMountOnStart = useCallback( diff --git a/src/components/RemoteCreateDrawer.tsx b/src/components/RemoteCreateDrawer.tsx index cc48f1a..9e0cf63 100644 --- a/src/components/RemoteCreateDrawer.tsx +++ b/src/components/RemoteCreateDrawer.tsx @@ -109,6 +109,7 @@ export default function RemoteCreateDrawer({ kind: 'error', } ) + return } await message(errorMessage, { diff --git a/src/components/RemoteEditDrawer.tsx b/src/components/RemoteEditDrawer.tsx index 8c15302..6000c53 100644 --- a/src/components/RemoteEditDrawer.tsx +++ b/src/components/RemoteEditDrawer.tsx @@ -1,10 +1,11 @@ import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, cn } from '@heroui/react' import { Button, Select, SelectItem } from '@heroui/react' import { useMutation, useQuery } from '@tanstack/react-query' -import { message } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import { ChevronDown, ChevronUp } from 'lucide-react' import { useMemo, useState } from 'react' +import { onErrorDialog } from '../../lib/errors' +import { useRemoteConfig } from '../../lib/hooks' import queryClient from '../../lib/query' import rclone from '../../lib/rclone/client' import { OVERRIDES } from '../../lib/rclone/overrides' @@ -22,18 +23,7 @@ export default function RemoteEditDrawer({ const [config, setConfig] = useState>({}) const [showMoreOptions, setShowMoreOptions] = useState(false) - const remoteConfigQuery = useQuery({ - queryKey: ['remote', remoteName, 'config'], - queryFn: async () => { - return await rclone('/config/get', { - params: { - query: { - name: remoteName, - }, - }, - }) - }, - }) + const remoteConfigQuery = useRemoteConfig(remoteName) const remoteConfig = useMemo(() => remoteConfigQuery.data, [remoteConfigQuery.data]) @@ -111,7 +101,9 @@ export default function RemoteEditDrawer({ return updatedRemoteConfig }, onSuccess: async (updatedRemoteConfig) => { - await rclone('/fscache/clear').catch() + // Best-effort cache clear; a failure here must not reject onSuccess and leave the + // drawer stranded open after an otherwise-successful save. + await rclone('/fscache/clear').catch(() => null) queryClient.setQueryData( ['remote', remoteName, 'config'], (old?: typeof remoteConfig) => ({ @@ -121,13 +113,10 @@ export default function RemoteEditDrawer({ ) onClose() }, - onError: async (error) => { - console.error('Failed to update remote:', error) - await message(error instanceof Error ? error.message : 'Unknown error occurred', { - title: 'Could not update remote', - kind: 'error', - }) - }, + onError: onErrorDialog('Could not update remote', 'Unknown error occurred', { + capture: false, + log: ['Failed to update remote:'], + }), }) // if (!remoteConfig) return null diff --git a/src/components/RemoteOptionsSection.tsx b/src/components/RemoteOptionsSection.tsx index 80198eb..5024dc1 100644 --- a/src/components/RemoteOptionsSection.tsx +++ b/src/components/RemoteOptionsSection.tsx @@ -1,9 +1,9 @@ import { Tab, Tabs } from '@heroui/react' import { useQueries, useQuery } from '@tanstack/react-query' -import { startTransition, useEffect, useMemo, useState } from 'react' +import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef } from 'react' import { getRemoteName } from '../../lib/format' +import { remoteConfigQueryOptions } from '../../lib/hooks' import rclone from '../../lib/rclone/client' -import type { FlagValue } from '../../types/rclone' import OptionsSection from '../components/OptionsSection' const IGNORED_OPTIONS = [ @@ -25,22 +25,23 @@ const IGNORED_OPTIONS = [ 'sse_customer_key_md5', ] +// Pure view over the remotes option state owned by useOptionGroups: each tab renders the raw +// per-remote JSON doc and writes through; parsing/retention/reset semantics live in the hook. export default function RemoteOptionsSection({ selectedRemotes, remoteOptionsLocked, - remoteOptionsJsonString, - setRemoteOptionsJsonString, + remoteOptionsJson, + setRemoteOptionsJson, + reconcileRemotes, setRemoteOptionsLocked, }: { selectedRemotes: string[] remoteOptionsLocked: boolean - remoteOptionsJsonString: string - setRemoteOptionsJsonString: (value: string) => void + remoteOptionsJson: Record + setRemoteOptionsJson: Dispatch>> + reconcileRemotes: (remoteNames: string[], force?: boolean) => void setRemoteOptionsLocked: (value: boolean) => void }) { - const [optionsJsonStrings, setOptionsJsonStrings] = useState>({}) - const [options, setOptions] = useState>>({}) - const backendsQuery = useQuery({ queryKey: ['backends'], queryFn: async () => { @@ -73,32 +74,16 @@ export default function RemoteOptionsSection({ ) const remoteConfigQueries = useQueries({ - queries: uniqueRemotes.map((remote) => ({ - queryKey: ['remote', remote, 'config', 'withName'], - queryFn: async () => { - const remoteConfig = await rclone('/config/get', { - params: { - query: { - name: remote, - }, - }, - }) - return { - name: remote, - config: remoteConfig, - } - }, - })), + queries: uniqueRemotes.map((remote) => remoteConfigQueryOptions(remote)), }) const remoteConfigs = useMemo( () => remoteConfigQueries - .map((query) => query.data) + .map((query, i) => ({ name: uniqueRemotes[i], config: query.data })) .map((data) => { - if (!data) return null - const { config, name } = data + if (!config) return null if (config.type === 's3') { if (config.provider) { @@ -149,81 +134,45 @@ export default function RemoteOptionsSection({ } }) .filter(Boolean), - [remoteConfigQueries, backends] + [remoteConfigQueries, backends, uniqueRemotes] ) + // Report the current unique remote names so the hook can rebuild the tab strings when the + // remote count changes (prune on deselect, seed on addition, discard mid-edit invalid text). + // The first call after (re)mounting forces the rebuild: the old per-tab strings were child + // state destroyed on unmount, so a remount always rebuilt every tab from the last-valid doc. + const isFirstReconcile = useRef(true) useEffect(() => { - console.log('[RemoteOptionsSection] optionsJsonStrings', optionsJsonStrings) - }, [optionsJsonStrings]) + reconcileRemotes(uniqueRemotes, isFirstReconcile.current) + isFirstReconcile.current = false + }, [uniqueRemotes, reconcileRemotes]) - useEffect(() => { - if (Object.keys(optionsJsonStrings).length === uniqueRemotes.length) { - console.log('[RemoteOptionsSection] optionsJsonStrings already set') - return - } - console.log( - '[RemoteOptionsSection] setting optionsJsonStrings, parsing remoteOptionsJsonString: ', - remoteOptionsJsonString - ) - const parsed = JSON.parse(remoteOptionsJsonString) as Record - console.log('[RemoteOptionsSection] setting optionsJsonStrings parsed', parsed) - const jsonStrings: Record = uniqueRemotes.reduce( - (acc, curr) => { - console.log('[RemoteOptionsSection] curr', curr) - console.log('[RemoteOptionsSection] parsed[curr]', parsed[curr]) - acc[curr] = parsed[curr] ?? '{}' - return acc - }, - {} as Record - ) - console.log('[RemoteOptionsSection] setting optionsJsonStrings to: ', jsonStrings) - startTransition(() => { - setOptionsJsonStrings(jsonStrings) - }) - }, [uniqueRemotes, optionsJsonStrings, remoteOptionsJsonString]) - - useEffect(() => { - const stringified = JSON.stringify( - Object.entries(options).reduce( - (acc, [r, o]) => { - acc[r] = JSON.stringify(o, null, 2) - return acc - }, - {} as Record - ) - ) - console.log('[RemoteOptionsSection] stringified', stringified) - startTransition(() => { - setRemoteOptionsJsonString(stringified) - }) - }, [options, setRemoteOptionsJsonString]) - - // OptionsSection calls setOptionsJson on every keystroke, including mid-edit - // when the JSON is temporarily invalid. OptionsSection shows "Invalid JSON" - // inline via its own isJsonValid state. The try/catch here just skips the - // update so we keep the last valid parsed options until the user fixes the JSON. - useEffect(() => { - const newOptions: Record> = {} - for (const [r, o] of Object.entries(optionsJsonStrings)) { - try { - newOptions[r] = JSON.parse(o) as Record - } catch { - return - } - } - startTransition(() => { - setOptions(newOptions) - }) - }, [optionsJsonStrings]) - - return ( - ({ + const tabItems = useMemo( + () => + remoteConfigs.map((data) => ({ id: data.name, label: data.name.toUpperCase(), options: data.options, config: data.config, - }))} + })), + [remoteConfigs] + ) + + const setOptionsJsonByRemote = useMemo(() => { + const map: Record void> = {} + for (const data of remoteConfigs) { + map[data.name] = (json: string) => + setRemoteOptionsJson((prev) => ({ + ...prev, + [data.name]: json, + })) + } + return map + }, [remoteConfigs, setRemoteOptionsJson]) + + return ( + ( - setOptionsJsonStrings((prev) => ({ - ...prev, - [item.id]: json, - })) - } + optionsJson={remoteOptionsJson[item.id] ?? '{}'} + setOptionsJson={setOptionsJsonByRemote[item.id]} globalOptions={item.config} availableOptions={item.options} isLocked={remoteOptionsLocked} diff --git a/src/components/ScheduleEditDrawer.tsx b/src/components/ScheduleEditDrawer.tsx index 017f311..6f4b36c 100644 --- a/src/components/ScheduleEditDrawer.tsx +++ b/src/components/ScheduleEditDrawer.tsx @@ -17,6 +17,7 @@ import { format } from 'date-fns' import { CalendarClockIcon } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { buildReadablePath } from '../../lib/format' +import { useNow } from '../../lib/hooks' import { useHostStore } from '../../store/host' import type { ScheduledTask } from '../../types/schedules' import CronEditor from './CronEditor' @@ -51,9 +52,15 @@ export default function ScheduleEditDrawer({ [selectedTask.args] ) + // The drawer stays mounted after close, so "the next 5 runs" must be re-anchored to the + // current time on every open (and kept fresh while open) — paused while closed. + const now = useNow(isOpen ? 30_000 : null) + const upcomingRuns = useMemo(() => { try { - const parsed = CronExpressionParser.parse(cronExpression) + const parsed = CronExpressionParser.parse(cronExpression, { + currentDate: new Date(now), + }) const runs: Date[] = [] for (let i = 0; i < 5; i++) { if (parsed.hasNext()) { @@ -64,7 +71,7 @@ export default function ScheduleEditDrawer({ } catch { return [] } - }, [cronExpression]) + }, [cronExpression, now]) const hasChanges = useMemo( () => cronExpression !== selectedTask.cron, diff --git a/src/components/TemplateAddDrawer.tsx b/src/components/TemplateAddDrawer.tsx index 9b2ef50..3c1cd9a 100644 --- a/src/components/TemplateAddDrawer.tsx +++ b/src/components/TemplateAddDrawer.tsx @@ -31,6 +31,7 @@ import { } from 'lucide-react' import { startTransition, useEffect, useMemo, useState } from 'react' import { useDebounce } from 'use-debounce' +import { formatErrorMessage } from '../../lib/errors' import { FLAG_CATEGORIES, getJsonKeyCount, @@ -147,9 +148,7 @@ export default function TemplateAddDrawer({ }, onError: async (error) => { await message( - error instanceof Error - ? error.message - : 'Error saving template. Please check your options.', + formatErrorMessage(error, 'Error saving template. Please check your options.'), { title: 'Error', kind: 'error', diff --git a/src/components/TemplateEditDrawer.tsx b/src/components/TemplateEditDrawer.tsx index b446264..e67427d 100644 --- a/src/components/TemplateEditDrawer.tsx +++ b/src/components/TemplateEditDrawer.tsx @@ -28,6 +28,7 @@ import { WrenchIcon, } from 'lucide-react' import { startTransition, useEffect, useMemo, useState } from 'react' +import { formatErrorMessage } from '../../lib/errors' import { FLAG_CATEGORIES, getJsonKeyCount, @@ -170,9 +171,7 @@ export default function TemplateEditDrawer({ }, onError: async (error) => { await message( - error instanceof Error - ? error.message - : 'Error saving template. Please check your options.', + formatErrorMessage(error, 'Error saving template. Please check your options.'), { title: 'Error', kind: 'error', diff --git a/src/components/navigator/FilePanel.tsx b/src/components/navigator/FilePanel.tsx index ab5e3c6..22db4ea 100644 --- a/src/components/navigator/FilePanel.tsx +++ b/src/components/navigator/FilePanel.tsx @@ -11,8 +11,8 @@ import { useRef, useState, } from 'react' +import { remoteConfigQueryOptions } from '../../../lib/hooks' import { supportsPublicLink } from '../../../lib/rclone/constants' -import rclone from '../../../lib/rclone/client' import { useHostStore } from '../../../store/host.ts' import FileList from './FileList' import PanelToolbar, { type ToolbarButtons } from './PanelToolbar' @@ -90,12 +90,7 @@ const FilePanel = forwardRef< }) const remoteConfigQuery = useQuery({ - queryKey: ['remote', nav.selectedRemote, 'config'], - queryFn: async () => { - return await rclone('/config/get', { - params: { query: { name: nav.selectedRemote! } }, - }) - }, + ...remoteConfigQueryOptions(nav.selectedRemote), enabled: nav.isRemote, }) @@ -181,12 +176,13 @@ const FilePanel = forwardRef< } }, [onDrop, nav.selectedRemote, nav.cwd]) - // biome-ignore lint/correctness/useExhaustiveDependencies: <> + // getSelection is useCallback'd on [selectedPaths], so its identity alone tracks selection + // changes — no extra dep or suppression needed. useEffect(() => { if (onSelectionChange) { onSelectionChange(nav.getSelection()) } - }, [nav.selectedPaths, onSelectionChange, nav.getSelection]) + }, [onSelectionChange, nav.getSelection]) useEffect(() => { if (onNavigate && nav.selectedRemote) { diff --git a/src/components/navigator/PathBreadcrumb.tsx b/src/components/navigator/PathBreadcrumb.tsx index 282fbc5..fe5e41a 100644 --- a/src/components/navigator/PathBreadcrumb.tsx +++ b/src/components/navigator/PathBreadcrumb.tsx @@ -1,9 +1,8 @@ import { Button, Input, Tooltip, cn } from '@heroui/react' -import { useQuery } from '@tanstack/react-query' import { platform } from '@tauri-apps/plugin-os' import { CheckIcon, ChevronRightIcon, LaptopIcon, PencilIcon, StarIcon } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' -import rclone from '../../../lib/rclone/client.ts' +import { useRemoteConfig } from '../../../lib/hooks' import type { RemoteString } from './types' import { getPathSegments } from './utils' @@ -25,16 +24,7 @@ export default function PathBreadcrumb({ const [isInputMode, setIsInputMode] = useState(false) const inputRef = useRef(null) - const remoteConfigQuery = useQuery({ - queryKey: ['remote', remote, 'config'], - queryFn: async () => { - if (!remote || remote === 'UI_LOCAL_FS' || remote === 'UI_FAVORITES') return null - return await rclone('/config/get', { - params: { query: { name: remote } }, - }) - }, - enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES', - }) + const remoteConfigQuery = useRemoteConfig(remote) const remoteType = remoteConfigQuery.data?.type diff --git a/src/components/navigator/PreviewDrawer.tsx b/src/components/navigator/PreviewDrawer.tsx index 0056ffe..19918bc 100644 --- a/src/components/navigator/PreviewDrawer.tsx +++ b/src/components/navigator/PreviewDrawer.tsx @@ -10,7 +10,7 @@ import { import { DownloadIcon, FileIcon as FileIconLucide, XIcon } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { formatBytes } from '../../../lib/format.ts' -import { usePersistedStore } from '../../../store/persisted.ts' +import { useCurrentHost } from '../../../store/persisted.ts' import FileIcon, { getFileType, isPreviewable } from './FileIcon' import type { Entry } from './types' @@ -44,7 +44,7 @@ export default function PreviewDrawer({ item: Entry | null onClose: () => void }) { - const currentHost = usePersistedStore((state) => state.currentHost) + const currentHost = useCurrentHost() const hostUrl = currentHost?.url const authUser = currentHost?.authUser const authPassword = currentHost?.authPassword diff --git a/src/components/navigator/RemoteSidebar.tsx b/src/components/navigator/RemoteSidebar.tsx index 33a6838..9c1c4f4 100644 --- a/src/components/navigator/RemoteSidebar.tsx +++ b/src/components/navigator/RemoteSidebar.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { platform } from '@tauri-apps/plugin-os' import { StarIcon } from 'lucide-react' import { useMemo } from 'react' +import { useRemoteConfig } from '../../../lib/hooks' import rclone from '../../../lib/rclone/client.ts' import type { AllowedKey, RemoteString } from './types' import { getDiskIcon, getDiskLabel, shouldShowDisk } from './utils' @@ -16,18 +17,7 @@ function RemoteButton({ onSelect: (remote: string) => void isSelected: boolean }) { - const remoteConfigQuery = useQuery({ - queryKey: ['remote', remote, 'config'], - queryFn: async () => { - return await rclone('/config/get', { - params: { - query: { - name: remote, - }, - }, - }) - }, - }) + const remoteConfigQuery = useRemoteConfig(remote) const info = remoteConfigQuery.data ?? null diff --git a/src/components/navigator/useCreateFolder.ts b/src/components/navigator/useCreateFolder.ts index d82377b..7827fb2 100644 --- a/src/components/navigator/useCreateFolder.ts +++ b/src/components/navigator/useCreateFolder.ts @@ -1,27 +1,16 @@ -import { useQuery } from '@tanstack/react-query' import { invoke } from '@tauri-apps/api/core' import { message } from '@tauri-apps/plugin-dialog' import { useCallback, useMemo } from 'react' +import { reportError } from '../../../lib/errors' import { getFsInfo } from '../../../lib/format' +import { useRemoteConfig } from '../../../lib/hooks' import rclone from '../../../lib/rclone/client' import { supportsPersistentEmptyFolders } from '../../../lib/rclone/constants' import type { RemoteString } from './types' import { RE_TRAILING_SEPARATORS } from './utils' -export default function useCreateFolder( - remote: RemoteString, - cwd: string, - refresh: () => void -) { - const remoteConfigQuery = useQuery({ - queryKey: ['remote', remote, 'config'], - queryFn: async () => { - return await rclone('/config/get', { - params: { query: { name: remote! } }, - }) - }, - enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES', - }) +export default function useCreateFolder(remote: RemoteString, cwd: string, refresh: () => void) { + const remoteConfigQuery = useRemoteConfig(remote) const backendType = useMemo(() => { if (!remote || remote === 'UI_FAVORITES') return null @@ -74,9 +63,10 @@ export default function useCreateFolder( refresh() } catch (error) { - await message(error instanceof Error ? error.message : 'Create folder failed', { + await reportError(error, { title: 'Error', - kind: 'error', + fallback: 'Create folder failed', + capture: false, }) } }, [remote, cwd, refresh, canCreateFolder]) diff --git a/src/components/navigator/useFileNavigation.ts b/src/components/navigator/useFileNavigation.ts index c74392a..eab87a5 100644 --- a/src/components/navigator/useFileNavigation.ts +++ b/src/components/navigator/useFileNavigation.ts @@ -319,14 +319,26 @@ export default function useFileNavigation({ setRefreshKey((k) => k + 1) }, [selectedRemote, cwd]) - // Initialize on first mount if no initial values provided + // Initialize once per activation. The guard is set inside the branches (the remotes branch + // only once the list has loaded, so late data can still finish the job) — after that, dep + // churn (e.g. a /config/listremotes refetch minting a new `remotes` identity) can no longer + // yank live navigation back to the initial location. Deliberately no effect cleanup: + // cancelling the pending homeDir() write would strand the panel on isLoading. + const hasInitializedRef = useRef(false) useEffect(() => { - if (!isActive) return + if (!isActive) { + // Deactivation re-arms initialization so a closed-and-reopened drawer (PathSelector + // passes isActive={isOpen}) still resets to its initial location. + hasInitializedRef.current = false + return + } + if (hasInitializedRef.current) return const hasInitial = initialRemote !== undefined const needsLocalPath = initialRemote === 'UI_LOCAL_FS' && !initialPath if (needsLocalPath || (!hasInitial && canShowLocal)) { + hasInitializedRef.current = true setIsLoading(true) homeDir().then((home) => { startTransition(() => { @@ -337,12 +349,22 @@ export default function useFileNavigation({ setIsLoading(false) }) } else if (!hasInitial && canShowFavorites) { + hasInitializedRef.current = true startTransition(() => setSelectedRemote('UI_FAVORITES')) - } else if (!hasInitial && canShowRemotes && remotes.length > 0) { - startTransition(() => { - setSelectedRemote(remotes[0]) - setCwd('') - }) + } else if (!hasInitial && canShowRemotes) { + // remotes still loading (empty list): stay uninitialized so the arrival re-run + // completes the initialization. + if (remotes.length > 0) { + hasInitializedRef.current = true + startTransition(() => { + setSelectedRemote(remotes[0]) + setCwd('') + }) + } + } else { + // hasInitial with a concrete remote/path: state was already seeded by the useState + // initializers; nothing to apply. + hasInitializedRef.current = true } }, [ isActive, @@ -355,6 +377,7 @@ export default function useFileNavigation({ ]) // Load directory content when remote/cwd changes + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional re-run trigger the body doesn't read — refresh() evicts the cacheRef entry, clears items, and bumps it to force a refetch of the current directory; removing it breaks the Refresh button (empty panel, isLoading stuck true) useEffect(() => { if (!isActive) return diff --git a/src/components/operation/OperationFooter.tsx b/src/components/operation/OperationFooter.tsx new file mode 100644 index 0000000..d656b49 --- /dev/null +++ b/src/components/operation/OperationFooter.tsx @@ -0,0 +1,213 @@ +import { + Button, + ButtonGroup, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, + Tooltip, +} from '@heroui/react' +import { platform } from '@tauri-apps/plugin-os' +import { AnimatePresence, motion } from 'framer-motion' +import { ClockIcon, EyeIcon } from 'lucide-react' +import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react' +import { openWindow } from '../../../lib/window' +import type { Template } from '../../../types/template' +import CommandInfoButton from '../CommandInfoButton' +import CommandsDropdown from '../CommandsDropdown' +import TemplatesDropdown from '../TemplatesDropdown' + +/** + * The footer strip shared by the operation pages: TemplatesDropdown wiring, the AnimatePresence + * START/NEW swap with the three reset items, and the ButtonGroup (dry-run when the page has one, + * schedule, help, commands). Everything page-specific stays page-supplied: the reset onPress + * bodies, the start/dry-run gating condition (`startBlocked` — the page's jsonError + path + * checks), button text/icon, the NEW label, and the help prose. + */ +export default function OperationFooter({ + operation, + templatesDisabled, + onTemplateSelect, + getTemplateOptions, + startIsSuccess, + startIsPending, + onStart, + onSchedule, + dryRunIsPending, + onDryRun, + startBlocked, + buttonText, + buttonIcon, + newLabel, + newButtonPrimary = true, + showViewTransfers = true, + resetPathsLabel = 'Reset Paths', + onResetPaths, + onResetOptions, + onResetAll, + helpContent, +}: { + operation: Template['tags'][number] + templatesDisabled: boolean + onTemplateSelect: ComponentProps['onSelect'] + getTemplateOptions: ComponentProps['getOptions'] + startIsSuccess: boolean + startIsPending: boolean + onStart: () => void + onSchedule: () => void + // Present only on pages with a dry-run mutation (Copy/Sync/Move/Delete). + dryRunIsPending?: boolean + onDryRun?: () => void + startBlocked: boolean + buttonText: string + buttonIcon: ReactNode + newLabel: string + newButtonPrimary?: boolean + showViewTransfers?: boolean + resetPathsLabel?: string + onResetPaths: () => void + onResetOptions: () => void + onResetAll: () => void + helpContent: string +}) { + const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), []) + + const handleStartPress = useCallback(() => { + setTimeout(() => onStart(), 100) + }, [onStart]) + + const handleDryRunPress = useCallback(() => { + if (dryRunIsPending || startBlocked) { + return + } + setTimeout(() => onDryRun?.(), 100) + }, [dryRunIsPending, startBlocked, onDryRun]) + + const handleSchedulePress = useCallback(() => { + setTimeout(() => onSchedule(), 100) + }, [onSchedule]) + + const handleViewTransfersPress = useCallback(async () => { + await openWindow({ + name: 'Transfers', + url: '/transfers', + }) + }, []) + + return ( + <> + + + {startIsSuccess ? ( + + + + + + + + {resetPathsLabel} + + + Reset Options + + + Reset All + + + + + {showViewTransfers ? ( + + ) : null} + + ) : ( + + + + )} + + + {onDryRun ? ( + + + + ) : null} + + + + + + + + ) +} diff --git a/src/components/operation/OptionsAccordion.tsx b/src/components/operation/OptionsAccordion.tsx new file mode 100644 index 0000000..8a3ba27 --- /dev/null +++ b/src/components/operation/OptionsAccordion.tsx @@ -0,0 +1,114 @@ +import { Accordion, AccordionItem, Avatar } from '@heroui/react' +import { + ClockIcon, + CopyIcon, + DiamondPercentIcon, + FilterIcon, + FolderSyncIcon, + MoveIcon, + ServerIcon, + WrenchIcon, +} from 'lucide-react' +import { type ComponentType, type ReactNode, useMemo } from 'react' +import ShowMoreOptionsBanner from '../ShowMoreOptionsBanner' + +// Avatar/indicator/title per option category — exactly what each page's accordion rendered. +export const CATEGORY_META: Record< + 'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes', + { + title: string + icon: ComponentType<{ className?: string }> + avatarColor?: 'primary' | 'success' | 'danger' | 'warning' | 'default' + avatarClassName?: string + avatarIconClassName?: string + } +> = { + copy: { title: 'Copy', icon: CopyIcon, avatarColor: 'primary' }, + sync: { title: 'Sync', icon: FolderSyncIcon, avatarColor: 'success' }, + move: { title: 'Move', icon: MoveIcon, avatarColor: 'primary' }, + bisync: { + title: 'Bisync', + icon: DiamondPercentIcon, + avatarClassName: 'bg-lime-500', + avatarIconClassName: 'text-success-foreground', + }, + filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' }, + cron: { title: 'Cron', icon: ClockIcon, avatarColor: 'warning' }, + config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' }, + remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' }, +} + +export type OptionCategory = keyof typeof CATEGORY_META + +export interface OptionsAccordionItemDef { + key: string + category: OptionCategory + subtitle?: string + children: ReactNode +} + +/** + * The option-group accordion shared by the operation pages: item scaffolding (Avatar, + * indicator, title) comes from CATEGORY_META; each item's content (OptionsSection / + * CronEditor / RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the + * relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it. + */ +export default function OptionsAccordion({ + items, + defaultExpandedKeys, + banner = false, +}: { + items: OptionsAccordionItemDef[] + defaultExpandedKeys?: string[] + banner?: boolean +}) { + const accordionItems = useMemo( + () => + items.map((item) => { + const meta = CATEGORY_META[item.category] + const Icon = meta.icon + return ( + } + /> + } + indicator={} + title={meta.title} + subtitle={item.subtitle} + > + {item.children} + + ) + }), + [items] + ) + + const accordion = ( + + {accordionItems} + + ) + + if (!banner) { + return accordion + } + + return ( +
+ {accordion} + +
+ ) +} diff --git a/src/components/operation/useOperationDryRun.ts b/src/components/operation/useOperationDryRun.ts new file mode 100644 index 0000000..ad4dce5 --- /dev/null +++ b/src/components/operation/useOperationDryRun.ts @@ -0,0 +1,34 @@ +import { useMutation } from '@tanstack/react-query' +import { ask } from '@tauri-apps/plugin-dialog' +import { onErrorDialog } from '../../../lib/errors' +import { openWindow } from '../../../lib/window' + +/** + * The dry-run mutation shared by the operation pages that offer one (Copy/Sync/Move/Delete). + * The page supplies the whole mutationFn — including its path validation and the per-page + * `config: { ...configOptions, dry_run: true }` merge, which must stay in the page so no page + * can silently lose the dry_run injection. + */ +export function useOperationDryRun(mutationFn: () => Promise) { + return useMutation({ + mutationFn, + onSuccess: async () => { + const result = await ask( + 'Dry run started, you can check the results in the Transfers screen', + { + title: 'Preview (Dry Run)', + kind: 'info', + okLabel: 'Open Transfers', + cancelLabel: 'OK', + } + ) + if (result) { + await openWindow({ name: 'Transfers', url: '/transfers' }) + } + }, + onError: onErrorDialog('Dry Run', 'Failed to start dry run', { + capture: false, + log: ['Error starting dry run:'], + }), + }) +} diff --git a/src/components/operation/useOptionGroups.ts b/src/components/operation/useOptionGroups.ts new file mode 100644 index 0000000..3b9b4ac --- /dev/null +++ b/src/components/operation/useOptionGroups.ts @@ -0,0 +1,344 @@ +import { + type Dispatch, + type SetStateAction, + startTransition, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import type { FlagValue } from '../../../types/rclone' + +export interface OptionGroupDef { + // Group identity: the jsonError step name and the state key. Parse order = defs order. + key: K + // Key looked up in a template's grouped options, when different from `key` (Move and Bisync + // load the template's `copy` group into their own group). Defaults to `key`. + templateKey?: string + // Seeded into the JSON string on mount and restored by resetJson. Omit for '{}'. + defaults?: Record +} + +export interface OptionGroupState { + options: Record + jsonString: string + setJsonString: (value: string) => void + locked: boolean + setLocked: (value: boolean) => void +} + +export interface RemoteOptionsGroupState { + // Last-valid parsed snapshot (the args source). Frozen whole while ANY tab is invalid. + options: Record> + // Raw per-remote JSON documents (remote name -> options JSON doc), possibly invalid mid-edit. + json: Record + setJson: Dispatch>> + // Called by RemoteOptionsSection with the current unique remote names; rebuilds the tab + // strings from the last-valid parsed doc when the remote COUNT changes (either direction), + // or unconditionally on `force` (the view's first call after mounting). + reconcile: (remoteNames: string[], force?: boolean) => void + locked: boolean + setLocked: (value: boolean) => void +} + +/** + * Owns the option-group state of an operation page: per-group locked/JSON-string/parsed values, + * defaults seeding, template load and resets. The JSON string is the single source of truth — + * parsed values are derived by the parse effect, and invalid JSON retains the last-good parsed + * values PAGE-WIDE (one try/catch: any invalid group freezes every group's parsed state) with + * jsonError reporting the first failing group in defs order — exactly the semantics the pages + * hand-rolled. + * + * `withRemotes` adds the remotes group: raw per-remote JSON documents owned here and edited by + * RemoteOptionsSection tabs. Its parsing is SEPARATE from the groups' try/catch — an invalid + * remote tab must never set jsonError or disable start; instead retention is ALL-OR-NOTHING + * across remotes (one invalid tab freezes the parsed snapshot for every remote). + * + * Group defs must be static — they are captured on first render. + */ +export function useOptionGroups({ + groups, + withRemotes = false, +}: { + groups: readonly OptionGroupDef[] + withRemotes?: boolean +}) { + // Defs are static per page — freeze the first-render value so effects don't depend on an + // inline-array identity. + const [defs] = useState(groups) + + const [jsonStrings, setJsonStrings] = useState>( + () => Object.fromEntries(defs.map((g) => [g.key, '{}'])) as Record + ) + const [parsed, setParsed] = useState>>( + () => + Object.fromEntries(defs.map((g) => [g.key, {}])) as Record> + ) + const [locked, setLockedMap] = useState>( + () => Object.fromEntries(defs.map((g) => [g.key, false])) as Record + ) + + // The remotes group replicates the old two-stage pipeline exactly: + // tab strings -> (all-or-nothing parse) -> remoteDocParsed -> (gated on group validity) + // -> remoteParsed (the args source). + // remoteDocParsed is the old "outer doc": the last successful full parse of the tab strings. + // Reset clears IT (not the strings), so tabs keep their text, submits carry no remote + // options, and the next tab edit re-parses everything back in — the old chain's behavior. + const [remoteOptionsJson, setRemoteOptionsJson] = useState>({}) + const [remoteDocParsed, setRemoteDocParsed] = useState< + Record> + >({}) + const [remoteParsed, setRemoteParsed] = useState>>({}) + const [remoteLocked, setRemoteLocked] = useState(false) + + const [jsonError, setJsonError] = useState(null) + + // Bumped by a forced (mount-time) reconcile so the groups parse effect re-runs even when the + // rebuilt strings round-trip to a value-equal doc — replicating the old mount write-back + // side-channel that re-latched jsonError on a still-invalid group after a remount. + const [parseNonce, setParseNonce] = useState(0) + + // Seed group defaults into the JSON strings on mount (the parse effect derives the values). + // defs is frozen on first render, so this still runs exactly once. + useEffect(() => { + startTransition(() => { + setJsonStrings((prev) => { + const next = { ...prev } + for (const g of defs) { + if (g.defaults) { + next[g.key] = JSON.stringify(g.defaults, null, 2) + } + } + return next + }) + }) + }, [defs]) + + // Parse effect: single try/catch over all groups so ANY invalid group freezes EVERY group's + // parsed values until the user fixes the JSON. remoteDocParsed is a dep on purpose — the old + // page effect re-parsed on outer-doc changes too, so a remote-tab edit while a group is + // invalid re-throws and RE-LATCHES jsonError (re-disabling START), and the remotes args + // source syncs to the doc only on a successful full parse. + // biome-ignore lint/correctness/useExhaustiveDependencies: parseNonce is a deliberate re-run trigger the body doesn't read + useEffect(() => { + let step: K = defs[0].key + try { + const nextParsed = {} as Record> + for (const g of defs) { + step = g.key + nextParsed[g.key] = JSON.parse(jsonStrings[g.key]) as Record + } + + startTransition(() => { + setParsed(nextParsed) + if (withRemotes) { + setRemoteParsed(remoteDocParsed) + } + setJsonError(null) + }) + } catch (error) { + setJsonError(step) + console.error(`Error parsing ${step} options:`, error) + } + }, [defs, jsonStrings, remoteDocParsed, parseNonce, withRemotes]) + + // Remotes parse — deliberately OUTSIDE the groups' try/catch: an invalid remote tab must NOT + // set jsonError or disable start. Retention is ALL-OR-NOTHING across remotes: the loop + // returns on the first invalid entry, freezing the doc for EVERY remote until the user fixes + // the tab (a valid edit in another tab does not reach submit meanwhile). The value-equality + // bailout mirrors the old write-back's identical-string setState bailout. + useEffect(() => { + if (!withRemotes) { + return + } + const next: Record> = {} + for (const [remote, json] of Object.entries(remoteOptionsJson)) { + try { + next[remote] = JSON.parse(json) as Record + } catch { + return + } + } + startTransition(() => { + setRemoteDocParsed((prev) => + JSON.stringify(prev) === JSON.stringify(next) ? prev : next + ) + }) + }, [remoteOptionsJson, withRemotes]) + + // Stable per-group setters (defs are static). Returning `prev` unchanged on a same-value + // write preserves React's Object.is bailout the dedicated useState setters had — a no-op + // write must not re-run the parse effect. + const setters = useMemo(() => { + const map = {} as Record< + K, + { setJsonString: (value: string) => void; setLocked: (value: boolean) => void } + > + for (const g of defs) { + map[g.key] = { + setJsonString: (value: string) => + setJsonStrings((prev) => + prev[g.key] === value ? prev : { ...prev, [g.key]: value } + ), + setLocked: (value: boolean) => + setLockedMap((prev) => + prev[g.key] === value ? prev : { ...prev, [g.key]: value } + ), + } + } + return map + }, [defs]) + + const groupStates = useMemo(() => { + const map = {} as Record + for (const g of defs) { + map[g.key] = { + options: parsed[g.key], + jsonString: jsonStrings[g.key], + setJsonString: setters[g.key].setJsonString, + locked: locked[g.key], + setLocked: setters[g.key].setLocked, + } + } + return map + }, [defs, parsed, jsonStrings, locked, setters]) + + // Count-change semantics (mirrors the old length-equality init guard): when the remote COUNT + // changes in EITHER direction, rebuild ALL tabs' strings from the last-valid parsed doc — + // pruning deselected remotes, seeding new ones with '{}', and discarding mid-edit invalid + // text. Same-count changes deliberately do not rebuild — EXCEPT on `force`, which the view + // passes on its first call after (re)mounting: the old tab strings were child state destroyed + // on unmount, so a remount always rebuilt from the doc regardless of the count. + const reconcileRemotes = useCallback( + (remoteNames: string[], force = false) => { + startTransition(() => { + if (force) { + // A remount must re-run the groups parse once regardless of whether the + // rebuild changes anything (see parseNonce). + setParseNonce((n) => n + 1) + } + setRemoteOptionsJson((prev) => { + if (!force && Object.keys(prev).length === remoteNames.length) { + return prev + } + const next: Record = {} + let changed = remoteNames.length !== Object.keys(prev).length + for (const remote of remoteNames) { + const lastValid = remoteDocParsed[remote] + next[remote] = + lastValid !== undefined ? JSON.stringify(lastValid, null, 2) : '{}' + if (next[remote] !== prev[remote]) { + changed = true + } + } + return changed ? next : prev + }) + }) + }, + [remoteDocParsed] + ) + + const remotes: RemoteOptionsGroupState = useMemo( + () => ({ + options: remoteParsed, + json: remoteOptionsJson, + setJson: setRemoteOptionsJson, + reconcile: reconcileRemotes, + locked: remoteLocked, + setLocked: setRemoteLocked, + }), + [remoteParsed, remoteOptionsJson, reconcileRemotes, remoteLocked] + ) + + // Template load writes the JSON-STRING state (the parse effect derives parsed values); merge + // spreads the incoming group over the current PARSED values, exactly as the pages did. + const applyTemplate = useCallback( + (groupedOptions: Record, shouldMerge: boolean) => { + startTransition(() => { + setJsonStrings((prev) => { + let changed = false + const next = { ...prev } + for (const g of defs) { + const incoming = groupedOptions[g.templateKey ?? g.key] as + | Record + | undefined + // Truthiness only (as the pages did): groupByCategory always returns + // objects, so replace mode rewrites every group — clearing uncovered + // ones to '{}'. + if (!incoming) { + continue + } + const serialized = JSON.stringify( + shouldMerge ? { ...parsed[g.key], ...incoming } : incoming, + null, + 2 + ) + if (next[g.key] !== serialized) { + next[g.key] = serialized + changed = true + } + } + // Same-value bailout as the pages' individual setters had. + return changed ? next : prev + }) + }) + }, + [defs, parsed] + ) + + // Spread of all parsed groups in defs order (remotes excluded) — the TemplatesDropdown + // getOptions source. + const getMergedOptions = useCallback((): Record => { + const merged: Record = {} + for (const g of defs) { + Object.assign(merged, parsed[g.key]) + } + return merged + }, [defs, parsed]) + + // Restore every group's JSON string to its default (and remotes to '{}'), clearing jsonError. + const resetJson = useCallback(() => { + setJsonStrings((prev) => { + let changed = false + const next = { ...prev } + for (const g of defs) { + const value = g.defaults ? JSON.stringify(g.defaults, null, 2) : '{}' + if (next[g.key] !== value) { + next[g.key] = value + changed = true + } + } + return changed ? next : prev + }) + if (withRemotes) { + // Reset clears only the DOC (and, via stage 2, the args source) and leaves the tab + // strings untouched — the old chain's exact behavior: after a reset the tabs still + // display their text, submits carry no remote options, and the next edit in any tab + // re-parses everything back in. Clearing the strings here would instead trigger a + // reconcile rebuild from the stale doc, undoing the reset. + setRemoteDocParsed({}) + } + setJsonError(null) + }, [defs, withRemotes]) + + const resetLocks = useCallback(() => { + setLockedMap((prev) => { + if (defs.every((g) => prev[g.key] === false)) { + return prev + } + return Object.fromEntries(defs.map((g) => [g.key, false])) as Record + }) + setRemoteLocked(false) + }, [defs]) + + return { + jsonError, + setJsonError, + groups: groupStates, + remotes, + applyTemplate, + getMergedOptions, + resetJson, + resetLocks, + } +} diff --git a/src/components/operation/useScheduleTask.ts b/src/components/operation/useScheduleTask.ts new file mode 100644 index 0000000..03b43f7 --- /dev/null +++ b/src/components/operation/useScheduleTask.ts @@ -0,0 +1,68 @@ +import { useMutation } from '@tanstack/react-query' +import { invoke } from '@tauri-apps/api/core' +import cronstrue from 'cronstrue' +import { onErrorDialog } from '../../../lib/errors' +import notify from '../../../lib/notify' +import { useHostStore } from '../../../store/host' +import type { ScheduledTask } from '../../../types/schedules' + +/** + * The schedule mutation shared by the operation pages: page-specific validation (path checks, + * the Copy/Move multi-source license gate) → cron validation → native name prompt → + * addScheduledTask with the page-built args. `buildArgs` must return the EXACT persisted args + * shape the page's start function takes — main.ts replays these verbatim. + */ +export function useScheduleTask({ + operation, + cronExpression, + buildArgs, + validate, +}: { + operation: O + cronExpression: string | null + buildArgs: () => Extract['args'] + validate?: () => void +}) { + return useMutation({ + mutationFn: async () => { + validate?.() + + if (!cronExpression) { + throw new Error('Please enter a cron expression') + } + + try { + cronstrue.toString(cronExpression) + } catch { + throw new Error('Invalid cron expression') + } + + const name = await invoke('prompt', { + title: 'Schedule Name', + message: 'Enter a name for this schedule', + default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`, + }) + + if (!name) { + throw new Error('Schedule name is required') + } + + useHostStore.getState().addScheduledTask({ + name, + operation, + cron: cronExpression, + args: buildArgs(), + }) + }, + onSuccess: async () => { + await notify({ + title: 'Success', + body: 'New schedule has been created', + }) + }, + onError: onErrorDialog('Schedule', 'Failed to schedule task', { + capture: false, + log: ['Error scheduling task:'], + }), + }) +} diff --git a/src/main.tsx b/src/main.tsx index 4caf030..839fc85 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -64,9 +64,9 @@ if ( // placed here to avoid circular dependency usePersistedStore.subscribe(async (state, prevState) => { - if (state.currentHost?.id !== prevState.currentHost?.id && state.currentHost?.id) { - console.log('[Store] Host changed to', state.currentHost.id) - await initHostStore(state.currentHost.id).catch(console.error) + if (state.currentHostId !== prevState.currentHostId && state.currentHostId) { + console.log('[Store] Host changed to', state.currentHostId) + await initHostStore(state.currentHostId).catch(console.error) await queryClient.cancelQueries() clearClient() queryClient.clear() diff --git a/src/pages/Bisync.tsx b/src/pages/Bisync.tsx index 6073175..0baea1e 100644 --- a/src/pages/Bisync.tsx +++ b/src/pages/Bisync.tsx @@ -1,51 +1,71 @@ -import { - Accordion, - AccordionItem, - Avatar, - Button, - ButtonGroup, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Switch, - Tooltip, -} from '@heroui/react' +import { Switch } from '@heroui/react' import { useMutation } from '@tanstack/react-query' -import { invoke } from '@tauri-apps/api/core' -import { message } from '@tauri-apps/plugin-dialog' -import { platform } from '@tauri-apps/plugin-os' -import cronstrue from 'cronstrue' -import { AnimatePresence, motion } from 'framer-motion' -import { - AlertOctagonIcon, - ClockIcon, - DiamondPercentIcon, - FilterIcon, - FoldersIcon, - PlayIcon, - ServerIcon, - WrenchIcon, -} from 'lucide-react' -import { startTransition, useEffect, useMemo, useState } from 'react' +import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { startTransition, useCallback, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../lib/errors' import { getOptionsSubtitle } from '../../lib/flags' import { useFlags } from '../../lib/hooks' -import notify from '../../lib/notify' import { startBisync } from '../../lib/rclone/api' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' -import { openWindow } from '../../lib/window' -import { useHostStore } from '../../store/host' -import type { FlagValue } from '../../types/rclone' -import CommandInfoButton from '../components/CommandInfoButton' -import CommandsDropdown from '../components/CommandsDropdown' import CronEditor from '../components/CronEditor' import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowFooter from '../components/OperationWindowFooter' import OptionsSection from '../components/OptionsSection' import { PathFinder } from '../components/PathFinder' import RemoteOptionsSection from '../components/RemoteOptionsSection' -import TemplatesDropdown from '../components/TemplatesDropdown' +import OperationFooter from '../components/operation/OperationFooter' +import OptionsAccordion, { + type OptionsAccordionItemDef, +} from '../components/operation/OptionsAccordion' +import { useOptionGroups } from '../components/operation/useOptionGroups' +import { useScheduleTask } from '../components/operation/useScheduleTask' + +const HELP_CONTENT = `Performs bidirectional synchronization between two paths. + +Bisync keeps both Path1 and Path2 in sync by propagating changes in both directions. On each run, it compares the current state to the previous run and detects New, Newer, Older, and Deleted files on each side, then propagates those changes to the other path. + +Bisync retains the filesystem listings from the prior run. This history allows it to determine what has changed since the last sync. If something evil happens, bisync goes into a safe state to block damage by later runs — you may need to run with resync to recover. + +This is an advanced command — use with care. Unlike Copy or Sync which have a clear "source of truth", Bisync must resolve conflicts when both sides have changed. When a file changes on both sides and the versions differ, bisync will rename both versions as conflicts (e.g., file.conflict1, file.conflict2) so nothing is lost. Make sure you understand the behavior before using on important data. + +If you only need one-way synchronization (making destination match source), use the SYNC command instead. + +Here's a quick guide to using the Bisync command: + +1. SELECT PATHS +Use the path selectors at the top to choose Path1 and Path2. Both paths will be kept in sync with each other — there is no "source" or "destination", changes flow both ways. + +2. CONFIGURE OPTIONS (Optional) +Expand the accordion sections to customize your bisync operation. The Bisync section has important switches at the top: + +• resync — Required for the first run, or to reset bisync after an error. This makes both paths contain a matching superset of all files by copying Path2 to Path1, then Path1 to Path2. Only use resync when starting fresh, after changing filter settings, or recovering from an error — using it routinely would prevent deletions from syncing (deleted files would keep reappearing from the other side). + +• checkAccess — Safety check that looks for matching RCLONE_TEST files on both paths before syncing. You must first create these files yourself in both paths. This prevents data loss if a path is temporarily unavailable or mounted incorrectly. + +• force — Override safety checks like max-delete protection. Use with caution, as this bypasses safeguards designed to prevent accidental mass deletions. + +• createEmptySrcDirs — Sync empty directories as well as files. Without this, only files are synced and empty directories are ignored. + +• removeEmptyDirs — Remove directories that become empty after syncing. Not compatible with createEmptySrcDirs — use one or the other. + +• ignoreListingChecksum — Skip checksum retrieval when creating file listings, which can speed things up considerably on backends where hashes must be computed on the fly (like local). Note this only affects listing comparisons, not the actual sync operations. + +• resilient — Allow bisync to retry on the next run after certain errors, instead of requiring a resync. Useful for running bisync as a scheduled background process. Combine with --recover and --max-lock for a robust "set-it-and-forget-it" setup. + +• noCleanup — Don't delete temporary working files after the operation. Useful for debugging issues, but normally you should leave this off. + +3. OTHER OPTIONS +Tap any chip on the right to add it to the JSON editor. Hover over chips to see what each option does. + +• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age). + +• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes. + +• Remotes — Override backend-specific settings for remotes involved in this operation. + +4. START BISYNC +Once paths are selected, tap "START BISYNC" to begin. For your first run, make sure "resync" is enabled to establish the initial baseline. You can monitor progress on the Transfers page.` export default function Bisync() { const [searchParams] = useSearchParams() @@ -58,167 +78,88 @@ export default function Bisync() { searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined ) - const [jsonError, setJsonError] = useState<'bisync' | 'filter' | 'config' | 'remote' | null>( - null - ) + const { + jsonError, + setJsonError, + groups: optionGroups, + remotes: remotesGroup, + applyTemplate, + getMergedOptions, + resetJson, + resetLocks, + } = useOptionGroups({ + groups: [ + { key: 'bisync', templateKey: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy }, + { key: 'filter' }, + { key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }, + ], + withRemotes: true, + }) + const bisyncGroup = optionGroups.bisync + const filterGroup = optionGroups.filter + const configGroup = optionGroups.config - const [bisyncOptionsLocked, setBisyncOptionsLocked] = useState(false) - const [bisyncOptions, setBisyncOptions] = useState>({}) - const [bisyncOptionsJsonString, setBisyncOptionsJsonString] = useState('{}') const [outerBisyncOptions, setOuterBisyncOptions] = useState>({}) - const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) - const [filterOptions, setFilterOptions] = useState>({}) - const [filterOptionsJsonString, setFilterOptionsJsonString] = useState('{}') - - const [configOptionsLocked, setConfigOptionsLocked] = useState(false) - const [configOptions, setConfigOptions] = useState>({}) - const [configOptionsJsonString, setConfigOptionsJsonString] = useState('{}') - - const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false) - const [remoteOptions, setRemoteOptions] = useState>>( - {} - ) - const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState('{}') - const [cronExpression, setCronExpression] = useState(null) const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest]) + const buildStartArgs = () => ({ + source: source!, + destination: dest!, + options: { + config: configGroup.options, + bisync: bisyncGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + outer: outerBisyncOptions, + }, + }) + + // The persisted schedule args deliberately omit the outer bisync switches — do not merge + // this with buildStartArgs. + const buildScheduleArgs = () => ({ + source: source!, + destination: dest!, + options: { + config: configGroup.options, + bisync: bisyncGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + }, + }) + const startBisyncMutation = useMutation({ mutationFn: async () => { if (!source || !dest) { throw new Error('Please select both a source and destination path') } - return startBisync({ - source: source, - destination: dest, - options: { - config: configOptions, - bisync: bisyncOptions, - filter: filterOptions, - remotes: remoteOptions, - outer: outerBisyncOptions, - }, - }) + return startBisync(buildStartArgs()) }, onSuccess: () => { if (cronExpression) { scheduleTaskMutation.mutate() } }, - onError: async (error) => { - console.error('Error starting bisync:', error) - const errorMessage = - error instanceof Error ? error.message : 'Failed to start bisync operation' - await message(errorMessage, { - title: 'Bisync', - kind: 'error', - }) - }, + onError: onErrorDialog('Bisync', 'Failed to start bisync operation', { + capture: false, + log: ['Error starting bisync:'], + }), }) - const scheduleTaskMutation = useMutation({ - mutationFn: async () => { + const scheduleTaskMutation = useScheduleTask({ + operation: 'bisync', + cronExpression, + validate: () => { if (!source || !dest) { throw new Error('Please select both a source and destination path') } - - if (!cronExpression) { - throw new Error('Please enter a cron expression') - } - - try { - cronstrue.toString(cronExpression) - } catch { - throw new Error('Invalid cron expression') - } - - const name = await invoke('prompt', { - title: 'Schedule Name', - message: 'Enter a name for this schedule', - default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`, - }) - - if (!name) { - throw new Error('Schedule name is required') - } - - useHostStore.getState().addScheduledTask({ - name, - operation: 'bisync', - cron: cronExpression, - args: { - source, - destination: dest, - options: { - config: configOptions, - bisync: bisyncOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }, - }) - }, - onSuccess: async () => { - await notify({ - title: 'Success', - body: 'New schedule has been created', - }) - }, - onError: async (error) => { - console.error('Error scheduling task:', error) - await message(error instanceof Error ? error.message : 'Failed to schedule task', { - title: 'Schedule', - kind: 'error', - }) }, + buildArgs: buildScheduleArgs, }) - useEffect(() => { - startTransition(() => { - setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2)) - setBisyncOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2)) - }) - }, []) - - useEffect(() => { - let step: 'bisync' | 'filter' | 'config' | 'remote' = 'bisync' - try { - const parsedBisync = JSON.parse(bisyncOptionsJsonString) as Record - - step = 'filter' - const parsedFilter = JSON.parse(filterOptionsJsonString) as Record - - step = 'config' - const parsedConfig = JSON.parse(configOptionsJsonString) as Record - - step = 'remote' - const outerRemote = JSON.parse(remoteOptionsJsonString) as Record - const parsedRemote: Record> = {} - for (const [key, val] of Object.entries(outerRemote)) { - parsedRemote[key] = JSON.parse(val) as Record - } - - startTransition(() => { - setBisyncOptions(parsedBisync) - setFilterOptions(parsedFilter) - setConfigOptions(parsedConfig) - setRemoteOptions(parsedRemote) - setJsonError(null) - }) - } catch (error) { - setJsonError(step) - console.error(`Error parsing ${step} options:`, error) - } - }, [ - bisyncOptionsJsonString, - filterOptionsJsonString, - configOptionsJsonString, - remoteOptionsJsonString, - ]) - const buttonText = useMemo(() => { if (startBisyncMutation.isPending) return 'STARTING...' if (!source) return 'Please select a source path' @@ -236,39 +177,14 @@ export default function Bisync() { return }, [startBisyncMutation.isPending, source, dest, jsonError]) - return ( -
- {/* Main Content */} - - {/* Paths Display */} - - - - - } - /> - } - indicator={} - title="Bisync" - subtitle={getOptionsSubtitle(Object.keys(bisyncOptions).length)} - > + const accordionItems = useMemo( + () => [ + { + key: 'bisync', + category: 'bisync', + subtitle: getOptionsSubtitle(Object.keys(bisyncGroup.options).length), + children: ( + <>
- - } /> - } - indicator={} - title="Filters" - subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)} - > - - - } /> - } - indicator={} - title="Cron" - > - - - } /> - } - indicator={} - title="Config" - subtitle={getOptionsSubtitle(Object.keys(configOptions).length)} - > - - + + ), + }, + { + key: 'filters', + category: 'filters', + subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length), + children: ( + + ), + }, + { + key: 'cron', + category: 'cron', + children: , + }, + { + key: 'config', + category: 'config', + subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length), + children: ( + + ), + }, + ...(selectedRemotes.length > 0 + ? [ + { + key: 'remotes', + category: 'remotes' as const, + subtitle: getOptionsSubtitle( + Object.values(remotesGroup.options).reduce( + (acc, opts) => acc + Object.keys(opts).length, + 0 + ) + ), + children: ( + + ), + }, + ] + : []), + ], + [ + bisyncGroup, + outerBisyncOptions, + globalFlags, + copyFlags, + filterGroup, + filterFlags, + cronExpression, + configGroup, + configFlags, + selectedRemotes, + remotesGroup, + ] + ) - {selectedRemotes.length > 0 ? ( - } - /> - } - indicator={} - title={'Remotes'} - subtitle={getOptionsSubtitle( - Object.values(remoteOptions).reduce( - (acc, opts) => acc + Object.keys(opts).length, - 0 - ) - )} - > - - - ) : null} - + const handleStart = useCallback( + () => startBisyncMutation.mutate(), + [startBisyncMutation.mutate] + ) + + const handleSchedule = useCallback( + () => scheduleTaskMutation.mutate(), + [scheduleTaskMutation.mutate] + ) + + const handleResetPaths = useCallback(() => { + startTransition(() => { + setSource(undefined) + setDest(undefined) + setJsonError(null) + startBisyncMutation.reset() + }) + }, [setJsonError, startBisyncMutation.reset]) + + const handleResetOptions = useCallback(() => { + startTransition(() => { + resetJson() + setOuterBisyncOptions({}) + startBisyncMutation.reset() + }) + }, [resetJson, startBisyncMutation.reset]) + + const handleResetAll = useCallback(() => { + startTransition(() => { + resetJson() + resetLocks() + setOuterBisyncOptions({}) + setSource(undefined) + setDest(undefined) + startBisyncMutation.reset() + }) + }, [resetJson, resetLocks, startBisyncMutation.reset]) + + return ( +
+ {/* Main Content */} + + {/* Paths Display */} + + + - { - startTransition(() => { - if (shouldMerge) { - if (groupedOptions.copy) - setBisyncOptionsJsonString(JSON.stringify({ ...bisyncOptions, ...groupedOptions.copy }, null, 2)) - if (groupedOptions.filter) - setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) - if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) - } else { - if (groupedOptions.copy) setBisyncOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2)) - if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) - if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) - } - }) - }} - getOptions={() => ({ - ...bisyncOptions, - ...filterOptions, - ...configOptions, - })} + templatesDisabled={!!jsonError} + onTemplateSelect={applyTemplate} + getTemplateOptions={getMergedOptions} + startIsSuccess={startBisyncMutation.isSuccess} + startIsPending={startBisyncMutation.isPending} + onStart={handleStart} + onSchedule={handleSchedule} + startBlocked={!!jsonError || !source || !dest || source === dest} + buttonText={buttonText} + buttonIcon={buttonIcon} + newLabel="NEW BISYNC" + onResetPaths={handleResetPaths} + onResetOptions={handleResetOptions} + onResetAll={handleResetAll} + helpContent={HELP_CONTENT} /> - - {startBisyncMutation.isSuccess ? ( - - - - - - - { - startTransition(() => { - setSource(undefined) - setDest(undefined) - setJsonError(null) - startBisyncMutation.reset() - }) - }} - > - Reset Paths - - { - startTransition(() => { - setBisyncOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setOuterBisyncOptions({}) - setJsonError(null) - startBisyncMutation.reset() - }) - }} - > - Reset Options - - { - startTransition(() => { - setBisyncOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setOuterBisyncOptions({}) - setBisyncOptionsLocked(false) - setFilterOptionsLocked(false) - setConfigOptionsLocked(false) - setRemoteOptionsLocked(false) - setJsonError(null) - setSource(undefined) - setDest(undefined) - startBisyncMutation.reset() - }) - }} - > - Reset All - - - - - - - ) : ( - - - - )} - - - - - - - -
) diff --git a/src/pages/Commander.tsx b/src/pages/Commander.tsx index 749aeb0..93192b8 100644 --- a/src/pages/Commander.tsx +++ b/src/pages/Commander.tsx @@ -19,7 +19,7 @@ import { import { useMutation, useQuery } from '@tanstack/react-query' import { invoke } from '@tauri-apps/api/core' import { writeText } from '@tauri-apps/plugin-clipboard-manager' -import { ask, message, save } from '@tauri-apps/plugin-dialog' +import { ask, save } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import { AnimatePresence, motion } from 'framer-motion' import { @@ -35,6 +35,7 @@ import { } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { Group, Panel, Separator } from 'react-resizable-panels' +import { onErrorDialog, reportError } from '../../lib/errors' import { getFsInfo } from '../../lib/format' // import { Document, Page, pdfjs } from 'react-pdf' import { formatBytes } from '../../lib/format.ts' @@ -123,9 +124,10 @@ export default function Browser() { const jobId = result?.jobid if (jobId) handleJobStarted(jobId) } catch (error) { - await message(error instanceof Error ? error.message : 'Download failed', { + await reportError(error, { title: 'Error', - kind: 'error', + fallback: 'Download failed', + capture: false, }) } }, @@ -160,9 +162,10 @@ export default function Browser() { leftPanelRef.current?.refresh() rightPanelRef.current?.refresh() } catch (error) { - await message(error instanceof Error ? error.message : 'Delete failed', { + await reportError(error, { title: 'Error', - kind: 'error', + fallback: 'Delete failed', + capture: false, }) } }, []) @@ -209,9 +212,10 @@ export default function Browser() { leftPanelRef.current?.refresh() rightPanelRef.current?.refresh() } catch (error) { - await message(error instanceof Error ? error.message : 'Rename failed', { + await reportError(error, { title: 'Error', - kind: 'error', + fallback: 'Rename failed', + capture: false, }) } }, []) @@ -235,14 +239,12 @@ export default function Browser() { }) } } catch (error) { - await message( - error instanceof Error ? error.message : 'Failed to generate public link', - { - title: 'Share Error', - kind: 'error', - okLabel: 'OK', - } - ) + await reportError(error, { + title: 'Share Error', + fallback: 'Failed to generate public link', + okLabel: 'OK', + capture: false, + }) } }, []) @@ -616,12 +618,7 @@ function OperationDialog({ onComplete?.() onClose() }, - onError: async (error) => { - await message(error instanceof Error ? error.message : 'Copy operation failed', { - title: 'Error', - kind: 'error', - }) - }, + onError: onErrorDialog('Error', 'Copy operation failed', { capture: false }), }) const moveMutation = useMutation({ @@ -645,12 +642,7 @@ function OperationDialog({ onComplete?.() onClose() }, - onError: async (error) => { - await message(error instanceof Error ? error.message : 'Move operation failed', { - title: 'Error', - kind: 'error', - }) - }, + onError: onErrorDialog('Error', 'Move operation failed', { capture: false }), }) const handleConfirm = useCallback(() => { diff --git a/src/pages/Copy.tsx b/src/pages/Copy.tsx index adb7e9a..4741849 100644 --- a/src/pages/Copy.tsx +++ b/src/pages/Copy.tsx @@ -1,628 +1,28 @@ -import { - Accordion, - AccordionItem, - Avatar, - Button, - ButtonGroup, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Tooltip, -} from '@heroui/react' import { useMutation } from '@tanstack/react-query' -import { invoke } from '@tauri-apps/api/core' -import { ask, message } from '@tauri-apps/plugin-dialog' -import { platform } from '@tauri-apps/plugin-os' -import cronstrue from 'cronstrue' -import { AnimatePresence, motion } from 'framer-motion' -import { - AlertOctagonIcon, - ClockIcon, - CopyIcon, - EyeIcon, - FilterIcon, - FoldersIcon, - PlayIcon, - ServerIcon, - WrenchIcon, -} from 'lucide-react' -import { startTransition, useEffect, useMemo, useState } from 'react' +import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../lib/errors' import { getOptionsSubtitle } from '../../lib/flags' import { useFlags } from '../../lib/hooks' -import notify from '../../lib/notify' import { startCopy, startDryRun } from '../../lib/rclone/api' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' -import { openWindow } from '../../lib/window' -import { useHostStore } from '../../store/host' import { usePersistedStore } from '../../store/persisted' -import type { FlagValue } from '../../types/rclone' -import CommandInfoButton from '../components/CommandInfoButton' -import CommandsDropdown from '../components/CommandsDropdown' import CronEditor from '../components/CronEditor' import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowFooter from '../components/OperationWindowFooter' import OptionsSection from '../components/OptionsSection' import { MultiPathFinder } from '../components/PathFinder' import RemoteOptionsSection from '../components/RemoteOptionsSection' -import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner' -import TemplatesDropdown from '../components/TemplatesDropdown' +import OperationFooter from '../components/operation/OperationFooter' +import OptionsAccordion, { + type OptionsAccordionItemDef, +} from '../components/operation/OptionsAccordion' +import { useOperationDryRun } from '../components/operation/useOperationDryRun' +import { useOptionGroups } from '../components/operation/useOptionGroups' +import { useScheduleTask } from '../components/operation/useScheduleTask' -export default function Copy() { - const [searchParams] = useSearchParams() - const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags() - - const [sources, setSources] = useState( - searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined - ) - const [dest, setDest] = useState( - searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined - ) - - const [jsonError, setJsonError] = useState<'copy' | 'filter' | 'config' | 'remote' | null>(null) - - const [copyOptionsLocked, setCopyOptionsLocked] = useState(false) - const [copyOptions, setCopyOptions] = useState>({}) - const [copyOptionsJsonString, setCopyOptionsJsonString] = useState('{}') - - const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) - const [filterOptions, setFilterOptions] = useState>({}) - const [filterOptionsJsonString, setFilterOptionsJsonString] = useState('{}') - - const [configOptionsLocked, setConfigOptionsLocked] = useState(false) - const [configOptions, setConfigOptions] = useState>({}) - const [configOptionsJsonString, setConfigOptionsJsonString] = useState('{}') - - const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false) - const [remoteOptions, setRemoteOptions] = useState>>( - {} - ) - const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState('{}') - - const [cronExpression, setCronExpression] = useState(null) - - const selectedRemotes = useMemo( - () => [...(sources || []), dest].filter(Boolean), - [sources, dest] - ) - - const startCopyMutation = useMutation({ - mutationFn: async () => { - if (!sources || sources.length === 0 || !dest) { - throw new Error('Please select both a source and destination path') - } - - return startCopy({ - sources, - destination: dest, - options: { - config: configOptions, - copy: copyOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }) - }, - onSuccess: () => { - if (cronExpression) { - scheduleTaskMutation.mutate() - } - }, - onError: async (error) => { - console.error('Error starting copy:', error) - await message(error instanceof Error ? error.message : 'Failed to start copy', { - title: 'Copy', - kind: 'error', - }) - }, - }) - - const scheduleTaskMutation = useMutation({ - mutationFn: async () => { - if (!sources || sources.length === 0 || !dest) { - throw new Error('Please select both a source and destination path') - } - - if (sources.length > 1 && !usePersistedStore.getState().licenseValid) { - throw new Error('You need a valid license to schedule multiple tasks at once') - } - - if (!cronExpression) { - throw new Error('Please enter a cron expression') - } - - try { - cronstrue.toString(cronExpression) - } catch { - throw new Error('Invalid cron expression') - } - - const name = await invoke('prompt', { - title: 'Schedule Name', - message: 'Enter a name for this schedule', - default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`, - }) - - if (!name) { - throw new Error('Schedule name is required') - } - - useHostStore.getState().addScheduledTask({ - name, - operation: 'copy', - cron: cronExpression, - args: { - sources, - destination: dest, - options: { - config: configOptions, - copy: copyOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }, - }) - }, - onSuccess: async () => { - await notify({ - title: 'Success', - body: 'New schedule has been created', - }) - }, - onError: async (error) => { - console.error('Error scheduling task:', error) - await message(error instanceof Error ? error.message : 'Failed to schedule task', { - title: 'Schedule', - kind: 'error', - }) - }, - }) - - const dryRunMutation = useMutation({ - mutationFn: async () => { - if (!sources || sources.length === 0 || !dest) { - throw new Error('Please select both a source and destination path') - } - return startDryRun(() => - startCopy({ - sources, - destination: dest, - options: { - config: { ...configOptions, dry_run: true }, - copy: copyOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }) - ) - }, - onSuccess: async () => { - const result = await ask( - 'Dry run started, you can check the results in the Transfers screen', - { - title: 'Preview (Dry Run)', - kind: 'info', - okLabel: 'Open Transfers', - cancelLabel: 'OK', - } - ) - if (result) { - await openWindow({ name: 'Transfers', url: '/transfers' }) - } - }, - onError: async (error) => { - console.error('Error starting dry run:', error) - await message(error instanceof Error ? error.message : 'Failed to start dry run', { - title: 'Dry Run', - kind: 'error', - }) - }, - }) - - useEffect(() => { - startTransition(() => { - setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2)) - setCopyOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2)) - }) - }, []) - - useEffect(() => { - let step: 'copy' | 'filter' | 'config' | 'remote' = 'copy' - try { - const parsedCopy = JSON.parse(copyOptionsJsonString) as Record - - step = 'filter' - const parsedFilter = JSON.parse(filterOptionsJsonString) as Record - - step = 'config' - const parsedConfig = JSON.parse(configOptionsJsonString) as Record - - step = 'remote' - const outerRemote = JSON.parse(remoteOptionsJsonString) as Record - const parsedRemote: Record> = {} - for (const [key, val] of Object.entries(outerRemote)) { - parsedRemote[key] = JSON.parse(val) as Record - } - - startTransition(() => { - setCopyOptions(parsedCopy) - setFilterOptions(parsedFilter) - setConfigOptions(parsedConfig) - setRemoteOptions(parsedRemote) - setJsonError(null) - }) - } catch (error) { - setJsonError(step) - console.error(`Error parsing ${step} options:`, error) - } - }, [ - copyOptionsJsonString, - filterOptionsJsonString, - configOptionsJsonString, - remoteOptionsJsonString, - ]) - - const buttonText = useMemo(() => { - if (startCopyMutation.isPending) return 'STARTING...' - if (!sources || sources.length === 0) return 'Please select a source path' - if (!dest) return 'Please select a destination path' - if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' - if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' - if (cronExpression) return 'START AND SCHEDULE COPY' - return 'START COPY' - }, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression]) - - const buttonIcon = useMemo(() => { - if (startCopyMutation.isPending) return - if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest)) - return - if (jsonError) return - return - }, [startCopyMutation.isPending, sources, dest, jsonError]) - - useEffect(() => { - console.log('[Copy] remoteOptions', remoteOptions) - console.log('[Copy] remoteOptionsJsonString', remoteOptionsJsonString) - }, [remoteOptionsJsonString, remoteOptions]) - - return ( -
- {/* Main Content */} - - {/* Paths Display */} - - -
- - } /> - } - indicator={} - title="Copy" - subtitle={getOptionsSubtitle(Object.keys(copyOptions).length)} - > - - - } /> - } - indicator={} - title="Filters" - subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)} - > - - - } /> - } - indicator={} - title="Cron" - > - - - } /> - } - indicator={} - title="Config" - subtitle={getOptionsSubtitle(Object.keys(configOptions).length)} - > - - - - {selectedRemotes.length > 0 ? ( - } - /> - } - indicator={} - title={'Remotes'} - subtitle={getOptionsSubtitle( - Object.values(remoteOptions).reduce( - (acc, opts) => acc + Object.keys(opts).length, - 0 - ) - )} - > - - - ) : null} - - - -
-
- - - { - startTransition(() => { - if (shouldMerge) { - if (groupedOptions.copy) - setCopyOptionsJsonString(JSON.stringify({ ...copyOptions, ...groupedOptions.copy }, null, 2)) - if (groupedOptions.filter) - setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) - if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) - } else { - if (groupedOptions.copy) setCopyOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2)) - if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) - if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) - } - }) - }} - getOptions={() => ({ - ...copyOptions, - ...filterOptions, - ...configOptions, - })} - /> - - {startCopyMutation.isSuccess ? ( - - - - - - - { - startTransition(() => { - setSources(undefined) - setDest(undefined) - setJsonError(null) - startCopyMutation.reset() - }) - }} - > - Reset Paths - - { - startTransition(() => { - setCopyOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setCronExpression(null) - setJsonError(null) - startCopyMutation.reset() - }) - }} - > - Reset Options - - { - startTransition(() => { - setCopyOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setCopyOptionsLocked(false) - setFilterOptionsLocked(false) - setConfigOptionsLocked(false) - setRemoteOptionsLocked(false) - setCronExpression(null) - setJsonError(null) - setSources(undefined) - setDest(undefined) - startCopyMutation.reset() - }) - }} - > - Reset All - - - - - - - ) : ( - - - - )} - - - - - - - - - ( + searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined + ) + const [dest, setDest] = useState( + searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined + ) + + const { + jsonError, + setJsonError, + groups: optionGroups, + remotes: remotesGroup, + applyTemplate, + getMergedOptions, + resetJson, + resetLocks, + } = useOptionGroups({ + groups: [ + { key: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy }, + { key: 'filter' }, + { key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }, + ], + withRemotes: true, + }) + const copyGroup = optionGroups.copy + const filterGroup = optionGroups.filter + const configGroup = optionGroups.config + + const [cronExpression, setCronExpression] = useState(null) + + const selectedRemotes = useMemo( + () => [...(sources || []), dest].filter(Boolean), + [sources, dest] + ) + + const buildArgs = () => ({ + sources: sources!, + destination: dest!, + options: { + config: configGroup.options, + copy: copyGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + }, + }) + + const startCopyMutation = useMutation({ + mutationFn: async () => { + if (!sources || sources.length === 0 || !dest) { + throw new Error('Please select both a source and destination path') + } + + return startCopy(buildArgs()) + }, + onSuccess: () => { + if (cronExpression) { + scheduleTaskMutation.mutate() + } + }, + onError: onErrorDialog('Copy', 'Failed to start copy', { + capture: false, + log: ['Error starting copy:'], + }), + }) + + const scheduleTaskMutation = useScheduleTask({ + operation: 'copy', + cronExpression, + validate: () => { + if (!sources || sources.length === 0 || !dest) { + throw new Error('Please select both a source and destination path') + } + + if (sources.length > 1 && !usePersistedStore.getState().licenseValid) { + throw new Error('You need a valid license to schedule multiple tasks at once') + } + }, + buildArgs, + }) + + const dryRunMutation = useOperationDryRun(async () => { + if (!sources || sources.length === 0 || !dest) { + throw new Error('Please select both a source and destination path') + } + return startDryRun(() => + startCopy({ + sources, + destination: dest, + options: { + config: { ...configGroup.options, dry_run: true }, + copy: copyGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + }, + }) + ) + }) + + const buttonText = useMemo(() => { + if (startCopyMutation.isPending) return 'STARTING...' + if (!sources || sources.length === 0) return 'Please select a source path' + if (!dest) return 'Please select a destination path' + if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + if (cronExpression) return 'START AND SCHEDULE COPY' + return 'START COPY' + }, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression]) + + const buttonIcon = useMemo(() => { + if (startCopyMutation.isPending) return + if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest)) + return + if (jsonError) return + return + }, [startCopyMutation.isPending, sources, dest, jsonError]) + + const accordionItems = useMemo( + () => [ + { + key: 'copy', + category: 'copy', + subtitle: getOptionsSubtitle(Object.keys(copyGroup.options).length), + children: ( + - - + ), + }, + { + key: 'filters', + category: 'filters', + subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length), + children: ( + + ), + }, + { + key: 'cron', + category: 'cron', + children: , + }, + { + key: 'config', + category: 'config', + subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length), + children: ( + + ), + }, + ...(selectedRemotes.length > 0 + ? [ + { + key: 'remotes', + category: 'remotes' as const, + subtitle: getOptionsSubtitle( + Object.values(remotesGroup.options).reduce( + (acc, opts) => acc + Object.keys(opts).length, + 0 + ) + ), + children: ( + + ), + }, + ] + : []), + ], + [ + copyGroup, + filterGroup, + configGroup, + remotesGroup, + globalFlags, + copyFlags, + filterFlags, + configFlags, + cronExpression, + selectedRemotes, + ] + ) + + const handleStart = useCallback(() => startCopyMutation.mutate(), [startCopyMutation.mutate]) + + const handleSchedule = useCallback( + () => scheduleTaskMutation.mutate(), + [scheduleTaskMutation.mutate] + ) + + const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate]) + + const handleResetPaths = useCallback(() => { + startTransition(() => { + setSources(undefined) + setDest(undefined) + setJsonError(null) + startCopyMutation.reset() + }) + }, [setJsonError, startCopyMutation.reset]) + + const handleResetOptions = useCallback(() => { + startTransition(() => { + resetJson() + setCronExpression(null) + startCopyMutation.reset() + }) + }, [resetJson, startCopyMutation.reset]) + + const handleResetAll = useCallback(() => { + startTransition(() => { + resetJson() + resetLocks() + setCronExpression(null) + setSources(undefined) + setDest(undefined) + startCopyMutation.reset() + }) + }, [resetJson, resetLocks, startCopyMutation.reset]) + + useEffect(() => { + console.log('[Copy] remoteOptions', remotesGroup.options) + console.log('[Copy] remoteOptionsJson', remotesGroup.json) + }, [remotesGroup.json, remotesGroup.options]) + + return ( +
+ {/* Main Content */} + + {/* Paths Display */} + + + + + + + s === dest) + } + buttonText={buttonText} + buttonIcon={buttonIcon} + newLabel="NEW COPY" + onResetPaths={handleResetPaths} + onResetOptions={handleResetOptions} + onResetAll={handleResetAll} + helpContent={HELP_CONTENT} + />
) diff --git a/src/pages/Delete.tsx b/src/pages/Delete.tsx index c9689f8..f11e204 100644 --- a/src/pages/Delete.tsx +++ b/src/pages/Delete.tsx @@ -1,511 +1,31 @@ -import { - Accordion, - AccordionItem, - Alert, - Avatar, - Button, - ButtonGroup, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Tooltip, -} from '@heroui/react' -import * as Sentry from '@sentry/browser' -import { useMutation, useQuery } from '@tanstack/react-query' -import { invoke } from '@tauri-apps/api/core' -import { ask, message } from '@tauri-apps/plugin-dialog' -import { platform } from '@tauri-apps/plugin-os' -import cronstrue from 'cronstrue' -import { AnimatePresence, motion } from 'framer-motion' -import { - AlertOctagonIcon, - ClockIcon, - EyeIcon, - FilterIcon, - FoldersIcon, - PlayIcon, - WrenchIcon, -} from 'lucide-react' -import { startTransition, useEffect, useMemo, useState } from 'react' +import { Alert } from '@heroui/react' +import { useMutation } from '@tanstack/react-query' +import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { startTransition, useCallback, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../lib/errors' import { getOptionsSubtitle } from '../../lib/flags' import { getRemoteName } from '../../lib/format' -import { useFlags } from '../../lib/hooks' +import { useFlags, useRemoteConfig } from '../../lib/hooks' import notify from '../../lib/notify' import { startDelete, startDryRun } from '../../lib/rclone/api' -import rclone from '../../lib/rclone/client' import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants' -import { openWindow } from '../../lib/window' -import { useHostStore } from '../../store/host' -import type { FlagValue } from '../../types/rclone' -import CommandInfoButton from '../components/CommandInfoButton' -import CommandsDropdown from '../components/CommandsDropdown' import CronEditor from '../components/CronEditor' import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowFooter from '../components/OperationWindowFooter' import OptionsSection from '../components/OptionsSection' import { PathField } from '../components/PathFinder' -import TemplatesDropdown from '../components/TemplatesDropdown' +import OperationFooter from '../components/operation/OperationFooter' +import OptionsAccordion, { + type OptionsAccordionItemDef, +} from '../components/operation/OptionsAccordion' +import { useOperationDryRun } from '../components/operation/useOperationDryRun' +import { useOptionGroups } from '../components/operation/useOptionGroups' +import { useScheduleTask } from '../components/operation/useScheduleTask' -export default function Delete() { - const [searchParams] = useSearchParams() - const { globalFlags, filterFlags, configFlags } = useFlags() +const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES'] - const [sourceFs, setSourceFs] = useState( - searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined - ) - - const [cronExpression, setCronExpression] = useState(null) - - const [jsonError, setJsonError] = useState<'filter' | 'config' | null>(null) - - const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) - const [filterOptions, setFilterOptions] = useState>({}) - const [filterOptionsJsonString, setFilterOptionsJsonString] = useState('{}') - - const [configOptionsLocked, setConfigOptionsLocked] = useState(false) - const [configOptions, setConfigOptions] = useState>({}) - const [configOptionsJsonString, setConfigOptionsJsonString] = useState('{}') - - const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs]) - - const sourceRemoteConfigQuery = useQuery({ - queryKey: ['remote', sourceRemoteName, 'config'], - queryFn: async () => { - return await rclone('/config/get', { - params: { - query: { - name: sourceRemoteName!, - }, - }, - }) - }, - enabled: !!sourceRemoteName, - }) - - const supportsPurge = useMemo( - () => - sourceRemoteConfigQuery.data - ? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type) - : false, - [sourceRemoteConfigQuery.data] - ) - - useEffect(() => { - startTransition(() => { - setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2)) - }) - }, []) - - useEffect(() => { - let step: 'filter' | 'config' = 'filter' - try { - const parsedFilter = JSON.parse(filterOptionsJsonString) as Record - - step = 'config' - const parsedConfig = JSON.parse(configOptionsJsonString) as Record - - startTransition(() => { - setFilterOptions(parsedFilter) - setConfigOptions(parsedConfig) - setJsonError(null) - }) - } catch (error) { - setJsonError(step) - console.error(`Error parsing ${step} options:`, error) - } - }, [filterOptionsJsonString, configOptionsJsonString]) - - const startDeleteMutation = useMutation({ - mutationFn: async () => { - if (!sourceFs) { - throw new Error('Please select a source path to delete') - } - - return startDelete({ - sources: [sourceFs], - options: { - filter: filterOptions, - config: configOptions, - }, - }) - }, - onSuccess: async () => { - await notify({ - title: 'Success', - body: 'Delete task started', - }) - if (cronExpression) { - scheduleTaskMutation.mutate() - } - }, - onError: (error) => { - console.error('Error starting delete:', error) - Sentry.captureException(error) - }, - }) - - const scheduleTaskMutation = useMutation({ - mutationFn: async () => { - if (!sourceFs) { - throw new Error('Please select a source path to delete') - } - - if (!cronExpression) { - throw new Error('Please enter a cron expression') - } - - try { - cronstrue.toString(cronExpression) - } catch { - throw new Error('Invalid cron expression') - } - - const name = await invoke('prompt', { - title: 'Schedule Name', - message: 'Enter a name for this schedule', - default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`, - }) - - if (!name) { - throw new Error('Schedule name is required') - } - - useHostStore.getState().addScheduledTask({ - name, - operation: 'delete', - cron: cronExpression, - args: { - sources: [sourceFs], - options: { - filter: filterOptions, - config: configOptions, - }, - }, - }) - }, - onSuccess: async () => { - await notify({ - title: 'Success', - body: 'New schedule has been created', - }) - }, - onError: async (error) => { - console.error('Error scheduling task:', error) - await message(error instanceof Error ? error.message : 'Failed to schedule task', { - title: 'Schedule', - kind: 'error', - }) - }, - }) - - const dryRunMutation = useMutation({ - mutationFn: async () => { - if (!sourceFs) { - throw new Error('Please select a source path to delete') - } - return startDryRun(() => - startDelete({ - sources: [sourceFs], - options: { - filter: filterOptions, - config: { ...configOptions, dry_run: true }, - }, - }) - ) - }, - onSuccess: async () => { - const result = await ask( - 'Dry run started, you can check the results in the Transfers screen', - { - title: 'Preview (Dry Run)', - kind: 'info', - okLabel: 'Open Transfers', - cancelLabel: 'OK', - } - ) - if (result) { - await openWindow({ name: 'Transfers', url: '/transfers' }) - } - }, - onError: async (error) => { - console.error('Error starting dry run:', error) - await message(error instanceof Error ? error.message : 'Failed to start dry run', { - title: 'Dry Run', - kind: 'error', - }) - }, - }) - - const buttonText = useMemo(() => { - if (startDeleteMutation.isPending) return 'STARTING...' - if (!sourceFs || sourceFs.length === 0) return 'Please select a source path' - if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' - if (cronExpression) return 'START AND SCHEDULE DELETE' - return 'START DELETE' - }, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression]) - - const buttonIcon = useMemo(() => { - if (startDeleteMutation.isPending) return - if (!sourceFs || sourceFs.length === 0) return - if (jsonError) return - return - }, [startDeleteMutation.isPending, sourceFs, jsonError]) - - return ( -
- {/* Main Content */} - - {/* Path Display */} - - - {supportsPurge && ( - - If you're deleting a entire folder, "{sourceRemoteName}" supports Purge - which is more efficient! - - )} - - - } /> - } - indicator={} - title="Filters" - subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)} - > - - - } /> - } - indicator={} - title="Config" - subtitle={getOptionsSubtitle(Object.keys(configOptions).length)} - > - - - } /> - } - indicator={} - title="Cron" - > - - - - - - - { - startTransition(() => { - if (shouldMerge) { - if (groupedOptions.filter) - setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) - if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) - } else { - if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) - if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) - } - }) - }} - getOptions={() => ({ - ...filterOptions, - ...configOptions, - })} - /> - - {startDeleteMutation.isSuccess ? ( - - - - - - - { - startTransition(() => { - setSourceFs(undefined) - setJsonError(null) - startDeleteMutation.reset() - }) - }} - > - Reset Path - - { - startTransition(() => { - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setCronExpression(null) - setJsonError(null) - startDeleteMutation.reset() - }) - }} - > - Reset Options - - { - startTransition(() => { - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setFilterOptionsLocked(false) - setConfigOptionsLocked(false) - setCronExpression(null) - setJsonError(null) - setSourceFs(undefined) - startDeleteMutation.reset() - }) - }} - > - Reset All - - - - - ) : ( - - - - )} - - - - - - - - - ( + searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined + ) + + const [cronExpression, setCronExpression] = useState(null) + + const { + jsonError, + setJsonError, + groups: optionGroups, + applyTemplate, + getMergedOptions, + resetJson, + resetLocks, + } = useOptionGroups({ + groups: [{ key: 'filter' }, { key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }], + }) + const filterGroup = optionGroups.filter + const configGroup = optionGroups.config + + const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs]) + + const sourceRemoteConfigQuery = useRemoteConfig(sourceRemoteName) + + const supportsPurge = useMemo( + () => + sourceRemoteConfigQuery.data + ? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type) + : false, + [sourceRemoteConfigQuery.data] + ) + + const buildArgs = () => ({ + sources: [sourceFs!], + options: { + filter: filterGroup.options, + config: configGroup.options, + }, + }) + + const startDeleteMutation = useMutation({ + mutationFn: async () => { + if (!sourceFs) { + throw new Error('Please select a source path to delete') + } + + return startDelete(buildArgs()) + }, + onSuccess: async () => { + await notify({ + title: 'Success', + body: 'Delete task started', + }) + if (cronExpression) { + scheduleTaskMutation.mutate() + } + }, + onError: onErrorDialog('Delete', 'Failed to start delete', { + log: ['Error starting delete:'], + }), + }) + + const scheduleTaskMutation = useScheduleTask({ + operation: 'delete', + cronExpression, + validate: () => { + if (!sourceFs) { + throw new Error('Please select a source path to delete') + } + }, + buildArgs, + }) + + const dryRunMutation = useOperationDryRun(async () => { + if (!sourceFs) { + throw new Error('Please select a source path to delete') + } + return startDryRun(() => + startDelete({ + sources: [sourceFs], + options: { + filter: filterGroup.options, + config: { ...configGroup.options, dry_run: true }, + }, + }) + ) + }) + + const buttonText = useMemo(() => { + if (startDeleteMutation.isPending) return 'STARTING...' + if (!sourceFs || sourceFs.length === 0) return 'Please select a source path' + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + if (cronExpression) return 'START AND SCHEDULE DELETE' + return 'START DELETE' + }, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression]) + + const buttonIcon = useMemo(() => { + if (startDeleteMutation.isPending) return + if (!sourceFs || sourceFs.length === 0) return + if (jsonError) return + return + }, [startDeleteMutation.isPending, sourceFs, jsonError]) + + const accordionItems = useMemo( + () => [ + { + key: 'filters', + category: 'filters', + subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length), + children: ( + - - + ), + }, + { + key: 'config', + category: 'config', + subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length), + children: ( + + ), + }, + { + key: 'cron', + category: 'cron', + children: , + }, + ], + [filterGroup, configGroup, globalFlags, filterFlags, configFlags, cronExpression] + ) + + const handleStart = useCallback( + () => startDeleteMutation.mutate(), + [startDeleteMutation.mutate] + ) + + const handleSchedule = useCallback( + () => scheduleTaskMutation.mutate(), + [scheduleTaskMutation.mutate] + ) + + const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate]) + + const handleResetPaths = useCallback(() => { + startTransition(() => { + setSourceFs(undefined) + setJsonError(null) + startDeleteMutation.reset() + }) + }, [setJsonError, startDeleteMutation.reset]) + + const handleResetOptions = useCallback(() => { + startTransition(() => { + resetJson() + setCronExpression(null) + startDeleteMutation.reset() + }) + }, [resetJson, startDeleteMutation.reset]) + + const handleResetAll = useCallback(() => { + startTransition(() => { + resetJson() + resetLocks() + setCronExpression(null) + setSourceFs(undefined) + startDeleteMutation.reset() + }) + }, [resetJson, resetLocks, startDeleteMutation.reset]) + + return ( +
+ {/* Main Content */} + + {/* Path Display */} + + + {supportsPurge && ( + + If you're deleting a entire folder, "{sourceRemoteName}" supports Purge + which is more efficient! + + )} + + + + + +
) diff --git a/src/pages/Download.tsx b/src/pages/Download.tsx index 058ef3e..ef9c86d 100644 --- a/src/pages/Download.tsx +++ b/src/pages/Download.tsx @@ -8,6 +8,7 @@ import { AlertOctagonIcon, ClockIcon, DownloadIcon, FoldersIcon } from 'lucide-r import pRetry from 'p-retry' import { startTransition, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../lib/errors' import notify from '../../lib/notify' import rclone from '../../lib/rclone/client' import CommandInfoButton from '../components/CommandInfoButton' @@ -107,14 +108,11 @@ export default function Download() { body: 'Download task started', }) }, - onError: async (error) => { - console.error('[Download] Failed to start download', error) - await message(error instanceof Error ? error.message : 'Failed to start download', { - title: 'Download Error', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Download Error', 'Failed to start download', { + okLabel: 'OK', + capture: false, + log: ['[Download] Failed to start download'], + }), }) const buttonText = useMemo(() => { diff --git a/src/pages/Mount.tsx b/src/pages/Mount.tsx index 19f860d..bb04982 100644 --- a/src/pages/Mount.tsx +++ b/src/pages/Mount.tsx @@ -28,6 +28,7 @@ import { } from 'lucide-react' import { startTransition, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { reportError } from '../../lib/errors' import { getOptionsSubtitle } from '../../lib/flags' import { useFlags } from '../../lib/hooks' import { startMount } from '../../lib/rclone/api' @@ -164,13 +165,11 @@ export default function Mount() { } console.log('[Mount] Mount plugin installed, but failed to start mount') console.error('Failed to start mount:', error) - await message( - error instanceof Error ? error.message : 'Failed to start mount operation', - { - title: 'Mount Error', - kind: 'error', - } - ) + await reportError(error, { + title: 'Mount Error', + fallback: 'Failed to start mount operation', + capture: false, + }) }, }) @@ -308,18 +307,54 @@ export default function Mount() { startTransition(() => { if (shouldMerge) { if (groupedOptions.mount) - setMountOptionsJsonString(JSON.stringify({ ...mountOptions, ...groupedOptions.mount }, null, 2)) + setMountOptionsJsonString( + JSON.stringify( + { ...mountOptions, ...groupedOptions.mount }, + null, + 2 + ) + ) if (groupedOptions.vfs) - setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2)) + setVfsOptionsJsonString( + JSON.stringify( + { ...vfsOptions, ...groupedOptions.vfs }, + null, + 2 + ) + ) if (groupedOptions.filter) - setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) + setFilterOptionsJsonString( + JSON.stringify( + { ...filterOptions, ...groupedOptions.filter }, + null, + 2 + ) + ) if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) + setConfigOptionsJsonString( + JSON.stringify( + { ...configOptions, ...groupedOptions.config }, + null, + 2 + ) + ) } else { - if (groupedOptions.mount) setMountOptionsJsonString(JSON.stringify(groupedOptions.mount, null, 2)) - if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2)) - if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) - if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) + if (groupedOptions.mount) + setMountOptionsJsonString( + JSON.stringify(groupedOptions.mount, null, 2) + ) + if (groupedOptions.vfs) + setVfsOptionsJsonString( + JSON.stringify(groupedOptions.vfs, null, 2) + ) + if (groupedOptions.filter) + setFilterOptionsJsonString( + JSON.stringify(groupedOptions.filter, null, 2) + ) + if (groupedOptions.config) + setConfigOptionsJsonString( + JSON.stringify(groupedOptions.config, null, 2) + ) } }) }} diff --git a/src/pages/Move.tsx b/src/pages/Move.tsx index 12cf713..07bd2fc 100644 --- a/src/pages/Move.tsx +++ b/src/pages/Move.tsx @@ -1,650 +1,28 @@ -import { - Accordion, - AccordionItem, - Avatar, - Button, - ButtonGroup, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Tooltip, -} from '@heroui/react' import { useMutation } from '@tanstack/react-query' -import { invoke } from '@tauri-apps/api/core' -import { ask, message } from '@tauri-apps/plugin-dialog' -import { platform } from '@tauri-apps/plugin-os' -import cronstrue from 'cronstrue' -import { AnimatePresence, motion } from 'framer-motion' -import { - AlertOctagonIcon, - ClockIcon, - EyeIcon, - FilterIcon, - FoldersIcon, - MoveIcon, - PlayIcon, - ServerIcon, - WrenchIcon, -} from 'lucide-react' -import { startTransition, useEffect, useMemo, useState } from 'react' +import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { startTransition, useCallback, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../lib/errors' import { getOptionsSubtitle } from '../../lib/flags' import { useFlags } from '../../lib/hooks' -import notify from '../../lib/notify' import { startDryRun, startMove } from '../../lib/rclone/api' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' -import { openWindow } from '../../lib/window' -import { useHostStore } from '../../store/host' import { usePersistedStore } from '../../store/persisted' -import type { FlagValue } from '../../types/rclone' -import CommandInfoButton from '../components/CommandInfoButton' -import CommandsDropdown from '../components/CommandsDropdown' import CronEditor from '../components/CronEditor' import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowFooter from '../components/OperationWindowFooter' import OptionsSection from '../components/OptionsSection' import { MultiPathFinder } from '../components/PathFinder' import RemoteOptionsSection from '../components/RemoteOptionsSection' -import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner' -import TemplatesDropdown from '../components/TemplatesDropdown' +import OperationFooter from '../components/operation/OperationFooter' +import OptionsAccordion, { + type OptionsAccordionItemDef, +} from '../components/operation/OptionsAccordion' +import { useOperationDryRun } from '../components/operation/useOperationDryRun' +import { useOptionGroups } from '../components/operation/useOptionGroups' +import { useScheduleTask } from '../components/operation/useScheduleTask' -/* - if (cronExpression) { - if (sources.length > 1) { - throw new Error( - 'Cron is not supported for multiple sources, please use a single source' - ) - } - try { - cronstrue.toString(cronExpression) - } catch { - throw new Error('Invalid cron expression') - } - useHostStore.getState().addScheduledTask({ - type: 'move', - cron: cronExpression, - args: { - srcFs: sources[0], - dstFs: dest, - createEmptySrcDirs, - deleteEmptyDstDirs, - _config: mergedConfig, - _filter: filterOptions, - }, - }) - } - */ - -export default function Move() { - const [searchParams] = useSearchParams() - const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags() - - const [sources, setSources] = useState( - searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined - ) - const [dest, setDest] = useState( - searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined - ) - - const [jsonError, setJsonError] = useState<'move' | 'filter' | 'config' | 'remote' | null>(null) - - const [moveOptionsLocked, setMoveOptionsLocked] = useState(false) - const [moveOptions, setMoveOptions] = useState>({}) - const [moveOptionsJsonString, setMoveOptionsJsonString] = useState('{}') - - const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) - const [filterOptions, setFilterOptions] = useState>({}) - const [filterOptionsJsonString, setFilterOptionsJsonString] = useState('{}') - - const [configOptionsLocked, setConfigOptionsLocked] = useState(false) - const [configOptions, setConfigOptions] = useState>({}) - const [configOptionsJsonString, setConfigOptionsJsonString] = useState('{}') - - const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false) - const [remoteOptions, setRemoteOptions] = useState>>( - {} - ) - const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState('{}') - - const [cronExpression, setCronExpression] = useState(null) - - const selectedRemotes = useMemo( - () => [...(sources || []), dest].filter(Boolean), - [sources, dest] - ) - - const startMoveMutation = useMutation({ - mutationFn: async () => { - if (!sources || sources.length === 0 || !dest) { - throw new Error('Please select both a source and destination path') - } - - return startMove({ - sources, - destination: dest, - options: { - config: configOptions, - move: moveOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }) - }, - onSuccess: () => { - if (cronExpression) { - scheduleTaskMutation.mutate() - } - }, - onError: async (error) => { - console.error('Error starting move:', error) - await message(error instanceof Error ? error.message : 'Failed to start move', { - title: 'Move', - kind: 'error', - }) - }, - }) - - const scheduleTaskMutation = useMutation({ - mutationFn: async () => { - if (!sources || sources.length === 0 || !dest) { - throw new Error('Please select both a source and destination path') - } - - if (sources.length > 1 && !usePersistedStore.getState().licenseValid) { - throw new Error('You need a valid license to schedule multiple tasks at once') - } - - if (!cronExpression) { - throw new Error('Please enter a cron expression') - } - - try { - cronstrue.toString(cronExpression) - } catch { - throw new Error('Invalid cron expression') - } - - const name = await invoke('prompt', { - title: 'Schedule Name', - message: 'Enter a name for this schedule', - default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`, - }) - - if (!name) { - throw new Error('Schedule name is required') - } - - useHostStore.getState().addScheduledTask({ - name, - operation: 'move', - cron: cronExpression, - args: { - sources, - destination: dest, - options: { - config: configOptions, - move: moveOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }, - }) - }, - onSuccess: async () => { - await notify({ - title: 'Success', - body: 'New schedule has been created', - }) - }, - onError: async (error) => { - console.error('Error scheduling task:', error) - await message(error instanceof Error ? error.message : 'Failed to schedule task', { - title: 'Schedule', - kind: 'error', - }) - }, - }) - - const dryRunMutation = useMutation({ - mutationFn: async () => { - if (!sources || sources.length === 0 || !dest) { - throw new Error('Please select both a source and destination path') - } - return startDryRun(() => - startMove({ - sources, - destination: dest, - options: { - config: { ...configOptions, dry_run: true }, - move: moveOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }) - ) - }, - onSuccess: async () => { - const result = await ask( - 'Dry run started, you can check the results in the Transfers screen', - { - title: 'Preview (Dry Run)', - kind: 'info', - okLabel: 'Open Transfers', - cancelLabel: 'OK', - } - ) - if (result) { - await openWindow({ name: 'Transfers', url: '/transfers' }) - } - }, - onError: async (error) => { - console.error('Error starting dry run:', error) - await message(error instanceof Error ? error.message : 'Failed to start dry run', { - title: 'Dry Run', - kind: 'error', - }) - }, - }) - - useEffect(() => { - startTransition(() => { - setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2)) - setMoveOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2)) - }) - }, []) - - useEffect(() => { - let step: 'move' | 'filter' | 'config' | 'remote' = 'move' - try { - const parsedMove = JSON.parse(moveOptionsJsonString) as Record - - step = 'filter' - const parsedFilter = JSON.parse(filterOptionsJsonString) as Record - - step = 'config' - const parsedConfig = JSON.parse(configOptionsJsonString) as Record - - step = 'remote' - const outerRemote = JSON.parse(remoteOptionsJsonString) as Record - const parsedRemote: Record> = {} - for (const [key, val] of Object.entries(outerRemote)) { - parsedRemote[key] = JSON.parse(val) as Record - } - - startTransition(() => { - setMoveOptions(parsedMove) - setFilterOptions(parsedFilter) - setConfigOptions(parsedConfig) - setRemoteOptions(parsedRemote) - setJsonError(null) - }) - } catch (error) { - setJsonError(step) - console.error(`Error parsing ${step} options:`, error) - } - }, [ - moveOptionsJsonString, - filterOptionsJsonString, - configOptionsJsonString, - remoteOptionsJsonString, - ]) - - const buttonText = useMemo(() => { - if (startMoveMutation.isPending) return 'STARTING...' - if (!sources || sources.length === 0) return 'Please select a source path' - if (!dest) return 'Please select a destination path' - if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' - if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' - if (cronExpression) return 'START AND SCHEDULE MOVE' - return 'START MOVE' - }, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression]) - - const buttonIcon = useMemo(() => { - if (startMoveMutation.isPending) return - if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest)) - return - if (jsonError) return - return - }, [startMoveMutation.isPending, sources, dest, jsonError]) - - return ( -
- {/* Main Content */} - - {/* Paths Display */} - - -
- - } /> - } - indicator={} - title="Move" - subtitle={getOptionsSubtitle(Object.keys(moveOptions).length)} - > - - - } /> - } - indicator={} - title="Filters" - subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)} - > - - - } /> - } - indicator={} - title="Cron" - > - - - } /> - } - indicator={} - title="Config" - subtitle={getOptionsSubtitle(Object.keys(configOptions).length)} - > - - - - {selectedRemotes.length > 0 ? ( - } - /> - } - indicator={} - title={'Remotes'} - subtitle={getOptionsSubtitle( - Object.values(remoteOptions).reduce( - (acc, opts) => acc + Object.keys(opts).length, - 0 - ) - )} - > - - - ) : null} - - - -
-
- - - { - startTransition(() => { - if (shouldMerge) { - if (groupedOptions.copy) - setMoveOptionsJsonString(JSON.stringify({ ...moveOptions, ...groupedOptions.copy }, null, 2)) - if (groupedOptions.filter) - setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) - if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) - } else { - if (groupedOptions.copy) setMoveOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2)) - if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) - if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) - } - }) - }} - getOptions={() => ({ - ...moveOptions, - ...filterOptions, - ...configOptions, - })} - /> - - {startMoveMutation.isSuccess ? ( - - - - - - - { - startTransition(() => { - setSources(undefined) - setDest(undefined) - setJsonError(null) - startMoveMutation.reset() - }) - }} - > - Reset Paths - - { - startTransition(() => { - setMoveOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setCronExpression(null) - setJsonError(null) - startMoveMutation.reset() - }) - }} - > - Reset Options - - { - startTransition(() => { - setMoveOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setMoveOptionsLocked(false) - setFilterOptionsLocked(false) - setConfigOptionsLocked(false) - setRemoteOptionsLocked(false) - setCronExpression(null) - setJsonError(null) - setSources(undefined) - setDest(undefined) - startMoveMutation.reset() - }) - }} - > - Reset All - - - - - - - ) : ( - - - - )} - - - - - - - - - ( + searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined + ) + const [dest, setDest] = useState( + searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined + ) + + const { + jsonError, + setJsonError, + groups: optionGroups, + remotes: remotesGroup, + applyTemplate, + getMergedOptions, + resetJson, + resetLocks, + } = useOptionGroups({ + groups: [ + { key: 'move', templateKey: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy }, + { key: 'filter' }, + { key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }, + ], + withRemotes: true, + }) + const moveGroup = optionGroups.move + const filterGroup = optionGroups.filter + const configGroup = optionGroups.config + + const [cronExpression, setCronExpression] = useState(null) + + const selectedRemotes = useMemo( + () => [...(sources || []), dest].filter(Boolean), + [sources, dest] + ) + + const buildArgs = () => ({ + sources: sources!, + destination: dest!, + options: { + config: configGroup.options, + move: moveGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + }, + }) + + const startMoveMutation = useMutation({ + mutationFn: async () => { + if (!sources || sources.length === 0 || !dest) { + throw new Error('Please select both a source and destination path') + } + + return startMove(buildArgs()) + }, + onSuccess: () => { + if (cronExpression) { + scheduleTaskMutation.mutate() + } + }, + onError: onErrorDialog('Move', 'Failed to start move', { + capture: false, + log: ['Error starting move:'], + }), + }) + + const scheduleTaskMutation = useScheduleTask({ + operation: 'move', + cronExpression, + validate: () => { + if (!sources || sources.length === 0 || !dest) { + throw new Error('Please select both a source and destination path') + } + + if (sources.length > 1 && !usePersistedStore.getState().licenseValid) { + throw new Error('You need a valid license to schedule multiple tasks at once') + } + }, + buildArgs, + }) + + const dryRunMutation = useOperationDryRun(async () => { + if (!sources || sources.length === 0 || !dest) { + throw new Error('Please select both a source and destination path') + } + return startDryRun(() => + startMove({ + sources, + destination: dest, + options: { + config: { ...configGroup.options, dry_run: true }, + move: moveGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + }, + }) + ) + }) + + const buttonText = useMemo(() => { + if (startMoveMutation.isPending) return 'STARTING...' + if (!sources || sources.length === 0) return 'Please select a source path' + if (!dest) return 'Please select a destination path' + if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + if (cronExpression) return 'START AND SCHEDULE MOVE' + return 'START MOVE' + }, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression]) + + const buttonIcon = useMemo(() => { + if (startMoveMutation.isPending) return + if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest)) + return + if (jsonError) return + return + }, [startMoveMutation.isPending, sources, dest, jsonError]) + + const accordionItems = useMemo( + () => [ + { + key: 'move', + category: 'move', + subtitle: getOptionsSubtitle(Object.keys(moveGroup.options).length), + children: ( + - - + ), + }, + { + key: 'filters', + category: 'filters', + subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length), + children: ( + + ), + }, + { + key: 'cron', + category: 'cron', + children: , + }, + { + key: 'config', + category: 'config', + subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length), + children: ( + + ), + }, + ...(selectedRemotes.length > 0 + ? [ + { + key: 'remotes', + category: 'remotes' as const, + subtitle: getOptionsSubtitle( + Object.values(remotesGroup.options).reduce( + (acc, opts) => acc + Object.keys(opts).length, + 0 + ) + ), + children: ( + + ), + }, + ] + : []), + ], + [ + moveGroup, + filterGroup, + configGroup, + remotesGroup, + globalFlags, + filterFlags, + configFlags, + copyFlags, + cronExpression, + selectedRemotes, + ] + ) + + const handleStart = useCallback(() => startMoveMutation.mutate(), [startMoveMutation.mutate]) + + const handleSchedule = useCallback( + () => scheduleTaskMutation.mutate(), + [scheduleTaskMutation.mutate] + ) + + const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate]) + + const handleResetPaths = useCallback(() => { + startTransition(() => { + setSources(undefined) + setDest(undefined) + setJsonError(null) + startMoveMutation.reset() + }) + }, [setJsonError, startMoveMutation.reset]) + + const handleResetOptions = useCallback(() => { + startTransition(() => { + resetJson() + setCronExpression(null) + startMoveMutation.reset() + }) + }, [resetJson, startMoveMutation.reset]) + + const handleResetAll = useCallback(() => { + startTransition(() => { + resetJson() + resetLocks() + setCronExpression(null) + setSources(undefined) + setDest(undefined) + startMoveMutation.reset() + }) + }, [resetJson, resetLocks, startMoveMutation.reset]) + + return ( +
+ {/* Main Content */} + + {/* Paths Display */} + + + + + + + s === dest) + } + buttonText={buttonText} + buttonIcon={buttonIcon} + newLabel="NEW MOVE" + onResetPaths={handleResetPaths} + onResetOptions={handleResetOptions} + onResetAll={handleResetAll} + helpContent={HELP_CONTENT} + />
) diff --git a/src/pages/Purge.tsx b/src/pages/Purge.tsx index 9b57dda..301c44c 100644 --- a/src/pages/Purge.tsx +++ b/src/pages/Purge.tsx @@ -1,363 +1,29 @@ -import { - Accordion, - AccordionItem, - Avatar, - Button, - ButtonGroup, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Tooltip, -} from '@heroui/react' -import * as Sentry from '@sentry/browser' import { useMutation } from '@tanstack/react-query' -import { invoke } from '@tauri-apps/api/core' -import { message } from '@tauri-apps/plugin-dialog' -import { platform } from '@tauri-apps/plugin-os' -import cronstrue from 'cronstrue' -import { AnimatePresence, motion } from 'framer-motion' -import { AlertOctagonIcon, ClockIcon, FoldersIcon, PlayIcon, WrenchIcon } from 'lucide-react' -import { startTransition, useEffect, useMemo, useState } from 'react' +import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { startTransition, useCallback, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../lib/errors' import { getOptionsSubtitle } from '../../lib/flags' import { useFlags } from '../../lib/hooks' -import notify from '../../lib/notify' import { startPurge } from '../../lib/rclone/api' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' -import { useHostStore } from '../../store/host' -import type { FlagValue } from '../../types/rclone' -import CommandInfoButton from '../components/CommandInfoButton' -import CommandsDropdown from '../components/CommandsDropdown' import CronEditor from '../components/CronEditor' import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowFooter from '../components/OperationWindowFooter' import OptionsSection from '../components/OptionsSection' import { PathField } from '../components/PathFinder' -import TemplatesDropdown from '../components/TemplatesDropdown' +import OperationFooter from '../components/operation/OperationFooter' +import OptionsAccordion, { + type OptionsAccordionItemDef, +} from '../components/operation/OptionsAccordion' +import { useOptionGroups } from '../components/operation/useOptionGroups' +import { useScheduleTask } from '../components/operation/useScheduleTask' -export default function Purge() { - const [searchParams] = useSearchParams() - const { globalFlags, configFlags } = useFlags() +const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES'] - const [source, setSource] = useState( - searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined - ) +const DEFAULT_EXPANDED_KEYS = ['config', 'cron'] - const [cronExpression, setCronExpression] = useState(null) - - const [jsonError, setJsonError] = useState<'config' | null>(null) - - const [configOptionsLocked, setConfigOptionsLocked] = useState(false) - const [configOptions, setConfigOptions] = useState>({}) - const [configOptionsJsonString, setConfigOptionsJsonString] = useState('{}') - - const startPurgeMutation = useMutation({ - mutationFn: async () => { - if (!source) { - throw new Error('Please select a source path') - } - - return startPurge({ - sources: [source], - options: { - config: configOptions, - }, - }) - }, - onSuccess: async () => { - if (cronExpression) { - scheduleTaskMutation.mutate() - } - }, - onError: async (error) => { - console.error('[Purge] Failed to start purge:', error) - Sentry.captureException(error) - await message(error instanceof Error ? error.message : 'Failed to start purge', { - title: 'Purge', - kind: 'error', - }) - }, - }) - - const scheduleTaskMutation = useMutation({ - mutationFn: async () => { - if (!source) { - throw new Error('Please select a source path to purge') - } - - if (!cronExpression) { - throw new Error('Please enter a cron expression') - } - - try { - cronstrue.toString(cronExpression) - } catch { - throw new Error('Invalid cron expression') - } - - const name = await invoke('prompt', { - title: 'Schedule Name', - message: 'Enter a name for this schedule', - default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`, - }) - - if (!name) { - throw new Error('Schedule name is required') - } - - useHostStore.getState().addScheduledTask({ - name, - operation: 'purge', - cron: cronExpression, - args: { - sources: [source], - options: { - config: configOptions, - }, - }, - }) - }, - onSuccess: async () => { - await notify({ - title: 'Success', - body: 'New schedule has been created', - }) - }, - onError: async (error) => { - console.error('Error scheduling task:', error) - await message(error instanceof Error ? error.message : 'Failed to schedule task', { - title: 'Schedule', - kind: 'error', - }) - }, - }) - - const buttonText = useMemo(() => { - if (startPurgeMutation.isPending) return 'STARTING...' - if (!source) return 'Please select a source path' - if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' - if (cronExpression) return 'START AND SCHEDULE PURGE' - return 'START PURGE' - }, [startPurgeMutation.isPending, source, jsonError, cronExpression]) - - const buttonIcon = useMemo(() => { - if (startPurgeMutation.isPending) return - if (!source) return - if (jsonError) return - return - }, [startPurgeMutation.isPending, source, jsonError]) - - useEffect(() => { - startTransition(() => { - setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2)) - }) - }, []) - - useEffect(() => { - const step = 'config' - try { - const parsedConfig = JSON.parse(configOptionsJsonString) as Record - - startTransition(() => { - setConfigOptions(parsedConfig) - setJsonError(null) - }) - } catch (error) { - setJsonError(step) - console.error(`Error parsing ${step} options:`, error) - } - }, [configOptionsJsonString]) - - return ( -
- {/* Main Content */} - - {/* Path Display */} - - - - } /> - } - indicator={} - title="Config" - subtitle={getOptionsSubtitle(Object.keys(configOptions).length)} - > - - - } /> - } - indicator={} - title="Cron" - > - - - - - - - { - startTransition(() => { - if (shouldMerge) { - if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) - } else if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) - }) - }} - getOptions={() => ({ - ...configOptions, - })} - /> - - {startPurgeMutation.isSuccess ? ( - - - - - - - { - startTransition(() => { - setSource(undefined) - setJsonError(null) - startPurgeMutation.reset() - }) - }} - > - Reset Path - - { - startTransition(() => { - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setCronExpression(null) - setJsonError(null) - startPurgeMutation.reset() - }) - }} - > - Reset Options - - { - startTransition(() => { - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setConfigOptionsLocked(false) - setCronExpression(null) - setJsonError(null) - setSource(undefined) - startPurgeMutation.reset() - }) - }} - > - Reset All - - - - - ) : ( - - - - )} - - - - - - ( + searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined + ) + + const [cronExpression, setCronExpression] = useState(null) + + const { + jsonError, + setJsonError, + groups: optionGroups, + applyTemplate, + getMergedOptions, + resetJson, + resetLocks, + } = useOptionGroups({ + groups: [{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }], + }) + const configGroup = optionGroups.config + + const buildArgs = () => ({ + sources: [source!], + options: { + config: configGroup.options, + }, + }) + + const startPurgeMutation = useMutation({ + mutationFn: async () => { + if (!source) { + throw new Error('Please select a source path') + } + + return startPurge(buildArgs()) + }, + onSuccess: async () => { + if (cronExpression) { + scheduleTaskMutation.mutate() + } + }, + onError: onErrorDialog('Purge', 'Failed to start purge', { + log: ['[Purge] Failed to start purge:'], + }), + }) + + const scheduleTaskMutation = useScheduleTask({ + operation: 'purge', + cronExpression, + validate: () => { + if (!source) { + throw new Error('Please select a source path to purge') + } + }, + buildArgs, + }) + + const buttonText = useMemo(() => { + if (startPurgeMutation.isPending) return 'STARTING...' + if (!source) return 'Please select a source path' + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + if (cronExpression) return 'START AND SCHEDULE PURGE' + return 'START PURGE' + }, [startPurgeMutation.isPending, source, jsonError, cronExpression]) + + const buttonIcon = useMemo(() => { + if (startPurgeMutation.isPending) return + if (!source) return + if (jsonError) return + return + }, [startPurgeMutation.isPending, source, jsonError]) + + const accordionItems = useMemo( + () => [ + { + key: 'config', + category: 'config', + subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length), + children: ( + - - + ), + }, + { + key: 'cron', + category: 'cron', + children: , + }, + ], + [configGroup, globalFlags, configFlags, cronExpression] + ) + + const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate]) + + const handleSchedule = useCallback( + () => scheduleTaskMutation.mutate(), + [scheduleTaskMutation.mutate] + ) + + const handleResetPaths = useCallback(() => { + startTransition(() => { + setSource(undefined) + setJsonError(null) + startPurgeMutation.reset() + }) + }, [setJsonError, startPurgeMutation.reset]) + + const handleResetOptions = useCallback(() => { + startTransition(() => { + resetJson() + setCronExpression(null) + startPurgeMutation.reset() + }) + }, [resetJson, startPurgeMutation.reset]) + + const handleResetAll = useCallback(() => { + startTransition(() => { + resetJson() + resetLocks() + setCronExpression(null) + setSource(undefined) + startPurgeMutation.reset() + }) + }, [resetJson, resetLocks, startPurgeMutation.reset]) + + return ( +
+ {/* Main Content */} + + {/* Path Display */} + + + + + + +
) diff --git a/src/pages/Schedules.tsx b/src/pages/Schedules.tsx index 93f3613..febdb5b 100644 --- a/src/pages/Schedules.tsx +++ b/src/pages/Schedules.tsx @@ -8,6 +8,7 @@ import { formatDistance } from 'date-fns' import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { buildReadablePath } from '../../lib/format' +import { useNow } from '../../lib/hooks' import { useHostStore } from '../../store/host' import type { ScheduledTask } from '../../types/schedules' import CommandsDropdown from '../components/CommandsDropdown' @@ -69,10 +70,14 @@ function TaskCard({ } }, [task.name, isEditingName]) + // The card's time-derived values are anchored to this tick — without it the memos freeze at + // their last dep change (e.g. a past occurrence kept showing as the "next run" forever). + const now = useNow() + const nextRun = useMemo(() => { - const parsed = CronExpressionParser.parse(task.cron) + const parsed = CronExpressionParser.parse(task.cron, { currentDate: new Date(now) }) return parsed.hasNext() ? parsed.next().toDate() : null - }, [task.cron]) + }, [task.cron, now]) const source = useMemo( () => ('source' in task.args ? task.args.source : task.args.sources[0]), @@ -81,24 +86,24 @@ function TaskCard({ const nextRunLabel = useMemo(() => { if (nextRun) { - const distance = formatDistance(nextRun, new Date(), { addSuffix: true }) + const distance = formatDistance(nextRun, new Date(now), { addSuffix: true }) return distance.charAt(0).toUpperCase() + distance.slice(1) } return 'Never' - }, [nextRun]) + }, [nextRun, now]) const lastRunLabel = useMemo(() => { if (task.isRunning) { return 'Running now' } if (task.lastRun) { - const distance = formatDistance(new Date(task.lastRun), new Date(), { + const distance = formatDistance(new Date(task.lastRun), new Date(now), { addSuffix: true, }) return distance.charAt(0).toUpperCase() + distance.slice(1) } return 'Never' - }, [task.isRunning, task.lastRun]) + }, [task.isRunning, task.lastRun, now]) return ( onOpenDrawer(task)} style={{ flexShrink: 0, - // border: '1px solid #e0e0e070', - // borderBottom: '1px solid #e0e0e070', - // padding: '0.5rem', }} className="p-2 border-b border-divider" > diff --git a/src/pages/Serve.tsx b/src/pages/Serve.tsx index 1778d97..b68fe01 100644 --- a/src/pages/Serve.tsx +++ b/src/pages/Serve.tsx @@ -12,7 +12,6 @@ import { SelectItem, Tooltip, } from '@heroui/react' -import * as Sentry from '@sentry/browser' import { useMutation } from '@tanstack/react-query' import { message } from '@tauri-apps/plugin-dialog' import { openUrl } from '@tauri-apps/plugin-opener' @@ -30,6 +29,7 @@ import { } from 'lucide-react' import { startTransition, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../lib/errors' import { getOptionsSubtitle } from '../../lib/flags' import { useFlags } from '../../lib/hooks' import { startServe } from '../../lib/rclone/api' @@ -90,14 +90,9 @@ export default function Serve() { ...(vfsOptions as Record), }) }, - onError: async (error) => { - console.error('[Serve] Failed to start serve:', error) - Sentry.captureException(error) - await message(error instanceof Error ? error.message : 'Failed to start serve', { - title: 'Serve', - kind: 'error', - }) - }, + onError: onErrorDialog('Serve', 'Failed to start serve', { + log: ['[Serve] Failed to start serve:'], + }), }) useEffect(() => { @@ -290,19 +285,54 @@ export default function Serve() { startTransition(() => { if (shouldMerge) { if (groupedOptions.serve && type) - setServeOptionsJsonString(JSON.stringify({ ...serveOptions, ...groupedOptions.serve[type] }, null, 2)) + setServeOptionsJsonString( + JSON.stringify( + { ...serveOptions, ...groupedOptions.serve[type] }, + null, + 2 + ) + ) if (groupedOptions.vfs) - setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2)) + setVfsOptionsJsonString( + JSON.stringify( + { ...vfsOptions, ...groupedOptions.vfs }, + null, + 2 + ) + ) if (groupedOptions.filter) - setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) + setFilterOptionsJsonString( + JSON.stringify( + { ...filterOptions, ...groupedOptions.filter }, + null, + 2 + ) + ) if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) + setConfigOptionsJsonString( + JSON.stringify( + { ...configOptions, ...groupedOptions.config }, + null, + 2 + ) + ) } else { if (groupedOptions.serve && type) - setServeOptionsJsonString(JSON.stringify(groupedOptions.serve[type], null, 2)) - if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2)) - if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) - if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) + setServeOptionsJsonString( + JSON.stringify(groupedOptions.serve[type], null, 2) + ) + if (groupedOptions.vfs) + setVfsOptionsJsonString( + JSON.stringify(groupedOptions.vfs, null, 2) + ) + if (groupedOptions.filter) + setFilterOptionsJsonString( + JSON.stringify(groupedOptions.filter, null, 2) + ) + if (groupedOptions.config) + setConfigOptionsJsonString( + JSON.stringify(groupedOptions.config, null, 2) + ) } }) }} diff --git a/src/pages/Settings/AboutSection.tsx b/src/pages/Settings/AboutSection.tsx index 59ebc0e..723e72a 100644 --- a/src/pages/Settings/AboutSection.tsx +++ b/src/pages/Settings/AboutSection.tsx @@ -18,11 +18,13 @@ import { useMemo } from 'react' import rclone from '../../../lib/rclone/client' import { getDefaultPaths } from '../../../lib/rclone/common' import { DOUBLE_BACKSLASH_REGEX } from '../../../lib/rclone/constants' -import { useHostStore } from '../../../store/host' +import { selectActiveConfigFile, useHostStore } from '../../../store/host' +import { usePersistedStore } from '../../../store/persisted' import BaseSection from './BaseSection' export default function AboutSection() { - const currentConfig = useHostStore((state) => state.activeConfigFile) + const currentConfig = useHostStore(selectActiveConfigFile) + const rclonePath = usePersistedStore((state) => state.rclonePath) const defaultPathsQuery = useQuery({ queryKey: ['about', 'defaultPaths'], @@ -74,6 +76,7 @@ export default function AboutSection() { }, paths: defaultPathsQuery.data, dirs: dirsQuery.data, + rcloneBinary: rclonePath, config: { id: currentConfig?.id, label: currentConfig?.label, @@ -88,6 +91,7 @@ export default function AboutSection() { currentConfig, defaultPathsQuery.data, dirsQuery.data, + rclonePath, ] ) diff --git a/src/pages/Settings/BinarySection.tsx b/src/pages/Settings/BinarySection.tsx new file mode 100644 index 0000000..fa24b1f --- /dev/null +++ b/src/pages/Settings/BinarySection.tsx @@ -0,0 +1,532 @@ +import { Button, Checkbox, Chip, Input, Progress, Spinner, Tooltip } from '@heroui/react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { message, open } from '@tauri-apps/plugin-dialog' +import { + CheckIcon, + DownloadIcon, + FolderOpenIcon, + HardDriveIcon, + RefreshCwIcon, + Trash2Icon, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { formatErrorMessage, reportError } from '../../../lib/errors' +import { formatBytes } from '../../../lib/format' +import { + classifyRclonePath, + compareVersions, + findSystemRclone, + probeRcloneBinaryOrThrow, + validateRcloneBinary, +} from '../../../lib/rclone/common' +import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants' +import { + type DownloadProgress, + activateRclonePath, + deleteVersion, + downloadVersion, + fetchAvailableVersions, + getPathIntegration, + listDownloadedVersions, + setPathIntegration, +} from '../../../lib/rclone/versions' +import { usePersistedStore } from '../../../store/persisted' +import BaseSection from './BaseSection' + +/** Warning for binaries below the version floor the app's Serve feature needs. */ +function subFloorWarning(version: string | null | undefined): string | null { + if (!version) return null + return compareVersions(version, MIN_RCLONE_VERSION) < 0 + ? `Serve requires rclone ≥ ${MIN_RCLONE_VERSION.split('.').slice(0, 2).join('.')}` + : null +} + +export default function BinarySection() { + const queryClient = useQueryClient() + const rclonePath = usePersistedStore((state) => state.rclonePath) + const [progress, setProgress] = useState>({}) + + const downloadedQuery = useQuery({ + queryKey: ['rclone', 'downloaded'], + queryFn: listDownloadedVersions, + }) + const releasesQuery = useQuery({ + queryKey: ['rclone', 'releases'], + queryFn: fetchAvailableVersions, + staleTime: 60 * 60 * 1000, + retry: 1, + }) + const systemQuery = useQuery({ + queryKey: ['rclone', 'system'], + queryFn: findSystemRclone, + }) + const systemVersionQuery = useQuery({ + queryKey: ['rclone', 'system-version', systemQuery.data], + queryFn: () => validateRcloneBinary(systemQuery.data!), + enabled: !!systemQuery.data, + }) + const classificationQuery = useQuery({ + queryKey: ['rclone', 'classify', rclonePath], + queryFn: () => (rclonePath ? classifyRclonePath(rclonePath) : null), + enabled: !!rclonePath, + }) + + const active = classificationQuery.data + + const invalidateActive = () => { + queryClient.invalidateQueries({ queryKey: ['rclone'] }) + } + + const downloadMutation = useMutation({ + mutationFn: async (version: string) => { + return await downloadVersion(version, (p) => + setProgress((prev) => ({ ...prev, [version]: p })) + ) + }, + onSettled: (_data, _err, version) => { + setProgress((prev) => { + const next = { ...prev } + delete next[version] + return next + }) + queryClient.invalidateQueries({ queryKey: ['rclone', 'downloaded'] }) + }, + onError: async (e) => { + await message(`Download failed: ${formatErrorMessage(e, String(e))}`, { + title: 'Error', + kind: 'error', + }) + }, + }) + + const activateMutation = useMutation({ + mutationFn: async (opts: { path: string; isSystem?: boolean }) => { + return await activateRclonePath(opts.path, { offerSystemConfig: opts.isSystem }) + }, + onSuccess: () => invalidateActive(), + }) + + const deleteMutation = useMutation({ + mutationFn: deleteVersion, + onSettled: () => queryClient.invalidateQueries({ queryKey: ['rclone', 'downloaded'] }), + onError: async (e) => { + await message(`Could not delete: ${formatErrorMessage(e, String(e))}`, { + title: 'Error', + kind: 'error', + }) + }, + }) + + const downloadedVersions = downloadedQuery.data ?? [] + const downloadedSet = useMemo( + () => new Set(downloadedVersions.map((v) => v.version)), + [downloadedVersions] + ) + const availableToDownload = (releasesQuery.data ?? []).filter( + (r) => !downloadedSet.has(r.version) + ) + + const latestVersion = releasesQuery.data?.[0]?.version + const updateAvailable = + active?.kind === 'managed' && + active.version && + latestVersion && + !downloadedSet.has(latestVersion) && + active.version !== latestVersion + + return ( + +
+ {/* ---- Custom binary ---- */} + + + {/* ---- PATH integration ---- */} + + + {/* ---- Auto update ---- */} + + + {/* ---- Versions ---- */} +
+ {/* System */} + {systemQuery.data && ( + + activateMutation.mutate({ + path: systemQuery.data!, + isSystem: true, + }) + } + /> + )} + + {/* Downloaded (managed) */} + {downloadedVersions.map((v) => { + const isActive = active?.kind === 'managed' && active.version === v.version + return ( + activateMutation.mutate({ path: v.path })} + onDelete={ + isActive ? undefined : () => deleteMutation.mutate(v.version) + } + isDeleting={ + deleteMutation.isPending && + deleteMutation.variables === v.version + } + /> + ) + })} + + {/* Available to download */} + {availableToDownload.map((r) => { + const prog = progress[r.version] + const percent = prog?.total + ? Math.min(100, Math.round((prog.downloaded / prog.total) * 100)) + : undefined + const isDownloading = + downloadMutation.isPending && downloadMutation.variables === r.version + return ( +
+
+ v{r.version} + {isDownloading && ( + + )} +
+ +
+ ) + })} + + {(downloadedVersions.length > 0 || systemQuery.data) && + availableToDownload.length === 0 && + releasesQuery.isError && ( +
+ + Couldn't load available versions (offline or rate-limited). + + +
+ )} + + {releasesQuery.isLoading && downloadedVersions.length === 0 && ( +
+ +
+ )} +
+ + {updateAvailable && ( +
+ + Update available: v{latestVersion} + + +
+ )} +
+
+ ) +} + +function VersionRow({ + label, + sublabel, + warning, + isActive, + actionLabel, + onActivate, + isActivating, + onDelete, + isDeleting, +}: { + label: string + sublabel: string + warning?: string | null + isActive: boolean + actionLabel: string + onActivate: () => void + isActivating?: boolean + onDelete?: () => void + isDeleting?: boolean +}) { + return ( +
+ +
+ {label} + {sublabel} + {warning && {warning}} +
+ {isActive ? ( + } + > + Active + + ) : ( + + )} + {onDelete ? ( + + ) : ( + + + + + + )} +
+ ) +} + +function CustomBinaryRow({ + active, + systemPath, + rclonePath, + onActivated, +}: { + active: { kind: string; version: string | null } | null | undefined + systemPath: string | null + rclonePath: string | undefined + onActivated: () => void +}) { + const isCustomActive = active?.kind === 'custom' + const [value, setValue] = useState('') + + // Seed with the current custom path, else the detected system rclone. + useEffect(() => { + setValue(isCustomActive && rclonePath ? rclonePath : (systemPath ?? '')) + }, [isCustomActive, rclonePath, systemPath]) + + const customVersionQuery = useQuery({ + queryKey: ['rclone', 'custom-version', rclonePath], + queryFn: () => validateRcloneBinary(rclonePath!), + enabled: isCustomActive && !!rclonePath, + }) + const customWarning = isCustomActive ? subFloorWarning(customVersionQuery.data) : null + + const useMutationState = useMutation({ + mutationFn: async (path: string) => { + const version = await probeRcloneBinaryOrThrow(path) + const ok = await activateRclonePath(path) + return { version, ok } + }, + onSuccess: () => onActivated(), + onError: async (e) => { + await reportError(e, { title: 'Invalid binary', fallback: String(e), capture: false }) + }, + }) + + const browse = async () => { + const selected = await open({ + multiple: false, + directory: false, + title: 'Select rclone binary', + }) + if (typeof selected === 'string') { + setValue(selected) + } + } + + return ( +
+
+ + + + } + /> + +
+ {isCustomActive && ( + + Currently using a custom binary + {customVersionQuery.data ? ` (v${customVersionQuery.data})` : ''}. + + )} + {customWarning && {customWarning}} +
+ ) +} + +function AutoUpdateRow() { + const autoUpdate = usePersistedStore((state) => state.autoUpdateRclone) + + return ( +
+ + usePersistedStore.getState().setAutoUpdateRclone(checked) + } + > + Automatically update rclone + + + Applies to versions installed by the app. When off, you'll be notified when a new + version is available. + +
+ ) +} + +function PathIntegrationRow({ + rclonePath, + isSystemActive, +}: { + rclonePath: string | undefined + isSystemActive: boolean +}) { + const queryClient = useQueryClient() + const statusQuery = useQuery({ + queryKey: ['rclone', 'path-integration'], + queryFn: getPathIntegration, + }) + + const toggleMutation = useMutation({ + mutationFn: async (enable: boolean) => { + if (!rclonePath) throw new Error('No active rclone to link.') + return await setPathIntegration(enable, rclonePath) + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['rclone', 'path-integration'] }), + onError: async (e) => { + await reportError(e, { title: 'PATH integration', fallback: String(e), capture: false }) + queryClient.invalidateQueries({ queryKey: ['rclone', 'path-integration'] }) + }, + }) + + const status = statusQuery.data + + return ( +
+ toggleMutation.mutate(checked)} + > + Add rclone to PATH + + {isSystemActive && ( + + The system rclone is already on your PATH. + + )} + {status?.warning && !isSystemActive && ( + {status.warning} + )} +
+ ) +} diff --git a/src/pages/Settings/ConfigSection.tsx b/src/pages/Settings/ConfigSection.tsx index 3c379a7..b0d4edb 100644 --- a/src/pages/Settings/ConfigSection.tsx +++ b/src/pages/Settings/ConfigSection.tsx @@ -28,11 +28,12 @@ import { Trash2Icon, } from 'lucide-react' import { useMemo, useState } from 'react' +import { onErrorDialog } from '../../../lib/errors' import { removeConfigPassword, setConfigPassword } from '../../../lib/rclone/api' import { promptForConfigPassword, restartActiveRclone } from '../../../lib/rclone/cli' import rclone from '../../../lib/rclone/client' import { getConfigPath } from '../../../lib/rclone/common' -import { useHostStore } from '../../../store/host' +import { selectActiveConfigFile, useHostStore } from '../../../store/host' import { usePersistedStore } from '../../../store/persisted' import type { ConfigFile } from '../../../types/config' import ConfigCreateDrawer from '../../components/ConfigCreateDrawer' @@ -44,7 +45,7 @@ export default function ConfigSection() { const licenseValid = usePersistedStore((state) => state.licenseValid) const configFiles = useHostStore((state) => state.configFiles) - const activeConfigFile = useHostStore((state) => state.activeConfigFile) + const activeConfigFile = useHostStore(selectActiveConfigFile) const queryClient = useQueryClient() @@ -94,14 +95,11 @@ export default function ConfigSection() { await queryClient.cancelQueries() await queryClient.resetQueries() }, - onError: async (error) => { - console.error('[switchConfig] failed to switch config', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Switch Config', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Switch Config', undefined, { + okLabel: 'OK', + capture: false, + log: ['[switchConfig] failed to switch config'], + }), }) const locateConfigMutation = useMutation({ @@ -109,14 +107,11 @@ export default function ConfigSection() { const configPath = await getConfigPath({ id: id, validate: true }) await revealItemInDir(configPath) }, - onError: async (error) => { - console.error('[locateConfig] failed to locate config', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Failed to locate config', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Failed to locate config', undefined, { + okLabel: 'OK', + capture: false, + log: ['[locateConfig] failed to locate config'], + }), }) const exportConfigMutation = useMutation({ @@ -142,14 +137,11 @@ export default function ConfigSection() { await writeTextFile(exportPath, text) }, - onError: async (error) => { - console.error('[exportConfig] failed to export config', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Failed to export config', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Failed to export config', undefined, { + okLabel: 'OK', + capture: false, + log: ['[exportConfig] failed to export config'], + }), }) const removePasswordMutation = useMutation({ @@ -178,14 +170,11 @@ export default function ConfigSection() { await removeConfigPassword() }, - onError: async (error) => { - console.error('[removePassword] failed to remove password', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Config Encryption', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Config Encryption', undefined, { + okLabel: 'OK', + capture: false, + log: ['[removePassword] failed to remove password'], + }), }) const setPasswordMutation = useMutation({ @@ -207,14 +196,11 @@ export default function ConfigSection() { persist: Boolean(activeConfigFile.pass), }) }, - onError: async (error) => { - console.error('[setPassword] failed to set password', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Config Encryption', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Config Encryption', undefined, { + okLabel: 'OK', + capture: false, + log: ['[setPassword] failed to set password'], + }), }) const savePasswordMutation = useMutation({ @@ -253,14 +239,11 @@ export default function ConfigSection() { okLabel: 'OK', }) }, - onError: async (error) => { - console.error('[savePasswordCommand] failed to save password command', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Config Password', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Config Password', undefined, { + okLabel: 'OK', + capture: false, + log: ['[savePasswordCommand] failed to save password command'], + }), }) const savePasswordCommandMutation = useMutation({ @@ -307,14 +290,11 @@ export default function ConfigSection() { okLabel: 'OK', }) }, - onError: async (error) => { - console.error('[savePasswordCommand] failed to save password command', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Config Password', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Config Password', undefined, { + okLabel: 'OK', + capture: false, + log: ['[savePasswordCommand] failed to save password command'], + }), }) const removeSavedPasswordMutation = useMutation({ @@ -351,14 +331,11 @@ export default function ConfigSection() { okLabel: 'OK', }) }, - onError: async (error) => { - console.error('[removeSavedPassword] failed to remove saved password', error) - await message(error instanceof Error ? error.message : 'An unknown error occurred', { - title: 'Config Password', - kind: 'error', - okLabel: 'OK', - }) - }, + onError: onErrorDialog('Config Password', undefined, { + okLabel: 'OK', + capture: false, + log: ['[removeSavedPassword] failed to remove saved password'], + }), }) return ( @@ -515,9 +492,7 @@ function ConfigCard({ const disabled = ['enable'] - if (configFile.passCommand) { - disabled.push('save-password', 'save-password-command') - } else if (configFile.pass) { + if (configFile.passCommand || configFile.pass) { disabled.push('save-password', 'save-password-command') } else { disabled.push('remove-password') @@ -718,22 +693,50 @@ function ConfigCard({ return } - if (!configFile.sync) { - const path = await getConfigPath({ - id: configFile.id!, - validate: true, - }) + try { + if (!configFile.sync) { + // validate: false so a manually-deleted + // directory doesn't throw before we can still + // clean up the store entry. + const path = await getConfigPath({ + id: configFile.id!, + validate: false, + }) - await remove(path.replace('rclone.conf', ''), { - recursive: true, - }) + try { + await remove( + path.replace('rclone.conf', ''), + { recursive: true } + ) + } catch (removeError) { + // An already-gone directory is fine — fall + // through and still remove the store entry. + const detail = + removeError instanceof Error + ? removeError.message + : String(removeError) + if ( + !/no such file|not found|cannot find|does not exist/i.test( + detail + ) + ) { + throw removeError + } + } + } + + if (activeConfigFile?.id === configFile.id) { + useHostStore + .getState() + .setActiveConfigFile('default') + } + + useHostStore + .getState() + .removeConfigFile(configFile.id!) + } catch (error) { + await onErrorDialog('Delete Config')(error) } - - if (activeConfigFile?.id === configFile.id) { - useHostStore.getState().setActiveConfigFile('default') - } - - useHostStore.getState().removeConfigFile(configFile.id!) }, 100) }} > diff --git a/src/pages/Settings/GeneralSection.tsx b/src/pages/Settings/GeneralSection.tsx index 1d47fc2..4db729f 100644 --- a/src/pages/Settings/GeneralSection.tsx +++ b/src/pages/Settings/GeneralSection.tsx @@ -2,7 +2,6 @@ import { Button, Checkbox, Chip, Input, Select, SelectItem } from '@heroui/react import * as Sentry from '@sentry/browser' import { useMutation, useQuery } from '@tanstack/react-query' import { invoke } from '@tauri-apps/api/core' -import { getCurrentWindow } from '@tauri-apps/api/window' import { disable, enable } from '@tauri-apps/plugin-autostart' import { ask, message } from '@tauri-apps/plugin-dialog' import { openUrl } from '@tauri-apps/plugin-opener' @@ -10,6 +9,7 @@ import { platform } from '@tauri-apps/plugin-os' import { type Update, check } from '@tauri-apps/plugin-updater' import { EyeIcon } from 'lucide-react' import { startTransition, useEffect, useMemo, useState } from 'react' +import { RELAUNCH_APP, emitToMain } from '../../../lib/events' import notify from '../../../lib/notify' import { usePersistedStore } from '../../../store/persisted' import BaseSection from './BaseSection' @@ -130,7 +130,7 @@ export default function GeneralSection() { return } - await getCurrentWindow().emit('relaunch-app') + await emitToMain(RELAUNCH_APP) }, }) @@ -234,7 +234,11 @@ export default function GeneralSection() { label="Tray Theme" selectedKeys={[appearance.tray]} onSelectionChange={(keys) => { - const value = Array.from(keys)[0] as 'light' | 'dark' | 'system' | 'color' + const value = Array.from(keys)[0] as + | 'light' + | 'dark' + | 'system' + | 'color' usePersistedStore.setState((state) => ({ appearance: { ...state.appearance, tray: value }, })) diff --git a/src/pages/Settings/HostsSection.tsx b/src/pages/Settings/HostsSection.tsx index 0000fdb..86da6a1 100644 --- a/src/pages/Settings/HostsSection.tsx +++ b/src/pages/Settings/HostsSection.tsx @@ -4,13 +4,13 @@ import { ask, message } from '@tauri-apps/plugin-dialog' import { PlusIcon, RefreshCcwIcon, Trash2Icon } from 'lucide-react' import { useMemo, useState } from 'react' import { type Host, LABEL_FOR_OS, LOCAL_HOST_ID, getHostInfo } from '../../../lib/hosts' -import { usePersistedStore } from '../../../store/persisted' +import { useCurrentHost, usePersistedStore } from '../../../store/persisted' import HostAddDrawer from '../../components/HostAddDrawer' import BaseSection from './BaseSection' export default function HostsSection() { const hosts = usePersistedStore((state) => state.hosts) - const currentHost = usePersistedStore((state) => state.currentHost) + const currentHost = useCurrentHost() const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false) @@ -89,9 +89,7 @@ function HostCard({ return } - usePersistedStore.setState({ - currentHost: host, - }) + usePersistedStore.getState().setCurrentHost(host.id) }, onError: () => { message('Failed to change host. Please try again.', { @@ -119,11 +117,6 @@ function HostCard({ usePersistedStore.setState((state) => ({ hosts: state.hosts.map((h) => (h.id === host.id ? { ...h, ...hostInfo } : h)), })) - if (isActive) { - usePersistedStore.setState((state) => ({ - currentHost: { ...state.currentHost!, ...hostInfo }, - })) - } }, onError: () => { message('Failed to update host. Please try again.', { diff --git a/src/pages/Settings/RemotesSection.tsx b/src/pages/Settings/RemotesSection.tsx index 9c0e9f4..db6e854 100644 --- a/src/pages/Settings/RemotesSection.tsx +++ b/src/pages/Settings/RemotesSection.tsx @@ -6,8 +6,8 @@ import { DropdownItem, DropdownMenu, DropdownTrigger, - Spinner, Input, + Spinner, } from '@heroui/react' import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' import { ask, message } from '@tauri-apps/plugin-dialog' @@ -23,7 +23,9 @@ import { } from 'lucide-react' import { type ReactNode, startTransition, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import { onErrorDialog } from '../../../lib/errors' import { formatBytes } from '../../../lib/format' +import { remoteConfigQueryOptions } from '../../../lib/hooks' import rclone from '../../../lib/rclone/client' import { SUPPORTS_ABOUT } from '../../../lib/rclone/constants' import { usePersistedStore } from '../../../store/persisted' @@ -53,42 +55,38 @@ export default function RemotesSection() { const remoteConfigQueries = useQueries({ queries: remotes.map((remote) => ({ - queryKey: ['remotes', remote, 'config', 'sortable'], - queryFn: async () => { - const config = await rclone('/config/get', { - params: { query: { name: remote } }, - }) - return { remote, type: config?.type ?? null } - }, + ...remoteConfigQueryOptions(remote), staleTime: 1000 * 60, })), }) - const sortedRemotes = useMemo( - () => - [...remotes].sort((a, b) => { - const configA = remoteConfigQueries.find((q) => q.data?.remote === a)?.data - const configB = remoteConfigQueries.find((q) => q.data?.remote === b)?.data + const sortedRemotes = useMemo(() => { + // useQueries preserves input order, so remoteConfigQueries[i] corresponds to remotes[i]. + const typeByRemote = new Map() + remotes.forEach((remote, i) => { + typeByRemote.set(remote, remoteConfigQueries[i]?.data?.type ?? null) + }) - const aSupportsAbout = configA?.type ? SUPPORTS_ABOUT.includes(configA.type) : false - const bSupportsAbout = configB?.type ? SUPPORTS_ABOUT.includes(configB.type) : false + return [...remotes].sort((a, b) => { + const aType = typeByRemote.get(a) + const bType = typeByRemote.get(b) - if (aSupportsAbout && !bSupportsAbout) return -1 - if (!aSupportsAbout && bSupportsAbout) return 1 + const aSupportsAbout = aType ? SUPPORTS_ABOUT.includes(aType) : false + const bSupportsAbout = bType ? SUPPORTS_ABOUT.includes(bType) : false - return a.localeCompare(b) - }), - [remotes, remoteConfigQueries] - ) + if (aSupportsAbout && !bSupportsAbout) return -1 + if (!aSupportsAbout && bSupportsAbout) return 1 + + return a.localeCompare(b) + }) + }, [remotes, remoteConfigQueries]) const [searchQuery, setSearchQuery] = useState('') const filteredRemotes = useMemo( () => searchQuery - ? sortedRemotes.filter((r) => - r.toLowerCase().includes(searchQuery.toLowerCase()) - ) + ? sortedRemotes.filter((r) => r.toLowerCase().includes(searchQuery.toLowerCase())) : sortedRemotes, [sortedRemotes, searchQuery] ) @@ -116,13 +114,10 @@ export default function RemotesSection() { ...(old ?? []).filter((r) => r !== remote), ]) }, - onError: async (error) => { - console.error('Failed to delete remote:', error) - await message(error instanceof Error ? error.message : 'Unknown error occurred', { - title: 'Could not delete remote', - kind: 'error', - }) - }, + onError: onErrorDialog('Could not delete remote', 'Unknown error occurred', { + capture: false, + log: ['Failed to delete remote:'], + }), }) const Placeholder = useMemo(() => { @@ -334,18 +329,7 @@ function RemoteCard({ onConfigPress: () => void onDeletePress: () => void }) { - const { data: remoteConfigData } = useQuery({ - queryKey: ['remotes', remote, 'config'], - queryFn: async () => { - return await rclone('/config/get', { - params: { - query: { - name: remote, - }, - }, - }) - }, - }) + const { data: remoteConfigData } = useQuery(remoteConfigQueryOptions(remote)) const type = useMemo(() => remoteConfigData?.type ?? null, [remoteConfigData?.type]) const provider = useMemo(() => remoteConfigData?.provider ?? null, [remoteConfigData?.provider]) diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 38d7b73..04a96ce 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -12,6 +12,7 @@ import { InfoIcon, KeyboardIcon, MedalIcon, + PackageIcon, SatelliteDishIcon, ServerIcon, TabletSmartphoneIcon, @@ -21,8 +22,9 @@ import { useSearchParams } from 'react-router-dom' import { LOCAL_HOST_ID } from '../../../lib/hosts' import rclone from '../../../lib/rclone/client' import { useStore } from '../../../store/memory' -import { usePersistedStore } from '../../../store/persisted' +import { useCurrentHost, usePersistedStore } from '../../../store/persisted' import AboutSection from './AboutSection' +import BinarySection from './BinarySection' import ConfigSection from './ConfigSection' import GeneralSection from './GeneralSection' import HostsSection from './HostsSection' @@ -35,7 +37,7 @@ import ToolbarSection from './ToolbarSection' export default function Settings() { const [searchParams] = useSearchParams() const settingsPass = usePersistedStore((state) => state.settingsPass) - const currentHost = usePersistedStore((state) => state.currentHost) + const currentHost = useCurrentHost() const isRestartingRclone = useStore((state) => state.isRestartingRclone) const isLocalHost = useMemo(() => currentHost?.id === LOCAL_HOST_ID, [currentHost?.id]) @@ -124,11 +126,7 @@ export default function Settings() { } /> -
@@ -234,6 +232,34 @@ export default function Settings() { > + +
+ + Binary +
+ + } + data-focus-visible="false" + isDisabled={currentHost?.id !== 'local'} + className="w-full max-h-screen p-0 overflow-scroll overscroll-none" + > + +
( - searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined - ) - const [dest, setDest] = useState( - searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined - ) +const SOURCE_OPTIONS = { + label: 'Source', + showPicker: true, + placeholder: 'Enter a remote:/path or local path, or tap to select a folder', + clearable: true, + showFiles: true, + allowedKeys: PATH_ALLOWED_KEYS, +} - const [jsonError, setJsonError] = useState<'sync' | 'filter' | 'config' | 'remote' | null>(null) +const DEST_OPTIONS = { + label: 'Destination', + showPicker: true, + placeholder: 'Enter a remote:/path or local path', + clearable: true, + showFiles: false, + allowedKeys: PATH_ALLOWED_KEYS, +} - const [syncOptionsLocked, setSyncOptionsLocked] = useState(false) - const [syncOptions, setSyncOptions] = useState>({}) - const [syncOptionsJsonString, setSyncOptionsJsonString] = useState('{}') - - const [filterOptionsLocked, setFilterOptionsLocked] = useState(false) - const [filterOptions, setFilterOptions] = useState>({}) - const [filterOptionsJsonString, setFilterOptionsJsonString] = useState('{}') - - const [configOptionsLocked, setConfigOptionsLocked] = useState(false) - const [configOptions, setConfigOptions] = useState>({}) - const [configOptionsJsonString, setConfigOptionsJsonString] = useState('{}') - - const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false) - const [remoteOptions, setRemoteOptions] = useState>>( - {} - ) - const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState('{}') - - const [cronExpression, setCronExpression] = useState(null) - - const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest]) - - const startSyncMutation = useMutation({ - mutationFn: async () => { - if (!source || !dest) { - throw new Error('Please select both a source and destination path') - } - - return startSync({ - source: source, - destination: dest, - options: { - config: configOptions, - sync: syncOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }) - }, - onSuccess: () => { - if (cronExpression) { - scheduleTaskMutation.mutate() - } - }, - onError: async (error) => { - console.error('Error starting sync:', error) - Sentry.captureException(error) - await message(error instanceof Error ? error.message : 'Failed to start sync', { - title: 'Sync', - kind: 'error', - }) - }, - }) - - const scheduleTaskMutation = useMutation({ - mutationFn: async () => { - if (!source || !dest) { - throw new Error('Please select both a source and destination path') - } - - if (!cronExpression) { - throw new Error('Please enter a cron expression') - } - - try { - cronstrue.toString(cronExpression) - } catch { - throw new Error('Invalid cron expression') - } - - const name = await invoke('prompt', { - title: 'Schedule Name', - message: 'Enter a name for this schedule', - default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`, - }) - - if (!name) { - throw new Error('Schedule name is required') - } - - useHostStore.getState().addScheduledTask({ - name, - operation: 'sync', - cron: cronExpression, - args: { - source, - destination: dest, - options: { - config: configOptions, - sync: syncOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }, - }) - }, - onSuccess: async () => { - await notify({ - title: 'Success', - body: 'New schedule has been created', - }) - }, - onError: async (error) => { - console.error('Error scheduling task:', error) - await message(error instanceof Error ? error.message : 'Failed to schedule task', { - title: 'Schedule', - kind: 'error', - }) - }, - }) - - const dryRunMutation = useMutation({ - mutationFn: async () => { - if (!source || !dest) { - throw new Error('Please select both a source and destination path') - } - return startDryRun(() => - startSync({ - source, - destination: dest, - options: { - config: { ...configOptions, dry_run: true }, - sync: syncOptions, - filter: filterOptions, - remotes: remoteOptions, - }, - }) - ) - }, - onSuccess: async () => { - const result = await ask( - 'Dry run started, you can check the results in the Transfers screen', - { - title: 'Preview (Dry Run)', - kind: 'info', - okLabel: 'Open Transfers', - cancelLabel: 'OK', - } - ) - if (result) { - await openWindow({ name: 'Transfers', url: '/transfers' }) - } - }, - onError: async (error) => { - console.error('Error starting dry run:', error) - await message(error instanceof Error ? error.message : 'Failed to start dry run', { - title: 'Dry Run', - kind: 'error', - }) - }, - }) - - const buttonText = useMemo(() => { - if (startSyncMutation.isPending) return 'STARTING...' - if (!source) return 'Please select a source path' - if (!dest) return 'Please select a destination path' - if (source === dest) return 'Source and destination cannot be the same' - if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' - if (cronExpression) return 'START AND SCHEDULE SYNC' - return 'START SYNC' - }, [startSyncMutation.isPending, source, dest, jsonError, cronExpression]) - - const buttonIcon = useMemo(() => { - if (startSyncMutation.isPending) return - if (!source || !dest || source === dest) return - if (jsonError) return - return - }, [startSyncMutation.isPending, source, dest, jsonError]) - - useEffect(() => { - startTransition(() => { - setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2)) - setSyncOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2)) - }) - }, []) - - useEffect(() => { - let step: 'sync' | 'filter' | 'config' | 'remote' = 'sync' - try { - const parsedSync = JSON.parse(syncOptionsJsonString) as Record - - step = 'filter' - const parsedFilter = JSON.parse(filterOptionsJsonString) as Record - - step = 'config' - const parsedConfig = JSON.parse(configOptionsJsonString) as Record - - step = 'remote' - const outerRemote = JSON.parse(remoteOptionsJsonString) as Record - const parsedRemote: Record> = {} - for (const [key, val] of Object.entries(outerRemote)) { - parsedRemote[key] = JSON.parse(val) as Record - } - - startTransition(() => { - setSyncOptions(parsedSync) - setFilterOptions(parsedFilter) - setConfigOptions(parsedConfig) - setRemoteOptions(parsedRemote) - setJsonError(null) - }) - } catch (error) { - setJsonError(step) - console.error(`Error parsing ${step} options:`, error) - } - }, [ - syncOptionsJsonString, - filterOptionsJsonString, - configOptionsJsonString, - remoteOptionsJsonString, - ]) - - return ( -
- {/* Main Content */} - - {/* Paths Display */} - - -
- - } /> - } - indicator={} - title="Sync" - subtitle={getOptionsSubtitle(Object.keys(syncOptions).length)} - > - - - } /> - } - indicator={} - title="Filters" - subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)} - > - - - } /> - } - indicator={} - title="Cron" - > - - - } /> - } - indicator={} - title="Config" - subtitle={getOptionsSubtitle(Object.keys(configOptions).length)} - > - - - - {selectedRemotes.length > 0 ? ( - } - /> - } - indicator={} - title={'Remotes'} - subtitle={getOptionsSubtitle( - Object.values(remoteOptions).reduce( - (acc, opts) => acc + Object.keys(opts).length, - 0 - ) - )} - > - - - ) : null} - - - -
-
- - - { - startTransition(() => { - if (shouldMerge) { - if (groupedOptions.sync) - setSyncOptionsJsonString(JSON.stringify({ ...syncOptions, ...groupedOptions.sync }, null, 2)) - if (groupedOptions.filter) - setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) - if (groupedOptions.config) - setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) - } else { - if (groupedOptions.sync) setSyncOptionsJsonString(JSON.stringify(groupedOptions.sync, null, 2)) - if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) - if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) - } - }) - }} - getOptions={() => ({ - ...syncOptions, - ...filterOptions, - ...configOptions, - })} - /> - - {startSyncMutation.isSuccess ? ( - - - - - - - { - startTransition(() => { - setSource(undefined) - setDest(undefined) - setJsonError(null) - startSyncMutation.reset() - }) - }} - > - Reset Paths - - { - startTransition(() => { - setSyncOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setCronExpression(null) - setJsonError(null) - startSyncMutation.reset() - }) - }} - > - Reset Options - - { - startTransition(() => { - setSyncOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.copy, - null, - 2 - ) - ) - setFilterOptionsJsonString('{}') - setConfigOptionsJsonString( - JSON.stringify( - RCLONE_CONFIG_DEFAULTS.config, - null, - 2 - ) - ) - setRemoteOptionsJsonString('{}') - setSyncOptionsLocked(false) - setFilterOptionsLocked(false) - setConfigOptionsLocked(false) - setRemoteOptionsLocked(false) - setCronExpression(null) - setJsonError(null) - setDest(undefined) - setSource(undefined) - startSyncMutation.reset() - }) - }} - > - Reset All - - - - - - - ) : ( - - - - )} - - - - - - - - - ( + searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined + ) + const [dest, setDest] = useState( + searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined + ) + + const { + jsonError, + setJsonError, + groups: optionGroups, + remotes: remotesGroup, + applyTemplate, + getMergedOptions, + resetJson, + resetLocks, + } = useOptionGroups({ + groups: [ + { key: 'sync', defaults: RCLONE_CONFIG_DEFAULTS.copy }, + { key: 'filter' }, + { key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }, + ], + withRemotes: true, + }) + const syncGroup = optionGroups.sync + const filterGroup = optionGroups.filter + const configGroup = optionGroups.config + + const [cronExpression, setCronExpression] = useState(null) + + const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest]) + + const buildArgs = () => ({ + source: source!, + destination: dest!, + options: { + config: configGroup.options, + sync: syncGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + }, + }) + + const startSyncMutation = useMutation({ + mutationFn: async () => { + if (!source || !dest) { + throw new Error('Please select both a source and destination path') + } + + return startSync(buildArgs()) + }, + onSuccess: () => { + if (cronExpression) { + scheduleTaskMutation.mutate() + } + }, + onError: onErrorDialog('Sync', 'Failed to start sync', { log: ['Error starting sync:'] }), + }) + + const scheduleTaskMutation = useScheduleTask({ + operation: 'sync', + cronExpression, + validate: () => { + if (!source || !dest) { + throw new Error('Please select both a source and destination path') + } + }, + buildArgs, + }) + + const dryRunMutation = useOperationDryRun(async () => { + if (!source || !dest) { + throw new Error('Please select both a source and destination path') + } + return startDryRun(() => + startSync({ + source, + destination: dest, + options: { + config: { ...configGroup.options, dry_run: true }, + sync: syncGroup.options, + filter: filterGroup.options, + remotes: remotesGroup.options, + }, + }) + ) + }) + + const buttonText = useMemo(() => { + if (startSyncMutation.isPending) return 'STARTING...' + if (!source) return 'Please select a source path' + if (!dest) return 'Please select a destination path' + if (source === dest) return 'Source and destination cannot be the same' + if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' + if (cronExpression) return 'START AND SCHEDULE SYNC' + return 'START SYNC' + }, [startSyncMutation.isPending, source, dest, jsonError, cronExpression]) + + const buttonIcon = useMemo(() => { + if (startSyncMutation.isPending) return + if (!source || !dest || source === dest) return + if (jsonError) return + return + }, [startSyncMutation.isPending, source, dest, jsonError]) + + const accordionItems = useMemo( + () => [ + { + key: 'sync', + category: 'sync', + subtitle: getOptionsSubtitle(Object.keys(syncGroup.options).length), + children: ( + - - + ), + }, + { + key: 'filters', + category: 'filters', + subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length), + children: ( + + ), + }, + { + key: 'cron', + category: 'cron', + children: , + }, + { + key: 'config', + category: 'config', + subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length), + children: ( + + ), + }, + ...(selectedRemotes.length > 0 + ? [ + { + key: 'remotes', + category: 'remotes' as const, + subtitle: getOptionsSubtitle( + Object.values(remotesGroup.options).reduce( + (acc, opts) => acc + Object.keys(opts).length, + 0 + ) + ), + children: ( + + ), + }, + ] + : []), + ], + [ + syncGroup, + filterGroup, + configGroup, + globalFlags, + syncFlags, + filterFlags, + configFlags, + cronExpression, + selectedRemotes, + remotesGroup, + ] + ) + + const handleStart = useCallback(() => startSyncMutation.mutate(), [startSyncMutation.mutate]) + + const handleSchedule = useCallback( + () => scheduleTaskMutation.mutate(), + [scheduleTaskMutation.mutate] + ) + + const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate]) + + const handleResetPaths = useCallback(() => { + startTransition(() => { + setSource(undefined) + setDest(undefined) + setJsonError(null) + startSyncMutation.reset() + }) + }, [setJsonError, startSyncMutation.reset]) + + const handleResetOptions = useCallback(() => { + startTransition(() => { + resetJson() + setCronExpression(null) + startSyncMutation.reset() + }) + }, [resetJson, startSyncMutation.reset]) + + const handleResetAll = useCallback(() => { + startTransition(() => { + resetJson() + resetLocks() + setCronExpression(null) + setDest(undefined) + setSource(undefined) + startSyncMutation.reset() + }) + }, [resetJson, resetLocks, startSyncMutation.reset]) + + return ( +
+ {/* Main Content */} + + {/* Paths Display */} + + + + + + +
) diff --git a/store/host.ts b/store/host.ts index 58aa8da..89e49d7 100644 --- a/store/host.ts +++ b/store/host.ts @@ -1,8 +1,9 @@ import { LazyStore } from '@tauri-apps/plugin-store' import { create } from 'zustand' -import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware' +import { createJSONStorage, persist } from 'zustand/middleware' import type { ConfigFile } from '../types/config' import type { ScheduledTask } from '../types/schedules' +import { createTauriStateStorage, waitForStoreHydration } from './lib' let activeHostId: string | null = null let activeStore: LazyStore | null = null @@ -10,15 +11,8 @@ let disposeKeyChange: (() => void) | null = null export async function initHostStore(hostId: string) { if (activeHostId === hostId && activeStore) { - async function waitForHostStoreHydration() { - await new Promise((resolve) => setTimeout(resolve, 50)) - if (!useHostStore.persist.hasHydrated()) { - await waitForHostStoreHydration() - } - console.log('[waitForHostStoreHydration] host store hydrated') - } - - await waitForHostStoreHydration() + await waitForStoreHydration(() => useHostStore.persist.hasHydrated()) + console.log('[waitForHostStoreHydration] host store hydrated') return } @@ -45,25 +39,6 @@ export async function initHostStore(hostId: string) { await useHostStore.persist.rehydrate() } -const getStorage = (): StateStorage => ({ - getItem: async (name: string): Promise => { - if (!activeStore) return null - // console.log('[HostStore] getItem', { name, host: activeHostId }) - return (await activeStore.get(name)) ?? null - }, - setItem: async (name: string, value: string): Promise => { - if (!activeStore) return - console.log('[HostStore] setItem', { name, value }) - await activeStore.set(name, value) - await activeStore.save() - }, - removeItem: async (name: string): Promise => { - if (!activeStore) return - await activeStore.delete(name) - await activeStore.save() - }, -}) - export interface RemoteConfig { mountOnStart?: { enabled: boolean @@ -103,11 +78,16 @@ interface HostState { configFiles: ConfigFile[] addConfigFile: (configFile: ConfigFile) => void removeConfigFile: (id: string) => void - activeConfigFile: ConfigFile | null - setActiveConfigFile: (configFile: string) => void + activeConfigId: string | null + setActiveConfigFile: (id: string) => void updateConfigFile: (id: string, configFile: Partial) => void lastSkippedVersion: string | undefined + + // Resolved-once location of the "default" rclone config for this host. Pinned so switching + // the rclone binary never relocates where the user's remotes are read from. + defaultConfigPath: string | undefined + setDefaultConfigPath: (path: string | undefined) => void } export const useHostStore = create()( @@ -138,7 +118,7 @@ export const useHostStore = create()( > ) => { const state = get() - const configId = state.activeConfigFile?.id + const configId = state.activeConfigId if (!configId) { console.error('No active config file for scheduled task') @@ -178,29 +158,52 @@ export const useHostStore = create()( set((state) => ({ configFiles: state.configFiles.filter((f) => f.id !== id), })), - activeConfigFile: null, + activeConfigId: null, setActiveConfigFile: (id: string) => set((state) => ({ - activeConfigFile: state.configFiles.find((f) => f.id === id) || null, + activeConfigId: state.configFiles.some((f) => f.id === id) ? id : null, })), updateConfigFile: (id: string, configFile: Partial) => set((state) => ({ configFiles: state.configFiles.map((f) => f.id === id ? { ...f, ...configFile } : f ), - activeConfigFile: - state.activeConfigFile?.id === id - ? { ...state.activeConfigFile, ...configFile } - : state.activeConfigFile, })), lastSkippedVersion: undefined, + + defaultConfigPath: undefined, + setDefaultConfigPath: (path: string | undefined) => + set((_) => ({ defaultConfigPath: path })), }), { name: 'host-store', - storage: createJSONStorage(getStorage), + storage: createJSONStorage(() => createTauriStateStorage(() => activeStore)), skipHydration: true, - version: 1, + version: 2, + migrate: (persistedState, version) => { + // v1 stored the full active ConfigFile object; v2 stores just its id. Also handles + // the version-1 blob written by the persisted-store's legacy migration, whose + // configFiles can be undefined. + if (version < 2 && persistedState) { + const { activeConfigFile, configFiles, ...rest } = persistedState as { + activeConfigFile?: ConfigFile | null + configFiles?: ConfigFile[] + [key: string]: unknown + } + return { + ...rest, + configFiles: configFiles ?? [], + activeConfigId: activeConfigFile?.id ?? null, + } + } + return persistedState + }, } ) ) + +/** Resolves the active ConfigFile object from the stored id, or null if it no longer exists. */ +export function selectActiveConfigFile(state: HostState): ConfigFile | null { + return state.configFiles.find((f) => f.id === state.activeConfigId) ?? null +} diff --git a/store/lib.ts b/store/lib.ts new file mode 100644 index 0000000..d105dc9 --- /dev/null +++ b/store/lib.ts @@ -0,0 +1,40 @@ +import type { LazyStore } from '@tauri-apps/plugin-store' +import type { StateStorage } from 'zustand/middleware' + +// Single zustand<->tauri-plugin-store adapter shared by the persisted and per-host stores. +// `getStore` is resolved lazily on every call so the host store can swap its backing file. +// A null store makes every operation a no-op (getItem -> null), which the host store relies on +// before a host has been selected. +export function createTauriStateStorage(getStore: () => LazyStore | null): StateStorage { + return { + getItem: async (name: string): Promise => { + const store = getStore() + if (!store) return null + console.log('getItem', { name }) + return (await store.get(name)) ?? null + }, + setItem: async (name: string, value: string): Promise => { + const store = getStore() + if (!store) return + console.log('setItem', { name }) + await store.set(name, value) + await store.save() + }, + removeItem: async (name: string): Promise => { + const store = getStore() + if (!store) return + console.log('removeItem', { name }) + await store.delete(name) + await store.save() + }, + } +} + +// 50ms recursive poll until a persist store reports hydration. Callers log around it so each +// store keeps its own identifiable trace. +export async function waitForStoreHydration(hasHydrated: () => boolean): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)) + if (!hasHydrated()) { + await waitForStoreHydration(hasHydrated) + } +} diff --git a/store/memory.ts b/store/memory.ts index 8c8c9bb..d8eb3ca 100644 --- a/store/memory.ts +++ b/store/memory.ts @@ -2,8 +2,6 @@ import { shared } from 'use-broadcast-ts' import { create } from 'zustand' interface State { - firstWindow: boolean - startupStatus: | null | 'initializing' @@ -17,11 +15,6 @@ interface State { isRestartingRclone: boolean - currentTheme: { - app: 'light' | 'dark' | 'system' - tray: 'light' | 'dark' | 'system' - } - cloudflaredTunnel: { pid: number url: string @@ -33,18 +26,11 @@ interface State { export const useStore = create()( shared( (_) => ({ - firstWindow: true, - startupStatus: null, startupDisplayed: false, isRestartingRclone: false, - currentTheme: { - app: 'dark', - tray: 'system', - }, - cloudflaredTunnel: null, dryRunJobIds: [], diff --git a/store/persisted.ts b/store/persisted.ts index 906a6a8..cbcf587 100644 --- a/store/persisted.ts +++ b/store/persisted.ts @@ -4,13 +4,14 @@ 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 { type StateStorage, createJSONStorage, persist } from 'zustand/middleware' +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 { ScheduledTask } from '../types/schedules' import type { Template } from '../types/template' import type { RemoteConfig as HostRemoteConfig } from './host' +import { createTauriStateStorage } from './lib' const store = new LazyStore('store.json') @@ -33,17 +34,6 @@ interface RemoteConfigV1 { remoteDefaults?: Record } -type SupportedAction = - | 'tray-mount' - | 'tray-sync' - | 'tray-copy' - | 'tray-serve' - | 'tray-move' - | 'tray-bisync' - | 'tray-delete' - | 'tray-purge' - | 'tray-download' - interface TemplateV1 { id: string name: string @@ -53,12 +43,6 @@ interface TemplateV1 { interface PersistedStateV1 { remoteConfigList: Record - setRemoteConfig: (remote: string, config: RemoteConfigV1) => void - mergeRemoteConfig: (remote: string, config: RemoteConfigV1) => void - - disabledActions: SupportedAction[] - - setDisabledActions: (actions: SupportedAction[]) => void proxy: | { @@ -70,34 +54,18 @@ interface PersistedStateV1 { favoritePaths: { remote: string; path: string; added: number }[] 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 scheduledTasks: ScheduledTask[] - addScheduledTask: ( - task: Omit< - ScheduledTask, - 'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled' - > - ) => void - removeScheduledTask: (id: string) => void - updateScheduledTask: (id: string, task: Partial) => void templates: TemplateV1[] configFiles: ConfigFile[] - addConfigFile: (configFile: ConfigFile) => void - removeConfigFile: (id: string) => void activeConfigFile: ConfigFile | null - setActiveConfigFile: (configFile: string) => void - updateConfigFile: (id: string, configFile: Partial) => void lastSkippedVersion: string | undefined @@ -126,8 +94,7 @@ interface PersistedStateV2 { templates: Template[] hosts: Host[] - currentHost: Host | null - updateHost: (id: Host['id'], host: Partial) => void + currentHostId: string | null setCurrentHost: (id: Host['id']) => void hideStartup: boolean @@ -138,24 +105,19 @@ interface PersistedStateV2 { tray: 'light' | 'dark' | 'system' | 'color' app: 'light' | 'dark' | 'system' } -} -const getStorage = (store: LazyStore): StateStorage => ({ - getItem: async (name: string): Promise => { - console.log('getItem', { name }) - return (await store.get(name)) ?? null - }, - setItem: async (name: string, value: string): Promise => { - console.log('setItem', { name, value }) - await store.set(name, value) - await store.save() - }, - removeItem: async (name: string): Promise => { - console.log('removeItem', { name }) - await store.delete(name) - await store.save() - }, -}) + // 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( @@ -180,32 +142,14 @@ export const usePersistedStore = create()( templates: [], hosts: [], - currentHost: null, - updateHost: (id: Host['id'], host: Partial) => + currentHostId: null, + setCurrentHost: (id: Host['id']) => set((state) => { if (!state.hosts.some((h) => h.id === id)) { return {} } - const hosts = state.hosts.map((h) => - h.id === id ? { ...h, ...host, id: h.id } : h - ) - - const currentHost = - state.currentHost?.id === id - ? (hosts.find((h) => h.id === id) ?? state.currentHost) - : state.currentHost - - return { hosts, currentHost } - }), - setCurrentHost: (id: Host['id']) => - set((state) => { - const host = state.hosts.find((h) => h.id === id) - if (!host) { - return {} - } - - return { currentHost: host } + return { currentHostId: id } }), hideStartup: false, @@ -216,11 +160,18 @@ export const usePersistedStore = create()( 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(() => getStorage(store)), - version: 2, + storage: createJSONStorage(() => createTauriStateStorage(() => store)), + version: 3, migrate: async (persistedState, version) => { if (!persistedState) { return persistedState as PersistedStateV2 @@ -385,12 +336,32 @@ export const usePersistedStore = create()( } 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 }) diff --git a/toolbar/actions.ts b/toolbar/actions.ts index 17401b0..eedb5d2 100644 --- a/toolbar/actions.ts +++ b/toolbar/actions.ts @@ -1,15 +1,16 @@ import { captureException } from '@sentry/browser' -import { getCurrentWindow } from '@tauri-apps/api/window' import { writeText } from '@tauri-apps/plugin-clipboard-manager' import { ask, message } from '@tauri-apps/plugin-dialog' import { openUrl, revealItemInDir } from '@tauri-apps/plugin-opener' +import { reportError } from '../lib/errors' +import { CLOSE_APP, emitToMain } from '../lib/events' import notify from '../lib/notify' import queryClient from '../lib/query' import type { fetchMountList, fetchServeList } from '../lib/rclone/api' import rclone from '../lib/rclone/client' import { SERVE_TYPES, SUPPORTS_CLEANUP, SUPPORTS_PURGE } from '../lib/rclone/constants' import { openFullWindow } from '../lib/window' -import { usePersistedStore } from '../store/persisted' +import { selectCurrentHost, usePersistedStore } from '../store/persisted' import { COMMAND_CONFIG, COMMAND_DESCRIPTIONS, COMMAND_KEYWORDS } from './constants' import type { ToolbarActionArgs, @@ -272,7 +273,7 @@ const actions: ToolbarActionDefinition[] = [ for (const mount of activeMounts) { const mountLabel = formatMountLabel(mount) - if (usePersistedStore.getState().currentHost?.id === 'local') { + if (usePersistedStore.getState().currentHostId === 'local') { results.push( createBaseResult( `Open ${mountLabel}`, @@ -343,14 +344,12 @@ const actions: ToolbarActionDefinition[] = [ try { await revealItemInDir(mountPoint) } catch (error) { - console.error('[toolbar] failed to open mount', error) - await message( - error instanceof Error ? error.message : 'Failed to open mount point', - { - title: 'Open Mount', - kind: 'error', - } - ) + await reportError(error, { + title: 'Open Mount', + fallback: 'Failed to open mount point', + capture: false, + log: ['[toolbar] failed to open mount'], + }) } return } @@ -395,14 +394,12 @@ const actions: ToolbarActionDefinition[] = [ old?.filter((m) => m.MountPoint !== mountPoint) ?? [] ) } catch (error) { - console.error('[toolbar] failed to stop mount', error) - await message( - error instanceof Error ? error.message : 'Failed to stop mount instance', - { - title: 'Stop Mount', - kind: 'error', - } - ) + await reportError(error, { + title: 'Stop Mount', + fallback: 'Failed to stop mount instance', + capture: false, + log: ['[toolbar] failed to stop mount'], + }) await queryClient.resetQueries({ queryKey: ['mount', 'list'] }) } return @@ -426,16 +423,12 @@ const actions: ToolbarActionDefinition[] = [ }) queryClient.setQueryData(['mount', 'list'], []) } catch (error) { - console.error('[toolbar] failed to stop all mounts', error) - await message( - error instanceof Error - ? error.message - : 'Failed to stop all mount instances', - { - title: 'Stop All Mounts', - kind: 'error', - } - ) + await reportError(error, { + title: 'Stop All Mounts', + fallback: 'Failed to stop all mount instances', + capture: false, + log: ['[toolbar] failed to stop all mounts'], + }) await queryClient.resetQueries({ queryKey: ['mount', 'list'] }) } return @@ -570,14 +563,12 @@ const actions: ToolbarActionDefinition[] = [ old?.filter((s) => s.id !== serveId) ?? [] ) } catch (error) { - console.error('[toolbar] failed to stop serve', error) - await message( - error instanceof Error ? error.message : 'Failed to stop serve instance', - { - title: 'Stop Serve', - kind: 'error', - } - ) + await reportError(error, { + title: 'Stop Serve', + fallback: 'Failed to stop serve instance', + capture: false, + log: ['[toolbar] failed to stop serve'], + }) } return } @@ -591,16 +582,12 @@ const actions: ToolbarActionDefinition[] = [ }) queryClient.setQueryData(['serve', 'list'], []) } catch (error) { - console.error('[toolbar] failed to stop all serves', error) - await message( - error instanceof Error - ? error.message - : 'Failed to stop all serve instances', - { - title: 'Stop All Serves', - kind: 'error', - } - ) + await reportError(error, { + title: 'Stop All Serves', + fallback: 'Failed to stop all serve instances', + capture: false, + log: ['[toolbar] failed to stop all serves'], + }) await queryClient.resetQueries({ queryKey: ['serve', 'list'] }) } return @@ -803,8 +790,8 @@ const actions: ToolbarActionDefinition[] = [ return } - const persistedStoreState = usePersistedStore.getState() - const hostUrl = persistedStoreState.currentHost?.url + const currentHost = selectCurrentHost(usePersistedStore.getState()) + const hostUrl = currentHost?.url if (!hostUrl) { await notify({ @@ -816,10 +803,10 @@ const actions: ToolbarActionDefinition[] = [ try { let auth: string | undefined - const authUser = persistedStoreState.currentHost?.authUser + const authUser = currentHost?.authUser if (authUser) { - const authPassword = persistedStoreState.currentHost?.authPassword + const authPassword = currentHost?.authPassword auth = btoa(`${authUser}:${authPassword ?? ''}`) } @@ -1175,7 +1162,7 @@ const actions: ToolbarActionDefinition[] = [ return [] }, onPress: async () => { - await getCurrentWindow().emit('close-app') + await emitToMain(CLOSE_APP) }, }, { @@ -1253,14 +1240,12 @@ const actions: ToolbarActionDefinition[] = [ (old: string[] | undefined) => old?.filter((v) => v !== fs) ?? [] ) } catch (error) { - console.error('[toolbar] failed to forget VFS cache', error) - await message( - error instanceof Error ? error.message : 'Failed to clear VFS cache', - { - title: 'VFS Forget', - kind: 'error', - } - ) + await reportError(error, { + title: 'VFS Forget', + fallback: 'Failed to clear VFS cache', + capture: false, + log: ['[toolbar] failed to forget VFS cache'], + }) } return } @@ -1274,14 +1259,12 @@ const actions: ToolbarActionDefinition[] = [ }) queryClient.setQueryData(['vfs', 'list'], []) } catch (error) { - console.error('[toolbar] failed to forget all VFS caches', error) - await message( - error instanceof Error ? error.message : 'Failed to clear all VFS caches', - { - title: 'VFS Forget All', - kind: 'error', - } - ) + await reportError(error, { + title: 'VFS Forget All', + fallback: 'Failed to clear all VFS caches', + capture: false, + log: ['[toolbar] failed to forget all VFS caches'], + }) } return }