sync default config
This commit is contained in:
@@ -15,6 +15,10 @@ export interface RestartRclonePayload {
|
|||||||
configFiles?: ConfigFile[]
|
configFiles?: ConfigFile[]
|
||||||
activeConfigId?: string | null
|
activeConfigId?: string | null
|
||||||
proxy?: { url: string; ignoredHosts: string[] } | undefined
|
proxy?: { url: string; ignoredHosts: string[] } | undefined
|
||||||
|
// The config-sync intent + ownership marker, so the main window's post-restart reconcile uses
|
||||||
|
// fresh values instead of not-yet-rehydrated (stale) ones from its own store.
|
||||||
|
syncConfigToSystem?: boolean
|
||||||
|
syncConfigLinkTarget?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppEventPayload = {
|
export type AppEventPayload = {
|
||||||
|
|||||||
+7
-1
@@ -368,7 +368,9 @@ export async function runRcloneCli(args: string[], input: string[] = []) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartActiveRclone() {
|
/** Requests the main window to restart the daemon with a full lifecycle snapshot. Returns false if
|
||||||
|
* the event could not even be emitted, so callers can roll back optimistic state on failure. */
|
||||||
|
export async function restartActiveRclone(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// The main window's store may not have rehydrated this webview's writes before the restart
|
// 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.
|
// runs — carry a full lifecycle snapshot from THIS webview's fresh stores in the payload.
|
||||||
@@ -380,9 +382,13 @@ export async function restartActiveRclone() {
|
|||||||
configFiles: host.configFiles,
|
configFiles: host.configFiles,
|
||||||
activeConfigId: host.activeConfigId,
|
activeConfigId: host.activeConfigId,
|
||||||
proxy: host.proxy,
|
proxy: host.proxy,
|
||||||
|
syncConfigToSystem: host.syncConfigToSystem,
|
||||||
|
syncConfigLinkTarget: host.syncConfigLinkTarget,
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Sentry.captureException(error)
|
Sentry.captureException(error)
|
||||||
console.error('[restartActiveRclone] failed to emit restart event', error)
|
console.error('[restartActiveRclone] failed to emit restart event', error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-1
@@ -1,7 +1,7 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { appLocalDataDir, sep } from '@tauri-apps/api/path'
|
import { appLocalDataDir, sep } from '@tauri-apps/api/path'
|
||||||
import { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
import { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||||
import { useHostStore } from '../../store/host'
|
import { selectActiveConfigFile, useHostStore } from '../../store/host'
|
||||||
import { getConfigParentFolder } from '../format'
|
import { getConfigParentFolder } from '../format'
|
||||||
import rclone from './client'
|
import rclone from './client'
|
||||||
import { DOUBLE_BACKSLASH_REGEX } from './constants'
|
import { DOUBLE_BACKSLASH_REGEX } from './constants'
|
||||||
@@ -57,6 +57,41 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid
|
|||||||
return configPath
|
return configPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on-disk path of a specific config file, mirroring initRclone's resolution: an external `sync`
|
||||||
|
* folder yields `<folder>/rclone.conf`, otherwise the config's own path (which for `default` honors
|
||||||
|
* the persisted defaultConfigPath). With `validate`, throws if the file is missing — including for a
|
||||||
|
* `sync` config, which `getConfigPath` cannot see (it only knows the app-private id-based path).
|
||||||
|
*
|
||||||
|
* Note: pointing a `sync` folder at the system rclone config dir (~/.config/rclone) is unsupported —
|
||||||
|
* it collides with the path config-sync manages. Config-sync treats it as circular (no ELOOP), but a
|
||||||
|
* switch-away/back cycle can shadow it; the sync-folder feature is meant for external/shared dirs.
|
||||||
|
*/
|
||||||
|
export async function resolveConfigFilePath(
|
||||||
|
config: { id?: string; sync?: string } | null | undefined,
|
||||||
|
{ validate = false }: { validate?: boolean } = {}
|
||||||
|
): Promise<string> {
|
||||||
|
if (config?.sync) {
|
||||||
|
const folder = config.sync.endsWith(sep()) ? config.sync : `${config.sync}${sep()}`
|
||||||
|
const path = `${folder}rclone.conf`
|
||||||
|
if (validate && !(await exists(path))) {
|
||||||
|
console.error('[resolveConfigFilePath] synced config file does not exist', path)
|
||||||
|
throw new Error('Config file does not exist')
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return getConfigPath({ id: config?.id ?? 'default', validate })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on-disk path of the app's active config file — the single source of truth for config sync.
|
||||||
|
* Defaults to no existence check so it stays usable during startup reconcile.
|
||||||
|
*/
|
||||||
|
export async function resolveActiveConfigPath(opts: { validate?: boolean } = {}): Promise<string> {
|
||||||
|
return resolveConfigFilePath(selectActiveConfigFile(useHostStore.getState()), opts)
|
||||||
|
}
|
||||||
|
|
||||||
export async function createConfigFile(path: string) {
|
export async function createConfigFile(path: string) {
|
||||||
console.log('[createConfigFile] path', path)
|
console.log('[createConfigFile] path', path)
|
||||||
|
|
||||||
|
|||||||
+133
-2
@@ -2,11 +2,11 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import { ask } from '@tauri-apps/plugin-dialog'
|
import { ask } from '@tauri-apps/plugin-dialog'
|
||||||
import { fetch } from '@tauri-apps/plugin-http'
|
import { fetch } from '@tauri-apps/plugin-http'
|
||||||
import { useHostStore } from '../../store/host'
|
import { flushHostStore, useHostStore } from '../../store/host'
|
||||||
import { usePersistedStore } from '../../store/persisted'
|
import { usePersistedStore } from '../../store/persisted'
|
||||||
import { restartActiveRclone } from './cli'
|
import { restartActiveRclone } from './cli'
|
||||||
import rcloneClient from './client'
|
import rcloneClient from './client'
|
||||||
import { appPrivateDefaultConfigPath, compareVersions } from './common'
|
import { appPrivateDefaultConfigPath, compareVersions, resolveActiveConfigPath } from './common'
|
||||||
import { MIN_RCLONE_VERSION, RCLONE_RELEASES_API, RCLONE_RELEASES_SHOWN } from './constants'
|
import { MIN_RCLONE_VERSION, RCLONE_RELEASES_API, RCLONE_RELEASES_SHOWN } from './constants'
|
||||||
|
|
||||||
export interface DownloadedVersion {
|
export interface DownloadedVersion {
|
||||||
@@ -26,6 +26,19 @@ export interface PathStatus {
|
|||||||
warning: 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 {
|
export interface DownloadProgress {
|
||||||
version: string
|
version: string
|
||||||
downloaded: number
|
downloaded: number
|
||||||
@@ -146,6 +159,11 @@ export async function activateRclonePath(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[activateRclonePath] update_path_pointer failed', 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()
|
await restartActiveRclone()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -164,6 +182,15 @@ async function maybeOfferSystemConfig(systemPath: string): Promise<string | null
|
|||||||
return null // already using a non-app-private (likely native) config
|
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 {
|
try {
|
||||||
const native = await invoke<string>('rclone_config_path', { path: systemPath })
|
const native = await invoke<string>('rclone_config_path', { path: systemPath })
|
||||||
if (!native || native === current) {
|
if (!native || native === current) {
|
||||||
@@ -198,3 +225,107 @@ export async function setPathIntegration(enable: boolean, targetPath: string): P
|
|||||||
targetPath,
|
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) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { listTransfers, startMount } from './lib/rclone/api'
|
|||||||
import rcloneClient from './lib/rclone/client'
|
import rcloneClient from './lib/rclone/client'
|
||||||
import { compareVersions } from './lib/rclone/common'
|
import { compareVersions } from './lib/rclone/common'
|
||||||
import { initRclone } from './lib/rclone/init'
|
import { initRclone } from './lib/rclone/init'
|
||||||
|
import { reconcileConfigSync } from './lib/rclone/versions'
|
||||||
import { initScheduler } from './lib/scheduler'
|
import { initScheduler } from './lib/scheduler'
|
||||||
import { initTray } from './lib/tray'
|
import { initTray } from './lib/tray'
|
||||||
import { openSmallWindow } from './lib/window'
|
import { openSmallWindow } from './lib/window'
|
||||||
@@ -375,22 +376,38 @@ async function registerRcloneWindowListeners() {
|
|||||||
if (payload.proxy !== undefined) {
|
if (payload.proxy !== undefined) {
|
||||||
useHostStore.setState({ proxy: payload.proxy })
|
useHostStore.setState({ proxy: payload.proxy })
|
||||||
}
|
}
|
||||||
|
// Apply BEFORE startRclone so the post-spawn reconcileConfigSync reads fresh intent +
|
||||||
|
// ownership marker (a stale marker would let it miss or misattribute the link).
|
||||||
|
if (payload.syncConfigToSystem !== undefined) {
|
||||||
|
useHostStore.setState({ syncConfigToSystem: payload.syncConfigToSystem })
|
||||||
|
}
|
||||||
|
if (payload.syncConfigLinkTarget !== undefined) {
|
||||||
|
useHostStore.setState({ syncConfigLinkTarget: payload.syncConfigLinkTarget })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useStore.getState().isRestartingRclone) {
|
if (useStore.getState().isRestartingRclone) {
|
||||||
console.log('[restart-rclone] restart already in progress, ignoring request')
|
// Don't DROP an overlapping request — that would leave the daemon on the old config while
|
||||||
|
// the store/symlink already point at the new one. The payload's state was applied above,
|
||||||
|
// so flag a re-run and let the in-flight restart pick it up when it finishes.
|
||||||
|
console.log('[restart-rclone] restart in progress, coalescing into a pending re-run')
|
||||||
|
useStore.setState({ rcloneRestartPending: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
useStore.setState({ isRestartingRclone: true })
|
useStore.setState({ isRestartingRclone: true, rcloneRestartPending: false })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Loop so a request that arrived (and applied its state) mid-restart still takes effect.
|
||||||
|
do {
|
||||||
|
useStore.setState({ rcloneRestartPending: false })
|
||||||
await killRcloneDaemon()
|
await killRcloneDaemon()
|
||||||
|
|
||||||
// Jobids do not survive a daemon restart — polling them would only 404.
|
// Jobids do not survive a daemon restart — polling them would only 404.
|
||||||
clearWatchedJobs()
|
clearWatchedJobs()
|
||||||
|
|
||||||
await startRclone()
|
await startRclone()
|
||||||
|
} while (useStore.getState().rcloneRestartPending)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[restart-rclone] failed to restart rclone', error)
|
console.error('[restart-rclone] failed to restart rclone', error)
|
||||||
Sentry.captureException(error)
|
Sentry.captureException(error)
|
||||||
@@ -520,6 +537,12 @@ async function startRclone() {
|
|||||||
}
|
}
|
||||||
console.log('[startRclone] running rclone, pid', pid)
|
console.log('[startRclone] running rclone, pid', pid)
|
||||||
|
|
||||||
|
// Heal the config-sync symlink against the now-settled active config: with intent on, re-point a
|
||||||
|
// stale link or recreate one deleted out-of-band; with intent off but a marker still recorded
|
||||||
|
// (a disable that crashed before persisting), remove the link we own. A true no-op only when
|
||||||
|
// both intent and marker are clear. Marker-proven, so a user's own symlink is never touched.
|
||||||
|
await reconcileConfigSync()
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -876,6 +876,8 @@ pub fn run() {
|
|||||||
zookeeper::update_path_pointer,
|
zookeeper::update_path_pointer,
|
||||||
zookeeper::get_rclone_path_integration,
|
zookeeper::get_rclone_path_integration,
|
||||||
zookeeper::set_rclone_path_integration,
|
zookeeper::set_rclone_path_integration,
|
||||||
|
zookeeper::get_config_sync_status,
|
||||||
|
zookeeper::set_config_sync,
|
||||||
scheduler::scheduler_supported,
|
scheduler::scheduler_supported,
|
||||||
scheduler::scheduler_validate_cron,
|
scheduler::scheduler_validate_cron,
|
||||||
scheduler::scheduler_register,
|
scheduler::scheduler_register,
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ pub struct PathStatus {
|
|||||||
pub warning: Option<String>,
|
pub warning: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Status of the system-config symlink. `managed` = the system path is a symlink we created;
|
||||||
|
/// `enabled` = it (or the circular direct-use case) currently points at the given app config.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConfigSyncStatus {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub managed: bool,
|
||||||
|
pub system_path: String,
|
||||||
|
pub backup_path: Option<String>,
|
||||||
|
/// True when the file just moved aside was the app's own default config, so the caller can
|
||||||
|
/// re-point `defaultConfigPath` at `backup_path` instead of orphaning it.
|
||||||
|
pub default_backed_up: bool,
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tracks the currently-running daemon so kills can be marked intentional (suppressing
|
/// Tracks the currently-running daemon so kills can be marked intentional (suppressing
|
||||||
/// the crash dialog) and so a webview reload cannot orphan the process.
|
/// the crash dialog) and so a webview reload cannot orphan the process.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -1020,6 +1035,461 @@ pub fn set_rclone_path_integration(
|
|||||||
get_rclone_path_integration(app)
|
get_rclone_path_integration(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config sync
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Symlinks the system rclone config path -> the app's active config file so `rclone` invoked from
|
||||||
|
// a terminal shares the app's remotes. rclone follows this symlink for both read and write and
|
||||||
|
// preserves it (verified live). Mirrors the PATH-integration commands above.
|
||||||
|
|
||||||
|
/// The effective `XDG_CONFIG_HOME` for locating the terminal rclone's config. Honors the app
|
||||||
|
/// process's own environment (a GUI launched from a session that exported it — login file, systemd
|
||||||
|
/// user env, launchd — inherits it), and under Flatpak the host value Flatpak re-exports as
|
||||||
|
/// `HOST_XDG_CONFIG_HOME` (the sandbox's own XDG_CONFIG_HOME points at the per-app dir the host
|
||||||
|
/// terminal never reads). Returns None (→ `~/.config`) otherwise. We deliberately do NOT shell out to
|
||||||
|
/// discover a value set only in an interactive rc file: that is a rare niche, and if `~/.config` is
|
||||||
|
/// wrong the only effect is the terminal doesn't share the config — visible, no data loss.
|
||||||
|
fn resolved_xdg_config_home() -> Option<PathBuf> {
|
||||||
|
let var = if crate::is_flatpak() {
|
||||||
|
"HOST_XDG_CONFIG_HOME"
|
||||||
|
} else {
|
||||||
|
"XDG_CONFIG_HOME"
|
||||||
|
};
|
||||||
|
std::env::var_os(var)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where rclone looks for its config by default: `$XDG_CONFIG_HOME/rclone/rclone.conf` when that is
|
||||||
|
/// set (see `resolved_xdg_config_home`), else `$HOME/.config/rclone/rclone.conf`. Mirrors rclone's
|
||||||
|
/// own `makeConfigPath` — it deliberately ignores the legacy `~/.rclone.conf` and the exe-adjacent
|
||||||
|
/// file, and uses `~/.config` on every platform (including macOS and Windows).
|
||||||
|
fn system_config_path() -> Result<PathBuf, String> {
|
||||||
|
let base = match resolved_xdg_config_home() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => dirs::home_dir()
|
||||||
|
.ok_or_else(|| "Could not resolve home directory".to_string())?
|
||||||
|
.join(".config"),
|
||||||
|
};
|
||||||
|
Ok(base.join("rclone").join("rclone.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if two config paths denote the same file *without following a final-component symlink*:
|
||||||
|
/// same file name in the same directory (parents canonicalized, so parent symlinks and `.`/`..`
|
||||||
|
/// still resolve). Used for the "circular" test — `canonical()` alone would follow our own link to
|
||||||
|
/// its target and wrongly report every healthy link as circular.
|
||||||
|
fn same_location(a: &Path, b: &Path) -> bool {
|
||||||
|
match (a.parent(), b.parent(), a.file_name(), b.file_name()) {
|
||||||
|
(Some(pa), Some(pb), Some(na), Some(nb)) => na == nb && canonical(pa) == canonical(pb),
|
||||||
|
_ => canonical(a) == canonical(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the active config `path` IS, or resolves through a symlink to, the system `target`'s
|
||||||
|
/// location. This is the "circular / direct-use" test: installing system -> active when active
|
||||||
|
/// already resolves to system would close a loop (active -> system -> active) whose reads fail with
|
||||||
|
/// ELOOP. Canonicalizes the ACTIVE path (following its chain to the real file) and compares that by
|
||||||
|
/// LOCATION to the system path — NOT following the system path's own final symlink, so our own
|
||||||
|
/// healthy link system -> active is never mistaken for circular. canonicalize handles multi-hop and
|
||||||
|
/// its own ELOOP guard; the realistic cases are hop 0 (active IS the system file) and a single
|
||||||
|
/// dotfile symlink to it.
|
||||||
|
fn resolves_to_location(path: &Path, target: &Path) -> bool {
|
||||||
|
same_location(&canonical(path), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `link` is the config-sync symlink WE created, proven by a positive ownership marker: its
|
||||||
|
/// target equals the exact path we recorded when we last installed it (`syncConfigLinkTarget`,
|
||||||
|
/// persisted per host). Target *location* is not proof of ownership — a user's own symlink to an app
|
||||||
|
/// config must never be misattributed to us — so a link matches only against what we actually wrote.
|
||||||
|
/// A now-dangling target still matches (both sides fall back to the same lexical path), so our own
|
||||||
|
/// link to a since-deleted config is still reclaimed. No marker recorded → we own nothing.
|
||||||
|
fn config_link_is_ours(link: &Path, owned_link_target: Option<&str>) -> bool {
|
||||||
|
let (Ok(target), Some(owned)) = (std::fs::read_link(link), owned_link_target) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
canonical(&target) == canonical(Path::new(owned))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<path>.backup`, or the first free `<path>.backup.N`, so an existing backup is never clobbered.
|
||||||
|
/// Uses `symlink_metadata` so a broken backup symlink still counts as occupied.
|
||||||
|
fn next_backup_path(path: &Path) -> PathBuf {
|
||||||
|
let mut base = path.as_os_str().to_os_string();
|
||||||
|
base.push(".backup");
|
||||||
|
let base = PathBuf::from(base);
|
||||||
|
if std::fs::symlink_metadata(&base).is_err() {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let mut n = 1;
|
||||||
|
loop {
|
||||||
|
let mut s = path.as_os_str().to_os_string();
|
||||||
|
s.push(format!(".backup.{}", n));
|
||||||
|
let candidate = PathBuf::from(s);
|
||||||
|
if std::fs::symlink_metadata(&candidate).is_err() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a file symlink `link` -> `target`. Uses a real symlink on every platform (no copy
|
||||||
|
/// fallback); on Windows this needs Developer Mode or elevation, surfaced as an actionable error.
|
||||||
|
fn create_config_symlink(target: &Path, link: &Path) -> Result<(), String> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
symlink(target, link).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::fs::symlink_file;
|
||||||
|
symlink_file(target, link).map_err(|e| {
|
||||||
|
// ERROR_PRIVILEGE_NOT_HELD (1314): creating symlinks is privileged on Windows.
|
||||||
|
if e.raw_os_error() == Some(1314) {
|
||||||
|
"Creating the config symlink needs permission on Windows. Enable Developer Mode \
|
||||||
|
(Settings → Privacy & security → For developers) or run as administrator, then \
|
||||||
|
try again."
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
{
|
||||||
|
let _ = (target, link);
|
||||||
|
Err("Config sync is not supported on this platform".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves `from` to `to`, falling back to copy+remove across filesystems (rename gives EXDEV between
|
||||||
|
/// the system config dir and app-local-data). For a same-dir move (the foreign `.backup`) rename
|
||||||
|
/// always succeeds, preserving a symlink as-is. Used for the foreign backup and for restoring either
|
||||||
|
/// kind of moved-aside entry on rollback. Callers ensure `to`'s parent exists and `to` is free.
|
||||||
|
fn move_file(from: &Path, to: &Path) -> Result<(), String> {
|
||||||
|
if std::fs::rename(from, to).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::fs::copy(from, to).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::remove_file(from).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies a config's *content* to `to`, leaving the source in place. `std::fs::copy` follows a
|
||||||
|
/// symlink, so the result is always a real file: a relative-target symlink is never reproduced in a
|
||||||
|
/// new directory (where its target would resolve differently, i.e. dangle), and a valid config is
|
||||||
|
/// never converted into a broken link. Used to displace the app's own default off the system path —
|
||||||
|
/// the relocated default must always be a usable file. The original is left for the caller's atomic
|
||||||
|
/// swap to replace, so the system path is never momentarily empty.
|
||||||
|
fn materialize_copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||||
|
std::fs::copy(from, to).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the existing system entry aside to `to` WITHOUT removing the original, preserving its exact
|
||||||
|
/// nature: a symlink is recreated as a symlink (a foreign link stays a link), a real file is copied.
|
||||||
|
/// `to` is a same-directory sibling (`next_backup_path`), so even a relative symlink target still
|
||||||
|
/// resolves after recreation. Leaving the original in place lets the atomic swap replace it with no
|
||||||
|
/// empty-path window and makes rollback a simple discard of this copy.
|
||||||
|
fn copy_aside(from: &Path, to: &Path) -> Result<(), String> {
|
||||||
|
match std::fs::symlink_metadata(from) {
|
||||||
|
Ok(m) if m.file_type().is_symlink() => {
|
||||||
|
let target = std::fs::read_link(from).map_err(|e| e.to_string())?;
|
||||||
|
create_config_symlink(&target, to)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
std::fs::copy(from, to).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A free app-private home for a default config displaced off the system path. Unlike a raw
|
||||||
|
/// `<file>.backup`, this keeps the `<dir>/rclone.conf` shape the rest of the app assumes when it
|
||||||
|
/// derives a config's folder — re-pinning `defaultConfigPath` here must never break config loading.
|
||||||
|
fn relocated_default_path(base: &Path) -> PathBuf {
|
||||||
|
let configs = base.join("configs");
|
||||||
|
let mut candidate = configs.join("default-synced").join("rclone.conf");
|
||||||
|
let mut n = 1;
|
||||||
|
// lexists (symlink_metadata), NOT exists(): exists() follows symlinks, so a *dangling* symlink at
|
||||||
|
// the candidate would look free and materialize_copy's std::fs::copy would then follow it and
|
||||||
|
// write through to the link's (out-of-app-data) target. symlink_metadata treats any existing
|
||||||
|
// entry — including a broken link — as occupied. Mirrors next_backup_path.
|
||||||
|
while std::fs::symlink_metadata(&candidate).is_ok() {
|
||||||
|
candidate = configs
|
||||||
|
.join(format!("default-synced.{}", n))
|
||||||
|
.join("rclone.conf");
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs `link` -> `target`, replacing whatever is at `link` atomically: the new link is built at
|
||||||
|
/// a sibling temp path and renamed over `link`, so a create failure leaves the prior link intact
|
||||||
|
/// (no empty-config-path window). On Windows, `rename` cannot overwrite, so we remove-then-rename
|
||||||
|
/// the already-built temp link (a small window, acceptable for that secondary platform).
|
||||||
|
///
|
||||||
|
/// A fixed sibling temp name is safe: set_config_sync's process-wide Mutex means swaps never run
|
||||||
|
/// concurrently, and the pre-create remove clears any leftover from a crashed prior run.
|
||||||
|
fn atomic_symlink_swap(target: &Path, link: &Path) -> Result<(), String> {
|
||||||
|
let mut tmp_os = link.as_os_str().to_os_string();
|
||||||
|
tmp_os.push(".tmp-link");
|
||||||
|
let tmp = PathBuf::from(tmp_os);
|
||||||
|
let _ = std::fs::remove_file(&tmp); // clear any leftover from a crashed prior run
|
||||||
|
create_config_symlink(target, &tmp)?;
|
||||||
|
if std::fs::rename(&tmp, link).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Windows: destination must not exist for rename. The old link is only removed once our own
|
||||||
|
// replacement is already built, so we never end up with nothing.
|
||||||
|
let _ = std::fs::remove_file(link);
|
||||||
|
if let Err(e) = std::fs::rename(&tmp, link) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_config_sync_status(
|
||||||
|
app: AppHandle,
|
||||||
|
app_config_path: String,
|
||||||
|
owned_link_target: Option<String>,
|
||||||
|
) -> Result<ConfigSyncStatus, String> {
|
||||||
|
let _ = app;
|
||||||
|
let system = system_config_path()?;
|
||||||
|
config_sync_status(&system, &app_config_path, owned_link_target.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core of `get_config_sync_status`, parameterized on the system-config path so it can be driven
|
||||||
|
/// against a scratch filesystem in tests. `owned_link_target` is our persisted ownership marker.
|
||||||
|
fn config_sync_status(
|
||||||
|
system: &Path,
|
||||||
|
app_config_path: &str,
|
||||||
|
owned_link_target: Option<&str>,
|
||||||
|
) -> Result<ConfigSyncStatus, String> {
|
||||||
|
let system_path = system.to_string_lossy().to_string();
|
||||||
|
let app_path = Path::new(app_config_path);
|
||||||
|
let app_canon = canonical(app_path);
|
||||||
|
|
||||||
|
// Circular: the app's active config IS the system config file — either at the same location (it
|
||||||
|
// adopted the system rclone's native config) or via a symlink chain that resolves to it. It writes
|
||||||
|
// that file directly, so a terminal already shares it and there is no link to manage; installing
|
||||||
|
// one would only create a loop. Walks the ACTIVE config's chain (not the system path's own final
|
||||||
|
// symlink), so a healthy managed link system -> active is never mistaken for this case.
|
||||||
|
if resolves_to_location(app_path, system) {
|
||||||
|
return Ok(ConfigSyncStatus {
|
||||||
|
enabled: true,
|
||||||
|
managed: false,
|
||||||
|
system_path,
|
||||||
|
backup_path: None,
|
||||||
|
default_backed_up: false,
|
||||||
|
warning: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_symlink = std::fs::symlink_metadata(system)
|
||||||
|
.map(|m| m.file_type().is_symlink())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !is_symlink {
|
||||||
|
// A plain file (foreign config) or nothing at all — not synced, nothing we manage.
|
||||||
|
return Ok(ConfigSyncStatus {
|
||||||
|
enabled: false,
|
||||||
|
managed: false,
|
||||||
|
system_path,
|
||||||
|
backup_path: None,
|
||||||
|
default_backed_up: false,
|
||||||
|
warning: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let managed = config_link_is_ours(system, owned_link_target);
|
||||||
|
let target = std::fs::read_link(system).ok();
|
||||||
|
// Require the target to actually exist: canonical() falls back to the lexical path for a missing
|
||||||
|
// file, so a dangling link to a since-deleted active config would otherwise compare equal to the
|
||||||
|
// (also-lexical) active path and be reported as healthy, suppressing the missing-file warning.
|
||||||
|
let enabled = managed
|
||||||
|
&& target
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.exists() && canonical(t) == app_canon)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// A managed link that no longer points at the active config (its config was deleted, or the
|
||||||
|
// active config changed out-of-band) — flag it; the next reconcile/switch/restart re-points it.
|
||||||
|
let warning = if managed && !enabled {
|
||||||
|
match target.as_ref() {
|
||||||
|
Some(t) if !t.exists() => Some(format!(
|
||||||
|
"The synced config link points at a missing file ({}). It will be re-pointed to the active config on the next switch or restart.",
|
||||||
|
t.to_string_lossy()
|
||||||
|
)),
|
||||||
|
Some(_) => Some(
|
||||||
|
"The terminal config link points at a different config than the active one; it will be re-pointed on the next switch or restart."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ConfigSyncStatus {
|
||||||
|
enabled,
|
||||||
|
managed,
|
||||||
|
system_path,
|
||||||
|
backup_path: None,
|
||||||
|
default_backed_up: false,
|
||||||
|
warning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_config_sync(
|
||||||
|
app: AppHandle,
|
||||||
|
enable: bool,
|
||||||
|
app_config_path: String,
|
||||||
|
owned_link_target: Option<String>,
|
||||||
|
default_config_path: Option<String>,
|
||||||
|
) -> Result<ConfigSyncStatus, String> {
|
||||||
|
// Serialize all mutations: concurrent reconciles (a startup heal racing a settings action) would
|
||||||
|
// otherwise both try to swap the single system path. A poisoned lock still yields the guard — we
|
||||||
|
// only guard filesystem ordering, and a panic mid-swap leaves recoverable on-disk state.
|
||||||
|
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
|
let system = system_config_path()?;
|
||||||
|
let base = app_local_data(&app)?;
|
||||||
|
apply_config_sync(
|
||||||
|
&system,
|
||||||
|
&base,
|
||||||
|
enable,
|
||||||
|
&app_config_path,
|
||||||
|
owned_link_target.as_deref(),
|
||||||
|
default_config_path.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core of `set_config_sync`, parameterized on the system-config path and app-local-data dir so it
|
||||||
|
/// can be driven against a scratch filesystem in tests. `owned_link_target` is our persisted
|
||||||
|
/// ownership marker (the target we last installed) — the sole proof that a link is ours.
|
||||||
|
fn apply_config_sync(
|
||||||
|
system: &Path,
|
||||||
|
base: &Path,
|
||||||
|
enable: bool,
|
||||||
|
app_config_path: &str,
|
||||||
|
owned_link_target: Option<&str>,
|
||||||
|
default_config_path: Option<&str>,
|
||||||
|
) -> Result<ConfigSyncStatus, String> {
|
||||||
|
let app_path = Path::new(app_config_path);
|
||||||
|
let mut backup_path: Option<String> = None;
|
||||||
|
let mut default_backed_up = false;
|
||||||
|
|
||||||
|
if enable {
|
||||||
|
// Circular case needs no link — the app config already IS (or resolves through a symlink
|
||||||
|
// chain to) the system file. Uses the same resolves_to_location walk as config_sync_status,
|
||||||
|
// so an active config that is a symlink to the system path is skipped here instead of getting
|
||||||
|
// a link installed on top of it (system -> active -> system would ELOOP). Kept consistent
|
||||||
|
// with the status call site; a plain same_location check would miss the symlink-chain case.
|
||||||
|
if !resolves_to_location(app_path, system) {
|
||||||
|
// Validate the target is a usable regular FILE (metadata follows symlinks): a dangling
|
||||||
|
// symlink or a directory would pass a bare symlink_metadata check yet install a
|
||||||
|
// broken/unusable link after moving a valid system config aside.
|
||||||
|
match std::fs::metadata(app_path) {
|
||||||
|
Ok(m) if m.is_file() => {}
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"The selected config file does not exist or is not a file: {}",
|
||||||
|
app_config_path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(parent) = system.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let occupied = std::fs::symlink_metadata(system).is_ok();
|
||||||
|
let ours = occupied && config_link_is_ours(system, owned_link_target);
|
||||||
|
|
||||||
|
// Already our link pointing at the active config — nothing to do (avoids churning a
|
||||||
|
// healthy link on every startup/switch reconcile).
|
||||||
|
if ours {
|
||||||
|
if let Ok(target) = std::fs::read_link(system) {
|
||||||
|
if canonical(&target) == canonical(app_path) {
|
||||||
|
return config_sync_status(system, app_config_path, Some(app_config_path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if occupied {
|
||||||
|
// A foreign real file or symlink sits here. Preserve it — never destroy user data.
|
||||||
|
// If it is the app's OWN default config, relocate it into app-local-data under a
|
||||||
|
// valid `<dir>/rclone.conf` name and flag it so the caller re-points defaultConfigPath
|
||||||
|
// there (a raw `.backup` name would break the app's folder-derivation). Otherwise
|
||||||
|
// move it aside to rclone.conf.backup.
|
||||||
|
let is_default = default_config_path
|
||||||
|
.map(|dc| same_location(system, Path::new(dc)))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let dest = if is_default {
|
||||||
|
default_backed_up = true;
|
||||||
|
relocated_default_path(base)
|
||||||
|
} else {
|
||||||
|
next_backup_path(system)
|
||||||
|
};
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
// Back the existing entry up by COPYING it aside, leaving the original in place: the
|
||||||
|
// atomic swap below replaces it via rename, so the system path is never momentarily
|
||||||
|
// empty. That closes a crash window (a crash between a move-aside and the swap would
|
||||||
|
// strand the system config in an unpersisted backup, leaving the path empty) and
|
||||||
|
// shrinks the external-writer race — a terminal `rclone` cannot drop a fresh config
|
||||||
|
// into an empty gap that no longer exists. The default is materialized to a real file
|
||||||
|
// (a relative symlink must not move cross-dir); a foreign entry is preserved exactly.
|
||||||
|
if is_default {
|
||||||
|
materialize_copy(system, &dest)?;
|
||||||
|
} else {
|
||||||
|
copy_aside(system, &dest)?;
|
||||||
|
}
|
||||||
|
backup_path = Some(dest.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install atomically over whatever is still at `system` (our own stale link, the
|
||||||
|
// preserved original, or nothing). On failure, undo: if the swap already removed the
|
||||||
|
// original (only its Windows fallback does) restore it from the backup copy; otherwise
|
||||||
|
// the original is untouched, so just discard the stray copy.
|
||||||
|
if let Err(e) = atomic_symlink_swap(app_path, system) {
|
||||||
|
if let Some(b) = backup_path.take() {
|
||||||
|
let b = Path::new(&b);
|
||||||
|
if std::fs::symlink_metadata(system).is_err() {
|
||||||
|
let _ = move_file(b, system);
|
||||||
|
} else {
|
||||||
|
let _ = std::fs::remove_file(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Disable: only remove a link we created (marker-proven); never touch foreign/user files,
|
||||||
|
// never restore backups.
|
||||||
|
if std::fs::symlink_metadata(system).is_ok()
|
||||||
|
&& config_link_is_ours(system, owned_link_target)
|
||||||
|
{
|
||||||
|
std::fs::remove_file(system).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report post-operation ownership: on enable we now own a link to app_path; on disable we own
|
||||||
|
// nothing. This mirrors the marker the caller persists (syncConfigLinkTarget), so the returned
|
||||||
|
// status is immediately accurate without waiting for that write to round-trip.
|
||||||
|
let effective_marker = if enable { Some(app_config_path) } else { None };
|
||||||
|
let mut status = config_sync_status(system, app_config_path, effective_marker)?;
|
||||||
|
// Carry the freshly-made backup + relocation flag through — config_sync_status can't know.
|
||||||
|
if backup_path.is_some() {
|
||||||
|
status.backup_path = backup_path;
|
||||||
|
}
|
||||||
|
status.default_backed_up = default_backed_up;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- macOS PATH helpers ----
|
// ---- macOS PATH helpers ----
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -1246,3 +1716,474 @@ fn windows_broadcast_env_change() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config-sync truth-model tests. They drive the real `apply_config_sync`/`config_sync_status`
|
||||||
|
// against a fully isolated scratch filesystem (system + app-local-data paths are passed in, so the
|
||||||
|
// user's real ~/.config is never touched).
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod config_sync_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn scratch(name: &str) -> PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("stray_cs_{}_{}", std::process::id(), name));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(path: &Path, contents: &str) {
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(path, contents).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_symlink(p: &Path) -> bool {
|
||||||
|
std::fs::symlink_metadata(p)
|
||||||
|
.map(|m| m.file_type().is_symlink())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A healthy managed link must report managed+enabled (NOT circular), so the UI does not disable
|
||||||
|
// the checkbox — and disable must then remove it. Ownership is proven by the marker.
|
||||||
|
#[test]
|
||||||
|
fn healthy_link_is_managed_not_circular() {
|
||||||
|
let root = scratch("healthy");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
let app_cfg = base.join("configs/default/rclone.conf");
|
||||||
|
write(&app_cfg, "[appremote]\n");
|
||||||
|
let app = app_cfg.to_str().unwrap();
|
||||||
|
|
||||||
|
// First enable: no prior marker.
|
||||||
|
let st = apply_config_sync(&system, &base, true, app, None, None).unwrap();
|
||||||
|
assert!(is_symlink(&system));
|
||||||
|
assert!(st.managed, "healthy link must be managed");
|
||||||
|
assert!(st.enabled);
|
||||||
|
assert!(st.backup_path.is_none());
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[appremote]\n");
|
||||||
|
|
||||||
|
// Status with the marker we now hold.
|
||||||
|
let st2 = config_sync_status(&system, app, Some(app)).unwrap();
|
||||||
|
assert!(st2.managed && st2.enabled);
|
||||||
|
|
||||||
|
let st3 = apply_config_sync(&system, &base, false, app, Some(app), None).unwrap();
|
||||||
|
assert!(!is_symlink(&system), "disable must remove the link");
|
||||||
|
assert!(!st3.managed && !st3.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The genuine circular case (app config IS the system file) stays enabled+unmanaged and enable
|
||||||
|
// is a no-op that leaves the real file untouched.
|
||||||
|
#[test]
|
||||||
|
fn circular_direct_use() {
|
||||||
|
let root = scratch("circular");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
write(&system, "[native]\n");
|
||||||
|
let app = system.to_str().unwrap();
|
||||||
|
|
||||||
|
let st = config_sync_status(&system, app, None).unwrap();
|
||||||
|
assert!(st.enabled && !st.managed);
|
||||||
|
|
||||||
|
let st2 = apply_config_sync(&system, &base, true, app, None, None).unwrap();
|
||||||
|
assert!(!is_symlink(&system));
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[native]\n");
|
||||||
|
assert!(st2.enabled && !st2.managed && st2.backup_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the app's own default config lives at the system path, syncing a DIFFERENT config must
|
||||||
|
// preserve it (relocate) and flag default_backed_up so the caller re-points default.
|
||||||
|
#[test]
|
||||||
|
fn default_at_system_path_is_relocated() {
|
||||||
|
let root = scratch("default_reloc");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
write(&system, "[defaultremote]\n");
|
||||||
|
let work = base.join("configs/work/rclone.conf");
|
||||||
|
write(&work, "[workremote]\n");
|
||||||
|
|
||||||
|
let st = apply_config_sync(
|
||||||
|
&system,
|
||||||
|
&base,
|
||||||
|
true,
|
||||||
|
work.to_str().unwrap(),
|
||||||
|
None,
|
||||||
|
Some(system.to_str().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(st.default_backed_up, "must flag that the default was moved");
|
||||||
|
let relocated = st.backup_path.clone().expect("the default must be relocated");
|
||||||
|
// Invariant: the relocated default keeps a `<dir>/rclone.conf` name (NOT `.backup`) and
|
||||||
|
// lives under app-local-data, so re-pinning defaultConfigPath there never breaks loading.
|
||||||
|
assert!(
|
||||||
|
relocated.ends_with("rclone.conf"),
|
||||||
|
"relocated default must keep the rclone.conf filename, got {relocated}"
|
||||||
|
);
|
||||||
|
assert!(!relocated.ends_with(".backup"));
|
||||||
|
assert!(Path::new(&relocated).starts_with(&base));
|
||||||
|
assert!(!is_symlink(Path::new(&relocated)), "relocated default must be a real file");
|
||||||
|
assert_eq!(std::fs::read_to_string(&relocated).unwrap(), "[defaultremote]\n");
|
||||||
|
assert!(is_symlink(&system));
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[workremote]\n");
|
||||||
|
assert!(st.managed && st.enabled);
|
||||||
|
|
||||||
|
// Re-pin default at the relocated path, reconcile with default active (marker = the link we
|
||||||
|
// hold, → work): it must NOT relocate again and must re-point the system link at default.
|
||||||
|
let st2 = apply_config_sync(
|
||||||
|
&system,
|
||||||
|
&base,
|
||||||
|
true,
|
||||||
|
&relocated,
|
||||||
|
Some(work.to_str().unwrap()),
|
||||||
|
Some(&relocated),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!st2.default_backed_up, "must not relocate an already-relocated default");
|
||||||
|
assert!(st2.backup_path.is_none());
|
||||||
|
assert!(st2.managed && st2.enabled);
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[defaultremote]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relocating a default that is a RELATIVE symlink must materialize its content into a
|
||||||
|
// real file (moving the symlink would leave a relative target resolving against the wrong dir).
|
||||||
|
#[test]
|
||||||
|
fn relative_symlink_default_is_materialized() {
|
||||||
|
let root = scratch("rel_default");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
let actual = system.parent().unwrap().join("actual.conf");
|
||||||
|
write(&actual, "[realdefault]\n");
|
||||||
|
// system -> "actual.conf" (relative to the system dir).
|
||||||
|
std::fs::create_dir_all(system.parent().unwrap()).unwrap();
|
||||||
|
std::os::unix::fs::symlink("actual.conf", &system).unwrap();
|
||||||
|
let work = base.join("configs/work/rclone.conf");
|
||||||
|
write(&work, "[work]\n");
|
||||||
|
|
||||||
|
let st = apply_config_sync(
|
||||||
|
&system,
|
||||||
|
&base,
|
||||||
|
true,
|
||||||
|
work.to_str().unwrap(),
|
||||||
|
None,
|
||||||
|
Some(system.to_str().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(st.default_backed_up);
|
||||||
|
let relocated = st.backup_path.expect("relocated");
|
||||||
|
assert!(
|
||||||
|
!is_symlink(Path::new(&relocated)),
|
||||||
|
"relocated default must be a real file, not a moved symlink"
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_to_string(&relocated).unwrap(), "[realdefault]\n");
|
||||||
|
assert!(is_symlink(&system));
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[work]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// An external-folder target (outside app-local-data) is owned via the marker: a re-point does
|
||||||
|
// not spuriously back up our own link, and disable removes it.
|
||||||
|
#[test]
|
||||||
|
fn external_sync_target_ownership() {
|
||||||
|
let root = scratch("external");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
let ext = root.join("external/rclone.conf");
|
||||||
|
write(&ext, "[extremote]\n");
|
||||||
|
let app_priv = base.join("configs/other/rclone.conf");
|
||||||
|
write(&app_priv, "[otherremote]\n");
|
||||||
|
let ext_s = ext.to_str().unwrap();
|
||||||
|
let priv_s = app_priv.to_str().unwrap();
|
||||||
|
|
||||||
|
let st = apply_config_sync(&system, &base, true, ext_s, None, None).unwrap();
|
||||||
|
assert!(st.managed, "external target link must be recognized as ours");
|
||||||
|
assert!(st.enabled && st.backup_path.is_none());
|
||||||
|
|
||||||
|
// Re-point to an app-private config; marker is the link we hold (→ext) → removed, not backed up.
|
||||||
|
let st2 = apply_config_sync(&system, &base, true, priv_s, Some(ext_s), None).unwrap();
|
||||||
|
assert!(st2.backup_path.is_none(), "re-point must not back up our own link");
|
||||||
|
assert!(st2.managed && st2.enabled);
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[otherremote]\n");
|
||||||
|
|
||||||
|
// Point back to external (marker now →app_priv), then disable (marker →ext): removed.
|
||||||
|
apply_config_sync(&system, &base, true, ext_s, Some(priv_s), None).unwrap();
|
||||||
|
let st3 = apply_config_sync(&system, &base, false, ext_s, Some(ext_s), None).unwrap();
|
||||||
|
assert!(!is_symlink(&system), "disable must remove the external-target link");
|
||||||
|
assert!(!st3.managed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling for a missing / dangling / directory target must fail WITHOUT disturbing a
|
||||||
|
// good system config (no move-aside, no broken link, no backup).
|
||||||
|
#[test]
|
||||||
|
fn unusable_target_does_not_touch_system() {
|
||||||
|
let root = scratch("unusable_target");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
write(&system, "[existing]\n");
|
||||||
|
|
||||||
|
let assert_untouched = || {
|
||||||
|
assert!(!is_symlink(&system));
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[existing]\n");
|
||||||
|
assert!(!system.with_file_name("rclone.conf.backup").exists());
|
||||||
|
};
|
||||||
|
|
||||||
|
// (a) missing file
|
||||||
|
let ghost = base.join("configs/ghost/rclone.conf");
|
||||||
|
assert!(apply_config_sync(&system, &base, true, ghost.to_str().unwrap(), None, None).is_err());
|
||||||
|
assert_untouched();
|
||||||
|
|
||||||
|
// (b) dangling symlink target
|
||||||
|
let dangling = base.join("configs/dangling/rclone.conf");
|
||||||
|
std::fs::create_dir_all(dangling.parent().unwrap()).unwrap();
|
||||||
|
std::os::unix::fs::symlink(base.join("nope/rclone.conf"), &dangling).unwrap();
|
||||||
|
assert!(
|
||||||
|
apply_config_sync(&system, &base, true, dangling.to_str().unwrap(), None, None).is_err(),
|
||||||
|
"a dangling symlink target must be rejected"
|
||||||
|
);
|
||||||
|
assert_untouched();
|
||||||
|
|
||||||
|
// (c) directory at the target path
|
||||||
|
let dir_target = base.join("configs/dir/rclone.conf");
|
||||||
|
std::fs::create_dir_all(&dir_target).unwrap();
|
||||||
|
assert!(
|
||||||
|
apply_config_sync(&system, &base, true, dir_target.to_str().unwrap(), None, None).is_err(),
|
||||||
|
"a directory target must be rejected"
|
||||||
|
);
|
||||||
|
assert_untouched();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the active synced config leaves a dangling link. On the next reconcile it must be
|
||||||
|
// reclaimed as ours via the marker (removed / re-pointed) — NOT treated as foreign and backed up
|
||||||
|
// — even when the app-local-data path traverses a symlink (macOS /var -> /private/var), which
|
||||||
|
// makes canonical() fall back to a lexical path for the now-missing target.
|
||||||
|
#[test]
|
||||||
|
fn dangling_link_to_deleted_config_is_reclaimed() {
|
||||||
|
let root = scratch("dangling");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(root.join("real_base")).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::symlink(root.join("real_base"), &base).unwrap();
|
||||||
|
std::fs::create_dir_all(&base).ok();
|
||||||
|
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
let deleted = base.join("configs/gone/rclone.conf");
|
||||||
|
write(&deleted, "[gone]\n");
|
||||||
|
let default_cfg = base.join("configs/default/rclone.conf");
|
||||||
|
write(&default_cfg, "[default]\n");
|
||||||
|
let gone_s = deleted.to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Enable while 'gone' is active (marker →gone), then delete its directory (dangling link).
|
||||||
|
apply_config_sync(&system, &base, true, &gone_s, None, None).unwrap();
|
||||||
|
std::fs::remove_dir_all(base.join("configs/gone")).unwrap();
|
||||||
|
assert!(is_symlink(&system));
|
||||||
|
|
||||||
|
// The dangling link is still ours (marker →gone): managed + not enabled + warning.
|
||||||
|
let st = config_sync_status(&system, default_cfg.to_str().unwrap(), Some(&gone_s)).unwrap();
|
||||||
|
assert!(st.managed, "dangling own link must still be recognized as ours");
|
||||||
|
assert!(!st.enabled);
|
||||||
|
assert!(st.warning.is_some(), "a missing-target warning should be surfaced");
|
||||||
|
|
||||||
|
// Reconcile onto default: our link is reclaimed and re-pointed, NOT backed up.
|
||||||
|
let st2 =
|
||||||
|
apply_config_sync(&system, &base, true, default_cfg.to_str().unwrap(), Some(&gone_s), None)
|
||||||
|
.unwrap();
|
||||||
|
assert!(st2.backup_path.is_none(), "reclaiming our dangling link must not create a backup");
|
||||||
|
assert!(st2.managed && st2.enabled);
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[default]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dangling link whose target IS the (now-deleted) active config must not report as healthy.
|
||||||
|
#[test]
|
||||||
|
fn dangling_link_to_deleted_active_is_not_healthy() {
|
||||||
|
let root = scratch("dangling_active");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
let active = base.join("configs/work/rclone.conf");
|
||||||
|
write(&active, "[work]\n");
|
||||||
|
let active_s = active.to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
apply_config_sync(&system, &base, true, &active_s, None, None).unwrap();
|
||||||
|
std::fs::remove_dir_all(base.join("configs/work")).unwrap();
|
||||||
|
|
||||||
|
let st = config_sync_status(&system, &active_s, Some(&active_s)).unwrap();
|
||||||
|
assert!(st.managed, "still our link");
|
||||||
|
assert!(!st.enabled, "a dangling link to the deleted active config must not report enabled");
|
||||||
|
assert!(st.warning.is_some(), "the missing-file warning must be surfaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user's OWN symlink at the system path — even one pointing at an app config — must
|
||||||
|
// never be misattributed to us (no marker, or a marker for a different target) and so is
|
||||||
|
// preserved (backed up), never silently replaced or removed.
|
||||||
|
#[test]
|
||||||
|
fn user_symlink_is_not_ours_and_is_preserved() {
|
||||||
|
let root = scratch("user_link");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
// The user manually symlinked the system path at ONE OF THE APP'S OWN config files.
|
||||||
|
let app_work = base.join("configs/work/rclone.conf");
|
||||||
|
write(&app_work, "[work]\n");
|
||||||
|
std::fs::create_dir_all(system.parent().unwrap()).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&app_work, &system).unwrap();
|
||||||
|
|
||||||
|
let active = base.join("configs/default/rclone.conf");
|
||||||
|
write(&active, "[default]\n");
|
||||||
|
let active_s = active.to_str().unwrap();
|
||||||
|
|
||||||
|
// No marker: the user's link is not ours even though its target is an app config.
|
||||||
|
let st = config_sync_status(&system, active_s, None).unwrap();
|
||||||
|
assert!(!st.managed, "a user's own symlink must NOT be classified as ours");
|
||||||
|
|
||||||
|
// Enabling for a different config must BACK UP the user's link, not silently replace it.
|
||||||
|
let st2 = apply_config_sync(&system, &base, true, active_s, None, None).unwrap();
|
||||||
|
let backup = st2.backup_path.expect("user's symlink must be backed up, not replaced");
|
||||||
|
assert!(is_symlink(Path::new(&backup)), "the backed-up entry is the user's symlink, preserved");
|
||||||
|
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "[work]\n");
|
||||||
|
assert!(is_symlink(&system));
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[default]\n");
|
||||||
|
|
||||||
|
// Disable against a marker for a DIFFERENT target must not remove a user's link.
|
||||||
|
let root2 = scratch("user_link2");
|
||||||
|
let base2 = root2.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base2).unwrap();
|
||||||
|
let system2 = root2.join("system/rclone/rclone.conf");
|
||||||
|
let user_target = root2.join("mine/rclone.conf");
|
||||||
|
write(&user_target, "[mine]\n");
|
||||||
|
std::fs::create_dir_all(system2.parent().unwrap()).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&user_target, &system2).unwrap();
|
||||||
|
apply_config_sync(&system2, &base2, false, "/x/rclone.conf", Some("/y/rclone.conf"), None)
|
||||||
|
.unwrap();
|
||||||
|
assert!(is_symlink(&system2), "disable must not remove a link that isn't ours");
|
||||||
|
assert_eq!(std::fs::read_to_string(&system2).unwrap(), "[mine]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A foreign real file at the system path (not the default) is preserved via backup on enable.
|
||||||
|
#[test]
|
||||||
|
fn foreign_file_backed_up() {
|
||||||
|
let root = scratch("foreign");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
write(&system, "[foreign]\n");
|
||||||
|
let app_cfg = base.join("configs/default/rclone.conf");
|
||||||
|
write(&app_cfg, "[app]\n");
|
||||||
|
|
||||||
|
let st = apply_config_sync(
|
||||||
|
&system,
|
||||||
|
&base,
|
||||||
|
true,
|
||||||
|
app_cfg.to_str().unwrap(),
|
||||||
|
None,
|
||||||
|
Some("/some/other/path/rclone.conf"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let backup = st.backup_path.expect("foreign file backed up");
|
||||||
|
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "[foreign]\n");
|
||||||
|
assert!(!st.default_backed_up, "not the default → no relocation flag");
|
||||||
|
assert!(is_symlink(&system));
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[app]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership is proven by the target marker. A link whose target equals our recorded
|
||||||
|
// marker is treated as ours even if a user hand-made an identical link — because removing it on
|
||||||
|
// disable is exactly the requested end state (no link at the system path), the outcome is correct
|
||||||
|
// either way. (Contrast user_symlink_*: a link to a DIFFERENT target, or with no marker, is never
|
||||||
|
// ours and is preserved.)
|
||||||
|
#[test]
|
||||||
|
fn link_matching_marker_is_ours() {
|
||||||
|
let root = scratch("marker_owns");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
let app_cfg = base.join("configs/default/rclone.conf");
|
||||||
|
write(&app_cfg, "[app]\n");
|
||||||
|
let app = app_cfg.to_str().unwrap();
|
||||||
|
// A link at the system path pointing exactly at our marker target, created out-of-band.
|
||||||
|
std::fs::create_dir_all(system.parent().unwrap()).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&app_cfg, &system).unwrap();
|
||||||
|
|
||||||
|
// With the marker == that target, it is ours (managed + enabled)...
|
||||||
|
let st = config_sync_status(&system, app, Some(app)).unwrap();
|
||||||
|
assert!(st.managed && st.enabled, "a link to our exact marker target is ours");
|
||||||
|
// ...and disable removes it — the requested end state (no link), regardless of who made it.
|
||||||
|
let st2 = apply_config_sync(&system, &base, false, app, Some(app), None).unwrap();
|
||||||
|
assert!(!is_symlink(&system) && !st2.managed, "disable clears a link matching our marker");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DANGLING symlink sitting at the first relocation candidate must be treated as
|
||||||
|
// occupied. exists() follows the link (missing target → "free"), which would make materialize_copy
|
||||||
|
// std::fs::copy THROUGH it and write outside app-data; symlink_metadata (lexists) counts it as
|
||||||
|
// taken, so relocation picks the next free slot and writes a real file instead.
|
||||||
|
#[test]
|
||||||
|
fn relocation_skips_dangling_candidate() {
|
||||||
|
let root = scratch("reloc_dangling");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let candidate = base.join("configs/default-synced/rclone.conf");
|
||||||
|
std::fs::create_dir_all(candidate.parent().unwrap()).unwrap();
|
||||||
|
let orphan_target = base.join("missing/elsewhere.conf");
|
||||||
|
std::os::unix::fs::symlink(&orphan_target, &candidate).unwrap();
|
||||||
|
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
write(&system, "[defaultremote]\n");
|
||||||
|
let work = base.join("configs/work/rclone.conf");
|
||||||
|
write(&work, "[work]\n");
|
||||||
|
|
||||||
|
let st = apply_config_sync(
|
||||||
|
&system,
|
||||||
|
&base,
|
||||||
|
true,
|
||||||
|
work.to_str().unwrap(),
|
||||||
|
None,
|
||||||
|
Some(system.to_str().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let relocated = st.backup_path.expect("relocated");
|
||||||
|
assert_ne!(Path::new(&relocated), candidate.as_path(), "must not reuse the dangling candidate");
|
||||||
|
assert!(
|
||||||
|
relocated.contains("default-synced.1"),
|
||||||
|
"should skip to the next free slot, got {relocated}"
|
||||||
|
);
|
||||||
|
assert!(!is_symlink(Path::new(&relocated)), "relocated default must be a real file");
|
||||||
|
assert_eq!(std::fs::read_to_string(&relocated).unwrap(), "[defaultremote]\n");
|
||||||
|
// The dangling candidate and its (never-existent) target are untouched — nothing written through it.
|
||||||
|
assert!(is_symlink(&candidate), "the dangling candidate link is left as-is");
|
||||||
|
assert!(!orphan_target.exists(), "must not have created the dangling link's target");
|
||||||
|
}
|
||||||
|
|
||||||
|
// An active config that is itself a symlink resolving to the system path must be treated as
|
||||||
|
// CIRCULAR on enable (not a manageable link). Installing system -> active would close a two-link
|
||||||
|
// cycle (active -> system -> active) whose reads fail with ELOOP, breaking both the terminal and
|
||||||
|
// the app. So enable is a no-op that leaves the real system file untouched. This can arise from a
|
||||||
|
// dotfile setup (an adopted default config symlinked to ~/.config/rclone/rclone.conf), not only
|
||||||
|
// deliberate sabotage.
|
||||||
|
#[test]
|
||||||
|
fn active_symlink_resolving_to_system_is_circular() {
|
||||||
|
let root = scratch("active_symlink_cycle");
|
||||||
|
let base = root.join("appdata");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let system = root.join("system/rclone/rclone.conf");
|
||||||
|
write(&system, "[native]\n");
|
||||||
|
// An app config that is a symlink pointing at the system path.
|
||||||
|
let app_cfg = base.join("configs/aliased/rclone.conf");
|
||||||
|
std::fs::create_dir_all(app_cfg.parent().unwrap()).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&system, &app_cfg).unwrap();
|
||||||
|
let app = app_cfg.to_str().unwrap();
|
||||||
|
|
||||||
|
// Status: circular (enabled, unmanaged) — the walk reaches the system location.
|
||||||
|
let st = config_sync_status(&system, app, None).unwrap();
|
||||||
|
assert!(st.enabled && !st.managed, "an active symlink resolving to system is circular");
|
||||||
|
|
||||||
|
// Enable is a no-op: no link installed (would ELOOP), real system file untouched, no backup.
|
||||||
|
let st2 = apply_config_sync(&system, &base, true, app, None, None).unwrap();
|
||||||
|
assert!(!is_symlink(&system), "must NOT install a link over the system file (would ELOOP)");
|
||||||
|
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[native]\n");
|
||||||
|
assert!(st2.backup_path.is_none());
|
||||||
|
assert!(st2.enabled && !st2.managed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
compareVersions,
|
compareVersions,
|
||||||
findSystemRclone,
|
findSystemRclone,
|
||||||
probeRcloneBinaryOrThrow,
|
probeRcloneBinaryOrThrow,
|
||||||
|
resolveActiveConfigPath,
|
||||||
validateRcloneBinary,
|
validateRcloneBinary,
|
||||||
} from '../../../lib/rclone/common'
|
} from '../../../lib/rclone/common'
|
||||||
import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants'
|
import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants'
|
||||||
@@ -26,11 +27,14 @@ import {
|
|||||||
deleteVersion,
|
deleteVersion,
|
||||||
downloadVersion,
|
downloadVersion,
|
||||||
fetchAvailableVersions,
|
fetchAvailableVersions,
|
||||||
|
getConfigSync,
|
||||||
getPathIntegration,
|
getPathIntegration,
|
||||||
listDownloadedVersions,
|
listDownloadedVersions,
|
||||||
|
setConfigSync,
|
||||||
setPathIntegration,
|
setPathIntegration,
|
||||||
|
withConfigSyncLock,
|
||||||
} from '../../../lib/rclone/versions'
|
} from '../../../lib/rclone/versions'
|
||||||
import { useHostStore } from '../../../store/host'
|
import { flushHostStore, useHostStore } from '../../../store/host'
|
||||||
import { usePersistedStore } from '../../../store/persisted'
|
import { usePersistedStore } from '../../../store/persisted'
|
||||||
import BaseSection from './BaseSection'
|
import BaseSection from './BaseSection'
|
||||||
|
|
||||||
@@ -174,16 +178,17 @@ export default function BinarySection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- PATH integration ---- */}
|
{/* ---- Integration ---- */}
|
||||||
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||||
<div className="flex flex-col items-end flex-1 gap-2">
|
<div className="flex flex-col items-end flex-1 gap-2">
|
||||||
<h3 className="font-medium">Path</h3>
|
<h3 className="font-medium">Integration</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-3/5 gap-3">
|
<div className="flex flex-col w-3/5 gap-6">
|
||||||
<PathIntegrationRow
|
<PathIntegrationRow
|
||||||
rclonePath={rclonePath}
|
rclonePath={rclonePath}
|
||||||
isSystemActive={active?.kind === 'system'}
|
isSystemActive={active?.kind === 'system'}
|
||||||
/>
|
/>
|
||||||
|
<ConfigSyncRow hasSystemRclone={!!systemQuery.data} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -578,3 +583,134 @@ function PathIntegrationRow({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConfigSyncRow({ hasSystemRclone }: { hasSystemRclone: boolean }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const activeConfigId = useHostStore((state) => state.activeConfigId)
|
||||||
|
const syncIntent = useHostStore((state) => state.syncConfigToSystem)
|
||||||
|
const syncMarker = useHostStore((state) => state.syncConfigLinkTarget)
|
||||||
|
const setConfigSyncState = useHostStore((state) => state.setConfigSyncState)
|
||||||
|
const setDefaultConfigPath = useHostStore((state) => state.setDefaultConfigPath)
|
||||||
|
|
||||||
|
// The active config's on-disk path (the symlink target); re-resolves when the active config changes.
|
||||||
|
const configPathQuery = useQuery({
|
||||||
|
queryKey: ['rclone', 'active-config-path', activeConfigId],
|
||||||
|
queryFn: () => resolveActiveConfigPath(),
|
||||||
|
})
|
||||||
|
const appConfigPath = configPathQuery.data
|
||||||
|
|
||||||
|
// Shares the PATH-integration cache so this stays in step with the checkbox above.
|
||||||
|
const pathQuery = useQuery({
|
||||||
|
queryKey: ['rclone', 'path-integration'],
|
||||||
|
queryFn: getPathIntegration,
|
||||||
|
})
|
||||||
|
// Only meaningful if a terminal rclone would read the system config path. But an ALREADY-enabled
|
||||||
|
// sync must always be switch-off-able even if availability later disappears (PATH turned off, no
|
||||||
|
// system binary) — otherwise the user is stuck with a link they can't remove.
|
||||||
|
const available = hasSystemRclone || Boolean(pathQuery.data?.enabled)
|
||||||
|
const canToggle = available || syncIntent
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['rclone', 'config-sync', appConfigPath, syncMarker],
|
||||||
|
queryFn: () => getConfigSync(appConfigPath!, syncMarker),
|
||||||
|
enabled: !!appConfigPath,
|
||||||
|
})
|
||||||
|
const status = statusQuery.data
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
// Serialized against reconcile (startup/switch/activation) via the shared config-sync lock, so
|
||||||
|
// a toggle and a stale reconcile can't interleave their read→invoke→persist sequences.
|
||||||
|
mutationFn: (enable: boolean) =>
|
||||||
|
withConfigSyncLock(async () => {
|
||||||
|
// Resolve the target fresh INSIDE the lock (a switch just before us may have moved it).
|
||||||
|
const path = await resolveActiveConfigPath()
|
||||||
|
const host = useHostStore.getState()
|
||||||
|
const result = await setConfigSync(
|
||||||
|
enable,
|
||||||
|
path,
|
||||||
|
host.syncConfigLinkTarget,
|
||||||
|
host.defaultConfigPath ?? null
|
||||||
|
)
|
||||||
|
// If the file we moved aside was the app's own default config, follow it to the
|
||||||
|
// relocated copy so switching back to `default` still reads the user's remotes.
|
||||||
|
if (result.defaultBackedUp && result.backupPath) {
|
||||||
|
setDefaultConfigPath(result.backupPath)
|
||||||
|
}
|
||||||
|
// Persist intent + ownership marker together (one store write): the marker is the
|
||||||
|
// active path when we now hold a link, else null. Startup reconcile uses these to
|
||||||
|
// self-heal. Flush durably so a crash can't desync the store from the on-disk link.
|
||||||
|
setConfigSyncState({
|
||||||
|
intent: enable,
|
||||||
|
linkTarget: enable && result.managed ? path : null,
|
||||||
|
})
|
||||||
|
await flushHostStore()
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (result.backupPath) {
|
||||||
|
await message(`Your existing rclone config was moved to:\n${result.backupPath}`, {
|
||||||
|
title: 'Config backed up',
|
||||||
|
kind: 'info',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rclone', 'config-sync'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rclone', 'active-config-path'] })
|
||||||
|
},
|
||||||
|
onError: async (e) => {
|
||||||
|
await reportError(e, { title: 'Config sync', fallback: String(e), capture: false })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rclone', 'config-sync'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The checkbox reflects the user's opt-in (persisted intent), not the momentary filesystem state.
|
||||||
|
// This keeps the circular/direct-use case honest: it reads "on" only when the user actually
|
||||||
|
// enabled sync, so switching to another config then re-points the system path as the UI implied.
|
||||||
|
const isSynced = syncIntent
|
||||||
|
// Circular/direct-use: the active config IS the system file, so there is no link to manage — but
|
||||||
|
// it stays toggleable so the user can opt in (intent) to have the terminal follow future switches.
|
||||||
|
const circular = Boolean(status?.enabled && !status?.managed)
|
||||||
|
// Intent is on but the link is not currently applied to the active config (drift, or a missing/
|
||||||
|
// blocked link) — surface it so the terminal isn't silently left on a different config.
|
||||||
|
const notApplied = syncIntent && !!status && !status.enabled && !circular
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Checkbox
|
||||||
|
isSelected={isSynced}
|
||||||
|
isDisabled={
|
||||||
|
toggleMutation.isPending ||
|
||||||
|
statusQuery.isLoading ||
|
||||||
|
configPathQuery.isLoading ||
|
||||||
|
!canToggle
|
||||||
|
}
|
||||||
|
onValueChange={(checked) => toggleMutation.mutate(checked)}
|
||||||
|
>
|
||||||
|
Sync config
|
||||||
|
</Checkbox>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
Keeps your selected config in sync with the system rclone config, so rclone in your
|
||||||
|
terminal shares the app's remotes.
|
||||||
|
</span>
|
||||||
|
{!available && (
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{syncIntent
|
||||||
|
? 'No terminal rclone is available right now (system rclone missing and “Add rclone to PATH” is off), so the shell won’t see this config until one is. You can still turn sync off.'
|
||||||
|
: 'Requires a system rclone or “Add rclone to PATH”.'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{circular && (
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
The app already uses the system rclone config directly.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status?.warning ? (
|
||||||
|
<span className="text-xs text-warning">{status.warning}</span>
|
||||||
|
) : notApplied ? (
|
||||||
|
<span className="text-xs text-warning">
|
||||||
|
Sync is on, but the system config link isn’t in place yet; it will be re-applied
|
||||||
|
on the next config switch or restart.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import { onErrorDialog } from '../../../lib/errors'
|
|||||||
import { removeConfigPassword, setConfigPassword } from '../../../lib/rclone/api'
|
import { removeConfigPassword, setConfigPassword } from '../../../lib/rclone/api'
|
||||||
import { promptForConfigPassword, restartActiveRclone } from '../../../lib/rclone/cli'
|
import { promptForConfigPassword, restartActiveRclone } from '../../../lib/rclone/cli'
|
||||||
import rclone from '../../../lib/rclone/client'
|
import rclone from '../../../lib/rclone/client'
|
||||||
import { getConfigPath } from '../../../lib/rclone/common'
|
import { getConfigPath, resolveConfigFilePath } from '../../../lib/rclone/common'
|
||||||
|
import { reconcileConfigSync } from '../../../lib/rclone/versions'
|
||||||
import { selectActiveConfigFile, useHostStore } from '../../../store/host'
|
import { selectActiveConfigFile, useHostStore } from '../../../store/host'
|
||||||
import { usePersistedStore } from '../../../store/persisted'
|
import { usePersistedStore } from '../../../store/persisted'
|
||||||
import type { ConfigFile } from '../../../types/config'
|
import type { ConfigFile } from '../../../types/config'
|
||||||
@@ -73,24 +74,46 @@ export default function ConfigSection() {
|
|||||||
|
|
||||||
const shouldRestartRclone =
|
const shouldRestartRclone =
|
||||||
Boolean(activeConfigFile?.isEncrypted) || Boolean(configFile.isEncrypted)
|
Boolean(activeConfigFile?.isEncrypted) || Boolean(configFile.isEncrypted)
|
||||||
|
const prevActiveId = activeConfigFile?.id ?? 'default'
|
||||||
|
|
||||||
useHostStore.getState().setActiveConfigFile(configFile.id!)
|
const reconcileSyncLink = async () => {
|
||||||
|
const sync = await reconcileConfigSync()
|
||||||
|
if (sync.error && useHostStore.getState().syncConfigToSystem) {
|
||||||
|
await message(
|
||||||
|
`Switched config, but the terminal config sync link could not be updated:\n${sync.error}`,
|
||||||
|
{ title: 'Config sync', kind: 'warning', okLabel: 'OK' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldRestartRclone) {
|
if (shouldRestartRclone) {
|
||||||
await restartActiveRclone()
|
// Encrypted path: the daemon re-inits from the restart snapshot, which reads the
|
||||||
|
// store — so update the store and re-point the sync link (capturing any relocated
|
||||||
|
// defaultConfigPath) BEFORE emitting the restart. If the emit itself fails, roll the
|
||||||
|
// store + link back so all of store/terminal/daemon stay on the previous config.
|
||||||
|
useHostStore.getState().setActiveConfigFile(configFile.id!)
|
||||||
|
await reconcileSyncLink()
|
||||||
|
if (!(await restartActiveRclone())) {
|
||||||
|
useHostStore.getState().setActiveConfigFile(prevActiveId)
|
||||||
|
await reconcileConfigSync()
|
||||||
|
throw new Error('Could not restart rclone to apply the config switch.')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const configPath = await getConfigPath({
|
// Live path: resolve + validate the target's real path (honoring an external `sync`
|
||||||
id: configFile.id!,
|
// folder) and move the RUNNING daemon FIRST — the throwable step. Only once it
|
||||||
validate: true,
|
// succeeds do we commit the store + re-point the sync link, so a /config/setpath
|
||||||
})
|
// failure leaves store, terminal, and daemon all on the previous config (no
|
||||||
|
// divergence, nothing to roll back).
|
||||||
|
const setpathTarget = await resolveConfigFilePath(configFile, { validate: true })
|
||||||
await rclone('/config/setpath', {
|
await rclone('/config/setpath', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
'path': configPath,
|
'path': setpathTarget,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
useHostStore.getState().setActiveConfigFile(configFile.id!)
|
||||||
|
await reconcileSyncLink()
|
||||||
}
|
}
|
||||||
await queryClient.cancelQueries()
|
await queryClient.cancelQueries()
|
||||||
await queryClient.resetQueries()
|
await queryClient.resetQueries()
|
||||||
@@ -467,6 +490,7 @@ function ConfigCard({
|
|||||||
exportConfigMutation: any
|
exportConfigMutation: any
|
||||||
}) {
|
}) {
|
||||||
const isActive = configFile.id === activeConfigFile?.id
|
const isActive = configFile.id === activeConfigFile?.id
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const disabledKeys = useMemo(() => {
|
const disabledKeys = useMemo(() => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
@@ -725,15 +749,102 @@ function ConfigCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeConfigFile?.id === configFile.id) {
|
const deletedWasActive =
|
||||||
|
activeConfigFile?.id === configFile.id
|
||||||
|
|
||||||
|
// Deleting the active config falls back to
|
||||||
|
// 'default': move the running daemon AND any
|
||||||
|
// config-sync link off the now-deleted file,
|
||||||
|
// mirroring a normal switch (otherwise both keep
|
||||||
|
// pointing at a config that no longer exists). The
|
||||||
|
// sync link is recognized as ours via the persisted
|
||||||
|
// marker, so ordering vs removeConfigFile no longer
|
||||||
|
// affects ownership — but removeConfigFile must still
|
||||||
|
// precede any restart so the snapshot can't resurrect
|
||||||
|
// the deleted config, and reconcile must precede the
|
||||||
|
// restart so the snapshot carries any relocated
|
||||||
|
// defaultConfigPath.
|
||||||
|
if (deletedWasActive) {
|
||||||
useHostStore
|
useHostStore
|
||||||
.getState()
|
.getState()
|
||||||
.setActiveConfigFile('default')
|
.setActiveConfigFile('default')
|
||||||
|
|
||||||
|
const sync = await reconcileConfigSync()
|
||||||
|
if (
|
||||||
|
sync.error &&
|
||||||
|
useHostStore.getState().syncConfigToSystem
|
||||||
|
) {
|
||||||
|
await message(
|
||||||
|
`Deleted the config, but the terminal config sync link could not be updated:\n${sync.error}`,
|
||||||
|
{
|
||||||
|
title: 'Config sync',
|
||||||
|
kind: 'warning',
|
||||||
|
okLabel: 'OK',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useHostStore
|
useHostStore
|
||||||
.getState()
|
.getState()
|
||||||
.removeConfigFile(configFile.id!)
|
.removeConfigFile(configFile.id!)
|
||||||
|
|
||||||
|
if (deletedWasActive) {
|
||||||
|
// The delete has already committed; a transient
|
||||||
|
// re-point failure must not read as "Delete
|
||||||
|
// failed" nor skip the cache reset below. The
|
||||||
|
// daemon re-points to default on the next
|
||||||
|
// restart/reconcile regardless.
|
||||||
|
try {
|
||||||
|
const defaultCfg = useHostStore
|
||||||
|
.getState()
|
||||||
|
.configFiles.find(
|
||||||
|
(c) => c.id === 'default'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
Boolean(configFile.isEncrypted) ||
|
||||||
|
Boolean(defaultCfg?.isEncrypted)
|
||||||
|
) {
|
||||||
|
// Emit-only. If the restart can't even be
|
||||||
|
// requested, the daemon may keep serving
|
||||||
|
// the deleted config until the next app
|
||||||
|
// restart. The delete is already
|
||||||
|
// committed, so warn (don't roll back) —
|
||||||
|
// startup reconcile re-points regardless.
|
||||||
|
if (!(await restartActiveRclone())) {
|
||||||
|
await message(
|
||||||
|
'Deleted the config, but rclone could not be restarted onto the default config. It will switch over on the next app restart.',
|
||||||
|
{
|
||||||
|
title: 'Config deleted',
|
||||||
|
kind: 'warning',
|
||||||
|
okLabel: 'OK',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const path = await getConfigPath({
|
||||||
|
id: 'default',
|
||||||
|
validate: true,
|
||||||
|
})
|
||||||
|
await rclone('/config/setpath', {
|
||||||
|
params: { query: { path } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (repointError) {
|
||||||
|
console.warn(
|
||||||
|
'[deleteConfig] daemon re-point after delete failed',
|
||||||
|
repointError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror switchConfigMutation's cache reset so the
|
||||||
|
// UI doesn't keep showing the deleted config's data
|
||||||
|
// after the daemon moved to default.
|
||||||
|
if (deletedWasActive) {
|
||||||
|
await queryClient.cancelQueries()
|
||||||
|
await queryClient.resetQueries()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await onErrorDialog('Delete Config')(error)
|
await onErrorDialog('Delete Config')(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ export async function initHostStore(hostId: string) {
|
|||||||
await useHostStore.persist.rehydrate()
|
await useHostStore.persist.rehydrate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durably flushes the host store to disk and awaits it. The persist middleware writes asynchronously
|
||||||
|
* (a bare `set()` returns before the file is written), leaving a crash window between a Rust
|
||||||
|
* filesystem mutation and its state being persisted. Call this right after a config-sync state write
|
||||||
|
* so the on-disk store matches the filesystem before proceeding. tauri-plugin-store serializes its
|
||||||
|
* operations, so the middleware's set lands before this save. Best-effort: a failed flush is logged,
|
||||||
|
* not thrown — the divergence carries no config-file data loss and self-heals on the next reconcile.
|
||||||
|
*/
|
||||||
|
export async function flushHostStore(): Promise<void> {
|
||||||
|
if (!activeStore) return
|
||||||
|
try {
|
||||||
|
await activeStore.save()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[flushHostStore] failed to flush host store', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteConfig {
|
export interface RemoteConfig {
|
||||||
mountOnStart?: {
|
mountOnStart?: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -83,6 +100,22 @@ interface HostState {
|
|||||||
// the rclone binary never relocates where the user's remotes are read from.
|
// the rclone binary never relocates where the user's remotes are read from.
|
||||||
defaultConfigPath: string | undefined
|
defaultConfigPath: string | undefined
|
||||||
setDefaultConfigPath: (path: string | undefined) => void
|
setDefaultConfigPath: (path: string | undefined) => void
|
||||||
|
|
||||||
|
// User intent to keep the system rclone config path symlinked to the active config, so a
|
||||||
|
// terminal `rclone` shares the app's remotes. Drives reconcile on startup/switch/activation.
|
||||||
|
// Set together with the ownership marker via setConfigSyncState.
|
||||||
|
syncConfigToSystem: boolean
|
||||||
|
|
||||||
|
// Positive ownership marker: the exact target our config-sync symlink currently points at (null
|
||||||
|
// when we hold no link). The ONLY proof that the system-path symlink is ours — target *location*
|
||||||
|
// is not proof, so a user's own symlink is never misattributed to us. Passed to the config-sync
|
||||||
|
// commands and updated from their result. Note it records the link's target, not its location, so
|
||||||
|
// if the system path itself moves (XDG_CONFIG_HOME set/unset between sessions) a stale link at the
|
||||||
|
// old location is left orphaned — harmless (it points at a valid app config), intentionally unswept.
|
||||||
|
syncConfigLinkTarget: string | null
|
||||||
|
// Atomically set both the intent and the ownership marker (a single store write, so a crash can
|
||||||
|
// never land between them and desync intent from what we actually linked).
|
||||||
|
setConfigSyncState: (state: { intent: boolean; linkTarget: string | null }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useHostStore = create<HostState>()(
|
export const useHostStore = create<HostState>()(
|
||||||
@@ -150,6 +183,11 @@ export const useHostStore = create<HostState>()(
|
|||||||
defaultConfigPath: undefined,
|
defaultConfigPath: undefined,
|
||||||
setDefaultConfigPath: (path: string | undefined) =>
|
setDefaultConfigPath: (path: string | undefined) =>
|
||||||
set((_) => ({ defaultConfigPath: path })),
|
set((_) => ({ defaultConfigPath: path })),
|
||||||
|
|
||||||
|
syncConfigToSystem: false,
|
||||||
|
syncConfigLinkTarget: null,
|
||||||
|
setConfigSyncState: ({ intent, linkTarget }) =>
|
||||||
|
set((_) => ({ syncConfigToSystem: intent, syncConfigLinkTarget: linkTarget })),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'host-store',
|
name: 'host-store',
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ interface State {
|
|||||||
startupDisplayed: boolean
|
startupDisplayed: boolean
|
||||||
|
|
||||||
isRestartingRclone: boolean
|
isRestartingRclone: boolean
|
||||||
|
// Set when a restart is requested while one is already running: instead of dropping the request
|
||||||
|
// (which would leave the daemon on a stale config while the store/symlink point at the new one),
|
||||||
|
// the in-flight restart loops once more after it finishes. See the RESTART_RCLONE listener.
|
||||||
|
rcloneRestartPending: boolean
|
||||||
|
|
||||||
cloudflaredTunnel: {
|
cloudflaredTunnel: {
|
||||||
pid: number
|
pid: number
|
||||||
@@ -42,6 +46,7 @@ export const useStore = create<State>()(
|
|||||||
startupDisplayed: false,
|
startupDisplayed: false,
|
||||||
|
|
||||||
isRestartingRclone: false,
|
isRestartingRclone: false,
|
||||||
|
rcloneRestartPending: false,
|
||||||
|
|
||||||
cloudflaredTunnel: null,
|
cloudflaredTunnel: null,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user