diff --git a/cli/src/main.rs b/cli/src/main.rs index a5104a7..501ec5e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -586,6 +586,17 @@ fn main() { return; } + // Hidden update-check worker, spawned detached by maybe_notify_update() to + // refresh the cached latest version without blocking a real command. + if env::args().nth(1).as_deref() == Some("__update-check") { + upgrade::run_update_check(); + return; + } + + // Non-blocking "update available" hint (stderr only; self-skips meta + // commands, daemon mode, CI, and the opt-out env vars). + upgrade::maybe_notify_update(); + // Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process if env::var("AGENT_BROWSER_DAEMON").is_ok() { // Ignore SIGPIPE so the daemon isn't killed when the parent drops diff --git a/cli/src/upgrade.rs b/cli/src/upgrade.rs index e4005d3..37811a2 100644 --- a/cli/src/upgrade.rs +++ b/cli/src/upgrade.rs @@ -1,5 +1,7 @@ use crate::color; -use std::process::{exit, Command}; +use std::path::PathBuf; +use std::process::{exit, Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -7,6 +9,143 @@ const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// upgrade path and the install path are identical (GitHub Release, no npm). const INSTALL_URL: &str = "https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh"; +/// GitHub API for the latest published release (used by the update check). +const LATEST_RELEASE_API: &str = + "https://api.github.com/repos/leeguooooo/chrome-use/releases/latest"; + +/// Re-check the latest version at most this often (seconds). +const UPDATE_CHECK_INTERVAL_SECS: u64 = 86_400; // once a day + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn update_cache_path() -> PathBuf { + crate::connection::config_home().join("update-check.json") +} + +fn write_update_cache(checked_at: u64, latest: &str) { + let path = update_cache_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let body = serde_json::json!({ "checked_at": checked_at, "latest": latest }).to_string(); + let _ = std::fs::write(&path, body); +} + +/// Parse a dotted version (`1.2.1`, `v1.2.1`, `1.2.1-fork.3`) into a comparable +/// `(major, minor, patch)`, ignoring any pre-release/build suffix. +fn parse_version(v: &str) -> Option<(u64, u64, u64)> { + let core = v.trim().trim_start_matches('v'); + let core = core.split(['-', '+']).next().unwrap_or(core); + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) +} + +fn is_newer(latest: &str, current: &str) -> bool { + matches!((parse_version(latest), parse_version(current)), (Some(l), Some(c)) if l > c) +} + +/// Hidden `__update-check` subcommand: fetch the latest release tag and cache it. +/// Spawned detached by [`maybe_notify_update`] so the network call never blocks a +/// real command. Uses `curl` (no extra deps, matches `upgrade`). +pub fn run_update_check() { + let latest = Command::new("curl") + .args([ + "-fsSL", + "--max-time", + "8", + "-H", + "User-Agent: chrome-use-update-check", + LATEST_RELEASE_API, + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| serde_json::from_slice::(&o.stdout).ok()) + .and_then(|j| { + j.get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.trim_start_matches('v').to_string()) + }); + if let Some(latest) = latest { + write_update_cache(now_secs(), &latest); + } +} + +/// Non-blocking "update available" notice. Called once per command run: +/// - prints a one-line hint to **stderr** (never stdout, so `--json` is clean) +/// when a cached release is newer than the running binary; +/// - refreshes the cached latest version at most once a day via a **detached** +/// background process, so the current command never waits on the network. +/// +/// Skipped for meta commands (upgrade/install/doctor/`__*`/--version/--help), +/// in CI, in daemon mode, and when CHROME_USE_NO_UPDATE_CHECK / +/// AGENT_BROWSER_NO_UPDATE_CHECK is set. +pub fn maybe_notify_update() { + if std::env::var_os("CHROME_USE_NO_UPDATE_CHECK").is_some() + || std::env::var_os("AGENT_BROWSER_NO_UPDATE_CHECK").is_some() + || std::env::var_os("CI").is_some() + || std::env::var_os("AGENT_BROWSER_DAEMON").is_some() + { + return; + } + let first = std::env::args().nth(1).unwrap_or_default(); + if first.starts_with("__") + || matches!( + first.as_str(), + "upgrade" | "install" | "doctor" | "dashboard" + ) + { + return; + } + if std::env::args().any(|a| matches!(a.as_str(), "--version" | "-V" | "--help" | "-h")) { + return; + } + + let (checked_at, latest) = std::fs::read_to_string(update_cache_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|j| { + ( + j.get("checked_at").and_then(|v| v.as_u64()).unwrap_or(0), + j.get("latest") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + ) + }) + .unwrap_or((0, String::new())); + + if is_newer(&latest, CURRENT_VERSION) { + eprintln!( + "{} chrome-use {latest} is available (you have {CURRENT_VERSION}) — run `chrome-use upgrade`", + color::warning_indicator() + ); + } + + // Refresh in the background at most once a day. Bump the timestamp first + // (keeping the last-known latest) so concurrent runs don't all spawn a + // checker, then fire a detached child that does the network fetch. + if now_secs().saturating_sub(checked_at) >= UPDATE_CHECK_INTERVAL_SECS { + write_update_cache(now_secs(), &latest); + if let Ok(exe) = std::env::current_exe() { + let _ = Command::new(exe) + .arg("__update-check") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } + } +} + /// Upgrade to the latest GitHub Release. /// /// The stealth fork ships as a prebuilt binary attached to a GitHub Release —