zookeeper + cleanup
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user