Revert "Fix Linux first-launch startup feedback when provisioning bundled rclone"

This reverts commit 3201b0306c.
This commit is contained in:
FTCHD
2026-05-10 21:52:01 +03:00
parent 3201b0306c
commit d749844073
7 changed files with 86 additions and 416 deletions
+12 -277
View File
@@ -25,198 +25,6 @@ import {
shouldUpdateRclone,
} from './common'
function setStartupMessage(startupMessage: string | null) {
useStore.setState({ startupMessage })
}
function resetStartupDownloadState() {
useStore.setState({
startupIsDownloading: false,
startupDownloadedBytes: 0,
startupTotalBytes: null,
startupDownloadSpeed: null,
})
}
function setStartupDownloadState({
downloadedBytes,
totalBytes,
speedBytesPerSecond,
}: {
downloadedBytes: number
totalBytes: number | null
speedBytesPerSecond: number | null
}) {
useStore.setState({
startupIsDownloading: true,
startupDownloadedBytes: downloadedBytes,
startupTotalBytes: totalBytes,
startupDownloadSpeed: speedBytesPerSecond,
})
}
async function withTimeout<T>(operation: Promise<T>, timeoutMs: number, errorMessage: string) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
try {
return await Promise.race([operation, timeout])
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
async function fetchTextWithTimeout(url: string, timeoutMs: number) {
const response = await withTimeout(
fetch(url),
timeoutMs,
`Request timed out after ${Math.round(timeoutMs / 1000)} seconds.`
)
if (!response.ok) {
throw new Error(`Request failed (${response.status}).`)
}
return await withTimeout(
response.text(),
timeoutMs,
`Reading response timed out after ${Math.round(timeoutMs / 1000)} seconds.`
)
}
async function downloadArrayBufferWithProgress(
url: string,
{
connectTimeoutMs = 30_000,
stallTimeoutMs = 45_000,
onProgress,
}: {
connectTimeoutMs?: number
stallTimeoutMs?: number
onProgress?: (progress: {
downloadedBytes: number
totalBytes: number | null
speedBytesPerSecond: number | null
}) => void
} = {}
) {
const controller = new AbortController()
let stalled = false
let stallTimeoutId: ReturnType<typeof setTimeout> | undefined
const resetStallTimeout = () => {
if (stallTimeoutId) {
clearTimeout(stallTimeoutId)
}
stallTimeoutId = setTimeout(() => {
stalled = true
controller.abort()
}, stallTimeoutMs)
}
const response = await fetch(url, {
connectTimeout: connectTimeoutMs,
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`Failed to download rclone (${response.status}).`)
}
const totalHeader = response.headers.get('content-length')
const parsedTotal = totalHeader ? Number.parseInt(totalHeader, 10) : Number.NaN
const totalBytes = Number.isFinite(parsedTotal) ? parsedTotal : null
onProgress?.({
downloadedBytes: 0,
totalBytes,
speedBytesPerSecond: null,
})
if (!response.body) {
const buffer = await response.arrayBuffer()
onProgress?.({
downloadedBytes: buffer.byteLength,
totalBytes: totalBytes ?? buffer.byteLength,
speedBytesPerSecond: null,
})
return buffer
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = totalBytes === null ? [] : []
const preallocated = totalBytes !== null ? new Uint8Array(totalBytes) : null
const startedAt = Date.now()
let downloadedBytes = 0
resetStallTimeout()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
if (!value) {
continue
}
resetStallTimeout()
if (preallocated) {
preallocated.set(value, downloadedBytes)
} else {
chunks.push(value)
}
downloadedBytes += value.byteLength
const elapsedSeconds = Math.max((Date.now() - startedAt) / 1000, 0.001)
onProgress?.({
downloadedBytes,
totalBytes,
speedBytesPerSecond: downloadedBytes / elapsedSeconds,
})
}
} catch (error) {
if (controller.signal.aborted && stalled) {
throw new Error(
`Downloading rclone stalled for ${Math.round(stallTimeoutMs / 1000)} seconds.`
)
}
throw error
} finally {
if (stallTimeoutId) {
clearTimeout(stallTimeoutId)
}
reader.releaseLock()
}
if (preallocated) {
return preallocated.buffer
}
const merged = new Uint8Array(downloadedBytes)
let offset = 0
for (const chunk of chunks) {
merged.set(chunk, offset)
offset += chunk.byteLength
}
return merged.buffer
}
export async function initRclone(args: string[]) {
console.log('[initRclone] starting with args:', args)
@@ -228,30 +36,12 @@ export async function initRclone(args: string[]) {
// rclone not available, let's download it
if (!system && !internal) {
console.log('[initRclone] no rclone installation found, provisioning...')
resetStartupDownloadState()
useStore.setState({
startupDisplayed: true,
startupStatus: 'initializing',
startupMessage: 'Preparing rclone for first launch',
})
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
await openSmallWindow({
name: 'Startup',
url: '/startup',
transparent: platform() !== 'linux',
})
const success = await provisionRclone().catch((error) => {
console.error(
'[initRclone] provision failed with error',
error instanceof Error ? error.message : error
)
Sentry.captureException(error)
setStartupMessage(
error instanceof Error
? error.message
: 'Failed to prepare rclone. Please try again later.'
)
return false
})
const success = await provisionRclone()
console.log('[initRclone] provision rclone result:', success)
if (!success) {
console.error('[initRclone] provision failed, setting fatal status')
@@ -260,8 +50,7 @@ export async function initRclone(args: string[]) {
}
console.log('[initRclone] provision succeeded')
resetStartupDownloadState()
useStore.setState({ startupStatus: 'initialized', startupMessage: null })
useStore.setState({ startupStatus: 'initialized' })
if (!['windows', 'macos'].includes(platform())) {
usePersistedStore.setState({ hideStartup: true })
@@ -276,16 +65,11 @@ export async function initRclone(args: string[]) {
if (shouldUpdateRclone(rcloneVersion)) {
console.log('[initRclone] needs update')
resetStartupDownloadState()
useStore.setState({
startupStatus: 'updating',
startupMessage: 'Updating rclone',
})
useStore.setState({ startupStatus: 'updating' })
await openSmallWindow({
name: 'Startup',
url: '/startup',
transparent: platform() !== 'linux',
})
try {
@@ -298,10 +82,7 @@ export async function initRclone(args: string[]) {
'[initRclone] system rclone update failed or was cancelled by user, code:',
code
)
useStore.setState({
startupStatus: 'error',
startupMessage: 'Could not update the system rclone installation.',
})
useStore.setState({ startupStatus: 'error' })
const skipping = await ask(
'You are running an outdated version of the CLI that could not be updated.\n\nPlease update manually and restart Rclone UI.',
{
@@ -318,13 +99,11 @@ export async function initRclone(args: string[]) {
}
} else {
console.log('[initRclone] system rclone updated successfully')
resetStartupDownloadState()
useStore.setState({ startupStatus: 'updated', startupMessage: null })
useStore.setState({ startupStatus: 'updated' })
}
}
if (internal) {
console.log('[initRclone] updating internal rclone')
setStartupMessage('Updating the bundled rclone binary')
const instance = Command.create('rclone-internal', ['selfupdate'])
const updateResult = await instance.execute()
console.log('[initRclone] updateResult', JSON.stringify(updateResult, null, 2))
@@ -333,23 +112,15 @@ export async function initRclone(args: string[]) {
'[initRclone] internal rclone update failed, code:',
updateResult.code
)
useStore.setState({
startupStatus: 'error',
startupMessage: 'Could not update the bundled rclone binary.',
})
useStore.setState({ startupStatus: 'error' })
} else {
console.log('[initRclone] internal rclone updated successfully')
resetStartupDownloadState()
useStore.setState({ startupStatus: 'updated', startupMessage: null })
useStore.setState({ startupStatus: 'updated' })
}
}
} catch (error) {
console.error('[initRclone] failed to update rclone', error)
useStore.setState({
startupStatus: 'error',
startupMessage:
error instanceof Error ? error.message : 'Failed to update rclone.',
})
useStore.setState({ startupStatus: 'error' })
}
await new Promise((resolve) => setTimeout(resolve, 1000))
@@ -614,13 +385,10 @@ export async function initRclone(args: string[]) {
*/
export async function provisionRclone() {
console.log('[provisionRclone] starting provisioning process')
resetStartupDownloadState()
setStartupMessage('Checking the latest rclone version')
console.log('[provisionRclone] fetching latest version info')
const currentVersionString = await fetchTextWithTimeout(
'https://downloads.rclone.org/version.txt',
30_000
const currentVersionString = await fetch('https://downloads.rclone.org/version.txt').then(
(res) => res.text()
)
console.log('[provisionRclone] currentVersionString', currentVersionString)
@@ -632,7 +400,6 @@ export async function provisionRclone() {
return false
}
console.log('[provisionRclone] currentVersion', currentVersion)
setStartupMessage(`Preparing rclone ${currentVersion}`)
const currentPlatform = platform()
console.log('[provisionRclone] currentPlatform', currentPlatform)
@@ -648,7 +415,6 @@ export async function provisionRclone() {
console.log('[provisionRclone] tempDirPath', tempDirPath)
console.log('[provisionRclone] detecting system architecture')
setStartupMessage('Detecting system architecture')
const arch = (await invoke('get_arch')) as 'arm64' | 'amd64' | '386' | 'unknown'
console.log('[provisionRclone] arch', arch)
@@ -662,34 +428,10 @@ export async function provisionRclone() {
console.log('[provisionRclone] downloadUrl', downloadUrl)
console.log('[provisionRclone] downloading rclone binary')
setStartupMessage(`Downloading rclone ${currentVersion}`)
let lastProgressUpdateAt = 0
const downloadedFile = await downloadArrayBufferWithProgress(downloadUrl, {
connectTimeoutMs: 30_000,
stallTimeoutMs: 45_000,
onProgress: ({ downloadedBytes, totalBytes, speedBytesPerSecond }) => {
const now = Date.now()
const isComplete = totalBytes !== null && downloadedBytes >= totalBytes
if (!isComplete && now - lastProgressUpdateAt < 250) {
return
}
lastProgressUpdateAt = now
setStartupDownloadState({
downloadedBytes,
totalBytes,
speedBytesPerSecond,
})
},
})
const downloadedFile = await fetch(downloadUrl).then((res) => res.arrayBuffer())
console.log('[provisionRclone] download complete, size:', downloadedFile.byteLength)
setStartupMessage('Download complete')
console.log('[provisionRclone] checking if temp rclone directory exists')
resetStartupDownloadState()
setStartupMessage('Preparing temporary files')
let tempDirExists = false
try {
tempDirExists = await exists('rclone', {
@@ -738,7 +480,6 @@ export async function provisionRclone() {
console.log('[provisionRclone] zipPath', zipPath)
console.log('[provisionRclone] writing zip file to disk')
setStartupMessage('Saving rclone archive')
try {
await writeFile(zipPath, new Uint8Array(downloadedFile))
console.log('[provisionRclone] wrote zip file successfully')
@@ -751,7 +492,6 @@ export async function provisionRclone() {
const extractPath = `${tempDirPath}${sep()}rclone${sep()}extracted`
console.log('[provisionRclone] extracting zip file to:', extractPath)
setStartupMessage('Extracting rclone')
try {
await invoke('unzip_file', {
zipPath,
@@ -780,7 +520,6 @@ export async function provisionRclone() {
console.log('[provisionRclone] rcloneBinaryPath', rcloneBinaryPath)
console.log('[provisionRclone] verifying extracted binary exists')
setStartupMessage('Verifying extracted rclone')
try {
const binaryExists = await exists(rcloneBinaryPath)
console.log('[provisionRclone] rcloneBinaryPathExists', binaryExists)
@@ -796,7 +535,6 @@ export async function provisionRclone() {
}
console.log('[provisionRclone] getting app local data directory')
setStartupMessage('Preparing application data folder')
const appLocalDataDirPath = await appLocalDataDir()
console.log('[provisionRclone] appLocalDataDirPath', appLocalDataDirPath)
@@ -816,7 +554,6 @@ export async function provisionRclone() {
console.log('[provisionRclone] targetBinaryPath', targetBinaryPath)
console.log('[provisionRclone] copying binary to final location')
setStartupMessage('Installing rclone')
const maxCopyRetries = 3
for (let attempt = 1; attempt <= maxCopyRetries; attempt++) {
console.log(`[provisionRclone] copy attempt ${attempt}/${maxCopyRetries}`)
@@ -851,7 +588,6 @@ export async function provisionRclone() {
}
console.log('[provisionRclone] verifying installation')
setStartupMessage('Verifying rclone installation')
const hasInstalled = await isInternalRcloneInstalled()
console.log('[provisionRclone] installation verified:', hasInstalled)
@@ -861,7 +597,6 @@ export async function provisionRclone() {
}
console.log('[provisionRclone] rclone has been installed successfully')
setStartupMessage('Rclone is ready')
return true
}
+1 -3
View File
@@ -35,14 +35,12 @@ export async function openWindow({
export async function openSmallWindow({
name,
url,
transparent = true,
}: {
name: string
url: string
transparent?: boolean
}) {
console.log('[openSmallWindow] ', name, url)
await invoke('open_small_window', { name, url, transparent })
await invoke('open_small_window', { name, url })
return WebviewWindow.getByLabel(name)
}
+9 -17
View File
@@ -395,7 +395,7 @@ async function registerRcloneWindowListeners() {
rcloneListenersRegistered = true
}
async function startRclone(): Promise<boolean> {
async function startRclone() {
console.log('[startRclone]')
await registerRcloneWindowListeners()
@@ -430,8 +430,7 @@ async function startRclone(): Promise<boolean> {
okLabel: 'Exit',
}
)
await exit(0)
return false
return await exit(0)
}
const command = rclone?.system || rclone?.internal
@@ -439,7 +438,7 @@ async function startRclone(): Promise<boolean> {
if (!command) {
console.error('[startRclone] initRclone returned without a runnable command')
Sentry.captureException(new Error('initRclone returned without a runnable command.'))
return false
return
}
command.addListener('close', async (event) => {
@@ -477,7 +476,6 @@ async function startRclone(): Promise<boolean> {
console.log('[startRclone] running rclone')
await new Promise((resolve) => setTimeout(resolve, 500))
return true
}
async function startupMounts() {
@@ -1120,16 +1118,10 @@ waitForHydration()
.then(() => validateInstance())
.then(() => checkAlreadyRunning())
.then(() => startRclone())
.then(async (started) => {
if (!started) {
console.log('[main] rclone did not start, leaving startup window visible')
return
}
await checkRclone()
await handleDeepLink()
await showStartup()
await startupMounts()
await resumeTasks()
await initTray()
})
.then(() => checkRclone())
.then(() => handleDeepLink())
.then(() => showStartup())
.then(() => startupMounts())
.then(() => resumeTasks())
.then(() => initTray())
.catch(console.error)
+3 -10
View File
@@ -213,7 +213,6 @@ pub async fn open_small_window(
app_handle: AppHandle,
name: String,
url: String,
transparent: Option<bool>,
) -> Result<(), String> {
if let Some(existing) = app_handle.get_webview_window(&name) {
existing.set_focus().map_err(|e| e.to_string())?;
@@ -235,16 +234,12 @@ pub async fn open_small_window(
#[cfg(target_os = "linux")]
{
if transparent.unwrap_or(true) {
builder = builder.transparent(true);
}
builder = builder.transparent(true);
}
let window = builder.build().map_err(|e| e.to_string())?;
if let Err(err) = window.center() {
log::warn!("failed to center small window before show: {}", err);
}
window.center().map_err(|e| e.to_string())?;
window.set_zoom(1.0).map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
@@ -260,9 +255,7 @@ pub async fn open_small_window(
window.set_always_on_top(true).map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
if let Err(err) = window.center() {
log::warn!("failed to center small window after show: {}", err);
}
window.center().map_err(|e| e.to_string())?;
if os != "windows" && os != "macos" {
window.set_resizable(true).map_err(|e| e.to_string())?;
+3 -3
View File
@@ -14,15 +14,15 @@
"windows": [
{
"title": "Rclone UI",
"width": 1,
"height": 1,
"width": 0,
"height": 0,
"center": true,
"hiddenTitle": false,
"resizable": false,
"fullscreen": false,
"visible": false,
"decorations": false,
"focus": false,
"focus": true,
"url": "tray.html",
"alwaysOnTop": true,
"backgroundThrottling": "disabled",
+58 -96
View File
@@ -1,11 +1,10 @@
import { Button, Divider, Progress } from '@heroui/react'
import { Button, Divider } from '@heroui/react'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
import { formatBytes } from '../../lib/format'
import { openSmallWindow } from '../../lib/window'
import { useStore } from '../../store/memory'
import { usePersistedStore } from '../../store/persisted'
@@ -44,11 +43,6 @@ export default function Startup() {
const [titleIndex, setTitleIndex] = useState(0)
const startupStatus = useStore((state) => state.startupStatus)
const startupMessage = useStore((state) => state.startupMessage)
const startupIsDownloading = useStore((state) => state.startupIsDownloading)
const startupDownloadedBytes = useStore((state) => state.startupDownloadedBytes)
const startupTotalBytes = useStore((state) => state.startupTotalBytes)
const startupDownloadSpeed = useStore((state) => state.startupDownloadSpeed)
const toolbarShortcut = usePersistedStore((state) => state.toolbarShortcut)
const shortcutDisplay = useMemo(() => {
@@ -71,37 +65,6 @@ export default function Startup() {
() => startupStatus === 'error' || startupStatus === 'fatal',
[startupStatus]
)
const isBusy = useMemo(
() => startupStatus === 'initializing' || startupStatus === 'updating',
[startupStatus]
)
const downloadProgress = useMemo(() => {
if (!startupTotalBytes || startupTotalBytes <= 0) {
return null
}
return Math.min((startupDownloadedBytes / startupTotalBytes) * 100, 100)
}, [startupDownloadedBytes, startupTotalBytes])
const downloadProgressLabel = useMemo(() => {
if (!startupIsDownloading) {
return null
}
const downloaded = formatBytes(startupDownloadedBytes)
const total = startupTotalBytes ? formatBytes(startupTotalBytes) : 'Unknown size'
const speed = startupDownloadSpeed ? `${formatBytes(startupDownloadSpeed)}/s` : null
if (speed) {
return `${downloaded} / ${total}${speed}`
}
return `${downloaded} / ${total}`
}, [
startupDownloadSpeed,
startupDownloadedBytes,
startupIsDownloading,
startupTotalBytes,
])
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null
@@ -121,16 +84,11 @@ export default function Startup() {
}
}, [startupStatus])
// Linux can emit focus changes while the startup window is still provisioning rclone.
// Close window when it loses focus
useEffect(() => {
const currentWindow = getCurrentWindow()
const unlisten = currentWindow.onFocusChanged(async ({ payload: focused }) => {
if (!focused) {
const status = useStore.getState().startupStatus
const busy = status === 'initializing' || status === 'updating'
if (platform() === 'linux' && busy) {
return
}
await currentWindow.hide()
await currentWindow.destroy()
}
@@ -158,8 +116,7 @@ export default function Startup() {
exit={{ opacity: 0 }}
className="ml-2 text-2xl"
>
{startupMessage ||
'Could not complete the operation, please try again later.'}
Could not complete the operation, please try again later.
</motion.p>
)}
{startupStatus === 'initialized' && (
@@ -184,40 +141,39 @@ export default function Startup() {
Rclone has just been updated, thanks for waiting!
</motion.p>
)}
{isBusy && (
<motion.div
key="busy"
{startupStatus === 'initializing' && (
<motion.p
key="initializing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-4 px-8"
className="ml-2 text-3xl"
>
<p className="ml-2 text-3xl">
<span
key={titleIndex}
className="inline-block align-middle animate-fade-in-up"
>
{startupStatus === 'updating'
? WAIT[titleIndex % WAIT.length]
: GREET[titleIndex % GREET.length]}
</span>{' '}
<span className="inline-block align-middle">👋</span>
</p>
{startupIsDownloading && (
<div className="w-full max-w-md space-y-2">
<Progress
value={downloadProgress ?? undefined}
isIndeterminate={downloadProgress === null}
aria-label="Rclone download progress"
className="w-full"
/>
<p className="text-sm text-center text-default-500">
{downloadProgressLabel}
</p>
</div>
)}
</motion.div>
<span
key={titleIndex}
className="inline-block align-middle animate-fade-in-up"
>
{GREET[titleIndex]}
</span>{' '}
<span className="inline-block align-middle">👋</span>
</motion.p>
)}
{startupStatus === 'updating' && (
<motion.p
key="updating"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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>
</motion.p>
)}
</AnimatePresence>
</div>
@@ -295,13 +251,13 @@ export default function Startup() {
</Button>
</motion.div>
)}
{isBusy && (
<motion.div
key="busy"
{startupStatus === 'initializing' && (
<motion.p
key="initializing"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="flex flex-col items-center w-full gap-4 px-8 text-center uppercase text-small"
className="uppercase text-small"
>
<motion.span
animate={{ opacity: [1, 0.5, 1] }}
@@ -311,23 +267,29 @@ export default function Startup() {
ease: 'easeInOut',
}}
>
{startupMessage ||
(startupStatus === 'updating'
? 'Rclone is updating'
: 'Rclone is initializing')}
Rclone is initializing
</motion.span>
{startupIsDownloading && (
<Button
variant="flat"
color="default"
onPress={async () => {
await exit(0)
}}
>
Quit for now
</Button>
)}
</motion.div>
</motion.p>
)}
{startupStatus === 'updating' && (
<motion.p
key="updating"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="uppercase text-small"
>
<motion.span
animate={{ opacity: [1, 0.5, 1] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 4,
ease: 'easeInOut',
}}
>
Rclone is updating
</motion.span>
</motion.p>
)}
</AnimatePresence>
</div>
-10
View File
@@ -14,11 +14,6 @@ interface State {
| 'fatal'
startupDisplayed: boolean
startupMessage: string | null
startupIsDownloading: boolean
startupDownloadedBytes: number
startupTotalBytes: number | null
startupDownloadSpeed: number | null
isRestartingRclone: boolean
@@ -42,11 +37,6 @@ export const useStore = create<State>()(
startupStatus: null,
startupDisplayed: false,
startupMessage: null,
startupIsDownloading: false,
startupDownloadedBytes: 0,
startupTotalBytes: null,
startupDownloadSpeed: null,
isRestartingRclone: false,