zookeeper + cleanup

This commit is contained in:
FTCHD
2026-07-10 14:41:22 +03:00
parent 44e71643cd
commit 02bfdb8ddf
72 changed files with 6111 additions and 5335 deletions
+46
View File
@@ -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 : <fallback>` 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<void> {
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<ReportErrorOptions, 'title' | 'fallback'>
): (error: unknown) => Promise<void> {
return (error: unknown) => reportError(error, { title, fallback, ...options })
}
+33
View File
@@ -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<E extends keyof AppEventPayload>(
event: E,
payload?: AppEventPayload[E]
): Promise<void> {
await getCurrentWindow().emit(event, payload)
}
+34
View File
@@ -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'],
+20 -2
View File
@@ -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
+59 -47
View File
@@ -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<T extends { error?: string }>(
endpoint: string,
licenseKey: string,
extraBody: Record<string, unknown>,
failVerb: string,
logs: LicenseCallLogs
): Promise<T> {
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<T>)
.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')
-1
View File
@@ -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'
+15 -23
View File
@@ -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) {
+145 -139
View File
@@ -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<string>
rclonePath: string
args: string[]
activeConfig: ConfigFile
configPath: string
configDirectory: string
env: Record<string, string>
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<string, string>
rclonePath: string,
env: Record<string, string>,
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<ExecResult>('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<string, string>,
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<string, string>
}): Promise<Record<string, string>> {
const env: Record<string, string> = {}
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<string, string>,
autoPromptForPassword = true
): Promise<RcloneCliCommandContext> {
console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args)
const env: Record<string, string> = {}
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<void>((resolve, reject) => {
command.stdout.on('data', (line) => {
console.log('[runRcloneCli] stdout:', line)
stdout += line
let result: ExecResult
try {
result = await invoke<ExecResult>('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)
+41 -69
View File
@@ -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<Init> = OpenApiRequiredKeysOf<Init> extends never
? [(Init & { [key: string]: unknown })?]
: [Init & { [key: string]: unknown }]
export default async function rclone<
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path],
'post'
>,
>(
path: Path,
...init: InitParam<Init>
): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
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<unknown> {
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<OpenApiMaybeOptionalInit<Paths[Path], 'post'>>)
)
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<typeof client, 'post', Path, Init>
return result.data
}
export async function rcloneAsync<
export default async function rclone<
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path],
@@ -156,51 +158,21 @@ export async function rcloneAsync<
>(
path: Path,
...init: InitParam<Init>
): Promise<AsyncJobResponse> {
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<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
return (await request('sync', path, init)) as OpenApiMethodResponse<
RCDClient,
'post',
Path,
Init
>
}
export async function rcloneAsync<
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path],
'post'
>,
>(path: Path, ...init: InitParam<Init>): Promise<AsyncJobResponse> {
return (await request('async', path, init)) as AsyncJobResponse
}
+110 -180
View File
@@ -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<string | null> {
try {
if (await invoke<boolean>('is_flatpak')) {
return null
}
return (await invoke<string | null>('find_system_rclone')) ?? null
} catch (error) {
console.error('[findSystemRclone] error', error)
return null
}
}
/** Runs `<path> 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<string> {
return await invoke<string>('validate_rclone_binary', { path })
}
/** Like probeRcloneBinaryOrThrow, but returns null instead of throwing. */
export async function validateRcloneBinary(path: string): Promise<string | null> {
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<RcloneClassification> {
try {
return await invoke<RcloneClassification>('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<string> {
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<string>('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<boolean>} 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<boolean>} 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<string, FlagValue>) {
@@ -176,7 +167,10 @@ export function parseRcloneOptions(options: Record<string, FlagValue>) {
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
}
+6 -66
View File
@@ -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: <FileIcon />,
// titleColor: 'text-foreground',
// indicatorColor: 'text-foreground-500',
// },
// {
// id: 'copy',
// name: 'Copy',
// icon: <CopyIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'move',
// name: 'Move',
// icon: <MoveIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'delete',
// name: 'Delete',
// icon: <TrashIcon />,
// titleColor: 'text-danger-400',
// indicatorColor: 'text-danger-300',
// },
// {
// id: 'sync',
// name: 'Sync',
// icon: <ArrowRightLeftIcon />,
// titleColor: 'text-success-300',
// indicatorColor: 'text-success-300',
// },
// {
// id: 'bisync',
// name: 'Bisync',
// icon: <ArrowRightLeftIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'mount',
// name: 'Mount',
// icon: <HardDriveIcon />,
// titleColor: 'text-secondary-500',
// indicatorColor: 'text-secondary-400',
// },
// {
// id: 'purge',
// name: 'Purge',
// icon: <Trash2Icon />,
// titleColor: 'text-warning-300',
// indicatorColor: 'text-warning-300',
// },
// {
// id: 'serve',
// name: 'Serve',
// icon: <ServerIcon />,
// titleColor: 'text-cyan-500',
// indicatorColor: 'text-cyan-300',
// },
// ] as const
+271 -411
View File
@@ -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<boolean>('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<string, string> } = {
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<string, string>
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<void>}
* 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<string | null> {
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<string | null>('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<string> {
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<string | false> {
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
}
+200
View File
@@ -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<DownloadedVersion[]> {
return await invoke<DownloadedVersion[]>('list_downloaded_rclone_versions')
}
/** Fetches stable rclone releases at or above the minimum supported version (best-effort). */
export async function fetchAvailableVersions(): Promise<AvailableRelease[]> {
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<string> {
const unlisten = await listen<DownloadProgress>('rclone-download-progress', (event) => {
if (event.payload.version === version) {
onProgress?.(event.payload)
}
})
try {
const proxyUrl = useHostStore.getState().proxy?.url ?? null
return await invoke<string>('download_rclone_version', { version, proxyUrl })
} finally {
unlisten()
}
}
export async function deleteVersion(version: string): Promise<void> {
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<boolean> {
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<boolean> {
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<string | null> {
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<string>('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<PathStatus> {
return await invoke<PathStatus>('get_rclone_path_integration')
}
export async function setPathIntegration(enable: boolean, targetPath: string): Promise<PathStatus> {
return await invoke<PathStatus>('set_rclone_path_integration', {
enable,
targetPath,
})
}
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
}