Files
rclone-ui/lib/rclone/init.ts
T
2026-07-11 19:47:32 +03:00

473 lines
19 KiB
TypeScript

import * as Sentry from '@sentry/browser'
import { invoke } from '@tauri-apps/api/core'
import { appLogDir, sep } from '@tauri-apps/api/path'
import { ask, message } from '@tauri-apps/plugin-dialog'
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 { 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 { buildRcloneEnv } from './cli'
import {
classifyRclonePath,
compareVersions,
createConfigFile,
findSystemRclone,
getConfigPath,
resolveDefaultConfigPath,
validateRcloneBinary,
} from './common'
import { downloadVersion, listDownloadedVersions, setPathIntegration } from './versions'
export async function initRclone(args: string[]) {
console.log('[initRclone] starting with args:', args)
// Resolve which rclone binary to run (adopting a system/legacy binary on first launch).
let rclonePath = await resolveActiveRclone()
// 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 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
}
usePersistedStore.getState().setRclonePath(provisionedPath)
rclonePath = provisionedPath
// The system had no rclone at all, so our copy should serve the shell too: enable PATH
// integration by default (the Settings toggle reflects it). Best-effort — on macOS this
// shows an admin prompt the user may cancel.
try {
await setPathIntegration(true, provisionedPath)
} catch (error) {
console.warn('[initRclone] default PATH integration failed', error)
}
useStore.setState({ startupStatus: 'initialized' })
if (!['windows', 'macos'].includes(platform())) {
usePersistedStore.setState({ hideStartup: true })
}
}
// Check for a newer stable release of a managed binary: auto-update or notify.
rclonePath = await maybeAutoUpdateRclone(rclonePath)
// 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)
})
// 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)
const existingDefaultConfig = configFiles.find((config) => config.id === 'default')
configFiles = configFiles.filter((config) => config.id !== 'default')
console.log('[initRclone] filtered config files, remaining count:', configFiles.length)
const defaultConfig = existingDefaultConfig
? existingDefaultConfig
: {
id: 'default',
label: 'Default config',
sync: undefined,
isEncrypted: false,
pass: undefined,
passCommand: undefined,
}
configFiles.unshift(defaultConfig)
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]
if (!activeConfigFile) {
console.error('[initRclone] failed to get active config file')
throw new Error('Failed to get active config file')
}
console.log('[initRclone] set active config file to:', activeConfigFile.id)
useHostStore.getState().setActiveConfigFile(activeConfigFile.id!)
}
let configFolderPath = activeConfigFile.sync
? activeConfigFile.sync
: getConfigParentFolder(await getConfigPath({ id: activeConfigFile.id!, validate: true }))
console.log('[initRclone] configFolderPath', configFolderPath)
let configPath = configFolderPath
if (configPath.endsWith(sep())) {
configPath = `${configPath}rclone.conf`
} else {
Sentry.captureException(new Error('configPath did not end with separator'))
configPath = `${configPath}${sep()}rclone.conf`
}
console.log('[initRclone] configPath', configPath)
if (activeConfigFile.sync) {
console.log('[initRclone] checking if synced config file exists', configPath)
if (await exists(configPath)) {
console.log('[initRclone] synced config file exists')
} else {
console.error('[initRclone] synced config file not found, switching to default')
await message('The config file could not be found. Switching to the default config.', {
title: 'Invalid synced config',
kind: 'error',
okLabel: 'OK',
})
activeConfigFile = configFiles[0]
// 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.getState().setActiveConfigFile(configFiles[0].id!)
}
}
const passwordConfigured = activeConfigFile.pass || activeConfigFile.passCommand || null
console.log('[initRclone] password configured:', !!passwordConfigured)
try {
console.log('[initRclone] reading config file', configPath)
const configContent = await readTextFile(configPath)
const isEncrypted = configContent.includes('RCLONE_ENCRYPT_V0:')
console.log('[initRclone] isEncrypted', isEncrypted)
if (isEncrypted) {
console.log('[initRclone] config file is encrypted')
if (passwordConfigured) {
console.log('[initRclone] using existing password configuration')
} else {
console.log('[initRclone] no stored password configured')
}
}
// 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)
const appLogDirPath = await appLogDir()
await message(
'Could not read config file, please file an issue on GitHub.\n\nLogs: ' + appLogDirPath,
{
title: 'Error',
kind: 'error',
okLabel: 'OK',
}
)
await exit(0)
return
}
// Proxy connectivity check (informational; the env vars themselves are set by buildRcloneEnv).
if (hostState.proxy) {
console.log('[initRclone] proxy configured:', hostState.proxy.url)
try {
console.log('[initRclone] testing proxy connection')
await invoke<string>('test_proxy_connection', { proxy_url: hostState.proxy.url })
console.log('[initRclone] proxy connection successful')
} catch (error) {
console.error('[initRclone] proxy connection failed:', error)
const continueAnyway = await ask(
'You have a proxy set, but it failed to connect. Do you want to continue anyway?',
{
title: 'Error',
kind: 'warning',
okLabel: 'Continue',
cancelLabel: 'Exit',
}
)
console.log('[initRclone] user continue anyway:', continueAnyway)
if (!continueAnyway) {
console.log('[initRclone] user chose to exit due to proxy failure')
await exit(0)
return
}
}
}
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
}
await exit(0)
return
}
throw error
}
console.log('[initRclone] returning rclone command', { path: rclonePath, args })
return { path: rclonePath, args, env }
}
/**
* 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.
*/
async function resolveActiveRclone(): Promise<string | null> {
const persisted = usePersistedStore.getState()
const stored = persisted.rclonePath
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)
// 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],
})
if (healed && (await validateRcloneBinary(healed))) {
console.log('[resolveActiveRclone] self-healed managed path ->', healed)
persisted.setRclonePath(healed)
return healed
}
}
// fall through to the adoption ladder
}
// 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 {
legacyAdopted = await invoke<{ version: string; path: string } | null>(
'adopt_legacy_rclone'
)
} catch (error) {
console.error('[resolveActiveRclone] adopt_legacy_rclone failed', error)
}
// 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 (useSystem) {
persisted.setRclonePath(system)
return system
}
}
}
// 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
}
// 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)
}
// 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
}