Fix Linux first-launch startup feedback when provisioning bundled rclone (#207)

* Improve Linux startup provisioning flow

* Refine Linux startup provisioning flow
This commit is contained in:
shallfun
2026-05-10 21:12:56 +03:00
committed by GitHub
parent bb70a97baf
commit 3201b0306c
7 changed files with 416 additions and 86 deletions
+277 -12
View File
@@ -25,6 +25,198 @@ 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)
@@ -36,12 +228,30 @@ export async function initRclone(args: string[]) {
// rclone not available, let's download it
if (!system && !internal) {
console.log('[initRclone] no rclone installation found, provisioning...')
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
resetStartupDownloadState()
useStore.setState({
startupDisplayed: true,
startupStatus: 'initializing',
startupMessage: 'Preparing rclone for first launch',
})
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')
@@ -50,7 +260,8 @@ export async function initRclone(args: string[]) {
}
console.log('[initRclone] provision succeeded')
useStore.setState({ startupStatus: 'initialized' })
resetStartupDownloadState()
useStore.setState({ startupStatus: 'initialized', startupMessage: null })
if (!['windows', 'macos'].includes(platform())) {
usePersistedStore.setState({ hideStartup: true })
@@ -65,11 +276,16 @@ export async function initRclone(args: string[]) {
if (shouldUpdateRclone(rcloneVersion)) {
console.log('[initRclone] needs update')
useStore.setState({ startupStatus: 'updating' })
resetStartupDownloadState()
useStore.setState({
startupStatus: 'updating',
startupMessage: 'Updating rclone',
})
await openSmallWindow({
name: 'Startup',
url: '/startup',
transparent: platform() !== 'linux',
})
try {
@@ -82,7 +298,10 @@ export async function initRclone(args: string[]) {
'[initRclone] system rclone update failed or was cancelled by user, code:',
code
)
useStore.setState({ startupStatus: 'error' })
useStore.setState({
startupStatus: 'error',
startupMessage: 'Could not update the system rclone installation.',
})
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.',
{
@@ -99,11 +318,13 @@ export async function initRclone(args: string[]) {
}
} else {
console.log('[initRclone] system rclone updated successfully')
useStore.setState({ startupStatus: 'updated' })
resetStartupDownloadState()
useStore.setState({ startupStatus: 'updated', startupMessage: null })
}
}
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))
@@ -112,15 +333,23 @@ export async function initRclone(args: string[]) {
'[initRclone] internal rclone update failed, code:',
updateResult.code
)
useStore.setState({ startupStatus: 'error' })
useStore.setState({
startupStatus: 'error',
startupMessage: 'Could not update the bundled rclone binary.',
})
} else {
console.log('[initRclone] internal rclone updated successfully')
useStore.setState({ startupStatus: 'updated' })
resetStartupDownloadState()
useStore.setState({ startupStatus: 'updated', startupMessage: null })
}
}
} catch (error) {
console.error('[initRclone] failed to update rclone', error)
useStore.setState({ startupStatus: 'error' })
useStore.setState({
startupStatus: 'error',
startupMessage:
error instanceof Error ? error.message : 'Failed to update rclone.',
})
}
await new Promise((resolve) => setTimeout(resolve, 1000))
@@ -385,10 +614,13 @@ 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 fetch('https://downloads.rclone.org/version.txt').then(
(res) => res.text()
const currentVersionString = await fetchTextWithTimeout(
'https://downloads.rclone.org/version.txt',
30_000
)
console.log('[provisionRclone] currentVersionString', currentVersionString)
@@ -400,6 +632,7 @@ export async function provisionRclone() {
return false
}
console.log('[provisionRclone] currentVersion', currentVersion)
setStartupMessage(`Preparing rclone ${currentVersion}`)
const currentPlatform = platform()
console.log('[provisionRclone] currentPlatform', currentPlatform)
@@ -415,6 +648,7 @@ 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)
@@ -428,10 +662,34 @@ export async function provisionRclone() {
console.log('[provisionRclone] downloadUrl', downloadUrl)
console.log('[provisionRclone] downloading rclone binary')
const downloadedFile = await fetch(downloadUrl).then((res) => res.arrayBuffer())
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,
})
},
})
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', {
@@ -480,6 +738,7 @@ 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')
@@ -492,6 +751,7 @@ 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,
@@ -520,6 +780,7 @@ 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)
@@ -535,6 +796,7 @@ 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)
@@ -554,6 +816,7 @@ 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}`)
@@ -588,6 +851,7 @@ export async function provisionRclone() {
}
console.log('[provisionRclone] verifying installation')
setStartupMessage('Verifying rclone installation')
const hasInstalled = await isInternalRcloneInstalled()
console.log('[provisionRclone] installation verified:', hasInstalled)
@@ -597,6 +861,7 @@ export async function provisionRclone() {
}
console.log('[provisionRclone] rclone has been installed successfully')
setStartupMessage('Rclone is ready')
return true
}