mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 15:43:57 -06:00
577 lines
18 KiB
Rust
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,
|
|
},
|
|
¬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::<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,
|
|
¬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<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 },
|
|
¬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
|
|
));
|
|
}
|
|
}
|
|
}
|