diff --git a/CHANGELOG.md b/CHANGELOG.md index c41f6197..46694fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Startup update check: tty7 asks GitHub once, in the background, whether a + newer release has shipped and — if so — shows a "Download" prompt in + Settings → About that opens the Releases page. It never downloads or updates + itself; you still install by hand. Turn the check off with `check_for_updates` + in `config.json` or the "Check for updates on launch" toggle in About. A + failed or offline check is silent. - ⌘K (Ctrl+K on Windows/Linux) clears the screen and scrollback — the same "Clear" the right-click menu already offered, now on the keyboard shortcut Terminal.app, iTerm2, and Ghostty users expect. Also available from the diff --git a/Cargo.lock b/Cargo.lock index 91ababd6..e152af08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,6 +645,29 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -681,7 +704,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -950,6 +973,12 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -1016,6 +1045,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -1103,6 +1141,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "command-fds" version = "0.3.3" @@ -1512,7 +1560,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1712,7 +1760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1985,6 +2033,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2302,7 +2356,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.62.2", + "windows 0.58.0", ] [[package]] @@ -2934,6 +2988,15 @@ dependencies = [ "util", ] +[[package]] +name = "http_client_tls" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe" +dependencies = [ + "rustls", + "rustls-platform-verifier", +] + [[package]] name = "httparse" version = "1.10.1" @@ -3009,7 +3072,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -3350,6 +3413,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -3878,7 +3957,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4105,7 +4184,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5506,6 +5585,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe" +dependencies = [ + "anyhow", + "bytes", + "futures", + "gpui_util", + "http_client", + "http_client_tls", + "log", + "regex", + "serde", + "tokio", + "zed-reqwest", +] + [[package]] name = "resvg" version = "0.45.1" @@ -5682,7 +5779,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5695,7 +5792,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5715,6 +5812,8 @@ version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -5754,12 +5853,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -6317,7 +6444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6363,7 +6490,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6710,7 +6837,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7185,6 +7312,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "portable-pty", + "reqwest_client", "serde", "serde_json", "smallvec", @@ -7213,7 +7341,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7765,6 +7893,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.8", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -7965,7 +8111,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8316,6 +8462,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8352,6 +8507,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -8412,6 +8582,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8424,6 +8600,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8436,6 +8618,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8460,6 +8648,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8472,6 +8666,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8484,6 +8684,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8496,6 +8702,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index bf616438..5e15ab59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,15 @@ smallvec.workspace = true serde = { workspace = true } serde_json.workspace = true +# HTTP client for the startup update check (`core::update`): one GET to the +# GitHub releases API to see if a newer version has shipped. `reqwest_client` +# wraps Zed's `zed-reqwest` fork behind gpui's `http_client` trait (re-exported +# as `gpui::http_client`) and manages its own tokio runtime. That reqwest+rustls +# stack is *already* compiled into the tree via `gpui-component-assets`, so this +# pins no new native code — it only exposes what we're already building. Pinned +# to gpui's rev so the shared `http_client`/`zed-reqwest` versions stay aligned. +reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe" } + # Zed's fork of alacritty_terminal — same rev Zed pins. Used by the *client* # (`terminal::remote`) for the VT parser + grid (`Term`/`ansi::Processor`) that # renders the mirror. The daemon's PTY itself is driven by `portable-pty` below. diff --git a/src/core/config.rs b/src/core/config.rs index 39377115..e6b0dc6b 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -68,6 +68,11 @@ pub struct Config { /// finishes. #[serde(default, deserialize_with = "de_lenient")] pub notify_on_command_finish: NotifyMode, + /// On startup, ask GitHub whether a newer release has shipped and, if so, + /// surface a "download" prompt in Settings → About. Never downloads or + /// self-updates — it only links to the Releases page. On by default; set to + /// `false` to skip the network call entirely (offline / privacy). + pub check_for_updates: bool, // ── Appearance ────────────────────────────────────────────────────────── /// The shape drawn for the terminal cursor. @@ -234,6 +239,9 @@ impl Default for Config { scrollback_limit: 10_000, new_tab_position: NewTabPosition::AfterCurrent, notify_on_command_finish: NotifyMode::Unfocused, + // Opt-out, not opt-in: a stale terminal that never tells you it's + // outdated is the status quo we're fixing. One cheap GET at startup. + check_for_updates: true, cursor_style: CursorStyle::Block, // Input/mouse defaults preserve today's behavior: GPUI already hides // the pointer while typing (its `CursorHideMode` default), so this diff --git a/src/core/mod.rs b/src/core/mod.rs index 61fcf7d3..8fd243ac 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -12,3 +12,4 @@ pub mod config; pub mod osc; pub mod session; pub mod threads; +pub mod update; diff --git a/src/core/update.rs b/src/core/update.rs new file mode 100644 index 00000000..27f35538 --- /dev/null +++ b/src/core/update.rs @@ -0,0 +1,199 @@ +//! Notify-only update check. +//! +//! On GUI startup (unless `config.check_for_updates` is off) we make one GET to +//! the GitHub releases API, compare the latest published version against the +//! running binary, and — if it's newer — stash an [`UpdateStatus`] global that +//! Settings → About reads to show a "download" prompt linking to the Releases +//! page. That's the whole feature: we never download, replace, or restart +//! anything. The user updates by hand (drag the new `.app`, unzip, …), exactly +//! as the README's Install section describes. +//! +//! Everything here fails soft: no network, a rate-limit, a private/renamed repo, +//! an unparseable tag — all collapse to "no prompt", logged at `debug` and never +//! surfaced. A terminal must open the same whether or not GitHub is reachable. + +use anyhow::{Context as _, Result}; +use gpui::http_client::{AsyncBody, HttpClient as _, HttpRequestExt as _, RedirectPolicy}; +use gpui::{App, Global, http_client}; +use reqwest_client::ReqwestClient; +use smol::io::AsyncReadExt as _; + +use crate::core::config::Config; + +/// `owner/repo` the release check queries — matches the repository the binary is +/// published from (see `Cargo.toml`'s `repository`). +const REPO: &str = "l0ng-ai/tty7"; + +/// Where the "Download" prompt points. GitHub's `/releases/latest` alias always +/// resolves to the newest published (non-prerelease) build, so it never goes +/// stale as versions roll — no need to embed a specific tag. +pub const RELEASES_URL: &str = "https://github.com/l0ng-ai/tty7/releases/latest"; + +/// A newer release than the one currently running. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AvailableUpdate { + /// The newer version, normalized without a leading `v` (e.g. `"0.3.1"`), for + /// display in the About panel. + pub version: String, +} + +/// The result of the startup update check, stored as a GPUI global so the +/// Settings view can read it. Absent until the check completes; `available` is +/// `None` when we're already current (or the check failed / was skipped). +#[derive(Clone, Debug, Default)] +pub struct UpdateStatus { + pub available: Option, +} + +impl Global for UpdateStatus {} + +/// Kick off the background update check. Returns immediately; the network work +/// runs on a detached task and, if a newer version exists, writes the +/// [`UpdateStatus`] global and repaints so an already-open About panel updates. +/// +/// Honors `config.check_for_updates`: when off, we make no network call at all. +pub fn spawn_check(cx: &mut App) { + if !cx.global::().check_for_updates { + return; + } + + cx.spawn(async move |cx| { + let current = env!("CARGO_PKG_VERSION"); + let latest = match fetch_latest_version().await { + Ok(v) => v, + Err(e) => { + // `{e:#}` includes the anyhow context chain; kept at debug so a + // routine offline start doesn't spam the log. + log::debug!("update check skipped: {e:#}"); + return; + } + }; + + if !is_update_available(&latest, current) { + log::debug!("update check: up to date (latest {latest}, running {current})"); + return; + } + + let version = latest.trim_start_matches('v').to_string(); + log::info!("update available: {version} (running {current})"); + let _ = cx.update(|cx| { + cx.set_global(UpdateStatus { + available: Some(AvailableUpdate { version }), + }); + // Repaint so a Settings → About that's already open shows the prompt + // now rather than only on the next interaction. + cx.refresh_windows(); + }); + }) + .detach(); +} + +/// The `tag_name` field of GitHub's release payload — the only piece we read. +#[derive(serde::Deserialize)] +struct LatestRelease { + tag_name: String, +} + +/// GET the repo's latest release and return its raw tag (e.g. `"v0.3.1"`). +async fn fetch_latest_version() -> Result { + // GitHub rejects requests without a User-Agent; identify ourselves. The + // reqwest+rustls stack this rides on is already compiled into the app via + // `gpui-component-assets`, so constructing a client here is cheap. + let client = ReqwestClient::user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION"))) + .context("building HTTP client")?; + + let url = format!("https://api.github.com/repos/{REPO}/releases/latest"); + let request = http_client::Request::get(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .follow_redirects(RedirectPolicy::FollowAll) + .body(AsyncBody::default()) + .context("building request")?; + + let mut response = client + .send(request) + .await + .context("requesting latest release")?; + + if !response.status().is_success() { + anyhow::bail!("GitHub API returned HTTP {}", response.status().as_u16()); + } + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("reading response body")?; + + let release: LatestRelease = + serde_json::from_slice(&body).context("parsing release JSON")?; + Ok(release.tag_name) +} + +/// Parse a version string into a `(major, minor, patch)` triple, tolerating a +/// leading `v` and ignoring any pre-release / build suffix (`-rc.1`, `+build`). +/// Missing minor/patch components read as `0`. Returns `None` if the numeric +/// core doesn't parse — the caller treats that as "don't prompt". +fn parse_version(s: &str) -> Option<(u64, u64, u64)> { + let core = s.trim().trim_start_matches('v'); + // A pre-release/build tag (`0.4.0-rc.1`, `0.4.0+ci`) compares by its release + // core here; we don't ship pre-releases, so finer ordering isn't worth it. + 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)) +} + +/// Whether `latest` names a strictly newer version than `current`. If either +/// side fails to parse we return `false`: an unrecognizable tag should never +/// nag the user to "update" to something we can't even order. +fn is_update_available(latest: &str, current: &str) -> bool { + match (parse_version(latest), parse_version(current)) { + (Some(latest), Some(current)) => latest > current, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_versions_with_and_without_prefix() { + assert_eq!(parse_version("v0.3.1"), Some((0, 3, 1))); + assert_eq!(parse_version("0.3.1"), Some((0, 3, 1))); + assert_eq!(parse_version(" 1.2.0 "), Some((1, 2, 0))); + // Missing components default to zero. + assert_eq!(parse_version("v2"), Some((2, 0, 0))); + assert_eq!(parse_version("v2.5"), Some((2, 5, 0))); + // Pre-release / build metadata is ignored down to the release core. + assert_eq!(parse_version("v0.4.0-rc.1"), Some((0, 4, 0))); + assert_eq!(parse_version("0.4.0+ci.7"), Some((0, 4, 0))); + // Garbage yields None. + assert_eq!(parse_version("nightly"), None); + assert_eq!(parse_version(""), None); + } + + #[test] + fn detects_newer_versions() { + assert!(is_update_available("v0.3.1", "0.3.0")); + assert!(is_update_available("v1.0.0", "0.9.9")); + assert!(is_update_available("0.4.0", "0.3.99")); + } + + #[test] + fn ignores_same_or_older_versions() { + assert!(!is_update_available("v0.3.0", "0.3.0")); + assert!(!is_update_available("v0.2.9", "0.3.0")); + assert!(!is_update_available("0.3.0", "0.3.1")); + } + + #[test] + fn unparseable_tag_never_prompts() { + assert!(!is_update_available("garbage", "0.3.0")); + assert!(!is_update_available("v0.3.1", "garbage")); + } +} diff --git a/src/main.rs b/src/main.rs index d153bd3f..fb0d0679 100644 --- a/src/main.rs +++ b/src/main.rs @@ -261,6 +261,10 @@ fn main() { crate::ui::theme::apply_cursor_hide_mode(cx); // Start watching `config.json` so edits hot-reload theme/colors live. spawn_config_watcher(cx); + // Ask GitHub (once, in the background) whether a newer release exists; + // if so, Settings → About surfaces a download prompt. Fails soft and + // is a no-op when `check_for_updates` is disabled. + crate::core::update::spawn_check(cx); keymap::init(cx); cx.spawn(async move |cx| { diff --git a/src/ui/app.rs b/src/ui/app.rs index c7eb58e0..d296de38 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -559,6 +559,13 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.link_url = on); } + /// Toggle the startup update check (Settings → About). Takes effect on the + /// next launch — this only persists the preference; it doesn't run or cancel + /// an in-flight check. + pub(crate) fn set_check_for_updates(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.check_for_updates = on); + } + pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.cursor_blink = on); // Turning blink off mid-cycle could leave the cursor in its hidden phase; @@ -1634,6 +1641,25 @@ impl Tty7App { log::warn!("failed to open {}: {e}", path.display()); } } + + /// Open the GitHub Releases page in the browser — the "Download" action of + /// the Settings → About update prompt. Deliberately hand-off, not + /// self-update: the newest build is one click away on the web. + pub(crate) fn open_releases_page(&self) { + let opener = if cfg!(target_os = "macos") { + "open" + } else if cfg!(windows) { + "explorer" + } else { + "xdg-open" + }; + if let Err(e) = std::process::Command::new(opener) + .arg(crate::core::update::RELEASES_URL) + .spawn() + { + log::warn!("failed to open releases page: {e}"); + } + } } impl Render for Tty7App { diff --git a/src/ui/settings.rs b/src/ui/settings.rs index ef09c7e4..8b5f2a4f 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1265,6 +1265,13 @@ impl Tty7App { let theme = cx.theme(); let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground); + // Startup update check (see `core::update`): a newer release, if one was + // found, plus the toggle that controls whether we look at all. + let update = cx + .try_global::() + .and_then(|s| s.available.clone()); + let check_for_updates = cx.global::().check_for_updates; + let logo = Arc::new(Image::from_bytes( ImageFormat::Png, include_bytes!("../../assets/logo@256.png").to_vec(), @@ -1313,6 +1320,65 @@ impl Tty7App { .child("GPU-rendered · daemon-backed · shell-aware"), ), ) + // Updates: the startup check drops a newer version here if it found + // one. We never self-update — "Download" just opens the Releases + // page; the toggle turns the check off (see `core::update`). + .child( + v_flex() + .mt_6() + .gap_2() + .child(self.section_rule(cx)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Updates"), + ) + .when_some(update, |this, upd| { + this.child( + h_flex() + .gap_3() + .items_center() + .child(div().text_sm().text_color(foreground).child( + format!("Version {} is available.", upd.version), + )) + .child( + // Match the sibling "Restart Background + // Service…" button (default style, not the + // dark `.primary()` fill) so About reads as + // one panel. + Button::new("download-update") + .label("Download") + .small() + .on_click(cx.listener(|this, _, _w, _cx| { + this.open_releases_page() + })), + ), + ) + }) + .child(div().text_sm().text_color(muted_fg).child( + "Check GitHub for a newer release on launch and show a download link here. tty7 never updates itself; the button opens the Releases page.", + )) + .child( + h_flex() + .gap_2() + .items_center() + .child( + Switch::new("check-updates") + .checked(check_for_updates) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + this.set_check_for_updates(*on, cx) + })), + ) + .child( + div() + .text_sm() + .text_color(foreground) + .child("Check for updates on launch"), + ), + ), + ) // Manage that daemon. A fresh process is the only way to pick up a // macOS permission granted after it started (e.g. Full Disk Access), // to recover if it wedges, or to start clean — quitting/reopening the