mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user