Files
rclone-ui/lib/rclone/versions.ts
T
2026-07-13 01:32:56 +03:00

332 lines
13 KiB
TypeScript

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 { flushHostStore, useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted'
import { restartActiveRclone } from './cli'
import rcloneClient from './client'
import { appPrivateDefaultConfigPath, compareVersions, resolveActiveConfigPath } 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 ConfigSyncStatus {
/** The system config path currently points at the given active config (or IS it, circular case). */
enabled: boolean
/** The system config path is a symlink we created (may point at a different config until re-pointed). */
managed: boolean
systemPath: string
/** Set when this call moved a pre-existing config aside; the path it was backed up to. */
backupPath: string | null
/** True when the file just backed up was the app's own default config (re-point defaultConfigPath). */
defaultBackedUp: boolean
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)
}
// The default config path may have moved (maybeOfferSystemConfig) — keep any config-sync
// symlink tracking the active config. Run BEFORE the restart so a relocated defaultConfigPath
// is captured in the restart snapshot (avoids the main window re-applying a stale value).
// Non-throwing.
await reconcileConfigSync()
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
}
// With config sync ON, the system config path is OUR symlink back to the app's active config —
// not an independent native config. `rclone_config_path` would return that system path, so
// adopting it as defaultConfigPath would alias `default` to the active config and orphan the
// real default. The terminal already shares the app config via the symlink, so there is nothing
// to adopt: skip the offer entirely.
if (host.syncConfigLinkTarget) {
return null
}
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,
})
}
export async function getConfigSync(
appConfigPath: string,
ownedLinkTarget: string | null
): Promise<ConfigSyncStatus> {
return await invoke<ConfigSyncStatus>('get_config_sync_status', {
appConfigPath,
ownedLinkTarget,
})
}
export async function setConfigSync(
enable: boolean,
appConfigPath: string,
ownedLinkTarget: string | null,
defaultConfigPath: string | null
): Promise<ConfigSyncStatus> {
return await invoke<ConfigSyncStatus>('set_config_sync', {
enable,
appConfigPath,
ownedLinkTarget,
defaultConfigPath,
})
}
/** The ownership marker implied by a status result: the active path when we now hold a link
* (`managed`), else null (circular or removed) — so a link we don't own is never marked as ours. */
function markerFromStatus(status: ConfigSyncStatus, appConfigPath: string): string | null {
return status.managed ? appConfigPath : null
}
/**
* Serializes every config-sync transaction — reconcile AND the settings toggle — end to end. The Rust
* mutex only serializes the filesystem swap, not this read-intent → invoke → persist-marker sequence,
* so without this a reconcile and a toggle could interleave and recreate a link the user just
* disabled or persist a marker that disagrees with the link on disk. Every caller MUST read fresh
* store state INSIDE the passed callback (never capture it beforehand).
*
* The chain is module-local, so it serializes only WITHIN one webview, not a Settings action against
* the hidden main window's reconcile. That cross-webview race is left as-is: it's a narrow window,
* async rehydration heals nearly all timings, and the worst case is a visible, one-click-correctable
* toggle — never silent or data loss.
*/
let configSyncChain: Promise<unknown> = Promise.resolve()
export function withConfigSyncLock<T>(fn: () => Promise<T>): Promise<T> {
// Run fn whether the predecessor resolved or rejected (a failed op must not wedge the chain).
const run = configSyncChain.then(fn, fn)
configSyncChain = run.then(
() => undefined,
() => undefined
)
return run
}
/**
* Reconciles the system-config symlink to the persisted intent and the current active config path.
* Idempotent and safe to call at startup, after a config switch, and after a binary activation.
* Enabling re-points/creates the link (backing up any foreign file); disabling removes the link we
* own — proven by the persisted marker, so a user's own symlink is never touched. This makes the
* reconcile self-healing in BOTH directions (a link left after a crashed disable is cleaned up; a
* link missing after a crashed enable is recreated). If enabling moved the app's own default config
* aside, defaultConfigPath follows it so `default` is never orphaned. Persists the resulting marker.
* Never throws — returns the status (and any error) so callers can surface a failed re-point.
*/
export async function reconcileConfigSync(): Promise<{
status: ConfigSyncStatus | null
error: string | null
}> {
return withConfigSyncLock(async () => {
try {
// Read intent/marker fresh INSIDE the lock so a toggle that ran just before us is honored.
const host = useHostStore.getState()
const intent = host.syncConfigToSystem
const marker = host.syncConfigLinkTarget
// Fast path: nothing to do and nothing we own to clean up. Avoids a Rust round-trip on the
// common "sync was never enabled" startup.
if (!intent && !marker) {
return { status: null, error: null }
}
const appConfigPath = await resolveActiveConfigPath()
const status = await setConfigSync(
intent,
appConfigPath,
marker,
host.defaultConfigPath ?? null
)
// Enabling vacated the system path and it held the app's own default config — follow it to
// the relocated copy so switching back to `default` still reads the user's remotes.
if (status.defaultBackedUp && status.backupPath) {
host.setDefaultConfigPath(status.backupPath)
}
host.setConfigSyncState({ intent, linkTarget: markerFromStatus(status, appConfigPath) })
// Durably persist intent + marker (+ any relocated default) before returning, so a crash
// can't leave the on-disk store disagreeing with the link we just made/removed.
await flushHostStore()
return { status, error: null }
} catch (error) {
console.warn('[reconcileConfigSync] failed', error)
return { status: null, error: error instanceof Error ? error.message : String(error) }
}
})
}