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:
parent
5ca7e293b7
commit
91b0829562
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue