diff --git a/docs/content/docs/cua-driver/guide/getting-started/installation.mdx b/docs/content/docs/cua-driver/guide/getting-started/installation.mdx index d97b62cc00..23075dab9a 100644 --- a/docs/content/docs/cua-driver/guide/getting-started/installation.mdx +++ b/docs/content/docs/cua-driver/guide/getting-started/installation.mdx @@ -98,6 +98,49 @@ If a grant still reads `NOT granted` after granting in the dialog, open **System (including unset) leaves the gate active. + + **Update-available banner (`cua-driver mcp` / `serve` / `doctor`).** The + long-running interactive entry points kick off a non-blocking background + check against the GitHub releases API at startup and print a small + two-line banner to stderr when a newer `cua-driver-rs-v*` release exists: + + ``` + ✨ cua-driver v0.1.4 is available (you have v0.1.3). + Update with: cua-driver update + Release notes: https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.1.4 + ``` + + The answer is cached at `~/.cua-driver-rs/version_check.json` for ~20 + hours, so subsequent launches reuse the cached result without a network + call. Network failures are silent — the next launch retries. + + **Scripted / machine-readable entry points are NOT instrumented.** + The banner only fires from the long-running entry points (`mcp`, + `serve`, `doctor`). One-shot and machine-readable subcommands — + `--version`, `list-tools`, `describe`, `call`, `dump-docs`, + `mcp-config`, `update`, `stop`, `status`, `recording`, `config`, + `diagnose`, and `telemetry install-event` — all skip it so piped + output (`cua-driver list-tools | jq …`) stays clean. + + **Disable per-invocation:** + + ```bash + CUA_DRIVER_RS_UPDATE_CHECK=false cua-driver serve + ``` + + Accepted "off" values (case-insensitive): `0`, `false`, `no`, `off`. + + **Disable permanently** via the persisted config: + + ```bash + cua-driver config set update_check_enabled false + ``` + + Source / dev builds (any `CARGO_PKG_VERSION` with a pre-release suffix + like `-dev`) auto-skip the check — there is no matching published + release for them to recommend. + + ## Requirements - macOS 14 (Sonoma) or later diff --git a/docs/content/docs/cua-driver/reference/cli-reference.mdx b/docs/content/docs/cua-driver/reference/cli-reference.mdx index 3fbb866803..a49ebb23f8 100644 --- a/docs/content/docs/cua-driver/reference/cli-reference.mdx +++ b/docs/content/docs/cua-driver/reference/cli-reference.mdx @@ -7,7 +7,7 @@ description: Command Line Interface reference for Cua Driver AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY Generated by: npx tsx scripts/docs-generators/cua-driver.ts Source: recursive Swift sources under libs/cua-driver/Sources - Version: 0.1.7 + Version: 0.1.9 */} import { Callout } from 'fumadocs-ui/components/callout'; @@ -16,7 +16,7 @@ import { VersionHeader } from '@/components/version-selector'; @@ -142,13 +142,6 @@ Subsequent `cua-driver call/list-tools/describe` invocations auto-detect the socket and forward their requests, so the AppStateEngine's per-pid element_index cache survives across CLI calls. -On macOS, `serve` runs a **first-launch permissions gate** before binding -the socket. When TCC grants for Accessibility or Screen Recording are -missing, it prints a banner listing exactly what is missing, auto-opens -the matching `System Settings → Privacy & Security` pane(s), and polls -every second until the user grants both. When grants are already active -the gate is a transparent no-op. - **Options:** | Name | Type | Default | Description | @@ -160,7 +153,6 @@ the gate is a transparent no-op. | Name | Description | | ---- | ----------- | | `--no-relaunch` | Stay in the current process instead of re-execing via `open -n -g -a CuaDriver`. | -| `--no-permissions-gate` | Skip the macOS TCC permissions gate at startup. Use for CI / headless runners where blocking on user input would deadlock the process. Also toggleable by setting `CUA_DRIVER_RS_PERMISSIONS_GATE` to any of `0`, `false`, `no`, or `off` (case-insensitive — e.g. `CUA_DRIVER_RS_PERMISSIONS_GATE=FALSE` works too). | ### cua-driver stop diff --git a/docs/content/docs/cua-driver/reference/mcp-tools.mdx b/docs/content/docs/cua-driver/reference/mcp-tools.mdx index c0982f220b..9e35e14119 100644 --- a/docs/content/docs/cua-driver/reference/mcp-tools.mdx +++ b/docs/content/docs/cua-driver/reference/mcp-tools.mdx @@ -7,7 +7,7 @@ description: Reference for every MCP tool cua-driver exposes AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY Generated by: npx tsx scripts/docs-generators/cua-driver.ts Source: recursive Swift sources under libs/cua-driver/Sources - Version: 0.1.7 + Version: 0.1.9 */} import { Callout } from 'fumadocs-ui/components/callout'; diff --git a/libs/cua-driver-rs/Cargo.lock b/libs/cua-driver-rs/Cargo.lock index 39297031a4..886f8eb704 100644 --- a/libs/cua-driver-rs/Cargo.lock +++ b/libs/cua-driver-rs/Cargo.lock @@ -254,8 +254,10 @@ dependencies = [ "platform-linux", "platform-macos", "platform-windows", + "semver", "serde", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/libs/cua-driver-rs/PARITY.md b/libs/cua-driver-rs/PARITY.md index dfc5df0dc9..c975e4840a 100644 --- a/libs/cua-driver-rs/PARITY.md +++ b/libs/cua-driver-rs/PARITY.md @@ -1775,3 +1775,109 @@ surfaced to stdout/stderr unless `CUA_DRIVER_RS_TELEMETRY_DEBUG=true`. GUI surface yet, so the constant is reserved but unused. - **`is_ci` uses env-var probing only.** Same probe list as Swift; no extra Rust-specific signals. + +## Startup flow: update-available banner (`mcp` / `serve` / `doctor`) + +- Swift: not present (the Swift port has no analogous banner today) +- Rust: `crates/cua-driver/src/version_check.rs` +- Status: INTENTIONAL_ADDITION (Rust-only) +- Test: `crates/cua-driver/src/version_check.rs` `#[cfg(test)] mod tests` + (22 unit tests: semver edge cases, cache round-trip in a + tempdir, dismissal persistence, 20-hour refresh threshold, + env-var + config opt-out, JSON release-list filtering, + banner format, ISO-8601 timestamp) + +### Behavior + +On the long-running interactive entry points (`mcp`, `serve`, +`doctor`) the binary kicks off a background HTTP check against +`https://api.github.com/repos/trycua/cua/releases?per_page=40`, +filters to the `cua-driver-rs-v*` tag prefix, and prints a two-line +banner to **stderr** if the highest non-draft non-prerelease release +is strictly newer than `CARGO_PKG_VERSION` and the user hasn't +previously dismissed it: + +```text +✨ cua-driver v0.1.4 is available (you have v0.1.3). + Update with: cua-driver update + Release notes: https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.1.4 +``` + +The check runs on `tokio::task::spawn_blocking` when a runtime is +live, else a short-lived OS thread, so the daemon's start-up path +is never delayed by network latency. + +### Cache + +The latest-version answer is cached on disk at +`~/.cua-driver-rs/version_check.json`: + +```json +{ + "last_checked_unix": 1700000000, + "last_checked_at": "2023-11-14T22:13:20Z", + "latest_version": "0.1.4", + "dismissed_versions": [] +} +``` + +Refreshed only when the cached `last_checked_unix` is more than 20 +hours old, bounding outbound requests to roughly one per machine per +day even on a hot reload loop. Failed HTTP fetches fall back to the +cached value (better an old banner than none on a brief network blip). + +### Skipped contexts + +Only `mcp`, `serve`, and `doctor` call `maybe_announce_update()`. +The following entry points are **NOT** instrumented because they're +routinely piped from scripts and a banner would corrupt their +parseable output: + +- `--version` / `-V` +- `list-tools` +- `describe ` +- `call ` +- `dump-docs` +- `mcp-config` +- `update`, `stop`, `status`, `recording`, `config`, `diagnose`, + `telemetry install-event` + +### Opt-out (three layers, any one disables the check) + +1. **Env var** `CUA_DRIVER_RS_UPDATE_CHECK=false` (also `0`, `no`, + `off`; case-insensitive) — single-invocation off. +2. **Config flag** `update_check_enabled = false` in + `~/.cua-driver/config.json` — persistent off. Set via + `cua-driver config set update_check_enabled false`. +3. **Pre-release build auto-skip** — any `CARGO_PKG_VERSION` that + carries a semver pre-release suffix (`-dev`, `-rc.1`, `-beta`) + short-circuits the check entirely. There is no matching published + release for a source / development build to recommend. + +### HTTP client + +`ureq` v3 with `Accept: application/vnd.github+json` and a +`cua-driver-rs/` user-agent. 4-second timeout. Single GET, +fire-and-forget — the response body is read into `serde_json::Value` +on the background task, never touching the foreground startup path. + +Network errors, timeouts, 4xx/5xx responses, and JSON-parse failures +are logged via `tracing::debug!(target: "cua_driver::version_check", +…)` only — never surfaced to stderr. + +### Shared with `cua-driver update` + +`run_update_cmd` calls into the same +`version_check::fetch_latest_version()` and `version_check::is_newer()` +helpers so the proactive banner and the manual subcommand agree on +tag-filtering rules and semver compare semantics. This also removes +the prior shell-out to `curl` from `cli::run_update_cmd`. + +### Dismissal API + +`version_check::dismiss_version(&str)` appends a version string to +the `dismissed_versions` list on disk. No call site in the current +binary (banner is informational only today); kept public so a future +interactive prompt path (TUI helper, GUI extra) can persist the +"skip until next version" choice without re-implementing the cache +layer. diff --git a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml index 23e51590c3..0f93d0b784 100644 --- a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml +++ b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml @@ -25,6 +25,10 @@ uuid = { workspace = true } # PostHog ingest is a single fire-and-forget POST with a 3s timeout. Uses # rustls (default) so Linux/Windows builds don't require system OpenSSL. ureq = { version = "3", features = ["json"] } +# Strict semver comparison for the startup update-available banner so that +# pre-release tags (`-dev`, `-rc.1`) sort below their corresponding release +# and we never recommend a pre-release as an "update". +semver = "1" # Used by crate::bundle::parent_is_not_launchd() for the TCC # auto-relaunch detection path on Unix (only the macOS heuristic @@ -45,6 +49,9 @@ platform-linux = { path = "../platform-linux" } tokio = { workspace = true, features = ["full"] } image = { workspace = true } base64 = { workspace = true } +# Isolated HOME / cache directories for `version_check` unit tests so they +# never touch the developer's real `~/.cua-driver-rs/version_check.json`. +tempfile = "3" [target.'cfg(target_os = "windows")'.dev-dependencies] platform-windows = { path = "../platform-windows" } diff --git a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs index 13bba63c67..da1aa9441b 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs @@ -796,23 +796,29 @@ pub fn run_recording_cmd(subcommand: &str, args: &[String], socket: Option<&str> /// `cua-driver update [--apply]` — check for a newer release and optionally apply it. /// -/// Uses `curl` to query the GitHub releases API (same as Swift reference). -/// Pass `--apply` to download and install via the canonical install.sh. +/// Shares the GitHub releases fetch with the startup banner via +/// [`crate::version_check::fetch_latest_version`] so both code paths +/// agree on tag filtering and HTTP semantics. Pass `--apply` to download +/// and install via the canonical install.sh. pub fn run_update_cmd(apply: bool) { let current = env!("CARGO_PKG_VERSION"); println!("Current version: {current}"); println!("Checking for updates…"); - let latest = fetch_latest_version(); + let latest = crate::version_check::fetch_latest_version(); match latest { - None => { + Err(e) => { + // The shared helper returns a human-readable error string for + // the CLI surface — pass it through so the user can see why + // (timeout, parse error, etc.) instead of just "unreachable". + tracing::debug!(target: "cua_driver::update", "fetch failed: {e}"); println!("Could not reach GitHub — check your connection and try again."); process::exit(1); } - Some(v) if !is_version_newer(&v, current) => { + Ok(v) if !crate::version_check::is_newer(&v, current) => { println!("Already up to date."); } - Some(v) => { + Ok(v) => { println!("New version available: {v}"); if !apply { @@ -845,56 +851,6 @@ pub fn run_update_cmd(apply: bool) { } } -/// Fetch the latest `cua-driver-v*` release tag from GitHub using curl. -/// Returns the version string (e.g. "0.1.2") or `None` on any error. -fn fetch_latest_version() -> Option { - let out = std::process::Command::new("curl") - .args(["-s", "--max-time", "4", - "-H", "Accept: application/vnd.github+json", - "https://api.github.com/repos/trycua/cua/releases?per_page=40"]) - .output() - .ok()?; - if !out.status.success() { return None; } - let text = String::from_utf8_lossy(&out.stdout); - let releases: serde_json::Value = serde_json::from_str(&text).ok()?; - // `cua-driver-rs-v*` releases are distinct from the Swift `cua-driver-v*` - // releases on the same repo — must filter by the Rust-port prefix or - // we'd recommend the Swift binary instead. - let prefix = "cua-driver-rs-v"; - let mut versions: Vec = releases.as_array()?.iter() - .filter_map(|r| { - let tag = r["tag_name"].as_str()?; - if !tag.starts_with(prefix) { return None; } - if r["draft"].as_bool().unwrap_or(false) { return None; } - if r["prerelease"].as_bool().unwrap_or(false) { return None; } - Some(tag[prefix.len()..].to_owned()) - }) - .collect(); - // Sort descending (newest first). - versions.sort_by(|a, b| compare_versions(b, a)); - versions.into_iter().next() -} - -/// True when `candidate` is strictly newer than `current` (semver compare). -fn is_version_newer(candidate: &str, current: &str) -> bool { - compare_versions(candidate, current) == std::cmp::Ordering::Greater -} - -fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { - let parts = |s: &str| -> Vec { - s.split('.').filter_map(|p| p.parse().ok()).collect() - }; - let pa = parts(a); - let pb = parts(b); - for (x, y) in pa.iter().zip(pb.iter()) { - match x.cmp(y) { - std::cmp::Ordering::Equal => continue, - other => return other, - } - } - pa.len().cmp(&pb.len()) -} - /// `cua-driver dump-docs [--pretty]` — output all MCP tool schemas as JSON. pub fn run_dump_docs(registry: &ToolRegistry, pretty: bool) { run_dump_docs_with_type(registry, pretty, "all") diff --git a/libs/cua-driver-rs/crates/cua-driver/src/main.rs b/libs/cua-driver-rs/crates/cua-driver/src/main.rs index d7b752f5b4..51fc67d394 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/main.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/main.rs @@ -29,6 +29,7 @@ mod cli; mod proxy; mod serve; mod telemetry; +mod version_check; use std::sync::Arc; @@ -114,6 +115,10 @@ fn main() { return; } cli::Command::Serve { socket, no_permissions_gate } => { + // Long-running daemon — kick off the background update check + // before any blocking work so the banner can land on stderr + // early in the serve lifecycle. + version_check::maybe_announce_update(); // First-launch permissions gate (Swift PermissionsGate parity). // Runs on every `serve` start; no-op when both grants are // already active. Honors --no-permissions-gate and @@ -177,6 +182,10 @@ fn main() { return; } cli::Command::Doctor => { + // Long-running interactive entry point — kick off the + // background "new version available?" check so the banner + // can land on stderr if the user is on an outdated install. + version_check::maybe_announce_update(); cli::run_doctor_cmd(); return; } @@ -192,6 +201,10 @@ fn main() { return; } cli::Command::Mcp { no_daemon_relaunch, socket } => { + // Long-running MCP server — kick off the background update + // check before any TCC / daemon-proxy decisions so the + // banner can land on stderr in either dispatch path. + version_check::maybe_announce_update(); // TCC sidestep: if we're a shell-spawned bare binary that // resolves into /Applications/CuaDriverRs.app, run the // proxy path instead of the in-process MCP server. The @@ -328,6 +341,9 @@ fn main() -> anyhow::Result<()> { return Ok(()); } cli::Command::Serve { socket, no_permissions_gate } => { + // Long-running daemon — kick off the background update check + // before any blocking work so the banner can land on stderr. + version_check::maybe_announce_update(); // The Rust permissions gate is macOS-only (TCC concept). // On Windows / Linux the flag is silently accepted for // CLI uniformity and ignored. @@ -369,6 +385,9 @@ fn main() -> anyhow::Result<()> { return Ok(()); } cli::Command::Doctor => { + // Long-running interactive entry point — kick off the + // background update check so the banner can land on stderr. + version_check::maybe_announce_update(); cli::run_doctor_cmd(); return Ok(()); } @@ -386,6 +405,9 @@ fn main() -> anyhow::Result<()> { return Ok(()); } cli::Command::Mcp { no_daemon_relaunch, socket } => { + // Long-running MCP server — kick off the background update + // check before falling through to the in-process server. + version_check::maybe_announce_update(); // Non-macOS: TCC doesn't exist, no daemon proxy path. The // flags parse cleanly so cross-platform MCP config // snippets work, but we ignore them and run in-process. diff --git a/libs/cua-driver-rs/crates/cua-driver/src/version_check.rs b/libs/cua-driver-rs/crates/cua-driver/src/version_check.rs new file mode 100644 index 0000000000..73db9e7280 --- /dev/null +++ b/libs/cua-driver-rs/crates/cua-driver/src/version_check.rs @@ -0,0 +1,818 @@ +//! Startup "new version available" banner. +//! +//! On the interactive entry points (`mcp`, `serve`, `doctor`) we check the +//! GitHub releases API for a newer `cua-driver-rs-v*` tag, cache the answer +//! on disk for ~20 hours, and print a small two-line banner to **stderr** +//! if a strictly-newer release exists and the user hasn't dismissed it. +//! +//! Design constraints: +//! +//! - **Non-blocking.** [`maybe_announce_update`] returns instantly. The +//! actual HTTP fetch runs on a background task (`tokio::task::spawn` when +//! a runtime is already live, else a short-lived OS thread). If the +//! network is slow, the daemon starts up regardless and the banner +//! either lands on a later stderr line or appears next launch. +//! - **Silent on failure.** HTTP timeouts, 4xx/5xx, JSON-parse errors, +//! refusal-to-write-cache — everything is `tracing::debug!` only. Never +//! pollute the user's stderr with "couldn't check for updates" noise; +//! the next launch just retries. +//! - **Cache-first.** A 20-hour-old cache short-circuits the network call +//! entirely. This bounds outbound requests to roughly one per machine +//! per day even on a hot reload loop. +//! - **Opt-out at three layers.** Env var `CUA_DRIVER_RS_UPDATE_CHECK=false` +//! (single invocation), persisted config `update_check_enabled = false` +//! in `~/.cua-driver/config.json` (permanent), and an automatic skip when +//! `CARGO_PKG_VERSION` carries any pre-release suffix (source / dev +//! builds — there is no matching published release to recommend). +//! - **Skip in machine-readable contexts.** Only the long-running +//! entry points call [`maybe_announce_update`]. `--version`, +//! `list-tools`, `describe`, `call`, `dump-docs`, etc. are routinely +//! piped through `jq` from scripts; a banner would corrupt parseable +//! output. The decision lives at the call sites in `main.rs`, not here. + +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Disk-resident cache file (sibling of the telemetry artifacts under +/// `~/.cua-driver-rs/`). Holds the last-seen latest version plus the list +/// of versions the user has actively dismissed. +const CACHE_FILE_NAME: &str = "version_check.json"; + +/// `~/.cua-driver-rs/` — same subdirectory the telemetry client uses, +/// kept separate from the `~/.cua-driver/` config tree that the Swift +/// reference owns. +const HOME_SUBDIRECTORY: &str = ".cua-driver-rs"; + +/// Single-invocation opt-out env var. Recognised values mirror the +/// telemetry opt-out: `0|false|no|off` disables the check, everything +/// else (including unset) leaves it on. +const ENV_UPDATE_CHECK: &str = "CUA_DRIVER_RS_UPDATE_CHECK"; + +/// Refresh threshold for the on-disk cache. A fresh cache short-circuits +/// the network call so we hit the GitHub API at most ~1× per machine per +/// day even when the daemon restarts repeatedly. +const CACHE_REFRESH_SECONDS: u64 = 20 * 60 * 60; // 20 hours + +/// HTTP timeout for the releases API fetch. Kept tight so the background +/// task can't linger past the daemon's normal startup window. +const HTTP_TIMEOUT_SECONDS: u64 = 4; + +/// Tag-name prefix used by the Rust-port releases on the trycua/cua repo. +/// Releases tagged with anything else (e.g. the Swift port's +/// `cua-driver-v*`) are filtered out so we never recommend the wrong binary. +pub(crate) const RELEASE_TAG_PREFIX: &str = "cua-driver-rs-v"; + +/// GitHub releases API endpoint. Paginates newest-first; 40 entries is +/// plenty of headroom past the most recent stable release even when +/// pre-releases are sprinkled in between. +const RELEASES_URL: &str = + "https://api.github.com/repos/trycua/cua/releases?per_page=40"; + +// ── Public API ─────────────────────────────────────────────────────────── + +/// Kick off a background "is there a newer release?" check. +/// +/// **Returns immediately.** The actual network round-trip happens on a +/// `tokio::task::spawn` task (when a runtime is live) or a short-lived +/// OS thread (sync entry points). The two-line banner is printed to +/// **stderr** if/when the check completes and finds a strictly-newer +/// non-dismissed version. +/// +/// No-op when: +/// - `CUA_DRIVER_RS_UPDATE_CHECK` env var is set to a falsy value +/// - The persisted config flag `update_check_enabled` is `false` +/// - `CARGO_PKG_VERSION` has a pre-release suffix (source / dev build — +/// nothing on GitHub will be "newer" in a meaningful way) +pub fn maybe_announce_update() { + if !is_enabled() { + tracing::debug!(target: "cua_driver::version_check", + "update check skipped (opt-out / pre-release build)"); + return; + } + + let current = env!("CARGO_PKG_VERSION").to_owned(); + + let task = move || { + run_check_and_announce(¤t, fetch_latest_version, std::io::stderr()); + }; + + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::spawn_blocking(task); + } else { + std::thread::Builder::new() + .name("cua-version-check".into()) + .spawn(task) + .ok(); + } +} + +/// Mark `version` as dismissed so the banner stops nagging the user about +/// this specific release. They will see the next banner the moment a +/// strictly-newer tag ships. +/// +/// Idempotent. Failures (no HOME, IO error) are logged via +/// `tracing::debug!` and silently dropped — dismissal is a UX nicety, not +/// a correctness boundary. +/// +/// Exposed publicly so a future interactive prompt (TUI, GUI helper) can +/// wire it in without re-implementing the persistence layer. No call site +/// in the current binary — the banner today is informational only. +#[allow(dead_code)] +pub fn dismiss_version(version: &str) { + let mut cache = read_cache().unwrap_or_default(); + if !cache.dismissed_versions.iter().any(|v| v == version) { + cache.dismissed_versions.push(version.to_owned()); + } + if let Err(e) = write_cache(&cache) { + tracing::debug!(target: "cua_driver::version_check", + "failed to persist dismissal: {e}"); + } +} + +// ── Core logic (testable seam) ─────────────────────────────────────────── + +/// Inner routine wired up by [`maybe_announce_update`]. +/// +/// Split out from the public entry point so tests can drive the check +/// against a stubbed fetcher and capture the banner into an in-memory +/// buffer. Errors here never propagate — the function consumes them +/// and converts to `tracing::debug!` lines. +fn run_check_and_announce(current: &str, fetch: F, mut writer: W) +where + F: FnOnce() -> Result, + W: std::io::Write, +{ + let now = unix_now(); + + // Decide whether the cache is still fresh enough to skip the network. + let cached = read_cache().unwrap_or_default(); + let needs_refresh = cached + .last_checked_unix + .map(|t| now.saturating_sub(t) >= CACHE_REFRESH_SECONDS) + .unwrap_or(true); + + let latest = if needs_refresh { + match fetch() { + Ok(v) => { + // Persist on success so the next launch re-uses the answer. + let new_cache = VersionCache { + last_checked_unix: Some(now), + last_checked_at: Some(iso8601(now)), + latest_version: Some(v.clone()), + dismissed_versions: cached.dismissed_versions.clone(), + }; + if let Err(e) = write_cache(&new_cache) { + tracing::debug!(target: "cua_driver::version_check", + "failed to write cache: {e}"); + } + v + } + Err(e) => { + tracing::debug!(target: "cua_driver::version_check", + "fetch failed: {e}"); + // Fall back to the cached value if any — better an old + // banner than none on a brief network blip. + match cached.latest_version.clone() { + Some(v) => v, + None => return, + } + } + } + } else { + match cached.latest_version.clone() { + Some(v) => v, + None => return, + } + }; + + // Re-read dismissals: dismiss_version may have run between our cache + // load and now (e.g. on a separately-spawned task in the same process). + let dismissed = read_cache() + .map(|c| c.dismissed_versions) + .unwrap_or(cached.dismissed_versions); + + if !is_newer(&latest, current) { + return; + } + if dismissed.iter().any(|v| v == &latest) { + tracing::debug!(target: "cua_driver::version_check", + "newer version {latest} dismissed; skipping banner"); + return; + } + + let banner = format_banner(&latest, current); + if let Err(e) = writer.write_all(banner.as_bytes()) { + tracing::debug!(target: "cua_driver::version_check", + "failed to print banner: {e}"); + } +} + +/// Format the two-line banner. Plain text, no ANSI colours — terminals +/// without UTF-8 still see the `✨` byte sequence but the text is +/// readable either way. +fn format_banner(latest: &str, current: &str) -> String { + format!( + "\n\u{2728} cua-driver v{latest} is available (you have v{current}).\n \ + Update with: cua-driver update\n \ + Release notes: https://github.com/trycua/cua/releases/tag/{prefix}{latest}\n\n", + prefix = RELEASE_TAG_PREFIX, + ) +} + +// ── Enable / disable logic ─────────────────────────────────────────────── + +/// True when the update check should run on this invocation. +/// +/// Order of precedence (any one being "off" wins): +/// 1. Env var `CUA_DRIVER_RS_UPDATE_CHECK` (falsy → off) +/// 2. Persisted config `update_check_enabled` (false → off) +/// 3. Built `CARGO_PKG_VERSION` is a pre-release (`-dev`, `-rc.1`, …) → off +fn is_enabled() -> bool { + if let Some(false) = parse_env_bool(ENV_UPDATE_CHECK) { + return false; + } + if let Some(false) = read_config_flag() { + return false; + } + if is_prerelease(env!("CARGO_PKG_VERSION")) { + return false; + } + true +} + +/// Read the `update_check_enabled` flag out of the same JSON config file +/// the `cua-driver config set` subcommand writes to. Returns `None` when +/// the file is missing, unreadable, or doesn't have the key. +fn read_config_flag() -> Option { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE"))?; + let path = PathBuf::from(home).join(".cua-driver").join("config.json"); + let raw = std::fs::read_to_string(&path).ok()?; + let json: serde_json::Value = serde_json::from_str(&raw).ok()?; + json.get("update_check_enabled").and_then(|v| v.as_bool()) +} + +fn parse_env_bool(var: &str) -> Option { + let raw = std::env::var(var).ok()?; + match raw.trim().to_ascii_lowercase().as_str() { + "0" | "false" | "no" | "off" => Some(false), + "1" | "true" | "yes" | "on" => Some(true), + _ => None, + } +} + +/// True when `version` carries any semver pre-release suffix. +/// +/// Returns `true` (skip the check) for unparseable strings too — better +/// to silently skip than to print a banner against a malformed version. +pub(crate) fn is_prerelease(version: &str) -> bool { + match semver::Version::parse(version) { + Ok(v) => !v.pre.is_empty(), + Err(_) => true, + } +} + +// ── Semver comparison ──────────────────────────────────────────────────── + +/// True when `latest` is strictly newer than `current` under semver. +/// +/// Unparseable inputs return `false` so a broken release tag or +/// `CARGO_PKG_VERSION` quirk can never produce a spurious "update +/// available" banner. We'd rather miss a real update than nag the user +/// about a phantom one. +pub fn is_newer(latest: &str, current: &str) -> bool { + let Ok(l) = semver::Version::parse(latest) else { return false; }; + let Ok(c) = semver::Version::parse(current) else { return false; }; + l > c +} + +// ── On-disk cache ──────────────────────────────────────────────────────── + +/// Serialised shape of `~/.cua-driver-rs/version_check.json`. +/// +/// `last_checked_unix` is the source of truth for cache-age decisions; +/// `last_checked_at` is the same instant rendered as ISO-8601 for human +/// inspection of the file. Both fields are optional so we can write +/// partial state (e.g. only a dismissed list) without forcing a sentinel. +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct VersionCache { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_checked_unix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_checked_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_version: Option, + #[serde(default)] + pub dismissed_versions: Vec, +} + +/// Read the cache file. Returns `None` when missing / unreadable / not +/// valid JSON — callers fall back to the default (empty) shape. +fn read_cache() -> Option { + let path = cache_path()?; + let raw = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&raw).ok() +} + +/// Write the cache file. Creates the parent directory if missing. +/// IO errors propagate so the caller can decide whether to log; the +/// fire-and-forget callers in this module always swallow them. +fn write_cache(cache: &VersionCache) -> std::io::Result<()> { + let path = cache_path().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no HOME / USERPROFILE — cannot resolve version_check cache path", + ) + })?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(cache) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&path, json) +} + +fn cache_path() -> Option { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE"))?; + Some(PathBuf::from(home).join(HOME_SUBDIRECTORY).join(CACHE_FILE_NAME)) +} + +// ── HTTP fetch (shared with the `update` subcommand) ───────────────────── + +/// Fetch the highest `cua-driver-rs-v*` release tag from GitHub. +/// +/// Uses `ureq` (already a dep via telemetry) so we don't shell out to +/// `curl` from a background task and stay cross-platform. Filters out: +/// +/// - tags that don't start with `cua-driver-rs-v` (Swift port releases) +/// - draft releases (`"draft": true`) +/// - pre-releases (`"prerelease": true`) +/// +/// Returns the bare version string (e.g. `"0.1.4"`) on success, or a +/// human-readable error string on failure. The caller is expected to +/// downgrade errors to `tracing::debug!`. +pub(crate) fn fetch_latest_version() -> Result { + let agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS))) + .build() + .new_agent(); + + let response = agent + .get(RELEASES_URL) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", concat!("cua-driver-rs/", env!("CARGO_PKG_VERSION"))) + .call() + .map_err(|e| format!("HTTP error: {e}"))?; + + let body: serde_json::Value = response + .into_body() + .read_json() + .map_err(|e| format!("JSON parse error: {e}"))?; + + pick_latest_release(&body) + .ok_or_else(|| "no matching cua-driver-rs-v* release in response".to_owned()) +} + +/// Pull the highest non-draft non-prerelease `cua-driver-rs-v*` tag out +/// of the parsed releases response. Split out so unit tests can feed in +/// canned JSON without hitting the network. +pub(crate) fn pick_latest_release(body: &serde_json::Value) -> Option { + let releases = body.as_array()?; + let mut versions: Vec = releases + .iter() + .filter_map(|r| { + let tag = r.get("tag_name")?.as_str()?; + let bare = tag.strip_prefix(RELEASE_TAG_PREFIX)?; + if r.get("draft").and_then(|d| d.as_bool()).unwrap_or(false) { + return None; + } + if r.get("prerelease").and_then(|p| p.as_bool()).unwrap_or(false) { + return None; + } + semver::Version::parse(bare).ok() + }) + .collect(); + versions.sort(); + versions.last().map(|v| v.to_string()) +} + +// ── Time helpers ───────────────────────────────────────────────────────── + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Render a unix timestamp as `YYYY-MM-DDTHH:MM:SSZ`. Mirrors the +/// telemetry module's formatter so the on-disk cache file stays +/// human-readable without dragging in `chrono`. +fn iso8601(unix_secs: u64) -> String { + let (year, month, day, hour, minute, second) = civil_from_unix(unix_secs); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_unix(unix_secs: u64) -> (i32, u32, u32, u32, u32, u32) { + let days = (unix_secs / 86_400) as i64; + let secs_of_day = (unix_secs % 86_400) as u32; + let hour = secs_of_day / 3600; + let minute = (secs_of_day % 3600) / 60; + let second = secs_of_day % 60; + + // Howard Hinnant's civil_from_days, shifted to 0000-03-01 era epoch. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = (z - era * 146_097) as u32; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = (y + if m <= 2 { 1 } else { 0 }) as i32; + (year, m, d, hour, minute, second) +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// All env-mutating tests serialise on this lock — `std::env::set_var` + /// is process-global, parallel tests would race. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Redirect `HOME` / `USERPROFILE` to a fresh temp dir for the body + /// of `f`, then restore. Ensures the cache file lives in an isolated + /// directory and never touches the developer's real + /// `~/.cua-driver-rs/version_check.json`. + fn with_isolated_home(f: impl FnOnce(&std::path::Path) -> R) -> R { + let tmp = tempfile::tempdir().expect("tempdir"); + let saved_home = std::env::var_os("HOME"); + let saved_userprofile = std::env::var_os("USERPROFILE"); + unsafe { std::env::set_var("HOME", tmp.path()); } + unsafe { std::env::set_var("USERPROFILE", tmp.path()); } + + let result = f(tmp.path()); + + match saved_home { + Some(s) => unsafe { std::env::set_var("HOME", s); }, + None => unsafe { std::env::remove_var("HOME"); }, + } + match saved_userprofile { + Some(s) => unsafe { std::env::set_var("USERPROFILE", s); }, + None => unsafe { std::env::remove_var("USERPROFILE"); }, + } + result + } + + // ── is_newer ──────────────────────────────────────────────────────── + + #[test] + fn is_newer_recognises_strict_patch_bump() { + assert!(is_newer("0.1.4", "0.1.3")); + } + + #[test] + fn is_newer_recognises_minor_bump_past_double_digit() { + // 0.2.0 > 0.1.99 — naive lexicographic compare would fail here. + assert!(is_newer("0.2.0", "0.1.99")); + } + + #[test] + fn is_newer_treats_pre_release_as_lower_than_release() { + // 0.1.3 > 0.1.3-dev — semver rule: release > pre-release of same triple. + assert!(is_newer("0.1.3", "0.1.3-dev")); + } + + #[test] + fn is_newer_returns_false_for_equal_versions() { + assert!(!is_newer("0.1.3", "0.1.3")); + } + + #[test] + fn is_newer_returns_false_for_older_candidate() { + assert!(!is_newer("0.1.2", "0.1.3")); + assert!(!is_newer("0.0.99", "0.1.0")); + } + + #[test] + fn is_newer_returns_false_for_unparseable_input() { + // Garbage in → false out. Better to miss a real update than nag + // about a phantom one. + assert!(!is_newer("not-a-version", "0.1.3")); + assert!(!is_newer("0.1.4", "broken")); + } + + // ── Pre-release detection ─────────────────────────────────────────── + + #[test] + fn is_prerelease_flags_dev_and_rc_suffixes() { + assert!(is_prerelease("0.1.4-dev")); + assert!(is_prerelease("0.1.4-rc.1")); + assert!(is_prerelease("0.1.4-beta")); + } + + #[test] + fn is_prerelease_passes_plain_release() { + assert!(!is_prerelease("0.1.4")); + assert!(!is_prerelease("1.0.0")); + } + + #[test] + fn is_prerelease_returns_true_for_garbage() { + // Unparseable strings count as "pre-release" so the update check + // skips them rather than blowing up. + assert!(is_prerelease("not-a-version")); + assert!(is_prerelease("")); + } + + // ── Cache round-trip ──────────────────────────────────────────────── + + #[test] + fn cache_round_trips_through_tempdir() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|_| { + let original = VersionCache { + last_checked_unix: Some(1_700_000_000), + last_checked_at: Some("2023-11-14T22:13:20Z".into()), + latest_version: Some("0.1.4".into()), + dismissed_versions: vec!["0.1.3".into()], + }; + write_cache(&original).expect("write_cache"); + let read_back = read_cache().expect("read_cache"); + assert_eq!(read_back.last_checked_unix, Some(1_700_000_000)); + assert_eq!(read_back.latest_version.as_deref(), Some("0.1.4")); + assert_eq!(read_back.dismissed_versions, vec!["0.1.3".to_owned()]); + }); + } + + #[test] + fn dismissed_versions_persist_across_writes() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|_| { + // First dismissal. + dismiss_version("0.1.4"); + let after_first = read_cache().expect("cache after first"); + assert_eq!(after_first.dismissed_versions, vec!["0.1.4".to_owned()]); + + // Second dismissal of a different version appends, doesn't replace. + dismiss_version("0.1.5"); + let after_second = read_cache().expect("cache after second"); + assert_eq!( + after_second.dismissed_versions, + vec!["0.1.4".to_owned(), "0.1.5".to_owned()], + ); + + // Re-dismissing an already-dismissed version is idempotent. + dismiss_version("0.1.4"); + let after_dup = read_cache().expect("cache after dup"); + assert_eq!( + after_dup.dismissed_versions, + vec!["0.1.4".to_owned(), "0.1.5".to_owned()], + ); + }); + } + + // ── 20h refresh threshold ─────────────────────────────────────────── + + #[test] + fn cache_older_than_threshold_triggers_refresh() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|_| { + // Seed the cache 21 hours in the past. + let now = unix_now(); + let stale = VersionCache { + last_checked_unix: Some(now.saturating_sub(21 * 60 * 60)), + last_checked_at: Some(iso8601(now.saturating_sub(21 * 60 * 60))), + latest_version: Some("0.1.3".into()), // stale data + dismissed_versions: vec![], + }; + write_cache(&stale).unwrap(); + + let mut buf: Vec = Vec::new(); + let fetch_calls = std::sync::atomic::AtomicUsize::new(0); + run_check_and_announce( + "0.1.3", + || { + fetch_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok("0.1.4".to_owned()) // newer version returned by network + }, + &mut buf, + ); + + assert_eq!( + fetch_calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "stale cache must trigger a fetch", + ); + let banner = String::from_utf8(buf).unwrap(); + assert!(banner.contains("v0.1.4 is available"), "got: {banner:?}"); + assert!(banner.contains("you have v0.1.3"), "got: {banner:?}"); + }); + } + + #[test] + fn cache_younger_than_threshold_skips_network() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|_| { + // Seed the cache 1 hour in the past with a known-newer version. + let now = unix_now(); + let fresh = VersionCache { + last_checked_unix: Some(now.saturating_sub(60 * 60)), + last_checked_at: Some(iso8601(now.saturating_sub(60 * 60))), + latest_version: Some("0.1.4".into()), + dismissed_versions: vec![], + }; + write_cache(&fresh).unwrap(); + + let mut buf: Vec = Vec::new(); + let fetch_calls = std::sync::atomic::AtomicUsize::new(0); + run_check_and_announce( + "0.1.3", + || { + fetch_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok("0.1.5".to_owned()) + }, + &mut buf, + ); + + assert_eq!( + fetch_calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "fresh cache must NOT trigger a fetch", + ); + let banner = String::from_utf8(buf).unwrap(); + // Banner uses the cached value (0.1.4), not the would-be network value (0.1.5). + assert!(banner.contains("v0.1.4 is available"), "got: {banner:?}"); + }); + } + + #[test] + fn dismissed_latest_suppresses_banner() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|_| { + // Cache is fresh and reports a newer version, but the user + // dismissed exactly that version — banner must stay silent. + let now = unix_now(); + let cache = VersionCache { + last_checked_unix: Some(now), + last_checked_at: Some(iso8601(now)), + latest_version: Some("0.1.4".into()), + dismissed_versions: vec!["0.1.4".into()], + }; + write_cache(&cache).unwrap(); + + let mut buf: Vec = Vec::new(); + run_check_and_announce("0.1.3", || Ok("0.1.4".to_owned()), &mut buf); + assert!(buf.is_empty(), "dismissed version must suppress banner"); + }); + } + + // ── Opt-out paths ─────────────────────────────────────────────────── + + #[test] + fn env_opt_out_short_circuits_enabled_check() { + let _g = ENV_LOCK.lock().unwrap(); + let saved = std::env::var_os(ENV_UPDATE_CHECK); + + unsafe { std::env::set_var(ENV_UPDATE_CHECK, "false"); } + // Use a separately-redirected HOME so any config file present on + // the developer's machine can't influence the result. + with_isolated_home(|_| { + assert!(!is_enabled(), "explicit false env var must disable"); + }); + + unsafe { std::env::set_var(ENV_UPDATE_CHECK, "0"); } + with_isolated_home(|_| { + assert!(!is_enabled(), "0 must disable"); + }); + + unsafe { std::env::set_var(ENV_UPDATE_CHECK, "off"); } + with_isolated_home(|_| { + assert!(!is_enabled(), "off must disable"); + }); + + // Restore. + match saved { + Some(s) => unsafe { std::env::set_var(ENV_UPDATE_CHECK, s); }, + None => unsafe { std::env::remove_var(ENV_UPDATE_CHECK); }, + } + } + + #[test] + fn config_flag_disables_check() { + let _g = ENV_LOCK.lock().unwrap(); + let saved = std::env::var_os(ENV_UPDATE_CHECK); + unsafe { std::env::remove_var(ENV_UPDATE_CHECK); } + + with_isolated_home(|home| { + // Write a config that disables the check. + let cfg_dir = home.join(".cua-driver"); + std::fs::create_dir_all(&cfg_dir).unwrap(); + std::fs::write( + cfg_dir.join("config.json"), + r#"{"update_check_enabled": false}"#, + ).unwrap(); + + // Build version is a normal release (this crate's pkg version + // ships as "0.1.3", a stable release — see workspace Cargo.toml). + // The env var is unset, so the config flag is the only signal. + assert!(!is_enabled(), + "persisted update_check_enabled=false must disable the check"); + }); + + match saved { + Some(s) => unsafe { std::env::set_var(ENV_UPDATE_CHECK, s); }, + None => unsafe { std::env::remove_var(ENV_UPDATE_CHECK); }, + } + } + + #[test] + fn config_flag_true_or_missing_leaves_check_on() { + let _g = ENV_LOCK.lock().unwrap(); + let saved = std::env::var_os(ENV_UPDATE_CHECK); + unsafe { std::env::remove_var(ENV_UPDATE_CHECK); } + + with_isolated_home(|home| { + // Case A: no config file at all → enabled. + // CARGO_PKG_VERSION is "0.1.3" (stable), env unset, no config file. + assert!(is_enabled(), + "no config file + stable version + no env opt-out → enabled"); + + // Case B: config file present but flag = true → still enabled. + let cfg_dir = home.join(".cua-driver"); + std::fs::create_dir_all(&cfg_dir).unwrap(); + std::fs::write( + cfg_dir.join("config.json"), + r#"{"update_check_enabled": true, "other_key": 42}"#, + ).unwrap(); + assert!(is_enabled(), + "explicit update_check_enabled=true must leave check on"); + }); + + match saved { + Some(s) => unsafe { std::env::set_var(ENV_UPDATE_CHECK, s); }, + None => unsafe { std::env::remove_var(ENV_UPDATE_CHECK); }, + } + } + + // ── Banner formatting ─────────────────────────────────────────────── + + #[test] + fn banner_contains_required_lines() { + let banner = format_banner("0.1.4", "0.1.3"); + // Headline. + assert!(banner.contains("cua-driver v0.1.4 is available"), "got: {banner:?}"); + assert!(banner.contains("you have v0.1.3"), "got: {banner:?}"); + // Update instruction. + assert!(banner.contains("Update with: cua-driver update"), "got: {banner:?}"); + // Release notes URL uses the correct tag prefix. + assert!( + banner.contains( + "https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.1.4" + ), + "got: {banner:?}", + ); + } + + // ── pick_latest_release ───────────────────────────────────────────── + + #[test] + fn pick_latest_release_filters_swift_port_tags() { + // Releases JSON contains both Swift-port tags and Rust-port tags; + // only the Rust-port (`cua-driver-rs-v*`) tags must be considered. + let body = serde_json::json!([ + {"tag_name": "cua-driver-v0.9.0", "draft": false, "prerelease": false}, + {"tag_name": "cua-driver-rs-v0.1.4", "draft": false, "prerelease": false}, + {"tag_name": "cua-driver-rs-v0.1.3", "draft": false, "prerelease": false}, + {"tag_name": "cua-driver-v9.9.9", "draft": false, "prerelease": false}, + ]); + assert_eq!(pick_latest_release(&body).as_deref(), Some("0.1.4")); + } + + #[test] + fn pick_latest_release_skips_drafts_and_prereleases() { + let body = serde_json::json!([ + {"tag_name": "cua-driver-rs-v0.2.0", "draft": true, "prerelease": false}, + {"tag_name": "cua-driver-rs-v0.1.5", "draft": false, "prerelease": true}, + {"tag_name": "cua-driver-rs-v0.1.4", "draft": false, "prerelease": false}, + ]); + assert_eq!(pick_latest_release(&body).as_deref(), Some("0.1.4")); + } + + #[test] + fn pick_latest_release_returns_none_for_empty_array() { + assert_eq!(pick_latest_release(&serde_json::json!([])), None); + } + + #[test] + fn iso8601_round_trips_against_known_unix_timestamp() { + // 2023-11-14T22:13:20Z = 1_700_000_000 unix seconds. + assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z"); + } +}