feat(desktop): in-app update toast with auto-check at startup (v0.4.0)

Replaces the native OS-modal update dialog (which blocked the editor
and looked dated) with a non-intrusive bottom-right toast that
appears 30 s after app mount when the Tauri updater finds a newer
release.

State machine:
  idle → no update detected, render nothing
  available → "Update available - Velxio Desktop X.Y.Z" + Install/Later
  downloading → progress bar with "X.X / Y.Y MB (NN%)"
  installing → "Installing X.Y.Z... will restart automatically"
  error → error message + Retry/Dismiss

Click "Install and restart":
  1. downloadAndInstall() streams the full signed installer (~70 MB)
  2. Tauri verifies the minisign sig against the embedded pubkey
  3. Replaces the install in-place
  4. Auto-relaunch (the app exits and reopens on the new version)

"Later" dismisses for the rest of the session (sessionStorage flag).
A close+reopen re-checks. Manual re-check via the menu still works.

Companion change in velxio-prod flips tauri.conf.json
updater.dialog from true to false so our custom toast is the only
update UI - no double-prompting.

Files:
- frontend/src/desktop/UpdateAvailableToast.tsx (new): the component
- frontend/src/desktop/desktop.css: toast styles + slide-in animation
- frontend/src/desktop/index.ts: mount alongside GraceBanner +
  Esp32QemuPrompt in the existing sidePanelRoot

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-26 15:46:39 -03:00
parent 5ca7e293b7
commit 91b0829562
3 changed files with 457 additions and 1 deletions

View File

@ -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<void>;
}
type DownloadEvent =
| { event: 'Started'; data: { contentLength?: number } }
| { event: 'Progress'; data: { chunkLength: number } }
| { event: 'Finished' };
interface TauriUpdater {
check?: () => Promise<UpdateHandle | null>;
}
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<State>({ 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 (
<div className="vlx-desktop-update-toast" role="status" aria-live="polite">
{state.kind === 'available' && (
<>
<div className="vlx-desktop-update-toast-header">
<span className="vlx-desktop-update-toast-icon" aria-hidden>
{'↑'}
</span>
<div>
<div className="vlx-desktop-update-toast-title">
Update available
</div>
<div className="vlx-desktop-update-toast-version">
Velxio Desktop {state.version}
</div>
</div>
</div>
{state.notes && (
<div className="vlx-desktop-update-toast-notes">
{truncate(state.notes, 200)}
</div>
)}
<div className="vlx-desktop-update-toast-actions">
<button
type="button"
className="vlx-desktop-update-toast-primary"
onClick={onInstall}
>
Install and restart
</button>
<button
type="button"
className="vlx-desktop-update-toast-secondary"
onClick={onDismiss}
>
Later
</button>
</div>
</>
)}
{state.kind === 'downloading' && (
<>
<div className="vlx-desktop-update-toast-title">
Downloading {state.version}...
</div>
<div className="vlx-desktop-update-toast-progress">
<div
className="vlx-desktop-update-toast-progress-bar"
style={{
width: state.total
? `${Math.min(100, Math.round((state.downloaded / state.total) * 100))}%`
: '40%',
animation: state.total
? undefined
: 'vlx-indeterminate 1.5s linear infinite',
}}
/>
</div>
<div className="vlx-desktop-update-toast-progress-label">
{formatProgress(state.downloaded, state.total)}
</div>
</>
)}
{state.kind === 'installing' && (
<>
<div className="vlx-desktop-update-toast-title">
Installing {state.version}...
</div>
<div className="vlx-desktop-update-toast-progress-label">
Velxio Desktop will restart automatically.
</div>
</>
)}
{state.kind === 'error' && (
<>
<div className="vlx-desktop-update-toast-title">
Update failed
</div>
<div className="vlx-desktop-update-toast-notes">{state.message}</div>
<div className="vlx-desktop-update-toast-actions">
<button
type="button"
className="vlx-desktop-update-toast-primary"
onClick={onRetry}
>
Retry
</button>
<button
type="button"
className="vlx-desktop-update-toast-secondary"
onClick={onDismiss}
>
Dismiss
</button>
</div>
</>
)}
</div>
);
};
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`;
}

View File

@ -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;
}

View File

@ -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),
),
);
}