From 8726f5a6dc4ced57ab84900516edaa3ea4090bc6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:09:12 +0800 Subject: [PATCH] feat: one-time "update available" modal, once per version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on the notify-only check: when a newer release is found, pop a native modal dialog ("Update available — Download / Later") in addition to the passive Settings -> About prompt. Download opens the Releases page; tty7 still never self-updates. - Pops exactly once per version: the shown version is recorded in a small update.json (mirroring session.json's load/save) and re-checked on the next launch, so we never nag twice for the same release. - wait_for_window handles the startup race where the network check finishes before the window opens — poll briefly, then host the modal. - open_releases_page moves to core::update as a shared free fn; the Settings -> About button now delegates to it (one opener, two callers). - Fails soft as before: no window / disabled check / offline all skip the modal silently; the About prompt still appears whenever we're outdated. --- CHANGELOG.md | 12 ++-- src/core/update.rs | 174 +++++++++++++++++++++++++++++++++++++++++++-- src/ui/app.rs | 17 +---- 3 files changed, 177 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46694fe4..8c6562e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ 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. + newer release has shipped. If so, it pops a one-time "Update available" dialog + (once per version — remembered in `update.json`, so it never nags twice for + the same release) and keeps a persistent "Download" prompt in Settings → + About. Both open the Releases page; tty7 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/src/core/update.rs b/src/core/update.rs index 27f35538..633302fe 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -14,9 +14,10 @@ use anyhow::{Context as _, Result}; use gpui::http_client::{AsyncBody, HttpClient as _, HttpRequestExt as _, RedirectPolicy}; -use gpui::{App, Global, http_client}; +use gpui::{AnyWindowHandle, App, AsyncApp, Global, PromptLevel, Window, http_client}; use reqwest_client::ReqwestClient; use smol::io::AsyncReadExt as _; +use std::time::Duration; use crate::core::config::Config; @@ -48,8 +49,12 @@ pub struct UpdateStatus { 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. +/// runs on a detached task and, if a newer version exists: +/// 1. writes the [`UpdateStatus`] global (the passive Settings → About prompt, +/// shown on every launch while outdated), and +/// 2. pops a one-time modal dialog for that version — but only the first +/// launch it's seen; the version is remembered in `update.json` so we never +/// nag twice for the same release. /// /// Honors `config.check_for_updates`: when off, we make no network call at all. pub fn spawn_check(cx: &mut App) { @@ -76,18 +81,149 @@ pub fn spawn_check(cx: &mut App) { let version = latest.trim_start_matches('v').to_string(); log::info!("update available: {version} (running {current})"); - let _ = cx.update(|cx| { + + // Record it for the passive Settings → About prompt and repaint so an + // already-open About picks it up now rather than on the next interaction. + cx.update(|cx| { cx.set_global(UpdateStatus { - available: Some(AvailableUpdate { version }), + available: Some(AvailableUpdate { + version: version.clone(), + }), }); - // Repaint so a Settings → About that's already open shows the prompt - // now rather than only on the next interaction. cx.refresh_windows(); }); + + // Active modal: pop exactly once per version. If a previous launch + // already showed it for this version, stop here — About still carries + // the passive prompt. + if UpdateState::load().last_prompted.as_deref() == Some(version.as_str()) { + return; + } + + // The check can outrace the window-open task at startup; wait briefly + // for a window to host the modal before giving up. + let Some(window) = wait_for_window(cx).await else { + return; + }; + let shown = cx.update(|cx| { + window + .update(cx, |_root, window, cx| prompt_update(&version, window, cx)) + .is_ok() + }); + + // Persist only after the modal actually went up, so a version we never + // managed to show still gets its one prompt on a later launch. + if shown { + UpdateState { + last_prompted: Some(version), + } + .save(); + } }) .detach(); } +/// Poll (briefly) for the app's main window. Returns `None` if none appears +/// within the window — treated as "no host for the modal", so we simply skip it. +async fn wait_for_window(cx: &mut AsyncApp) -> Option { + // ~5s of 100ms ticks. The network round-trip almost always finishes after + // the window is already up, so this usually returns on the first poll. + for _ in 0..50 { + if let Some(handle) = cx.update(|cx| cx.windows().first().copied()) { + return Some(handle); + } + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + } + None +} + +/// Show the one-time "update available" modal, and open the Releases page if the +/// user picks Download. Mirrors the window-close confirmation's prompt style. +fn prompt_update(version: &str, window: &mut Window, cx: &mut App) { + let detail = format!( + "tty7 {version} is available — you're on {}. Open the download page to get it.", + env!("CARGO_PKG_VERSION") + ); + // Index 1 == "Download"; index 0 (Later) and a dismissed prompt do nothing. + let answer = window.prompt( + PromptLevel::Info, + "Update available", + Some(&detail), + &["Later", "Download"], + cx, + ); + cx.spawn(async move |_cx| { + if let Ok(1) = answer.await { + open_releases_page(); + } + }) + .detach(); +} + +/// Open the GitHub Releases page with the OS default handler. Shared by the +/// modal's Download button and the Settings → About Download button. +pub fn open_releases_page() { + 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(RELEASES_URL).spawn() { + log::warn!("failed to open releases page: {e}"); + } +} + +/// Tiny persisted state for the update checker, stored at `update.json` in the +/// config dir (alongside `config.json` / `session.json`). Currently just the +/// last version we popped the modal for, so we never nag twice for one release. +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +struct UpdateState { + #[serde(default)] + last_prompted: Option, +} + +impl UpdateState { + fn path() -> Option { + crate::core::config::config_path("update.json") + } + + /// Load persisted state; a missing / unreadable / malformed file all yield + /// the default (never prompted), so at worst we prompt once more. + fn load() -> Self { + let Some(path) = Self::path() else { + return Self::default(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return Self::default(); + }; + serde_json::from_str(&text).unwrap_or_else(|e| { + log::warn!("failed to parse {}: {e}; ignoring", path.display()); + Self::default() + }) + } + + /// Persist state; IO / serialization errors are logged and swallowed. + fn save(&self) { + let Some(path) = Self::path() else { + return; + }; + let json = match serde_json::to_string_pretty(self) { + Ok(j) => j, + Err(e) => { + log::warn!("failed to serialize update state: {e}"); + return; + } + }; + if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) { + log::warn!("failed to write {}: {e}", path.display()); + } + } +} + /// The `tag_name` field of GitHub's release payload — the only piece we read. #[derive(serde::Deserialize)] struct LatestRelease { @@ -196,4 +332,28 @@ mod tests { assert!(!is_update_available("garbage", "0.3.0")); assert!(!is_update_available("v0.3.1", "garbage")); } + + #[test] + fn update_state_round_trips_and_defaults() { + // Pin a throwaway config dir (first-call-wins; same scheme the session + // tests use, so the whole test binary shares one temp dir). + let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + crate::core::config::set_config_dir(dir); + let path = UpdateState::path().expect("config dir pinned"); + + // Missing file → default (never prompted), so we'd prompt. + let _ = std::fs::remove_file(&path); + assert_eq!(UpdateState::load().last_prompted, None); + + // A recorded version round-trips, so a second launch skips the modal. + UpdateState { + last_prompted: Some("0.4.0".into()), + } + .save(); + assert_eq!(UpdateState::load().last_prompted.as_deref(), Some("0.4.0")); + + // Don't leak state into other runs sharing the pinned dir. + let _ = std::fs::remove_file(&path); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index d296de38..2f23f43c 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1644,21 +1644,10 @@ impl Tty7App { /// 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. + /// self-update: the newest build is one click away on the web. Delegates to + /// `core::update` so the settings button and the update modal share it. 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}"); - } + crate::core::update::open_releases_page(); } }