update rclone on startup

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-09-15 11:08:38 +02:00
parent 4d3dcf1632
commit 456887c4f8
7 changed files with 351 additions and 55 deletions
+61
View File
@@ -153,3 +153,64 @@ export function parseRcloneOptions(options: Record<string, string | number | boo
return options
}
export function compareVersions(version1: string, version2: string): number {
const parseVersion = (version: string) => {
const parts = version.split('.').map((num) => Number.parseInt(num, 10))
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
}
}
const v1 = parseVersion(version1)
const v2 = parseVersion(version2)
if (v1.major !== v2.major) {
return v1.major > v2.major ? 1 : -1
}
if (v1.minor !== v2.minor) {
return v1.minor > v2.minor ? 1 : -1
}
if (v1.patch !== v2.patch) {
return v1.patch > v2.patch ? 1 : -1
}
return 0
}
const YOURS_VERSION_REGEX = /yours:\s+([^\s]+)/
const LATEST_VERSION_REGEX = /latest:\s+([^\s]+)/
export function shouldUpdateRclone(output: string) {
if (!output.includes('yours')) return false
// parse the output text to extract versions
const yoursMatch = output.match(YOURS_VERSION_REGEX)
const latestMatch = output.match(LATEST_VERSION_REGEX)
if (!yoursMatch || !latestMatch) {
console.warn('[shouldUpdateRclone] could not parse version output:', output)
return false
}
const currentVersion = yoursMatch[1]
const latestVersion = latestMatch[1]
if (!currentVersion || !latestVersion) {
console.warn('[shouldUpdateRclone] could not get versions')
return false
}
console.log('[shouldUpdateRclone] current version:', currentVersion)
console.log('[shouldUpdateRclone] latest version:', latestVersion)
// Compare versions using the existing compareVersions function
const versionComparison = compareVersions(currentVersion, latestVersion)
if (versionComparison < 0) {
console.log('[shouldUpdateRclone] internal rclone needs update')
return true
}
console.log('[shouldUpdateRclone] internal rclone is up to date')
return false
}
+74 -7
View File
@@ -16,8 +16,11 @@ import {
getDefaultPath,
isInternalRcloneInstalled,
isSystemRcloneInstalled,
shouldUpdateRclone,
} from './common'
const RCLONE_CONF_REGEX = /\/rclone\.conf$/
export async function initRclone(args: string[]) {
console.log('[initRclone]')
@@ -34,16 +37,80 @@ export async function initRclone(args: string[]) {
})
const success = await provisionRclone()
if (!success) {
await new Promise((resolve) => setTimeout(resolve, 1000))
await exit(0)
useStore.setState({ startupStatus: 'fatal' })
return
}
useStore.setState({ startupStatus: 'initialized' })
internal = true
}
const state = usePersistedStore.getState()
let configFiles = state.configFiles || []
let activeConfigFile = state.activeConfigFile
let needsUpdate = false
if (system) {
console.log('[initRclone] checking system rclone version')
const checkInstance = Command.create('rclone-system', ['selfupdate', '--check'])
const checkResult = await checkInstance.execute()
const output = checkResult.stdout.trim()
needsUpdate = shouldUpdateRclone(output)
}
if (internal) {
console.log('[initRclone] checking internal rclone version')
const checkInstance = Command.create('rclone-internal', ['selfupdate', '--check'])
const checkResult = await checkInstance.execute()
const output = checkResult.stdout.trim()
needsUpdate = shouldUpdateRclone(output)
}
if (needsUpdate) {
console.log('[initRclone] needs update')
useStore.setState({ startupStatus: 'updating' })
await openSmallWindow({
name: 'Startup',
url: '/startup',
})
try {
if (system) {
console.log('[initRclone] updating system rclone')
const code = (await invoke('update_system_rclone')) as number
console.log('[initRclone] update_rclone code', code)
if (code !== 0) {
console.log(
'[initRclone] system rclone update failed or was cancelled by user, code:',
code
)
useStore.setState({ startupStatus: 'error' })
} else {
useStore.setState({ startupStatus: 'updated' })
}
}
if (internal) {
console.log('[initRclone] updating internal rclone')
const instance = Command.create('rclone-internal', ['selfupdate'])
const updateResult = await instance.execute()
console.log('[initRclone] updateResult', JSON.stringify(updateResult, null, 2))
if (updateResult.code !== 0) {
console.log(
'[initRclone] internal rclone update failed, code:',
updateResult.code
)
useStore.setState({ startupStatus: 'error' })
} else {
useStore.setState({ startupStatus: 'updated' })
}
}
} catch (error) {
console.error('[initRclone] failed to update rclone', error)
useStore.setState({ startupStatus: 'error' })
}
}
const persistedState = usePersistedStore.getState()
let configFiles = persistedState.configFiles || []
let activeConfigFile = persistedState.activeConfigFile
const defaultPath = await getDefaultPath(system ? 'system' : 'internal')
console.log('[initRclone] defaultPath', defaultPath)
@@ -79,7 +146,7 @@ export async function initRclone(args: string[]) {
let configFolderPath = activeConfigFile.sync
? activeConfigFile.sync
: (await getConfigPath({ id: activeConfigFile.id!, validate: true })).replace(
/\/rclone\.conf$/,
RCLONE_CONF_REGEX,
''
)
@@ -94,7 +161,7 @@ export async function initRclone(args: string[]) {
})
activeConfigFile = configFiles[0]
configFolderPath = (await getConfigPath({ id: 'default', validate: true })).replace(
/\/rclone\.conf$/,
RCLONE_CONF_REGEX,
''
)
usePersistedStore.setState({ activeConfigFile: configFiles[0] })
+8 -1
View File
@@ -41,7 +41,14 @@ interface State {
addRemote: (remote: string) => void
removeRemote: (remote: string) => void
startupStatus: null | 'initializing' | 'initialized'
startupStatus:
| null
| 'initializing'
| 'initialized'
| 'updating'
| 'updated'
| 'error'
| 'fatal'
}
interface PersistedState {
+1 -1
View File
@@ -136,7 +136,7 @@ export async function openSmallWindow({
width: 0,
resizable: false,
visibleOnAllWorkspaces: false,
alwaysOnTop: true,
alwaysOnTop: false,
visible: false,
focus: true,
title: name,
+11 -30
View File
@@ -20,6 +20,7 @@ import {
startMove,
startSync,
} from './lib/rclone/api'
import { compareVersions } from './lib/rclone/common'
import { initRclone } from './lib/rclone/init'
import { usePersistedStore, useStore } from './lib/store'
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
@@ -148,11 +149,16 @@ async function startRclone() {
])
} catch (error) {
Sentry.captureException(error)
await message(error.message || 'Failed to start rclone, please try again later.', {
title: 'Error',
kind: 'error',
okLabel: 'Exit',
})
await message(
error instanceof Error
? error.message
: 'Failed to start rclone, please try again later.',
{
title: 'Error',
kind: 'error',
okLabel: 'Exit',
}
)
return await exit(0)
}
@@ -436,31 +442,6 @@ async function handleTask(task: ScheduledTask) {
}
}
function compareVersions(version1: string, version2: string): number {
const parseVersion = (version: string) => {
const parts = version.split('.').map((num) => Number.parseInt(num, 10))
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
}
}
const v1 = parseVersion(version1)
const v2 = parseVersion(version2)
if (v1.major !== v2.major) {
return v1.major > v2.major ? 1 : -1
}
if (v1.minor !== v2.minor) {
return v1.minor > v2.minor ? 1 : -1
}
if (v1.patch !== v2.patch) {
return v1.patch > v2.patch ? 1 : -1
}
return 0
}
async function checkVersion() {
try {
const metaJson = await fetch(
+107 -1
View File
@@ -323,6 +323,112 @@ async fn prompt_password(title: String, message: String) -> Result<Option<String
}
}
#[tauri::command]
async fn update_system_rclone() -> Result<i32, String> {
#[cfg(target_os = "macos")]
{
use std::process::Command as SysCommand;
fn quote_posix(value: &str) -> String {
let escaped = value.replace("'", "'\\''");
format!("'{}'", escaped)
}
let mut cmdline = String::from("PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; ");
cmdline.push_str(&quote_posix("rclone"));
cmdline.push(' ');
cmdline.push_str(&quote_posix("selfupdate"));
// Escape for embedding inside an AppleScript string literal
let applescript_cmd = cmdline.replace('\\', "\\\\").replace('"', "\\\"");
let prompt = "Rclone UI needs permission to run rclone selfupdate.";
let script = format!(
"do shell script \"{}\" with administrator privileges with prompt \"{}\"",
applescript_cmd,
prompt.replace('"', "\\\"")
);
let status = SysCommand::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| e.to_string())?;
return Ok(status.code().unwrap_or(0));
}
#[cfg(target_os = "linux")]
{
use std::process::Command as SysCommand;
let path_env = "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin";
// Try PolicyKit first (graphical auth prompt on most desktops)
let mut pkexec_args: Vec<String> = Vec::new();
pkexec_args.push("--description".to_string());
pkexec_args.push("Rclone UI needs to run rclone selfupdate".to_string());
pkexec_args.push("env".to_string());
pkexec_args.push(path_env.to_string());
pkexec_args.push("rclone".to_string());
pkexec_args.push("selfupdate".to_string());
match SysCommand::new("pkexec").args(&pkexec_args).status() {
Ok(status) => return Ok(status.code().unwrap_or(0)),
Err(_e) => {
// Fallback to sudo with custom prompt (works if the user has NOPASSWD or cached credentials)
let mut sudo_env = std::collections::HashMap::new();
sudo_env.insert("SUDO_PROMPT", "Rclone UI needs permission to run rclone selfupdate. Please enter your password: ");
let mut sudo_args: Vec<String> = Vec::new();
sudo_args.push("-n".to_string());
sudo_args.push("env".to_string());
sudo_args.push(path_env.to_string());
sudo_args.push("rclone".to_string());
sudo_args.push("selfupdate".to_string());
let status = SysCommand::new("sudo")
.envs(&sudo_env)
.args(&sudo_args)
.status()
.map_err(|e| e.to_string())?;
return Ok(status.code().unwrap_or(0));
}
}
}
#[cfg(target_os = "windows")]
{
use std::process::Command as SysCommand;
fn quote_ps(value: &str) -> String {
// PowerShell single-quote escaping: ' -> ''
format!("'{}'", value.replace('\'', "''"))
}
let file_path = quote_ps("rclone");
let arg_list = String::from("@('selfupdate')");
let ps_script = format!(
"$p = Start-Process -Verb RunAs -WindowStyle Hidden -PassThru -FilePath {file} -ArgumentList {args}; \n\
$p.WaitForExit();\n\
exit $p.ExitCode",
file = file_path,
args = arg_list
);
let status = SysCommand::new("powershell")
.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps_script])
.status()
.map_err(|e| e.to_string())?;
return Ok(status.code().unwrap_or(0));
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
Err("Unsupported platform".to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let client = sentry::init((
@@ -360,7 +466,7 @@ pub fn run() {
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid, prompt_password, stop_pid])
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid, prompt_password, stop_pid, update_system_rclone])
.setup(|_app| Ok(()))
// .setup(|app| {
// if cfg!(debug_assertions) {
+89 -15
View File
@@ -1,9 +1,11 @@
import { Button, Divider } from '@heroui/react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { exit } from '@tauri-apps/plugin-process'
import { useEffect, useState } from 'react'
import { useStore } from '../../lib/store'
const GREETINGS = [
const GREET = [
'Hello',
'こんにちは',
'Salut',
@@ -18,19 +20,45 @@ const GREETINGS = [
'مرحباً',
]
const WAIT = [
'Just a moment',
'少々お待ちください',
'Un moment',
'Chwileczkę',
'Ett ögonblick',
'Juste un instant',
'Só um momento',
'Un attimo',
'请稍等一下',
'Einen Moment, bitte',
'Bir saniye lütfen',
'لحظة من فضلك',
]
export default function Startup() {
const [greetingIndex, setGreetingIndex] = useState(0)
const [titleIndex, setTitleIndex] = useState(0)
const startupStatus = useStore((state) => state.startupStatus)
const isInitialized = startupStatus === 'initialized'
const isError = startupStatus === 'error' || startupStatus === 'fatal'
useEffect(() => {
const intervalId = setInterval(() => {
setGreetingIndex((previousIndex) => (previousIndex + 1) % GREETINGS.length)
}, 2500)
return () => clearInterval(intervalId)
}, [])
let intervalId: NodeJS.Timeout | null = null
if (startupStatus === 'initializing') {
intervalId = setInterval(() => {
setTitleIndex((previousIndex) => (previousIndex + 1) % GREET.length)
}, 2500)
} else if (startupStatus === 'updating') {
intervalId = setInterval(() => {
setTitleIndex((previousIndex) => (previousIndex + 1) % WAIT.length)
}, 2500)
}
return () => {
if (intervalId) {
clearInterval(intervalId)
}
}
}, [startupStatus])
return (
<div className="flex flex-col h-screen rounded-lg">
@@ -40,37 +68,83 @@ export default function Startup() {
<div className="flex flex-col w-full h-full justify-evenly">
<div className="flex flex-col items-center w-full gap-8 overflow-visible">
{isInitialized && (
{isError && (
<p className="ml-2 text-2xl">
Could not complete the operation, please try again later.
</p>
)}
{startupStatus === 'initialized' && (
<p className="ml-2 text-2xl">
Rclone has initialized, you can find it in the tray menu!
</p>
)}
{!isInitialized && (
{startupStatus === 'updated' && (
<p className="ml-2 text-2xl">
Rclone has updated, you can find it in the tray menu!
</p>
)}
{startupStatus === 'initializing' && (
<p className="ml-2 text-3xl">
<span
key={greetingIndex}
key={titleIndex}
className="inline-block align-middle animate-fade-in-up"
>
{GREETINGS[greetingIndex]}
{GREET[titleIndex]}
</span>{' '}
<span className="inline-block align-middle">👋</span>
</p>
)}
{startupStatus === 'updating' && (
<p className="ml-2 text-3xl">
<span
key={titleIndex}
className="inline-block align-middle animate-fade-in-up"
>
{WAIT[titleIndex]}
</span>{' '}
<span className="inline-block align-middle">👋</span>
</p>
)}
</div>
<div className="flex flex-col items-center w-full bg-red-500/0">
{isInitialized ? (
{(startupStatus === 'initialized' || startupStatus === 'updated') && (
<Button
className="w-full max-w-md py-8 text-large"
variant="shadow"
color="primary"
size="lg"
onPress={() => {}}
onPress={async () => {
await getCurrentWindow().hide()
await getCurrentWindow().destroy()
}}
>
START
</Button>
) : (
)}
{isError && (
<Button
className="w-full max-w-md py-8 text-large"
variant="shadow"
color="primary"
size="lg"
onPress={async () => {
if (startupStatus === 'error') {
await getCurrentWindow().hide()
await getCurrentWindow().destroy()
} else {
await exit(0)
}
}}
>
{startupStatus === 'error' ? 'OK' : 'QUIT'}
</Button>
)}
{startupStatus === 'initializing' && (
<p className="uppercase text-small animate-pulse">Rclone is initalizing</p>
)}
{startupStatus === 'updating' && (
<p className="uppercase text-small animate-pulse">Rclone is updating</p>
)}
</div>
</div>
</div>