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