feat(updater): in-app auto-update with auto-download + restart prompt (#677) (#1114)

This commit is contained in:
CodeGhost21
2026-05-03 08:29:25 -07:00
committed by GitHub
parent df6c0a941d
commit 1d8e1bfa0d
15 changed files with 2349 additions and 2 deletions
+2 -1
View File
@@ -21,6 +21,7 @@
"notification:allow-notify",
"opener:default",
"updater:default",
"allow-core-process"
"allow-core-process",
"allow-app-update"
]
}
@@ -0,0 +1,22 @@
[[permission]]
identifier = "allow-app-update"
description = "Tauri shell auto-update commands — probe the updater endpoint, stage a download, and install it on user confirmation."
[permission.commands]
allow = [
# Probe-only: hits the configured updater endpoint and returns the
# detected version info. Does not download or install.
"check_app_update",
# Legacy combined path — downloads + installs + restarts in one call.
# Kept for backwards compat but the auto-update flow now uses the
# download/install split below.
"apply_app_update",
# Download the bundle bytes into memory and stage them in Tauri state.
# Does NOT install — the frontend can defer install until the user
# confirms a restart at a safe moment.
"download_app_update",
# Install previously-staged bytes and relaunch. Errors if no download
# has been staged this session.
"install_app_update",
]
deny = []
+186 -1
View File
@@ -418,6 +418,11 @@ async fn apply_app_update(
if let Err(e) = download_result {
log::error!("[app-update] download/install failed: {e}");
// Same recovery as `install_app_update`: the .app wasn't swapped,
// so revive the in-process core we shut down above.
if let Err(start_err) = state.inner().ensure_running().await {
log::error!("[app-update] failed to restart core after apply error: {start_err}");
}
let _ = app.emit("app-update:status", "error");
return Err(format!("download_and_install failed: {e}"));
}
@@ -428,6 +433,183 @@ async fn apply_app_update(
app.restart();
}
/// Holds an `Update` handle plus its downloaded bytes between the
/// `download_app_update` (background) and `install_app_update` (user
/// confirmed restart) commands. Sized at ~100MB on macOS for the .app
/// bundle, which is fine to keep in RAM until the user is ready.
struct PendingAppUpdate {
update: tauri_plugin_updater::Update,
bytes: Vec<u8>,
version: String,
}
/// Tauri-managed state slot for the in-flight pending update. `None` means
/// "no update has been downloaded since launch"; `Some(_)` means the bytes
/// are ready and `install_app_update` can finalize without re-downloading.
#[derive(Default)]
struct PendingAppUpdateState(tokio::sync::Mutex<Option<PendingAppUpdate>>);
/// Result returned to the frontend after a download attempt.
#[derive(Debug, Clone, serde::Serialize)]
struct AppUpdateDownloadResult {
/// True when an update was found and the bytes are now staged.
ready: bool,
/// Version of the staged update (if any).
version: Option<String>,
/// Release notes for the staged update.
body: Option<String>,
}
/// Probe the updater endpoint and, if a newer build is advertised, download
/// the bundle bytes into memory but do NOT install. The frontend can then
/// surface a "Restart to apply" prompt at a moment that's safe for the user
/// (no in-flight conversation, etc.) before calling `install_app_update`.
///
/// Emits the same `app-update:status` and `app-update:progress` events as
/// `apply_app_update`, so the React state machine can drive a single UI off
/// either path. Status sequence: `checking` → `downloading` → `ready_to_install`,
/// or `up_to_date` / `error`.
#[tauri::command]
async fn download_app_update(
app: tauri::AppHandle<AppRuntime>,
state: tauri::State<'_, PendingAppUpdateState>,
) -> Result<AppUpdateDownloadResult, String> {
use tauri::Emitter;
use tauri_plugin_updater::UpdaterExt;
log::info!("[app-update] download_app_update invoked from frontend");
let updater = app
.updater()
.map_err(|e| format!("updater plugin not initialized: {e}"))?;
let _ = app.emit("app-update:status", "checking");
let update = match updater.check().await {
Ok(Some(u)) => u,
Ok(None) => {
log::info!("[app-update] no update available");
let _ = app.emit("app-update:status", "up_to_date");
return Ok(AppUpdateDownloadResult {
ready: false,
version: None,
body: None,
});
}
Err(e) => {
log::warn!("[app-update] check failed: {e}");
let _ = app.emit("app-update:status", "error");
return Err(format!("update check failed: {e}"));
}
};
let new_version = update.version.clone();
let body = update.body.clone();
log::info!("[app-update] downloading {} (background)", new_version);
let _ = app.emit("app-update:status", "downloading");
let progress_app = app.clone();
let download_result = update
.download(
move |chunk_length, content_length| {
let payload = serde_json::json!({
"chunk": chunk_length,
"total": content_length,
});
let _ = progress_app.emit("app-update:progress", payload);
},
|| {
log::info!("[app-update] download complete — staging for install");
},
)
.await;
let bytes = match download_result {
Ok(b) => b,
Err(e) => {
log::error!("[app-update] download failed: {e}");
let _ = app.emit("app-update:status", "error");
return Err(format!("download failed: {e}"));
}
};
let mut slot = state.0.lock().await;
*slot = Some(PendingAppUpdate {
update,
bytes,
version: new_version.clone(),
});
drop(slot);
log::info!(
"[app-update] staged {} — awaiting user-initiated install",
new_version
);
let _ = app.emit("app-update:status", "ready_to_install");
Ok(AppUpdateDownloadResult {
ready: true,
version: Some(new_version),
body,
})
}
/// Install the previously-downloaded update bytes (staged by
/// `download_app_update`), then relaunch. Errors with `no pending update`
/// if `download_app_update` hasn't run yet — the frontend should fall back
/// to a fresh `apply_app_update` in that case.
///
/// Acquires the core restart lock + shuts the in-process core server down
/// before install, same as `apply_app_update`, so the macOS .app bundle
/// replacement does not race against a live core holding file handles.
#[tauri::command]
async fn install_app_update(
core_state: tauri::State<'_, core_process::CoreProcessHandle>,
pending: tauri::State<'_, PendingAppUpdateState>,
app: tauri::AppHandle<AppRuntime>,
) -> Result<(), String> {
use tauri::Emitter;
log::info!("[app-update] install_app_update invoked from frontend");
let staged = {
let mut slot = pending.0.lock().await;
slot.take()
};
let staged = match staged {
Some(s) => s,
None => {
log::warn!("[app-update] install requested but no staged update");
let _ = app.emit("app-update:status", "error");
return Err("no pending update — call download_app_update first".into());
}
};
log::info!("[app-update] installing staged version {}", staged.version);
let _guard = core_state.inner().restart_lock().await;
log::debug!("[app-update] acquired core restart lock");
core_state.inner().shutdown().await;
let _ = app.emit("app-update:status", "installing");
if let Err(e) = staged.update.install(staged.bytes) {
log::error!("[app-update] install failed: {e}");
// The .app on disk wasn't replaced, so we keep running the
// pre-install build — bring the core back up before returning
// so the user can keep working instead of being silently offline.
if let Err(start_err) = core_state.inner().ensure_running().await {
log::error!("[app-update] failed to restart core after install error: {start_err}");
}
let _ = app.emit("app-update:status", "error");
return Err(format!("install failed: {e}"));
}
log::info!("[app-update] install complete — relaunching");
let _ = app.emit("app-update:status", "restarting");
// Note: app.restart() never returns. Anything after this is unreachable.
app.restart();
}
/// Register (or re-register) the global dictation toggle hotkey.
/// Emits `dictation://toggle` to all webviews when the shortcut is pressed.
#[tauri::command]
@@ -970,7 +1152,8 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(DictationHotkeyState(Mutex::new(Vec::new())))
.manage(webview_accounts::WebviewAccountsState::default())
.manage(notification_settings::NotificationSettingsState::new());
.manage(notification_settings::NotificationSettingsState::new())
.manage(PendingAppUpdateState::default());
let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new()));
let builder = builder.manage(std::sync::Arc::new(
gmessages_scanner::ScannerRegistry::new(),
@@ -1402,6 +1585,8 @@ pub fn run() {
apply_core_update,
check_app_update,
apply_app_update,
download_app_update,
install_app_update,
restart_core_process,
restart_app,
get_active_user_id,
+2
View File
@@ -5,6 +5,7 @@ import { HashRouter as Router, useLocation, useNavigate } from 'react-router-dom
import { PersistGate } from 'redux-persist/integration/react';
import AppRoutes from './AppRoutes';
import AppUpdatePrompt from './components/AppUpdatePrompt';
import BottomTabBar from './components/BottomTabBar';
import CommandProvider from './components/commands/CommandProvider';
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
@@ -53,6 +54,7 @@ function App() {
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
</ServiceBlockingGate>
</CommandProvider>
</Router>
+263
View File
@@ -0,0 +1,263 @@
/**
* App auto-update prompt.
*
* Globally-mounted banner that surfaces the Tauri shell updater to the user.
* The state machine, listeners, and auto-download orchestration all live in
* `useAppUpdate`; this component is a thin presentational layer on top.
*
* UX contract: the banner is **silent during background download**. The user
* only sees a prompt once bytes are staged (`ready_to_install`) — at which
* point they can choose "Restart now" or "Later". Errors and the active
* install/restart flow also surface visually.
*
* Visual conventions mirror `LocalAIDownloadSnackbar` — bottom-right portal,
* stone-900 panel, primary gradient progress bar.
*/
import { useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { useAppUpdate } from '../hooks/useAppUpdate';
import { formatBytes } from '../utils/localAiHelpers';
interface AppUpdatePromptProps {
/** Override auto-check defaults (mostly for tests). */
autoCheck?: boolean;
initialCheckDelayMs?: number;
recheckIntervalMs?: number;
autoDownload?: boolean;
}
/**
* Phases that should surface a visible banner. Background-only phases
* (`checking`, `available`, `downloading`) stay silent so the user isn't
* pestered while we're working — the prompt only appears once the user
* has a meaningful decision to make.
*/
function shouldShow(phase: ReturnType<typeof useAppUpdate>['phase']): boolean {
return (
phase === 'ready_to_install' ||
phase === 'installing' ||
phase === 'restarting' ||
phase === 'error'
);
}
const AppUpdatePrompt = (props: AppUpdatePromptProps) => {
const { phase, info, bytesDownloaded, totalBytes, error, install, download, reset } =
useAppUpdate(props);
const [dismissed, setDismissed] = useState(false);
const [prevPhase, setPrevPhase] = useState(phase);
// Re-show on every transition INTO a visible phase, even if the user had
// dismissed a previous error/prompt earlier in the session.
if (phase !== prevPhase) {
setPrevPhase(phase);
if (shouldShow(phase) && !shouldShow(prevPhase)) {
setDismissed(false);
}
}
const handleInstall = useCallback(() => {
void install();
}, [install]);
const handleLater = useCallback(() => {
setDismissed(true);
}, []);
const handleRetryDownload = useCallback(() => {
setDismissed(false);
reset();
void download();
}, [reset, download]);
const handleDismissError = useCallback(() => {
reset();
setDismissed(true);
}, [reset]);
if (!shouldShow(phase) || dismissed) return null;
const newVersion = info?.available_version ?? null;
const currentVersion = info?.current_version ?? null;
const percent =
totalBytes != null && totalBytes > 0
? Math.min(100, Math.round((bytesDownloaded / totalBytes) * 100))
: null;
return createPortal(
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-[9998] w-[340px] animate-fade-up"
data-testid="app-update-prompt">
<div className="bg-stone-900 border border-stone-700/50 rounded-2xl shadow-large overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<div className="flex items-center gap-2">
<UpdateIcon className="w-4 h-4 text-primary-400" />
<span className="text-sm font-medium text-white">{headerLabel(phase)}</span>
</div>
{(phase === 'ready_to_install' || phase === 'error') && (
<button
onClick={phase === 'error' ? handleDismissError : handleLater}
className="p-1 text-stone-500 hover:text-stone-300 transition-colors"
aria-label="Dismiss update notification">
<CloseIcon className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Body */}
<div className="px-4 pt-1 pb-3">
{phase === 'ready_to_install' && (
<>
<p className="text-xs text-stone-300 leading-relaxed">
{newVersion
? `Version ${newVersion} is ready to install.`
: 'A new version is ready to install.'}
{currentVersion && (
<span className="text-stone-500"> Currently on {currentVersion}.</span>
)}
</p>
{info?.body && <ReleaseNotes body={info.body} />}
<p className="mt-2 text-[11px] text-stone-500 leading-relaxed">
Restarting will close any open conversations briefly. The new build launches
automatically.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={handleInstall}
className="flex-1 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-400 text-white text-xs font-medium transition-colors">
Restart now
</button>
<button
onClick={handleLater}
className="px-3 py-1.5 rounded-lg border border-stone-700 text-stone-300 hover:bg-stone-800 text-xs transition-colors">
Later
</button>
</div>
</>
)}
{(phase === 'installing' || phase === 'restarting') && (
<>
<ProgressBar indeterminate />
<div className="mt-2 flex items-center justify-between text-[11px] text-stone-400">
<span>{progressDetail(phase, bytesDownloaded, totalBytes, percent)}</span>
{newVersion && <span className="text-stone-500">v{newVersion}</span>}
</div>
</>
)}
{phase === 'error' && (
<>
<p className="text-xs text-coral-300 leading-relaxed">
{error ?? 'Something went wrong while updating.'}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={handleRetryDownload}
className="flex-1 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-400 text-white text-xs font-medium transition-colors">
Try again
</button>
<button
onClick={handleDismissError}
className="px-3 py-1.5 rounded-lg border border-stone-700 text-stone-300 hover:bg-stone-800 text-xs transition-colors">
Dismiss
</button>
</div>
</>
)}
</div>
</div>
</div>,
document.body
);
};
function headerLabel(phase: ReturnType<typeof useAppUpdate>['phase']): string {
switch (phase) {
case 'ready_to_install':
return 'Update ready to install';
case 'installing':
return 'Installing update';
case 'restarting':
return 'Restarting…';
case 'error':
return 'Update failed';
default:
return 'Update';
}
}
function progressDetail(
phase: ReturnType<typeof useAppUpdate>['phase'],
downloaded: number,
total: number | null,
percent: number | null
): string {
if (phase === 'installing') return 'Installing the new version…';
if (phase === 'restarting') return 'Relaunching the app…';
if (total != null && total > 0) {
return `${formatBytes(downloaded)} / ${formatBytes(total)}`;
}
if (downloaded > 0) return `${formatBytes(downloaded)} downloaded`;
return percent != null ? `${percent}%` : 'Working…';
}
const ProgressBar = ({
indeterminate,
percent,
}: {
indeterminate?: boolean;
percent?: number | null;
}) => {
const indet = indeterminate || percent == null;
return (
<div className="h-1.5 w-full rounded-full bg-stone-800 overflow-hidden">
<div
className={`h-full rounded-full bg-gradient-to-r from-primary-500 to-primary-400 transition-all duration-500 ${
indet ? 'animate-pulse' : ''
}`}
style={{ width: indet ? '100%' : `${percent ?? 0}%` }}
/>
</div>
);
};
const ReleaseNotes = ({ body }: { body: string }) => {
const [expanded, setExpanded] = useState(false);
const trimmed = body.trim();
if (!trimmed) return null;
const isLong = trimmed.length > 160;
const display = expanded || !isLong ? trimmed : `${trimmed.slice(0, 160).trimEnd()}`;
return (
<div className="mt-2 rounded-lg bg-stone-800/60 border border-stone-700/40 px-3 py-2">
<p className="text-[11px] text-stone-400 whitespace-pre-line break-words">{display}</p>
{isLong && (
<button
type="button"
onClick={() => setExpanded(prev => !prev)}
className="mt-1 text-[11px] text-primary-300 hover:text-primary-200 transition-colors">
{expanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
);
};
const UpdateIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 2a8 8 0 015.292 13.97v1.78a.75.75 0 01-1.5 0v-1.06a.75.75 0 01.22-.53A6.5 6.5 0 1010 16.5a.75.75 0 010 1.5A8 8 0 1110 2z" />
<path d="M9.25 6.75a.75.75 0 011.5 0v3.69l2.22 2.22a.75.75 0 11-1.06 1.06l-2.44-2.44a.75.75 0 01-.22-.53V6.75z" />
</svg>
);
const CloseIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
</svg>
);
export default AppUpdatePrompt;
@@ -0,0 +1,236 @@
/**
* Tests for the global app-update prompt.
*
* Drives the underlying `useAppUpdate` hook through the shared mocks and
* asserts the user-visible UX contract:
* - silent during background download (no banner on `available`/`downloading`)
* - prompt with "Restart now" / "Later" once bytes are staged
* (`ready_to_install`)
* - error surface with retry path
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import AppUpdatePrompt from '../AppUpdatePrompt';
const hoisted = vi.hoisted(() => ({
mockCheckAppUpdate: vi.fn(),
mockApplyAppUpdate: vi.fn(),
mockDownloadAppUpdate: vi.fn(),
mockInstallAppUpdate: vi.fn(),
mockIsTauri: vi.fn(() => true),
statusListeners: [] as ((event: { payload: string }) => void)[],
}));
const {
mockCheckAppUpdate,
mockApplyAppUpdate,
mockDownloadAppUpdate,
mockInstallAppUpdate,
mockIsTauri,
statusListeners,
} = hoisted;
vi.mock('../../utils/tauriCommands', () => ({
checkAppUpdate: hoisted.mockCheckAppUpdate,
applyAppUpdate: hoisted.mockApplyAppUpdate,
downloadAppUpdate: hoisted.mockDownloadAppUpdate,
installAppUpdate: hoisted.mockInstallAppUpdate,
isTauri: hoisted.mockIsTauri,
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn((event: string, handler: (event: { payload: string }) => void) => {
if (event === 'app-update:status') {
hoisted.statusListeners.push(handler);
}
return Promise.resolve(() => {
const idx = hoisted.statusListeners.indexOf(handler);
if (idx >= 0) hoisted.statusListeners.splice(idx, 1);
});
}),
}));
const emitStatus = (payload: string) => {
for (const listener of [...statusListeners]) listener({ payload });
};
describe('AppUpdatePrompt', () => {
beforeEach(() => {
statusListeners.length = 0;
mockCheckAppUpdate.mockReset();
mockApplyAppUpdate.mockReset();
mockDownloadAppUpdate.mockReset();
mockInstallAppUpdate.mockReset();
mockIsTauri.mockReturnValue(true);
});
it('stays silent while a download is in progress', async () => {
mockCheckAppUpdate.mockResolvedValueOnce({
current_version: '0.50.0',
available: true,
available_version: '0.51.0',
body: null,
});
// Simulate a check that finds an update + a download that's still
// running — the hook will move into "available" then "downloading".
mockDownloadAppUpdate.mockImplementation(
() =>
new Promise(() => {
/* never resolves during the test */
})
);
renderWithProviders(<AppUpdatePrompt initialCheckDelayMs={0} recheckIntervalMs={0} />);
// Give the auto-check + auto-download timers a chance to run.
await new Promise(resolve => setTimeout(resolve, 50));
expect(screen.queryByTestId('app-update-prompt')).not.toBeInTheDocument();
});
it('shows the "Restart now" prompt once the download is staged', async () => {
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
// Wait for listeners to register.
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
// Simulate the Rust side emitting ready_to_install.
emitStatus('ready_to_install');
await waitFor(() => {
expect(screen.getByText('Update ready to install')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /Restart now/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Later/ })).toBeInTheDocument();
});
it('clicking "Restart now" invokes installAppUpdate (the staged path)', async () => {
mockInstallAppUpdate.mockResolvedValueOnce(undefined);
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
// The Rust side emits `ready_to_install` once bytes are staged. The
// hook's status listener flips `stagedRef` to true on that event, so a
// subsequent install() must take the fast staged path and call
// `installAppUpdate` directly — never falling back to the legacy
// combined `applyAppUpdate`.
emitStatus('ready_to_install');
const restartBtn = await screen.findByRole('button', { name: /Restart now/ });
fireEvent.click(restartBtn);
await waitFor(() => {
expect(mockInstallAppUpdate).toHaveBeenCalledTimes(1);
});
expect(mockApplyAppUpdate).not.toHaveBeenCalled();
});
it('clicking "Later" hides the banner without calling install', async () => {
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
emitStatus('ready_to_install');
const laterBtn = await screen.findByRole('button', { name: /Later/ });
fireEvent.click(laterBtn);
await waitFor(() => {
expect(screen.queryByText('Update ready to install')).not.toBeInTheDocument();
});
expect(mockInstallAppUpdate).not.toHaveBeenCalled();
expect(mockApplyAppUpdate).not.toHaveBeenCalled();
});
it('renders an error banner with retry on failure', async () => {
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
emitStatus('error');
await waitFor(() => {
expect(screen.getByText('Update failed')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /Try again/ })).toBeInTheDocument();
});
it('clicking "Try again" after error invokes downloadAppUpdate', async () => {
mockDownloadAppUpdate.mockResolvedValueOnce({ ready: true, version: '0.51.0', body: null });
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
emitStatus('error');
const retryBtn = await screen.findByRole('button', { name: /Try again/ });
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockDownloadAppUpdate).toHaveBeenCalledTimes(1);
});
});
it('shows the installing-phase banner with progress copy', async () => {
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
emitStatus('installing');
await waitFor(() => {
expect(screen.getByText('Installing update')).toBeInTheDocument();
});
expect(screen.getByText(/Installing the new version/i)).toBeInTheDocument();
});
it('shows the restarting-phase banner', async () => {
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
emitStatus('restarting');
await waitFor(() => {
// Header label is "Restarting…" (with the ellipsis char).
expect(screen.getByText(/Restarting/)).toBeInTheDocument();
});
});
it('clicking "Dismiss" on the error banner hides the prompt', async () => {
renderWithProviders(
<AppUpdatePrompt autoCheck={false} initialCheckDelayMs={0} recheckIntervalMs={0} />
);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
emitStatus('error');
await waitFor(() => expect(screen.getByText('Update failed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /^Dismiss$/i }));
await waitFor(() => {
expect(screen.queryByText('Update failed')).not.toBeInTheDocument();
});
});
it('renders nothing when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
renderWithProviders(<AppUpdatePrompt initialCheckDelayMs={0} recheckIntervalMs={0} />);
await new Promise(resolve => setTimeout(resolve, 30));
expect(screen.queryByTestId('app-update-prompt')).not.toBeInTheDocument();
});
});
@@ -200,6 +200,23 @@ const SettingsHome = () => {
onClick: () => navigateToSettings('notification-routing'),
dangerous: false,
},
{
id: 'about',
title: 'About',
description: 'App version and software updates',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
onClick: () => navigateToSettings('about'),
dangerous: false,
},
{
id: 'developer-options',
title: 'Developer Options',
@@ -0,0 +1,134 @@
/**
* About / Updates settings panel.
*
* Surfaces the running app version, the user-triggered "Check for updates"
* action, and a link to the GitHub releases page. The actual install flow
* is driven by the globally-mounted `<AppUpdatePrompt />` — calling `apply()`
* here would race with that component's own state machine.
*/
import { useState } from 'react';
import { useAppUpdate } from '../../../hooks/useAppUpdate';
import { APP_VERSION, LATEST_APP_DOWNLOAD_URL } from '../../../utils/config';
import { openUrl } from '../../../utils/openUrl';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const AboutPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
// The auto-cadence is already running via the global <AppUpdatePrompt />;
// disable it here so opening the panel doesn't double-trigger probes.
const { phase, info, error, check } = useAppUpdate({ autoCheck: false });
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(null);
const isChecking = phase === 'checking';
const summary = summaryFor(phase, info, error);
const handleCheck = async () => {
console.debug('[app-update] AboutPanel: manual check');
const result = await check();
if (result !== null) setLastCheckedAt(new Date());
};
return (
<div className="z-10 relative">
<SettingsHeader
title="About"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="text-xs text-stone-500">Version</div>
<div className="mt-1 text-lg font-semibold text-stone-900">v{APP_VERSION}</div>
{info?.available && info.available_version && (
<div className="mt-1 text-xs text-primary-500">
v{info.available_version} is available
</div>
)}
</div>
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900">Software updates</div>
<div className="mt-1 text-xs text-stone-500 leading-relaxed">{summary}</div>
{lastCheckedAt && (
<div className="mt-1 text-[11px] text-stone-400">
Last checked {formatRelative(lastCheckedAt)}
</div>
)}
</div>
<button
type="button"
onClick={handleCheck}
disabled={isChecking}
className="shrink-0 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-400 text-white text-xs font-medium transition-colors disabled:opacity-50">
{isChecking ? 'Checking…' : 'Check for updates'}
</button>
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="text-sm font-medium text-stone-900">Releases</div>
<p className="mt-1 text-xs text-stone-500 leading-relaxed">
Browse release notes and earlier builds on GitHub.
</p>
<button
type="button"
onClick={() => {
void openUrl(LATEST_APP_DOWNLOAD_URL);
}}
className="mt-3 px-3 py-1.5 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-100 text-xs transition-colors">
Open GitHub releases
</button>
</div>
</div>
</div>
);
};
function summaryFor(
phase: ReturnType<typeof useAppUpdate>['phase'],
info: ReturnType<typeof useAppUpdate>['info'],
error: string | null
): string {
switch (phase) {
case 'checking':
return 'Contacting the update server…';
case 'available':
return info?.available_version
? `Version ${info.available_version} found — downloading in the background…`
: 'A new version was found — downloading…';
case 'downloading':
return 'Downloading the latest version in the background…';
case 'ready_to_install':
return info?.available_version
? `Version ${info.available_version} is downloaded and ready. Use the prompt at the bottom right to restart.`
: 'A new version is downloaded and ready. Restart to apply.';
case 'installing':
return 'Installing the update…';
case 'restarting':
return 'Relaunching with the new version…';
case 'up_to_date':
return 'You are running the latest version.';
case 'error':
return error ?? 'Last update check failed. Try again in a moment.';
default:
return 'Click "Check for updates" to look for a newer version.';
}
}
function formatRelative(date: Date): string {
const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
if (seconds < 60) return 'just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return date.toLocaleString();
}
export default AboutPanel;
@@ -0,0 +1,146 @@
/**
* Tests for the Settings → About panel.
*
* Covers the basic render (version + summary copy), the manual
* "Check for updates" button (invoking the hook's `check`), and the
* summary text variants for the new download/install state machine.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import AboutPanel from '../AboutPanel';
const hoisted = vi.hoisted(() => ({
mockCheckAppUpdate: vi.fn(),
mockApplyAppUpdate: vi.fn(),
mockDownloadAppUpdate: vi.fn(),
mockInstallAppUpdate: vi.fn(),
mockIsTauri: vi.fn(() => true),
mockOpenUrl: vi.fn(),
statusListeners: [] as ((event: { payload: string }) => void)[],
}));
const { mockCheckAppUpdate, mockOpenUrl, statusListeners } = hoisted;
vi.mock('../../../../utils/tauriCommands', () => ({
checkAppUpdate: hoisted.mockCheckAppUpdate,
applyAppUpdate: hoisted.mockApplyAppUpdate,
downloadAppUpdate: hoisted.mockDownloadAppUpdate,
installAppUpdate: hoisted.mockInstallAppUpdate,
isTauri: hoisted.mockIsTauri,
}));
vi.mock('../../../../utils/openUrl', () => ({ openUrl: hoisted.mockOpenUrl }));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn((event: string, handler: (event: { payload: string }) => void) => {
if (event === 'app-update:status') {
hoisted.statusListeners.push(handler);
}
return Promise.resolve(() => {
const idx = hoisted.statusListeners.indexOf(handler);
if (idx >= 0) hoisted.statusListeners.splice(idx, 1);
});
}),
}));
const emitStatus = (payload: string) => {
for (const listener of [...statusListeners]) listener({ payload });
};
describe('AboutPanel', () => {
beforeEach(() => {
statusListeners.length = 0;
mockCheckAppUpdate.mockReset();
mockOpenUrl.mockReset();
hoisted.mockApplyAppUpdate.mockReset();
hoisted.mockDownloadAppUpdate.mockReset();
hoisted.mockInstallAppUpdate.mockReset();
hoisted.mockIsTauri.mockReturnValue(true);
});
it('renders the running app version + check button + releases link', () => {
renderWithProviders(<AboutPanel />);
// The test config stubs APP_VERSION to '0.0.0-test'.
expect(screen.getByText('v0.0.0-test')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Check for updates/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Open GitHub releases/ })).toBeInTheDocument();
});
it('clicks "Check for updates" → calls checkAppUpdate and records timestamp', async () => {
mockCheckAppUpdate.mockResolvedValueOnce({
current_version: '0.50.0',
available: false,
available_version: null,
body: null,
});
renderWithProviders(<AboutPanel />);
const checkBtn = screen.getByRole('button', { name: /Check for updates/ });
fireEvent.click(checkBtn);
await waitFor(() => {
expect(mockCheckAppUpdate).toHaveBeenCalledTimes(1);
});
// After a successful check, the panel records "Last checked …".
await waitFor(() => {
expect(screen.getByText(/Last checked/i)).toBeInTheDocument();
});
});
it('shows the "ready to install" summary once the hook hits ready_to_install', async () => {
renderWithProviders(<AboutPanel />);
await waitFor(() => expect(statusListeners.length).toBeGreaterThan(0));
emitStatus('ready_to_install');
await waitFor(() => {
expect(
screen.getByText(/downloaded and ready|Use the prompt at the bottom right to restart/i)
).toBeInTheDocument();
});
});
it('shows the up-to-date summary after a check finds no update', async () => {
mockCheckAppUpdate.mockResolvedValueOnce({
current_version: '0.50.0',
available: false,
available_version: null,
body: null,
});
renderWithProviders(<AboutPanel />);
fireEvent.click(screen.getByRole('button', { name: /Check for updates/ }));
await waitFor(() => {
expect(screen.getByText(/You are running the latest version/i)).toBeInTheDocument();
});
});
it('clicking "Open GitHub releases" calls openUrl with the configured URL', () => {
renderWithProviders(<AboutPanel />);
fireEvent.click(screen.getByRole('button', { name: /Open GitHub releases/ }));
expect(mockOpenUrl).toHaveBeenCalledTimes(1);
expect(mockOpenUrl.mock.calls[0][0]).toEqual(
expect.stringContaining('github.com/tinyhumansai/openhuman')
);
});
it('shows the error summary when the check throws', async () => {
mockCheckAppUpdate.mockRejectedValueOnce(new Error('endpoint unreachable'));
renderWithProviders(<AboutPanel />);
fireEvent.click(screen.getByRole('button', { name: /Check for updates/ }));
await waitFor(() => {
expect(screen.getByText(/endpoint unreachable/)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,603 @@
/**
* Tests for the `useAppUpdate` hook.
*
* Covers the state machine transitions driven by direct calls
* (check / download / install / apply) and the `app-update:status` /
* `app-update:progress` events that the Rust side emits.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppUpdate } from '../useAppUpdate';
// `vi.mock` factories are hoisted above top-level `const` declarations, so
// any state they reference must come from `vi.hoisted` (which is also hoisted).
const hoisted = vi.hoisted(() => {
return {
mockCheckAppUpdate: vi.fn(),
mockApplyAppUpdate: vi.fn(),
mockDownloadAppUpdate: vi.fn(),
mockInstallAppUpdate: vi.fn(),
mockIsTauri: vi.fn(() => true),
statusListeners: [] as ((event: { payload: string }) => void)[],
progressListeners: [] as ((event: {
payload: { chunk: number; total: number | null };
}) => void)[],
};
});
const {
mockCheckAppUpdate,
mockApplyAppUpdate,
mockDownloadAppUpdate,
mockInstallAppUpdate,
mockIsTauri,
statusListeners,
progressListeners,
} = hoisted;
vi.mock('../../utils/tauriCommands', () => ({
checkAppUpdate: hoisted.mockCheckAppUpdate,
applyAppUpdate: hoisted.mockApplyAppUpdate,
downloadAppUpdate: hoisted.mockDownloadAppUpdate,
installAppUpdate: hoisted.mockInstallAppUpdate,
isTauri: hoisted.mockIsTauri,
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(
(
event: string,
handler:
| ((event: { payload: string }) => void)
| ((event: { payload: { chunk: number; total: number | null } }) => void)
) => {
if (event === 'app-update:status') {
hoisted.statusListeners.push(handler as (event: { payload: string }) => void);
} else if (event === 'app-update:progress') {
hoisted.progressListeners.push(
handler as (event: { payload: { chunk: number; total: number | null } }) => void
);
}
return Promise.resolve(() => {
const list =
event === 'app-update:status' ? hoisted.statusListeners : hoisted.progressListeners;
const index = list.indexOf(
handler as ((event: { payload: string }) => void) &
((event: { payload: { chunk: number; total: number | null } }) => void)
);
if (index >= 0) list.splice(index, 1);
});
}
),
}));
const flush = async () => {
// Allow the listen() promises inside the hook's effect to resolve.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
const emitStatus = async (payload: string) => {
await act(async () => {
for (const listener of [...statusListeners]) {
listener({ payload });
}
});
};
const emitProgress = async (chunk: number, total: number | null) => {
await act(async () => {
for (const listener of [...progressListeners]) {
listener({ payload: { chunk, total } });
}
});
};
describe('useAppUpdate', () => {
beforeEach(() => {
vi.useFakeTimers();
statusListeners.length = 0;
progressListeners.length = 0;
mockIsTauri.mockReturnValue(true);
mockCheckAppUpdate.mockReset();
mockApplyAppUpdate.mockReset();
mockDownloadAppUpdate.mockReset();
mockInstallAppUpdate.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
describe('check()', () => {
it('moves to "available" when the updater advertises a new version', async () => {
mockCheckAppUpdate.mockResolvedValueOnce({
current_version: '0.50.0',
available: true,
available_version: '0.51.0',
body: 'Bug fixes',
});
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.check();
});
expect(result.current.phase).toBe('available');
expect(result.current.info?.available_version).toBe('0.51.0');
expect(result.current.error).toBeNull();
});
it('moves to "up_to_date" when no update is available', async () => {
mockCheckAppUpdate.mockResolvedValueOnce({
current_version: '0.51.0',
available: false,
available_version: null,
body: null,
});
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.check();
});
expect(result.current.phase).toBe('up_to_date');
});
it('moves to "error" with the error message when the check throws', async () => {
mockCheckAppUpdate.mockRejectedValueOnce(new Error('endpoint unreachable'));
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.check();
});
expect(result.current.phase).toBe('error');
expect(result.current.error).toBe('endpoint unreachable');
});
it('no-ops when isTauri() is false', async () => {
mockIsTauri.mockReturnValue(false);
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
const out = await act(async () => result.current.check());
expect(out).toBeNull();
expect(mockCheckAppUpdate).not.toHaveBeenCalled();
expect(result.current.phase).toBe('idle');
});
});
describe('event listeners', () => {
it('drives the phase from `app-update:status` events', async () => {
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await emitStatus('downloading');
expect(result.current.phase).toBe('downloading');
await emitStatus('ready_to_install');
expect(result.current.phase).toBe('ready_to_install');
await emitStatus('installing');
expect(result.current.phase).toBe('installing');
await emitStatus('restarting');
expect(result.current.phase).toBe('restarting');
});
it('accumulates `app-update:progress` chunks and tracks total', async () => {
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await emitStatus('downloading');
await emitProgress(1024, 8192);
await emitProgress(2048, 8192);
expect(result.current.bytesDownloaded).toBe(3072);
expect(result.current.totalBytes).toBe(8192);
});
it('falls back to "error" with a default message on unknown payloads', async () => {
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await emitStatus('something-bogus');
expect(result.current.phase).toBe('error');
expect(result.current.error).toBe('Update failed. See logs for details.');
});
it('removes listeners on unmount', async () => {
const { unmount } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
expect(statusListeners.length).toBe(1);
expect(progressListeners.length).toBe(1);
unmount();
expect(statusListeners.length).toBe(0);
expect(progressListeners.length).toBe(0);
});
});
describe('download()', () => {
it('moves to ready_to_install on success and stages bytes for install', async () => {
mockDownloadAppUpdate.mockImplementationOnce(async () => {
// Real Rust side emits ready_to_install before resolving.
await emitStatus('ready_to_install');
return { ready: true, version: '0.51.0', body: null };
});
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.download();
});
expect(result.current.phase).toBe('ready_to_install');
expect(result.current.error).toBeNull();
});
it('surfaces error when download throws', async () => {
mockDownloadAppUpdate.mockRejectedValueOnce(new Error('disk full'));
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.download();
});
expect(result.current.phase).toBe('error');
expect(result.current.error).toBe('disk full');
});
});
describe('install()', () => {
it('falls back to applyAppUpdate when no download has been staged', async () => {
mockApplyAppUpdate.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.install();
});
expect(mockApplyAppUpdate).toHaveBeenCalledTimes(1);
expect(mockInstallAppUpdate).not.toHaveBeenCalled();
});
it('uses installAppUpdate when bytes are staged', async () => {
mockDownloadAppUpdate.mockImplementationOnce(async () => {
await emitStatus('ready_to_install');
return { ready: true, version: '0.51.0', body: null };
});
mockInstallAppUpdate.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.download();
});
await act(async () => {
await result.current.install();
});
expect(mockInstallAppUpdate).toHaveBeenCalledTimes(1);
expect(mockApplyAppUpdate).not.toHaveBeenCalled();
});
it('surfaces install errors as phase=error', async () => {
mockDownloadAppUpdate.mockImplementationOnce(async () => {
await emitStatus('ready_to_install');
return { ready: true, version: '0.51.0', body: null };
});
mockInstallAppUpdate.mockRejectedValueOnce(new Error('disk full'));
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.download();
});
await act(async () => {
await result.current.install();
});
expect(result.current.phase).toBe('error');
expect(result.current.error).toBe('disk full');
});
});
describe('apply()', () => {
it('invokes applyAppUpdate when called manually', async () => {
mockApplyAppUpdate.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.apply();
});
expect(mockApplyAppUpdate).toHaveBeenCalledTimes(1);
});
it('surfaces apply errors as phase=error', async () => {
mockApplyAppUpdate.mockRejectedValueOnce(new Error('disk full'));
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.apply();
});
expect(result.current.phase).toBe('error');
expect(result.current.error).toBe('disk full');
});
it('no-ops when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.apply();
});
expect(mockApplyAppUpdate).not.toHaveBeenCalled();
});
});
describe('install() skip paths', () => {
it('no-ops when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.install();
});
expect(mockInstallAppUpdate).not.toHaveBeenCalled();
expect(mockApplyAppUpdate).not.toHaveBeenCalled();
});
it('surfaces error from the apply fallback when no download is staged', async () => {
mockApplyAppUpdate.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.install();
});
expect(result.current.phase).toBe('error');
expect(result.current.error).toBe('boom');
});
});
describe('check() guards', () => {
it('skips when a download / install is already in flight', async () => {
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await emitStatus('downloading');
expect(result.current.phase).toBe('downloading');
const out = await act(async () => result.current.check());
expect(out).toBeNull();
expect(mockCheckAppUpdate).not.toHaveBeenCalled();
});
});
describe('download() guards', () => {
it('does not start a second download while one is in flight', async () => {
let resolveFirst: ((value: unknown) => void) | null = null;
mockDownloadAppUpdate.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveFirst = resolve;
})
);
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
// Kick off two concurrent downloads — the second should short-circuit.
let firstPromise: Promise<void> | undefined;
let secondPromise: Promise<void> | undefined;
await act(async () => {
firstPromise = result.current.download();
secondPromise = result.current.download();
await Promise.resolve();
});
expect(mockDownloadAppUpdate).toHaveBeenCalledTimes(1);
// Resolve the first one and let both promises settle so the test's
// afterEach (vi.useRealTimers) doesn't see leftover pending work.
await act(async () => {
resolveFirst?.({ ready: true, version: '0.51.0', body: null });
await firstPromise;
await secondPromise;
});
});
});
describe('reset()', () => {
it('clears error state and returns to idle from a quiet phase', async () => {
mockCheckAppUpdate.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.check();
});
expect(result.current.phase).toBe('error');
act(() => result.current.reset());
expect(result.current.phase).toBe('idle');
expect(result.current.error).toBeNull();
});
});
describe('auto-check cadence', () => {
it('runs an initial check after the configured delay', async () => {
mockCheckAppUpdate.mockResolvedValue({
current_version: '0.50.0',
available: false,
available_version: null,
body: null,
});
renderHook(() =>
useAppUpdate({ initialCheckDelayMs: 1000, recheckIntervalMs: 0, autoDownload: false })
);
await flush();
expect(mockCheckAppUpdate).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1000);
});
await act(async () => {
await Promise.resolve();
});
expect(mockCheckAppUpdate).toHaveBeenCalledTimes(1);
});
it('repeats checks at the configured interval', async () => {
mockCheckAppUpdate.mockResolvedValue({
current_version: '0.50.0',
available: false,
available_version: null,
body: null,
});
renderHook(() =>
useAppUpdate({ initialCheckDelayMs: 100, recheckIntervalMs: 500, autoDownload: false })
);
await flush();
await act(async () => {
vi.advanceTimersByTime(100);
});
await act(async () => {
await Promise.resolve();
});
expect(mockCheckAppUpdate).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(500);
});
await act(async () => {
await Promise.resolve();
});
expect(mockCheckAppUpdate).toHaveBeenCalledTimes(2);
await act(async () => {
vi.advanceTimersByTime(500);
});
await act(async () => {
await Promise.resolve();
});
expect(mockCheckAppUpdate).toHaveBeenCalledTimes(3);
});
it('skips auto-check when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
renderHook(() =>
useAppUpdate({ initialCheckDelayMs: 100, recheckIntervalMs: 100, autoDownload: false })
);
await flush();
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(mockCheckAppUpdate).not.toHaveBeenCalled();
});
});
describe('auto-download', () => {
it('starts a download automatically when phase becomes available', async () => {
mockCheckAppUpdate.mockResolvedValueOnce({
current_version: '0.50.0',
available: true,
available_version: '0.51.0',
body: null,
});
mockDownloadAppUpdate.mockImplementationOnce(async () => {
await emitStatus('ready_to_install');
return { ready: true, version: '0.51.0', body: null };
});
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: true }));
await flush();
await act(async () => {
await result.current.check();
});
expect(result.current.phase).toBe('available');
// Auto-download grace timer is 1000ms.
await act(async () => {
vi.advanceTimersByTime(1000);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockDownloadAppUpdate).toHaveBeenCalledTimes(1);
expect(result.current.phase).toBe('ready_to_install');
});
it('does not auto-download when autoDownload is false', async () => {
mockCheckAppUpdate.mockResolvedValueOnce({
current_version: '0.50.0',
available: true,
available_version: '0.51.0',
body: null,
});
const { result } = renderHook(() => useAppUpdate({ autoCheck: false, autoDownload: false }));
await flush();
await act(async () => {
await result.current.check();
});
expect(result.current.phase).toBe('available');
await act(async () => {
vi.advanceTimersByTime(5000);
});
await act(async () => {
await Promise.resolve();
});
expect(mockDownloadAppUpdate).not.toHaveBeenCalled();
expect(result.current.phase).toBe('available');
});
});
});
+429
View File
@@ -0,0 +1,429 @@
/**
* App auto-update hook.
*
* Owns:
* - the state machine for the Tauri shell updater
* (idle | checking | available | downloading | ready_to_install |
* installing | restarting | up_to_date | error)
* - listeners on the `app-update:status` + `app-update:progress` events
* emitted by the Rust download/install commands
* - an opt-in auto-check cadence: one probe shortly after launch, then
* a periodic re-probe while the app stays open
* - an opt-in auto-download: when a check reports "available", the hook
* automatically calls `download_app_update` so the user only sees a
* "Restart to apply" prompt — never a "click to start downloading" one
*
* Pairs with the Rust side in `app/src-tauri/src/lib.rs` (`check_app_update`,
* `download_app_update`, `install_app_update`). See `docs/AUTO_UPDATE.md`.
*/
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
applyAppUpdate,
type AppUpdateInfo,
checkAppUpdate,
downloadAppUpdate,
installAppUpdate,
isTauri,
} from '../utils/tauriCommands';
/** Phases driven by `app-update:status`, plus locally-derived ones. */
export type AppUpdatePhase =
| 'idle'
| 'checking'
| 'available'
| 'downloading'
| 'ready_to_install'
| 'installing'
| 'restarting'
| 'up_to_date'
| 'error';
export interface AppUpdateProgress {
/** Bytes received in the latest chunk callback. */
chunk: number;
/** Total bytes (null when the manifest didn't advertise a content-length). */
total: number | null;
}
export interface UseAppUpdateOptions {
/**
* Run an automatic check shortly after the hook mounts.
* Default: true. Skipped when `isTauri()` is false.
*/
autoCheck?: boolean;
/** Delay before the first auto-check fires, in ms. Default: 30_000. */
initialCheckDelayMs?: number;
/**
* Repeat interval between background checks, in ms. Default: 4 * 60 * 60 * 1000.
* Set to 0 (or a negative number) to disable repeating.
*/
recheckIntervalMs?: number;
/**
* When a check reports an available update, automatically start the
* download in the background so the user is only ever prompted to
* restart. Default: true.
*/
autoDownload?: boolean;
}
export interface UseAppUpdateResult {
phase: AppUpdatePhase;
/** Last successful check result (current/available versions, body). */
info: AppUpdateInfo | null;
/** Bytes downloaded so far (sum of every `app-update:progress` chunk this run). */
bytesDownloaded: number;
/** Latest `total` reported by the updater (may stay null). */
totalBytes: number | null;
/** Last error message, if any phase landed on `error`. */
error: string | null;
/** Manually run a check (does not download). */
check: () => Promise<AppUpdateInfo | null>;
/**
* Start a background download. Normally called automatically when a check
* reports an available update; exposed so callers can retry on error.
*/
download: () => Promise<void>;
/**
* Install previously-downloaded bytes and restart. Never resolves on
* success (the process exits mid-await). Falls back to {@link apply}
* if no download has been staged.
*/
install: () => Promise<void>;
/**
* Legacy combined download+install+restart. Prefer the auto-download flow
* above; kept for callers that want a single explicit "do everything"
* action.
*/
apply: () => Promise<void>;
/** Reset transient state (error, downloaded bytes) without changing `info`. */
reset: () => void;
}
const DEFAULT_INITIAL_DELAY_MS = 30_000;
const DEFAULT_RECHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
/** A short grace before the auto-download fires, so the UI can show the
* fact that an update was *detected* (briefly) before going into "downloading"
* state. Cosmetic, not load-bearing. */
const AUTO_DOWNLOAD_GRACE_MS = 1_000;
/**
* Translate a raw `app-update:status` payload into our phase enum, defaulting
* to `error` for any unrecognized string so we don't silently swallow a bad
* payload from the Rust side.
*/
function parseStatusPayload(raw: unknown): AppUpdatePhase {
if (raw === 'checking') return 'checking';
if (raw === 'downloading') return 'downloading';
if (raw === 'ready_to_install') return 'ready_to_install';
if (raw === 'installing') return 'installing';
if (raw === 'restarting') return 'restarting';
if (raw === 'up_to_date') return 'up_to_date';
if (raw === 'error') return 'error';
console.warn('[app-update] hook: unknown status payload', raw);
return 'error';
}
export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateResult {
const {
autoCheck = true,
initialCheckDelayMs = DEFAULT_INITIAL_DELAY_MS,
recheckIntervalMs = DEFAULT_RECHECK_INTERVAL_MS,
autoDownload = true,
} = options;
const [phase, setPhase] = useState<AppUpdatePhase>('idle');
const [info, setInfo] = useState<AppUpdateInfo | null>(null);
const [bytesDownloaded, setBytesDownloaded] = useState(0);
const [totalBytes, setTotalBytes] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Refs to keep callbacks stable + survive React 18 strict-mode double-invoke.
const mountedRef = useRef(true);
const phaseRef = useRef<AppUpdatePhase>(phase);
phaseRef.current = phase;
// Tracks whether we've already kicked off a download for the current
// `available` detection so the auto-download effect doesn't loop on
// re-renders.
const downloadInFlightRef = useRef(false);
// Tracks whether bytes have been staged successfully. `install()` checks
// this so it can fall back to the legacy combined apply path if the user
// reaches "install" without a prior download (e.g. error mid-flow).
const stagedRef = useRef(false);
/** Probe the updater endpoint. Does not download. */
const check = useCallback(async (): Promise<AppUpdateInfo | null> => {
if (!isTauri()) {
console.debug('[app-update] hook.check: skipped — not running in Tauri');
return null;
}
if (
phaseRef.current === 'downloading' ||
phaseRef.current === 'installing' ||
phaseRef.current === 'ready_to_install'
) {
console.debug('[app-update] hook.check: skipped — flow in progress', phaseRef.current);
return null;
}
console.debug('[app-update] hook.check: starting');
setPhase('checking');
setError(null);
try {
const result = await checkAppUpdate();
if (!mountedRef.current) return result;
if (result?.available) {
console.info(
`[app-update] hook.check: update available ${result.current_version} -> ${result.available_version}`
);
setInfo(result);
setPhase('available');
} else {
console.debug('[app-update] hook.check: up to date');
setInfo(result);
setPhase('up_to_date');
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn('[app-update] hook.check: failed', message);
if (mountedRef.current) {
setError(message);
setPhase('error');
}
return null;
}
}, []);
/** Download bytes in the background. Normally fires automatically. */
const download = useCallback(async (): Promise<void> => {
if (!isTauri()) {
console.debug('[app-update] hook.download: skipped — not running in Tauri');
return;
}
if (downloadInFlightRef.current) {
console.debug('[app-update] hook.download: already in flight');
return;
}
downloadInFlightRef.current = true;
stagedRef.current = false;
setBytesDownloaded(0);
setTotalBytes(null);
setError(null);
console.info('[app-update] hook.download: starting');
try {
const result = await downloadAppUpdate();
if (!mountedRef.current) return;
if (result?.ready) {
stagedRef.current = true;
console.info(`[app-update] hook.download: staged ${result.version}`);
// The Rust side has already emitted `ready_to_install`. The status
// listener will move us into that phase; nothing else to do here.
} else {
console.debug('[app-update] hook.download: nothing to download');
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[app-update] hook.download: failed', message);
if (mountedRef.current) {
setError(message);
setPhase('error');
}
} finally {
downloadInFlightRef.current = false;
}
}, []);
/** Install the staged bytes and restart. Falls back to `apply()` if nothing is staged. */
const install = useCallback(async (): Promise<void> => {
if (!isTauri()) {
console.debug('[app-update] hook.install: skipped — not running in Tauri');
return;
}
if (!stagedRef.current) {
console.warn('[app-update] hook.install: no staged update — falling back to apply');
try {
await applyAppUpdate();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[app-update] hook.install: apply fallback failed', message);
if (mountedRef.current) {
setError(message);
setPhase('error');
}
}
return;
}
console.info('[app-update] hook.install: starting');
setError(null);
// The Rust side consumes the staged bytes via `slot.take()` before
// calling `Update::install`, so once we invoke install_app_update the
// backend no longer has a pending update — keep `stagedRef` in sync so
// a retry after a transient install failure falls back to the legacy
// `apply` path (fresh check + download + install) instead of looping
// on a now-empty Rust state slot.
stagedRef.current = false;
try {
await installAppUpdate();
console.debug('[app-update] hook.install: returned without restart');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[app-update] hook.install: failed', message);
// Defensive — the early clear above already handled this, but if a
// future change moves the install_app_update call without resetting
// the ref, this guarantees retries don't reuse a consumed staging.
stagedRef.current = false;
if (mountedRef.current) {
setError(message);
setPhase('error');
}
}
}, []);
/**
* Legacy combined download+install+restart. Prefer the auto-download flow.
* Restarts the process mid-promise on success.
*/
const apply = useCallback(async (): Promise<void> => {
if (!isTauri()) {
console.debug('[app-update] hook.apply: skipped — not running in Tauri');
return;
}
console.info('[app-update] hook.apply: starting (legacy path)');
setBytesDownloaded(0);
setTotalBytes(null);
setError(null);
setPhase('checking');
try {
await applyAppUpdate();
console.debug('[app-update] hook.apply: returned without restart');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[app-update] hook.apply: failed', message);
if (mountedRef.current) {
setError(message);
setPhase('error');
}
}
}, []);
const reset = useCallback(() => {
console.debug('[app-update] hook.reset');
setError(null);
setBytesDownloaded(0);
setTotalBytes(null);
if (
phaseRef.current === 'error' ||
phaseRef.current === 'up_to_date' ||
phaseRef.current === 'available'
) {
setPhase('idle');
}
}, []);
// Subscribe to Rust-side updater events for the lifetime of the hook.
useEffect(() => {
if (!isTauri()) return;
mountedRef.current = true;
let unlistenStatus: UnlistenFn | undefined;
let unlistenProgress: UnlistenFn | undefined;
let cancelled = false;
(async () => {
try {
unlistenStatus = await listen<string>('app-update:status', event => {
if (!mountedRef.current) return;
const next = parseStatusPayload(event.payload);
console.debug('[app-update] hook: status →', next);
setPhase(next);
if (next === 'downloading') {
setBytesDownloaded(0);
setTotalBytes(null);
setError(null);
}
if (next === 'ready_to_install') {
stagedRef.current = true;
}
if (next === 'error') {
setError(prev => prev ?? 'Update failed. See logs for details.');
}
});
unlistenProgress = await listen<AppUpdateProgress>('app-update:progress', event => {
if (!mountedRef.current) return;
const { chunk, total } = event.payload ?? { chunk: 0, total: null };
setBytesDownloaded(prev => prev + (typeof chunk === 'number' ? chunk : 0));
if (typeof total === 'number') setTotalBytes(total);
});
if (cancelled) {
unlistenStatus?.();
unlistenProgress?.();
}
} catch (err) {
console.debug('[app-update] hook: failed to attach listeners', err);
}
})();
return () => {
cancelled = true;
mountedRef.current = false;
unlistenStatus?.();
unlistenProgress?.();
};
}, []);
// Auto-check cadence: one delayed probe, then a periodic re-probe.
useEffect(() => {
if (!autoCheck || !isTauri()) return;
const initialTimer = setTimeout(
() => {
void check();
},
Math.max(0, initialCheckDelayMs)
);
let recheckTimer: ReturnType<typeof setInterval> | undefined;
if (recheckIntervalMs > 0) {
recheckTimer = setInterval(() => {
void check();
}, recheckIntervalMs);
}
return () => {
clearTimeout(initialTimer);
if (recheckTimer) clearInterval(recheckTimer);
};
}, [autoCheck, initialCheckDelayMs, recheckIntervalMs, check]);
// Auto-download: when a check transitions us to `available`, kick off a
// background download so the user is only ever asked to restart, never to
// download.
useEffect(() => {
if (!autoDownload || !isTauri()) return;
if (phase !== 'available') return;
if (downloadInFlightRef.current) return;
const timer = setTimeout(() => {
void download();
}, AUTO_DOWNLOAD_GRACE_MS);
return () => clearTimeout(timer);
}, [autoDownload, phase, download]);
return {
phase,
info,
bytesDownloaded,
totalBytes,
error,
check,
download,
install,
apply,
reset,
};
}
+3
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import AboutPanel from '../components/settings/panels/AboutPanel';
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
import AIPanel from '../components/settings/panels/AIPanel';
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
@@ -294,6 +295,8 @@ const Settings = () => {
<Route path="webhooks-debug" element={wrapSettingsPage(<WebhooksDebugPanel />)} />
<Route path="memory-data" element={wrapSettingsPage(<MemoryDataPanel />)} />
<Route path="memory-debug" element={wrapSettingsPage(<MemoryDebugPanel />)} />
{/* About / updates */}
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
+78
View File
@@ -92,6 +92,84 @@ describe('tauriCommands/core', () => {
});
});
describe('app-update wrappers', () => {
let checkAppUpdate: typeof import('./core').checkAppUpdate;
let applyAppUpdate: typeof import('./core').applyAppUpdate;
let downloadAppUpdate: typeof import('./core').downloadAppUpdate;
let installAppUpdate: typeof import('./core').installAppUpdate;
beforeEach(async () => {
const actual = await vi.importActual<typeof import('./core')>('./core');
checkAppUpdate = actual.checkAppUpdate;
applyAppUpdate = actual.applyAppUpdate;
downloadAppUpdate = actual.downloadAppUpdate;
installAppUpdate = actual.installAppUpdate;
});
test('checkAppUpdate returns null when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
const out = await checkAppUpdate();
expect(out).toBeNull();
expect(mockInvoke).not.toHaveBeenCalled();
});
test('checkAppUpdate forwards invoke result on the happy path', async () => {
const expected = {
current_version: '0.50.0',
available: true,
available_version: '0.51.0',
body: 'notes',
};
mockInvoke.mockResolvedValueOnce(expected);
const out = await checkAppUpdate();
expect(mockInvoke).toHaveBeenCalledWith('check_app_update');
expect(out).toEqual(expected);
});
test('downloadAppUpdate returns null when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
const out = await downloadAppUpdate();
expect(out).toBeNull();
expect(mockInvoke).not.toHaveBeenCalled();
});
test('downloadAppUpdate forwards invoke result on the happy path', async () => {
const expected = { ready: true, version: '0.51.0', body: null };
mockInvoke.mockResolvedValueOnce(expected);
const out = await downloadAppUpdate();
expect(mockInvoke).toHaveBeenCalledWith('download_app_update');
expect(out).toEqual(expected);
});
test('installAppUpdate is a no-op when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await installAppUpdate();
expect(mockInvoke).not.toHaveBeenCalled();
});
test('installAppUpdate invokes install_app_update', async () => {
mockInvoke.mockResolvedValueOnce(undefined);
await installAppUpdate();
expect(mockInvoke).toHaveBeenCalledWith('install_app_update');
});
test('applyAppUpdate is a no-op when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await applyAppUpdate();
expect(mockInvoke).not.toHaveBeenCalled();
});
test('applyAppUpdate invokes apply_app_update', async () => {
mockInvoke.mockResolvedValueOnce(undefined);
await applyAppUpdate();
expect(mockInvoke).toHaveBeenCalledWith('apply_app_update');
});
});
describe('scheduleCefProfilePurge', () => {
test('returns null and does not invoke when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
+55
View File
@@ -175,6 +175,10 @@ export const checkAppUpdate = async (): Promise<AppUpdateInfo | null> => {
/**
* Download + install the latest shell build, then relaunch.
*
* Legacy combined path — kept so the manual "do everything" flow still
* works. The auto-update flow uses {@link downloadAppUpdate} +
* {@link installAppUpdate} so the user can defer the restart.
*
* The Rust side shuts the core sidecar down before the install step so the
* macOS .app bundle replacement does not race with live file handles. After
* `app.restart()` the new bundled sidecar is launched fresh.
@@ -196,6 +200,57 @@ export const applyAppUpdate = async (): Promise<void> => {
console.debug('[app-update] applyAppUpdate: returned (no update was applied)');
};
export interface AppUpdateDownloadResult {
/** True when an update was found and bundle bytes are now staged. */
ready: boolean;
/** Version of the staged update, if any. */
version: string | null;
/** Release notes for the staged update, if the manifest provided any. */
body: string | null;
}
/**
* Probe the updater endpoint and, if a newer build is available, download
* the bundle bytes into memory but DO NOT install. Pair with
* {@link installAppUpdate} to finalize at a moment that's safe for the user.
*
* Emits the same `app-update:status` and `app-update:progress` events as
* {@link applyAppUpdate}, with status sequence
* `checking` → `downloading` → `ready_to_install` (or `up_to_date` / `error`).
*/
export const downloadAppUpdate = async (): Promise<AppUpdateDownloadResult | null> => {
if (!isTauri()) {
console.debug('[app-update] downloadAppUpdate: skipped — not running in Tauri');
return null;
}
console.debug('[app-update] downloadAppUpdate: invoking download_app_update');
const result = await invoke<AppUpdateDownloadResult>('download_app_update');
console.debug('[app-update] downloadAppUpdate: result', result);
return result;
};
/**
* Install the bundle bytes staged by a prior {@link downloadAppUpdate}, then
* relaunch. Throws if no download has been staged this session — the caller
* should fall back to {@link applyAppUpdate} in that case.
*
* The Rust side shuts the core sidecar down before install for the same
* reason as `apply_app_update` (avoid live file handles during the .app
* replacement on macOS).
*/
export const installAppUpdate = async (): Promise<void> => {
if (!isTauri()) {
console.debug('[app-update] installAppUpdate: skipped — not running in Tauri');
return;
}
console.debug('[app-update] installAppUpdate: invoking install_app_update');
// Like applyAppUpdate, the process restarts mid-await on success. Promise
// rejection from the abrupt termination is expected; failures BEFORE the
// restart bubble up here.
await invoke<void>('install_app_update');
console.debug('[app-update] installAppUpdate: returned (install did not relaunch)');
};
export async function resetOpenHumanDataAndRestartCore(): Promise<void> {
if (!isTauri()) {
console.debug('[core] resetOpenHumanDataAndRestartCore: skipped — not running in Tauri');
+173
View File
@@ -0,0 +1,173 @@
# Auto-update
The desktop shell (`app/src-tauri`) auto-updates itself via Tauri's
[`plugin-updater`](https://tauri.app/plugin/updater/) against a manifest
published on GitHub Releases. The OpenHuman core sidecar (`openhuman` binary)
ships inside the `.app` bundle, so a shell update upgrades both.
## Architecture
| Piece | Role |
| --------------------------------------------------- | ------------------------------------------------------------- |
| `app/src-tauri/Cargo.toml` | declares `tauri-plugin-updater` |
| `app/src-tauri/tauri.conf.json` (`plugins.updater`) | endpoint + minisign pubkey |
| `app/src-tauri/permissions/allow-app-update.toml` | ACL allow-list for the four updater commands |
| `app/src-tauri/src/lib.rs::check_app_update` | probe-only; returns version info |
| `app/src-tauri/src/lib.rs::download_app_update` | downloads bundle bytes, stages them, does NOT install |
| `app/src-tauri/src/lib.rs::install_app_update` | installs previously-staged bytes + relaunches |
| `app/src-tauri/src/lib.rs::apply_app_update` | legacy combined download+install+restart (kept for compat) |
| `app/src/hooks/useAppUpdate.ts` | React state machine, auto-check, auto-download |
| `app/src/components/AppUpdatePrompt.tsx` | global banner (mounted in `App.tsx`) — silent during download |
| `app/src/components/settings/panels/AboutPanel.tsx` | manual "Check for updates" |
| `.github/workflows/release.yml` | builds + signs + publishes `latest.json` |
The shell emits two Tauri events while updating:
- `app-update:status` — string payload, one of `checking`, `downloading`,
`ready_to_install`, `installing`, `restarting`, `up_to_date`, `error`
- `app-update:progress``{ chunk: number, total: number | null }`
`useAppUpdate` listens on both and exposes a state machine
(`idle | checking | available | downloading | ready_to_install | installing |
restarting | up_to_date | error`).
## User flow (Option 2: auto-download, prompt to restart)
1. ~30 seconds after launch, the hook runs a silent `check_app_update`. It
re-checks every 4 hours.
2. If the manifest reports a newer version, the hook **automatically calls
`download_app_update`** in the background — the user sees nothing.
3. Once the bytes are staged, the Rust side emits `ready_to_install` and the
bottom-right banner appears with the header **"Update ready to install"**
and a body line of **"Version <version> is ready to install."** (falling
back to **"A new version is ready to install."** when the manifest didn't
supply a version), followed by **Restart now** / **Later** buttons.
4. Clicking **Restart now** invokes `install_app_update`, which acquires the
core restart lock, shuts down the in-process core, calls
`Update::install(staged_bytes)` (no re-download), and then `app.restart()`.
5. **Later** dismisses the banner without canceling the staged bytes — the
user can also click "Check for updates" in Settings → About to surface the
prompt again on demand.
Why this flow vs. silent install: a chat / AI app often has in-flight
conversations and background agent work. Yanking the process away mid-task
costs more user trust than a one-click "Restart now" prompt earns in
convenience. We download invisibly so the _only_ action the user takes is
choosing the restart moment.
## Validating end-to-end (issue #677 acceptance criteria)
The auto-update path must be validated against a real signed bundle and a
real `latest.json``pnpm tauri dev` does not produce updater-compatible
artifacts. Use this recipe.
### Prerequisites
- A published GitHub release at a higher version than what you'll build
locally (e.g. the latest `v0.53.x`). The release must include the signed
bundle for your platform plus `latest.json`.
- **No signing key needed for verification.** Minisign signature
verification on the downloaded bundle uses the public key already baked
into `tauri.conf.json::plugins.updater.pubkey`, which the local build
picks up automatically. `TAURI_SIGNING_PRIVATE_KEY` (+ its password) is
required only by CI to _sign_ new releases — never to verify existing
ones. Treat the private key as a secret and keep it out of dev machines.
### Recipe
1. **Pick a target older than the published release.** Edit all four version
sources to a known-older value (e.g. `0.53.0` if `0.53.4` is published):
```text
app/package.json::version
app/src-tauri/Cargo.toml::package.version
app/src-tauri/tauri.conf.json::version
Cargo.toml::workspace.package.version
```
`scripts/release/verify-version-sync.js` exists exactly to keep these
four in lockstep — run it after editing.
2. **Build a packaged bundle locally.**
```bash
pnpm --filter openhuman-app tauri:ensure # vendored CEF-aware tauri-cli
pnpm --filter openhuman-app tauri:build:ui # exports CEF_PATH + builds the .app
```
`tauri:build:ui` exports `CEF_PATH=~/Library/Caches/tauri-cef` before
running `cargo tauri build` — the bundler needs this to copy
`Chromium Embedded Framework.framework` into `Contents/Frameworks/`.
A bare `pnpm tauri build` skips that step and the resulting binary
panics in `cef::library_loader::LibraryLoader::new`.
On macOS the artifact lands in
`app/src-tauri/target/release/bundle/macos/OpenHuman.app`.
3. **Run the packaged build.**
```bash
open app/src-tauri/target/release/bundle/macos/OpenHuman.app
# or, with Rust + frontend logs in the terminal:
./app/src-tauri/target/release/bundle/macos/OpenHuman.app/Contents/MacOS/OpenHuman
```
You should see `[app-update]` lines start to flow ~30 seconds after
launch (auto-check), or immediately after clicking
**Settings → About → Check for updates**.
4. **Trigger the check** — either wait ~30s for the auto-check, or open
**Settings → About** → **Check for updates**. The check is silent;
the prompt appears only once the download has staged.
5. **Watch the auto-download flow** (fires automatically — no click needed
to start the download). Expected log sequence:
- `[app-update] check requested (current: <old-version>)`
- `[app-update] update available: <old> -> <new>`
- `[app-update] download_app_update invoked from frontend`
- `[app-update] downloading <new-version> (background)`
- `[app-update] download complete — staging for install`
- `[app-update] staged <new-version> — awaiting user-initiated install`
At this point the bottom-right banner appears with the header
**"Update ready to install"** and a body line of
**"Version <new-version> is ready to install."** (or
**"A new version is ready to install."** as the fallback when the
manifest didn't supply a version string), followed by **Restart now** /
**Later** buttons.
6. **Click "Restart now"**. Expected log sequence:
- `[app-update] install_app_update invoked from frontend`
- `[app-update] installing staged version <new-version>`
- `[app-update] install complete — relaunching`
The app relaunches itself; the new bundle's version (in
**Settings → About**) should match the published release.
7. **Confirm the core sidecar came back up.** `[core]` log lines should
appear after relaunch and `core_rpc` calls from the UI must succeed.
### Troubleshooting
- **"signature did not verify"** — the local bundle was built with a
different signing key than the one whose pubkey is in `tauri.conf.json`.
Rebuild against the same `TAURI_SIGNING_PRIVATE_KEY` used by the
release workflow, or temporarily swap the pubkey while testing.
- **"endpoint did not return a valid JSON manifest"** — the redirect from
`releases/latest/download/latest.json` resolved to a release that lacks
the asset. Confirm the latest non-draft release on GitHub has
`latest.json` attached (job `publish-updater-manifest`).
- **Updater doesn't fire in dev** — `pnpm tauri dev` sets
`bundle.createUpdaterArtifacts: false` (see `scripts/prepareTauriConfig.js`),
so the dev profile never produces a bundle the updater can swap in. Use
`pnpm tauri build`.
- **The banner never shows on first launch** — that's expected; the
initial probe is delayed 30s. To force it, click "Check for updates" in
the About panel.
## Logs
- Rust side: `log::info!("[app-update] ...")` / `log::warn!` / `log::error!`
- Frontend: `console.debug('[app-update] ...')` and friends
Both prefixes are stable and grep-friendly per `CLAUDE.md`.