update rclone on startup
Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -136,7 +136,7 @@ export async function openSmallWindow({
|
||||
width: 0,
|
||||
resizable: false,
|
||||
visibleOnAllWorkspaces: false,
|
||||
alwaysOnTop: true,
|
||||
alwaysOnTop: false,
|
||||
visible: false,
|
||||
focus: true,
|
||||
title: name,
|
||||
|
||||
@@ -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
@@ -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("e_posix("rclone"));
|
||||
cmdline.push(' ');
|
||||
cmdline.push_str("e_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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user