use serde::Serialize; use std::ffi::OsString; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; use tauri_plugin_opener::OpenerExt; use tauri_plugin_updater::{Update, UpdaterExt}; pub(crate) const NOT_AVAILABLE_EVENT: &str = "updater://not-available"; pub(crate) const AVAILABLE_EVENT: &str = "updater://available"; pub(crate) const AVAILABLE_MANUAL_EVENT: &str = "updater://available-manual"; pub(crate) const PROGRESS_EVENT: &str = "updater://progress"; pub(crate) const READY_EVENT: &str = "updater://ready"; pub(crate) const ERROR_EVENT: &str = "updater://error"; const RELEASE_URL: &str = "https://github.com/openclaw/openclaw/releases/latest"; #[cfg(any(target_os = "macos", target_os = "windows"))] // Test desktop builds need a channel that Linux-only releases never replace. const DESKTOP_TEST_UPDATE_ENDPOINT: &str = "https://github.com/openclaw/openclaw/releases/download/desktop-test/latest-desktop-test.json"; const AUTO_CHECK_DELAY: Duration = Duration::from_secs(3); #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InstallKind { SelfInstall, DeferredInstall, NotifyOnly, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TerminalResultKind { NotAvailable, CheckFailed, PackageUpdateAvailable, UpdateReady, UpdateFailed, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ResultDestination { None, Webview, WebviewAndNotificationWhenUnfocused, WebviewAndNotification, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] // Keep every discriminator available so one host can test all platform rules. #[allow(dead_code)] enum Platform { Linux, Macos, Windows, } #[derive(Default)] pub struct UpdaterState { auto_check_started: AtomicBool, check_in_progress: Arc, deferred_update: Mutex>, // Set when a manual (tray/command) check is requested. The one in-flight // check reads this at emit time so a manual click that lands while the // silent startup auto-check is running still surfaces a result instead of // being coalesced away into silence. manual_pending: Arc, } struct DeferredUpdate { update: Update, bytes: Vec, } struct CheckGuard { in_progress: Arc, manual_pending: Arc, } impl Drop for CheckGuard { fn drop(&mut self) { self.manual_pending.store(false, Ordering::Release); self.in_progress.store(false, Ordering::Release); } } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct UpdateInfo { version: String, notes: Option, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ManualUpdateInfo { version: String, notes: Option, release_url: &'static str, } #[derive(Clone, Serialize)] struct Progress { downloaded: u64, total: Option, } #[derive(Clone, Serialize)] struct UpdateError { message: String, } pub fn schedule_auto_check(app: AppHandle) { let state = app.state::(); if state.auto_check_started.swap(true, Ordering::AcqRel) { return; } std::thread::spawn(move || { std::thread::sleep(AUTO_CHECK_DELAY); // Auto-check is silent: a launch that finds no update (or hits a // transient network error) must not nag with a banner every time. tauri::async_runtime::block_on(run_check(app, false)); }); } pub fn spawn_check(app: AppHandle) { tauri::async_runtime::spawn(async move { run_check(app, true).await; }); } #[tauri::command] pub async fn check_for_updates(app: AppHandle) { run_check(app, true).await; } #[tauri::command] pub fn updater_ready(app: AppHandle) { schedule_auto_check(app); } #[tauri::command] pub fn relaunch(app: AppHandle) { let state = app.state::(); let deferred = state .deferred_update .lock() .expect("deferred updater state lock poisoned") .take(); let Some(deferred) = deferred else { app.restart(); }; let result = deferred.update.install(&deferred.bytes); match result { Ok(()) => app.restart(), Err(error) => { state .deferred_update .lock() .expect("deferred updater state lock poisoned") .replace(deferred); emit_error(&app, error); } } } #[tauri::command] pub fn open_release_page(app: AppHandle) -> Result<(), String> { app.opener() .open_url(RELEASE_URL, None::<&str>) .map_err(|error| format!("Could not open release page: {error}")) } // A manual (tray/command) check surfaces the "up to date" and check-error // notices; the launch auto-check runs silent. Manual intent is recorded on the // shared state before racing for the single-flight guard, so a manual click // that lands while the silent auto-check is running still gets a response // (`should_notify` reads it). Once an update is found, download // progress/ready/errors always surface, since the user has been told an update // is coming. async fn run_check(app: AppHandle, manual: bool) { let manual_pending = Arc::clone(&app.state::().manual_pending); if manual { manual_pending.store(true, Ordering::Release); } let Some(_guard) = begin_check(&app) else { return; }; let manual_requested = || manual_pending.load(Ordering::Acquire); #[cfg(target_os = "linux")] let updater = app.updater(); #[cfg(any(target_os = "macos", target_os = "windows"))] let updater = app .updater_builder() .endpoints(vec![DESKTOP_TEST_UPDATE_ENDPOINT .parse() .expect("desktop test updater endpoint is valid")]) .and_then(|builder| builder.build()); let updater = match updater { Ok(updater) => updater, Err(error) => { deliver_error( &app, manual_requested(), TerminalResultKind::CheckFailed, error, ); return; } }; let update = match updater.check().await { Ok(Some(update)) => update, Ok(None) => { deliver_result( &app, manual_requested(), TerminalResultKind::NotAvailable, NOT_AVAILABLE_EVENT, (), "OpenClaw is up to date — no update is available", ); return; } Err(error) => { deliver_error( &app, manual_requested(), TerminalResultKind::CheckFailed, error, ); return; } }; let info = UpdateInfo { version: update.version.clone(), notes: update.body.clone(), }; let install_kind = install_kind(); if install_kind == InstallKind::NotifyOnly { let version = info.version.clone(); let notification_body = manual_notification_body(&version); deliver_result( &app, manual_requested(), TerminalResultKind::PackageUpdateAvailable, AVAILABLE_MANUAL_EVENT, ManualUpdateInfo { version: info.version, notes: info.notes, release_url: RELEASE_URL, }, ¬ification_body, ); return; } emit(&app, AVAILABLE_EVENT, info.clone()); let Some(window) = main_window(&app) else { return; }; let result = match install_kind { InstallKind::SelfInstall => update .download_and_install(progress_callback(window.clone()), || {}) .await .map(|()| None), InstallKind::DeferredInstall => update .download(progress_callback(window.clone()), || {}) .await .map(Some), InstallKind::NotifyOnly => unreachable!("notify-only updates return before downloading"), }; match result { Ok(deferred_bytes) => { let version = info.version.clone(); if let Some(bytes) = deferred_bytes { app.state::() .deferred_update .lock() .expect("deferred updater state lock poisoned") .replace(DeferredUpdate { update, bytes }); } let notification_body = ready_notification_body(&version); deliver_result( &app, manual_requested(), TerminalResultKind::UpdateReady, READY_EVENT, info, ¬ification_body, ); } Err(error) => deliver_error( &app, manual_requested(), TerminalResultKind::UpdateFailed, error, ), } } fn result_delivery( manual: bool, main_content_is_remote: bool, result: TerminalResultKind, ) -> ResultDestination { if manual && main_content_is_remote { return ResultDestination::WebviewAndNotification; } match result { TerminalResultKind::NotAvailable | TerminalResultKind::CheckFailed if !manual => { ResultDestination::None } TerminalResultKind::PackageUpdateAvailable | TerminalResultKind::UpdateReady => { ResultDestination::WebviewAndNotificationWhenUnfocused } TerminalResultKind::NotAvailable | TerminalResultKind::CheckFailed | TerminalResultKind::UpdateFailed => ResultDestination::Webview, } } fn begin_check(app: &AppHandle) -> Option { let state = app.state::(); let in_progress = Arc::clone(&state.check_in_progress); let manual_pending = Arc::clone(&state.manual_pending); in_progress .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .ok() .map(|_| CheckGuard { in_progress, manual_pending, }) } fn install_kind() -> InstallKind { #[cfg(target_os = "linux")] let platform = Platform::Linux; #[cfg(target_os = "macos")] let platform = Platform::Macos; #[cfg(target_os = "windows")] let platform = Platform::Windows; install_kind_from_appimage_env(std::env::var_os("APPIMAGE"), platform) } fn install_kind_from_appimage_env(appimage: Option, platform: Platform) -> InstallKind { match platform { Platform::Linux if appimage.is_some() => InstallKind::SelfInstall, Platform::Linux => { // Package managers own deb/rpm files, so replacing them would corrupt their contract. InstallKind::NotifyOnly } Platform::Macos => { // Tauri owns .app replacement and returns after installing, like the AppImage path. InstallKind::SelfInstall } Platform::Windows => { // Tauri's NSIS install exits the process, so wait for user-confirmed relaunch. InstallKind::DeferredInstall } } } fn main_window(app: &AppHandle) -> Option { app.get_webview_window("main") } fn main_content_is_remote(app: &AppHandle, window: Option<&WebviewWindow>) -> bool { !window.is_some_and(|window| { app.state::() .main_window_has_local_content(window) }) } fn progress_callback(window: WebviewWindow) -> impl FnMut(usize, Option) { let mut downloaded = 0_u64; move |chunk_size, total| { downloaded = downloaded.saturating_add(chunk_size as u64); let _ = window.emit(PROGRESS_EVENT, Progress { downloaded, total }); } } fn emit(app: &AppHandle, event: &str, payload: S) { if let Some(window) = main_window(app) { let _ = window.emit(event, payload); } } fn deliver_result( app: &AppHandle, manual: bool, result: TerminalResultKind, event: &str, payload: S, notification_body: &str, ) { let window = main_window(app); let destination = result_delivery(manual, main_content_is_remote(app, window.as_ref()), result); if !matches!(destination, ResultDestination::None) { if let Some(window) = window.as_ref() { let _ = window.emit(event, payload); } } let notify = match destination { ResultDestination::None | ResultDestination::Webview => false, ResultDestination::WebviewAndNotificationWhenUnfocused => window .as_ref() .is_some_and(|window| matches!(window.is_focused(), Ok(false))), ResultDestination::WebviewAndNotification => true, }; if notify { crate::notify::notify(app, "OpenClaw", notification_body); } } fn deliver_error( app: &AppHandle, manual: bool, result: TerminalResultKind, error: impl std::fmt::Display, ) { let message = error.to_string(); let notification_body = error_notification_body(result, &message); deliver_result( app, manual, result, ERROR_EVENT, UpdateError { message }, ¬ification_body, ); } fn error_notification_body(result: TerminalResultKind, message: &str) -> String { let prefix = match result { TerminalResultKind::CheckFailed => "Update check failed", TerminalResultKind::UpdateFailed => "Update failed", _ => unreachable!("only error results have error notification copy"), }; format!("{prefix}: {message}") } fn emit_error(app: &AppHandle, error: impl std::fmt::Display) { emit( app, ERROR_EVENT, UpdateError { message: error.to_string(), }, ); } fn ready_notification_body(version: &str) -> String { format!("Update ready — restart OpenClaw to install v{version}") } fn manual_notification_body(version: &str) -> String { format!("Update available: v{version} — download from the release page") } #[cfg(test)] mod tests { use super::*; #[test] fn install_kind_covers_every_platform_path() { assert_eq!( install_kind_from_appimage_env(None, Platform::Linux), InstallKind::NotifyOnly ); assert_eq!( install_kind_from_appimage_env( Some(OsString::from("/tmp/OpenClaw.AppImage")), Platform::Linux, ), InstallKind::SelfInstall ); assert_eq!( install_kind_from_appimage_env(None, Platform::Macos), InstallKind::SelfInstall ); assert_eq!( install_kind_from_appimage_env(None, Platform::Windows), InstallKind::DeferredInstall ); } #[test] fn updater_event_names_are_stable() { assert_eq!(NOT_AVAILABLE_EVENT, "updater://not-available"); assert_eq!(AVAILABLE_EVENT, "updater://available"); assert_eq!(AVAILABLE_MANUAL_EVENT, "updater://available-manual"); assert_eq!(PROGRESS_EVENT, "updater://progress"); assert_eq!(READY_EVENT, "updater://ready"); assert_eq!(ERROR_EVENT, "updater://error"); } #[test] fn notification_copy_includes_update_version() { assert_eq!( ready_notification_body("2026.7.16"), "Update ready — restart OpenClaw to install v2026.7.16" ); assert_eq!( manual_notification_body("2026.7.16"), "Update available: v2026.7.16 — download from the release page" ); assert_eq!( error_notification_body(TerminalResultKind::CheckFailed, "offline"), "Update check failed: offline" ); assert_eq!( error_notification_body(TerminalResultKind::UpdateFailed, "disk full"), "Update failed: disk full" ); } #[test] fn every_manual_terminal_result_notifies_when_main_content_is_remote() { for result in [ TerminalResultKind::NotAvailable, TerminalResultKind::CheckFailed, TerminalResultKind::PackageUpdateAvailable, TerminalResultKind::UpdateReady, TerminalResultKind::UpdateFailed, ] { assert_eq!( result_delivery(true, true, result), ResultDestination::WebviewAndNotification, "manual {result:?} must have a visible destination when the WebView is remote" ); } } #[test] fn background_result_delivery_preserves_existing_behavior() { let expected = [ (TerminalResultKind::NotAvailable, ResultDestination::None), (TerminalResultKind::CheckFailed, ResultDestination::None), ( TerminalResultKind::PackageUpdateAvailable, ResultDestination::WebviewAndNotificationWhenUnfocused, ), ( TerminalResultKind::UpdateReady, ResultDestination::WebviewAndNotificationWhenUnfocused, ), (TerminalResultKind::UpdateFailed, ResultDestination::Webview), ]; for main_content_is_remote in [false, true] { for (result, destination) in expected { assert_eq!( result_delivery(false, main_content_is_remote, result), destination ); } } } #[test] fn manual_local_results_keep_the_in_page_delivery_path() { for result in [ TerminalResultKind::NotAvailable, TerminalResultKind::CheckFailed, TerminalResultKind::PackageUpdateAvailable, TerminalResultKind::UpdateReady, TerminalResultKind::UpdateFailed, ] { assert!(!matches!( result_delivery(true, false, result), ResultDestination::None )); } } }