diff --git a/frontend/src/desktop/UpdateAvailableToast.tsx b/frontend/src/desktop/UpdateAvailableToast.tsx new file mode 100644 index 00000000..943435ca --- /dev/null +++ b/frontend/src/desktop/UpdateAvailableToast.tsx @@ -0,0 +1,317 @@ +/** + * Update-available toast (v0.4.0+). + * + * Renders as a non-intrusive card in the bottom-right corner when the + * Tauri updater plugin finds a newer release. The user can either + * install (downloads the full signed installer + restarts) or dismiss + * for the rest of the session. + * + * Why a custom UI instead of `tauri.conf.json::updater.dialog: true`? + * The native dialog is OS-modal, blocks the editor, and looks dated. + * A toast respects the user's flow (they can finish the sketch they + * were typing) but is visible enough that the update doesn't get + * forgotten. + * + * Auto-check fires once on mount with a 30 s delay so it doesn't + * compete with sidecar startup and the first paint. Manual re-check + * still works via the Velxio menu's "Check for Updates..." item. + * + * State machine: + * idle → no update detected, render nothing + * available → render toast with Install / Later + * downloading → render progress bar + "downloading X%" + * installing → render "installing..." (Tauri relauncher takes + * over and the app exits before this state can + * linger in practice) + * error → render error message + Retry button + * + * Dismissal persists in sessionStorage so a refresh / re-mount during + * the same session doesn't spam the user. Closing + reopening the + * app re-checks. + */ + +import { useEffect, useRef, useState } from 'react'; +import { isTauri } from './tauriBridge'; +import { dlog } from './log'; + +const DISMISS_KEY = 'vlx-desktop-update-dismissed'; +const AUTO_CHECK_DELAY_MS = 30_000; + +type State = + | { kind: 'idle' } + | { + kind: 'available'; + version: string; + notes: string | null; + update: UpdateHandle; + } + | { + kind: 'downloading'; + version: string; + downloaded: number; + total: number | null; + } + | { kind: 'installing'; version: string } + | { kind: 'error'; message: string }; + +// Minimal shape of what `updater.check()` returns. Matches the +// tauri-plugin-updater 2.x API exposed via `window.__TAURI__.updater`. +interface UpdateHandle { + version: string; + date?: string; + body?: string | null; + downloadAndInstall: ( + onEvent?: (event: DownloadEvent) => void, + ) => Promise; +} + +type DownloadEvent = + | { event: 'Started'; data: { contentLength?: number } } + | { event: 'Progress'; data: { chunkLength: number } } + | { event: 'Finished' }; + +interface TauriUpdater { + check?: () => Promise; +} + +function getUpdater(): TauriUpdater | null { + if (!isTauri()) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window as any).__TAURI__?.updater ?? null; +} + +export const UpdateAvailableToast = () => { + const [state, setState] = useState({ kind: 'idle' }); + const checked = useRef(false); + + useEffect(() => { + if (checked.current) return; + checked.current = true; + + if (sessionStorage.getItem(DISMISS_KEY) === '1') { + dlog('UpdateToast: dismissed earlier this session, skipping auto-check'); + return; + } + const updater = getUpdater(); + if (!updater?.check) { + dlog('UpdateToast: tauri-plugin-updater not present in this build'); + return; + } + + const timer = window.setTimeout(async () => { + try { + const result = await updater.check!(); + if (!result) { + dlog('UpdateToast: no update available'); + return; + } + dlog('UpdateToast: update found', { version: result.version }); + setState({ + kind: 'available', + version: result.version, + notes: result.body ?? null, + update: result, + }); + } catch (err) { + dlog('UpdateToast: check() failed', { err: String(err) }); + // Silent failure - we don't want to nag the user with errors + // from a background network check. Manual re-check via the + // menu still surfaces the error. + } + }, AUTO_CHECK_DELAY_MS); + + return () => window.clearTimeout(timer); + }, []); + + if (state.kind === 'idle') return null; + + const onInstall = async () => { + if (state.kind !== 'available') return; + const update = state.update; + setState({ + kind: 'downloading', + version: update.version, + downloaded: 0, + total: null, + }); + try { + await update.downloadAndInstall((evt) => { + if (evt.event === 'Started') { + setState((prev) => + prev.kind === 'downloading' + ? { ...prev, total: evt.data.contentLength ?? null } + : prev, + ); + } else if (evt.event === 'Progress') { + setState((prev) => + prev.kind === 'downloading' + ? { ...prev, downloaded: prev.downloaded + evt.data.chunkLength } + : prev, + ); + } else if (evt.event === 'Finished') { + setState({ kind: 'installing', version: update.version }); + } + }); + // downloadAndInstall calls relauncher internally - if we got + // here the app is about to exit. Leave the "installing" card up + // so the user sees something happening before the window dies. + } catch (err) { + dlog('UpdateToast: download/install failed', { err: String(err) }); + setState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + }; + + const onDismiss = () => { + sessionStorage.setItem(DISMISS_KEY, '1'); + setState({ kind: 'idle' }); + }; + + const onRetry = () => { + sessionStorage.removeItem(DISMISS_KEY); + checked.current = false; + setState({ kind: 'idle' }); + // Force a re-mount-style re-check by clearing checked.current and + // re-running the useEffect would be ideal, but we don't unmount + // the component. Instead, run an inline check now. + void (async () => { + const updater = getUpdater(); + if (!updater?.check) return; + try { + const result = await updater.check(); + if (result) { + setState({ + kind: 'available', + version: result.version, + notes: result.body ?? null, + update: result, + }); + } + } catch (err) { + setState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + })(); + }; + + return ( +
+ {state.kind === 'available' && ( + <> +
+ + {'↑'} + +
+
+ Update available +
+
+ Velxio Desktop {state.version} +
+
+
+ {state.notes && ( +
+ {truncate(state.notes, 200)} +
+ )} +
+ + +
+ + )} + + {state.kind === 'downloading' && ( + <> +
+ Downloading {state.version}... +
+
+
+
+
+ {formatProgress(state.downloaded, state.total)} +
+ + )} + + {state.kind === 'installing' && ( + <> +
+ Installing {state.version}... +
+
+ Velxio Desktop will restart automatically. +
+ + )} + + {state.kind === 'error' && ( + <> +
+ Update failed +
+
{state.message}
+
+ + +
+ + )} +
+ ); +}; + +function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, n - 3).trimEnd() + '...'; +} + +function formatProgress(done: number, total: number | null): string { + const mb = (b: number) => (b / (1 << 20)).toFixed(1); + if (total) { + const pct = Math.min(100, Math.round((done / total) * 100)); + return `${mb(done)} / ${mb(total)} MB (${pct}%)`; + } + return `${mb(done)} MB downloaded`; +} diff --git a/frontend/src/desktop/desktop.css b/frontend/src/desktop/desktop.css index a418f32f..d8381719 100644 --- a/frontend/src/desktop/desktop.css +++ b/frontend/src/desktop/desktop.css @@ -267,3 +267,132 @@ body.vlx-desktop-readonly [data-velxio-action="save"] { pointer-events: none; cursor: not-allowed; } + +/* ── Update-available toast (v0.4.0+) ──────────────────────── */ + +.vlx-desktop-update-toast { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 9700; /* above grace banner (9000), below lockout (10001) */ + width: 340px; + max-width: calc(100vw - 32px); + padding: 16px; + background: #1a1d24; + color: #e6e6e9; + border: 1px solid #2c2c33; + border-radius: 8px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + animation: vlx-update-toast-in 0.25s ease; +} + +@keyframes vlx-update-toast-in { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.vlx-desktop-update-toast-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 10px; +} + +.vlx-desktop-update-toast-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 14px; + background: linear-gradient(135deg, #007acc 0%, #005ea1 100%); + color: white; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; +} + +.vlx-desktop-update-toast-title { + font-size: 14px; + font-weight: 600; + color: #fff; + margin-bottom: 2px; +} + +.vlx-desktop-update-toast-version { + font-size: 12px; + color: #888; +} + +.vlx-desktop-update-toast-notes { + margin: 8px 0 12px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + border-radius: 4px; + font-size: 12px; + color: #aaa; + line-height: 1.45; + max-height: 100px; + overflow-y: auto; +} + +.vlx-desktop-update-toast-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 4px; +} + +.vlx-desktop-update-toast-primary { + padding: 7px 14px; + font-size: 13px; + font-weight: 600; + color: white; + background: linear-gradient(135deg, #007acc 0%, #005ea1 100%); + border: 1px solid #005ea1; + border-radius: 5px; + cursor: pointer; + font-family: inherit; +} + +.vlx-desktop-update-toast-primary:hover { + filter: brightness(1.1); +} + +.vlx-desktop-update-toast-secondary { + padding: 7px 14px; + font-size: 13px; + color: #aaa; + background: transparent; + border: 1px solid #2c2c33; + border-radius: 5px; + cursor: pointer; + font-family: inherit; +} + +.vlx-desktop-update-toast-secondary:hover { + background: #232730; + color: #ddd; +} + +.vlx-desktop-update-toast-progress { + margin: 12px 0 6px; + height: 6px; + background: #0c0c11; + border-radius: 3px; + overflow: hidden; +} + +.vlx-desktop-update-toast-progress-bar { + height: 100%; + background: linear-gradient(90deg, #007acc 0%, #00a4ff 100%); + transition: width 0.2s ease; +} + +.vlx-desktop-update-toast-progress-label { + font-size: 11px; + color: #888; + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/desktop/index.ts b/frontend/src/desktop/index.ts index 57e58852..952d856c 100644 --- a/frontend/src/desktop/index.ts +++ b/frontend/src/desktop/index.ts @@ -29,6 +29,7 @@ import { DesktopWelcomePage } from './DesktopWelcomePage'; import { Esp32QemuPrompt } from './Esp32QemuPrompt'; import { GraceBanner } from './GraceBanner'; import { LockoutOverlay, type LockoutReason } from './LockoutOverlay'; +import { UpdateAvailableToast } from './UpdateAvailableToast'; import { getGateInfo, invoke, @@ -93,7 +94,16 @@ function mountSidePanels(): void { document.body.appendChild(host); sidePanelRoot = createRoot(host); sidePanelRoot.render( - h(Fragment, null, h(GraceBanner, null), h(Esp32QemuPrompt, null)), + h( + Fragment, + null, + h(GraceBanner, null), + h(Esp32QemuPrompt, null), + // v0.4.0 auto-update toast (~30s after mount). Lives below the + // grace banner z-index so a lockout / hard-grace doesn't get + // covered by a "new version available" pitch. + h(UpdateAvailableToast, null), + ), ); }