diff --git a/lib/rclone/init.ts b/lib/rclone/init.ts index d4b70bd..9fd8d24 100644 --- a/lib/rclone/init.ts +++ b/lib/rclone/init.ts @@ -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(operation: Promise, timeoutMs: number, errorMessage: string) { + let timeoutId: ReturnType | undefined + const timeout = new Promise((_, 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 | 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 } diff --git a/lib/window.ts b/lib/window.ts index c645545..e9ea1a7 100644 --- a/lib/window.ts +++ b/lib/window.ts @@ -35,12 +35,14 @@ 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 }) + await invoke('open_small_window', { name, url, transparent }) return WebviewWindow.getByLabel(name) } diff --git a/main.ts b/main.ts index 3870b4d..9b53cb8 100644 --- a/main.ts +++ b/main.ts @@ -395,7 +395,7 @@ async function registerRcloneWindowListeners() { rcloneListenersRegistered = true } -async function startRclone() { +async function startRclone(): Promise { console.log('[startRclone]') await registerRcloneWindowListeners() @@ -430,7 +430,8 @@ async function startRclone() { okLabel: 'Exit', } ) - return await exit(0) + await exit(0) + return false } const command = rclone?.system || rclone?.internal @@ -438,7 +439,7 @@ async function startRclone() { if (!command) { console.error('[startRclone] initRclone returned without a runnable command') Sentry.captureException(new Error('initRclone returned without a runnable command.')) - return + return false } command.addListener('close', async (event) => { @@ -476,6 +477,7 @@ async function startRclone() { console.log('[startRclone] running rclone') await new Promise((resolve) => setTimeout(resolve, 500)) + return true } async function startupMounts() { @@ -1118,10 +1120,16 @@ waitForHydration() .then(() => validateInstance()) .then(() => checkAlreadyRunning()) .then(() => startRclone()) - .then(() => checkRclone()) - .then(() => handleDeepLink()) - .then(() => showStartup()) - .then(() => startupMounts()) - .then(() => resumeTasks()) - .then(() => initTray()) + .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() + }) .catch(console.error) diff --git a/src-tauri/common/window.rs b/src-tauri/common/window.rs index 23eb43c..3356099 100644 --- a/src-tauri/common/window.rs +++ b/src-tauri/common/window.rs @@ -213,6 +213,7 @@ pub async fn open_small_window( app_handle: AppHandle, name: String, url: String, + transparent: Option, ) -> Result<(), String> { if let Some(existing) = app_handle.get_webview_window(&name) { existing.set_focus().map_err(|e| e.to_string())?; @@ -234,12 +235,16 @@ pub async fn open_small_window( #[cfg(target_os = "linux")] { - builder = builder.transparent(true); + if transparent.unwrap_or(true) { + builder = builder.transparent(true); + } } let window = builder.build().map_err(|e| e.to_string())?; - window.center().map_err(|e| e.to_string())?; + if let Err(err) = window.center() { + log::warn!("failed to center small window before show: {}", err); + } window.set_zoom(1.0).map_err(|e| e.to_string())?; #[cfg(target_os = "macos")] @@ -255,7 +260,9 @@ pub async fn open_small_window( window.set_always_on_top(true).map_err(|e| e.to_string())?; #[cfg(target_os = "linux")] - window.center().map_err(|e| e.to_string())?; + if let Err(err) = window.center() { + log::warn!("failed to center small window after show: {}", err); + } if os != "windows" && os != "macos" { window.set_resizable(true).map_err(|e| e.to_string())?; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0546260..623dd26 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,15 +14,15 @@ "windows": [ { "title": "Rclone UI", - "width": 0, - "height": 0, + "width": 1, + "height": 1, "center": true, "hiddenTitle": false, "resizable": false, "fullscreen": false, "visible": false, "decorations": false, - "focus": true, + "focus": false, "url": "tray.html", "alwaysOnTop": true, "backgroundThrottling": "disabled", diff --git a/src/pages/Startup.tsx b/src/pages/Startup.tsx index 907f86d..91e9023 100644 --- a/src/pages/Startup.tsx +++ b/src/pages/Startup.tsx @@ -1,10 +1,11 @@ -import { Button, Divider } from '@heroui/react' +import { Button, Divider, Progress } 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' @@ -43,6 +44,11 @@ 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(() => { @@ -65,6 +71,37 @@ 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 @@ -84,11 +121,16 @@ export default function Startup() { } }, [startupStatus]) - // Close window when it loses focus + // Linux can emit focus changes while the startup window is still provisioning rclone. 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() } @@ -116,7 +158,8 @@ export default function Startup() { exit={{ opacity: 0 }} className="ml-2 text-2xl" > - Could not complete the operation, please try again later. + {startupMessage || + 'Could not complete the operation, please try again later.'} )} {startupStatus === 'initialized' && ( @@ -141,39 +184,40 @@ export default function Startup() { Rclone has just been updated, thanks for waiting! )} - {startupStatus === 'initializing' && ( - - - {GREET[titleIndex]} - {' '} - 👋 - - )} - {startupStatus === 'updating' && ( - - - {WAIT[titleIndex]} - {' '} - 👋 - +

+ + {startupStatus === 'updating' + ? WAIT[titleIndex % WAIT.length] + : GREET[titleIndex % GREET.length]} + {' '} + 👋 +

+ + {startupIsDownloading && ( +
+ +

+ {downloadProgressLabel} +

+
+ )} + )} @@ -251,13 +295,13 @@ export default function Startup() { )} - {startupStatus === 'initializing' && ( - - Rclone is initializing + {startupMessage || + (startupStatus === 'updating' + ? 'Rclone is updating' + : 'Rclone is initializing')} - - )} - {startupStatus === 'updating' && ( - - - Rclone is updating - - + {startupIsDownloading && ( + + )} + )} diff --git a/store/memory.ts b/store/memory.ts index 8c8c9bb..ba42b06 100644 --- a/store/memory.ts +++ b/store/memory.ts @@ -14,6 +14,11 @@ interface State { | 'fatal' startupDisplayed: boolean + startupMessage: string | null + startupIsDownloading: boolean + startupDownloadedBytes: number + startupTotalBytes: number | null + startupDownloadSpeed: number | null isRestartingRclone: boolean @@ -37,6 +42,11 @@ export const useStore = create()( startupStatus: null, startupDisplayed: false, + startupMessage: null, + startupIsDownloading: false, + startupDownloadedBytes: 0, + startupTotalBytes: null, + startupDownloadSpeed: null, isRestartingRclone: false,