mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
feat: one-time "update available" modal, once per version
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.
This commit is contained in:
+7
-5
@@ -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
|
||||
|
||||
+167
-7
@@ -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<AnyWindowHandle> {
|
||||
// ~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<String>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-14
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user