Files
2026-07-27 01:26:28 -04:00

577 lines
18 KiB
Rust

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<AtomicBool>,
deferred_update: Mutex<Option<DeferredUpdate>>,
// 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<AtomicBool>,
}
struct DeferredUpdate {
update: Update,
bytes: Vec<u8>,
}
struct CheckGuard {
in_progress: Arc<AtomicBool>,
manual_pending: Arc<AtomicBool>,
}
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<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManualUpdateInfo {
version: String,
notes: Option<String>,
release_url: &'static str,
}
#[derive(Clone, Serialize)]
struct Progress {
downloaded: u64,
total: Option<u64>,
}
#[derive(Clone, Serialize)]
struct UpdateError {
message: String,
}
pub fn schedule_auto_check(app: AppHandle) {
let state = app.state::<UpdaterState>();
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::<UpdaterState>();
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::<UpdaterState>().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,
},
&notification_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::<UpdaterState>()
.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,
&notification_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<CheckGuard> {
let state = app.state::<UpdaterState>();
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<OsString>, 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<WebviewWindow> {
app.get_webview_window("main")
}
fn main_content_is_remote(app: &AppHandle, window: Option<&WebviewWindow>) -> bool {
!window.is_some_and(|window| {
app.state::<crate::DesktopState>()
.main_window_has_local_content(window)
})
}
fn progress_callback(window: WebviewWindow) -> impl FnMut(usize, Option<u64>) {
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<S: Serialize + Clone>(app: &AppHandle, event: &str, payload: S) {
if let Some(window) = main_window(app) {
let _ = window.emit(event, payload);
}
}
fn deliver_result<S: Serialize + Clone>(
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 },
&notification_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
));
}
}
}