From a6754b28bc088e084d1eea697cea94181c0447db Mon Sep 17 00:00:00 2001 From: ayamir Date: Sun, 2 Aug 2026 22:25:10 +0800 Subject: [PATCH] feat(update): install verified macOS releases in app --- .github/scripts/bundle-macos.sh | 35 +- .github/workflows/ci.yml | 5 + .github/workflows/release.yml | 10 +- CHANGELOG.md | 11 + Cargo.lock | 1 + Cargo.toml | 10 + .../tty7-core/src/daemon/install/checksums.rs | 2 +- src/bin/tty7-updater.rs | 410 +++++++++++++ src/core/update.rs | 567 +++++++++++++++++- src/ui/app.rs | 4 - src/ui/settings.rs | 88 ++- 11 files changed, 1095 insertions(+), 48 deletions(-) create mode 100644 src/bin/tty7-updater.rs diff --git a/.github/scripts/bundle-macos.sh b/.github/scripts/bundle-macos.sh index 650919a8..e9e648cf 100755 --- a/.github/scripts/bundle-macos.sh +++ b/.github/scripts/bundle-macos.sh @@ -1,7 +1,8 @@ #!/bin/bash # Usage: bundle-macos.sh -# Package the release binary into dist/tty7.app and wrap it in a -# drag-to-Applications DMG: dist/tty7--macos-.dmg. +# Package the release binary into dist/tty7.app, then publish both: +# dist/tty7--macos-.zip (in-app updater) +# dist/tty7--macos-.dmg (drag-to-Applications install) # # Signing posture is chosen from the environment: # * Developer ID secrets present (APPLE_SIGNING_IDENTITY + APPLE_CERTIFICATE) @@ -21,6 +22,10 @@ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then echo "bundle-macos: could not read a version from Cargo.toml (got '$VERSION')" >&2 exit 1 fi +PACKAGE_UPDATE_ZIP="${TTY7_PACKAGE_UPDATE_ZIP:-1}" +if [[ "$VERSION" == *-nightly.* ]]; then + PACKAGE_UPDATE_ZIP=0 +fi APP="dist/tty7.app" rm -rf dist @@ -34,6 +39,15 @@ chmod +x "$APP/Contents/MacOS/tty7-app" # relative to its own executable. cp "target/${TARGET}/release/tty7" "$APP/Contents/MacOS/tty7" chmod +x "$APP/Contents/MacOS/tty7" +if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then + # A focused out-of-process updater can replace the bundle after the GUI + # exits, then relaunch or roll back without teaching the GUI to mutate + # itself. Stable macOS builds carry it beside the app/CLI so its signature + # is covered by the outer bundle; Nightly remains byte-for-byte on its old + # packaging path for the first updater release. + cp "target/${TARGET}/release/tty7-updater" "$APP/Contents/MacOS/tty7-updater" + chmod +x "$APP/Contents/MacOS/tty7-updater" +fi cp assets/tty7.icns "$APP/Contents/Resources/tty7.icns" # Completion signatures are loaded at runtime (not embedded), resolved relative # to the executable as ../Resources/completions — see terminal::signature. @@ -112,6 +126,10 @@ ENT # them. codesign --force --options runtime --timestamp \ --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7" + if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then + codesign --force --options runtime --timestamp \ + --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7-updater" + fi codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7-app" codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ @@ -137,6 +155,16 @@ else codesign --force --deep --sign - "$APP" fi +# The stable-channel in-app updater needs the signed, notarized .app itself +# rather than a disk image that requires Finder interaction. Nightly versions +# skip this path above: their rolling release remains unchanged until the stable +# updater has shipped and been exercised. +ZIP="" +if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then + ZIP="dist/tty7-${VERSION}-macos-${ARCH}.zip" + ditto -c -k --keepParent "$APP" "$ZIP" +fi + # Package the (now stapled) bundle as a drag-to-Applications DMG. DMG="dist/tty7-${VERSION}-macos-${ARCH}.dmg" STAGE="dist/dmg-stage" @@ -149,4 +177,7 @@ rm -rf "$STAGE" if [[ -n "$SIGN_ID" && -n "${APPLE_CERTIFICATE:-}" ]]; then codesign --force --timestamp --sign "$SIGN_ID" "$DMG" fi +if [[ -n "$ZIP" ]]; then + echo "✅ $ZIP" +fi echo "✅ $DMG" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33d7f5c6..2123db7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,11 @@ jobs: timeout-minutes: 20 run: cargo test --locked --target ${{ matrix.target }} + - name: Test macOS updater + if: runner.os == 'macOS' + timeout-minutes: 10 + run: cargo test --locked --features updater --bin tty7-updater --target ${{ matrix.target }} + # There is deliberately no post-mortem step here, and that is worth # recording, because an earlier version of this file had one: on failure it # dumped the process table to find the surviving test binary. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5a3c182..a2ed9ed7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,13 @@ jobs: # a plain checkout of the tagged commit. - name: Build working-directory: tty7 - run: cargo build --release --locked --target ${{ matrix.target }} + shell: bash + run: | + cargo build --release --locked --target "${{ matrix.target }}" + if [[ "${{ matrix.os }}" == "macos" ]]; then + cargo build --release --locked --features updater \ + --bin tty7-updater --target "${{ matrix.target }}" + fi # ---- Packaging: one step per OS ---------------------------------------- # macOS gets a signed + notarized drag-to-Applications DMG. Windows gets @@ -254,7 +260,7 @@ jobs: # created as a **draft** and left that way: a draft is invisible to both # /releases/latest and the releases page, so nothing can prompt a user to # download a version whose asset set is incomplete or whose notes are still - # empty. Publishing is the release skill's job — it verifies the six assets and + # empty. Publishing is the release skill's job — it verifies the platform assets and # writes the body first, then flips the draft. See .claude/skills/release/SKILL.md. draft-release: needs: [build, server-musl] diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d83b6a..2828a55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Update tty7 without leaving the app** — the launch check and + **Settings → About → Check Now** now offer **Update and Relaunch** for + packaged macOS installs instead of sending the user to GitHub Releases. A + dedicated `tty7-updater` helper verifies the release checksum, bundle + version, and code-signing requirement before replacing the app, and restores + the previous bundle if relaunch fails. The new GUI reuses a running local + server when its wire protocol is compatible, preserving shells; an + incompatible server keeps its shells too and raises the existing explicit + keep-or-restart prompt. Other platforms and unsupported layouts retain the + release-page fallback, and Nightly remains unchanged for the first version. + - **`tty7 wait` — the primitive an agent team was missing** — block until a pane's agent needs input, finishes its turn, or dies: `tty7 wait %3 --until waiting,done --changed --timeout 600`. The daemon diff --git a/Cargo.lock b/Cargo.lock index 77199582..8dc7ee9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9682,6 +9682,7 @@ dependencies = [ "serde_yaml", "smallvec", "smol", + "tempfile", "tray-icon", "tty7-core", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 3301faef..2cdeb5e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,11 @@ default-run = "tty7-app" name = "tty7-app" path = "src/main.rs" +[[bin]] +name = "tty7-updater" +path = "src/bin/tty7-updater.rs" +required-features = ["updater"] + [dependencies] # The framework-free half of tty7: wire protocol, session daemon, PTY, the # native SSH engine, and the domain model the headless `tty7-server` shares with @@ -47,6 +52,7 @@ smol.workspace = true smallvec.workspace = true serde = { workspace = true } serde_json.workspace = true +tempfile = "3" # SSH profile ids in the connection manager UI (`ui::ssh_connect`, # `ui::settings`, the command palette). The profiles themselves — and the @@ -187,6 +193,10 @@ gpui = { workspace = true, features = ["test-support"] } [lints] workspace = true +[features] +default = [] +updater = [] + # ---- Standalone workspace mirroring gpui-component's pins so the git/source # ---- caches are shared and versions stay aligned. ---- [workspace] diff --git a/crates/tty7-core/src/daemon/install/checksums.rs b/crates/tty7-core/src/daemon/install/checksums.rs index 914febad..91f702f3 100644 --- a/crates/tty7-core/src/daemon/install/checksums.rs +++ b/crates/tty7-core/src/daemon/install/checksums.rs @@ -39,7 +39,7 @@ impl fmt::Display for ChecksumError { } => write!( f, "{asset} failed sha256 verification: release says {expected}, downloaded bytes are \ - {actual}. Install aborted; nothing was written to the remote machine" + {actual}. Install aborted; the downloaded asset was not installed" ), } } diff --git a/src/bin/tty7-updater.rs b/src/bin/tty7-updater.rs new file mode 100644 index 00000000..5f9d1e2d --- /dev/null +++ b/src/bin/tty7-updater.rs @@ -0,0 +1,410 @@ +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +#[cfg(target_os = "macos")] +mod macos { + use std::fs::{self, OpenOptions}; + use std::io::Write as _; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Stdio}; + use std::thread; + use std::time::Duration; + + const PARENT_POLL: Duration = Duration::from_millis(100); + const LAUNCH_GRACE: Duration = Duration::from_secs(1); + + pub fn run() -> Result<(), String> { + let mut args = std::env::args_os().skip(1); + let command = args + .next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage)?; + match command.as_str() { + "verify" => { + let current = next_path(&mut args)?; + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let stage = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + reject_extra(args)?; + verify_archive(&archive, &checksums, &asset_name)?; + let replacement = extract_archive(&archive, &stage)?; + verify_update(¤t, &replacement, &expected_version) + } + "install" => { + let parent_pid = next_string(&mut args)? + .parse::() + .map_err(|_| "parent pid is not an unsigned integer".to_string())?; + let current = next_path(&mut args)?; + let stage = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + let log = next_path(&mut args)?; + reject_extra(args)?; + install(InstallPlan { + parent_pid, + current, + stage, + expected_version, + log, + }) + } + _ => Err(usage()), + } + } + + fn usage() -> String { + "usage: tty7-updater verify \ + \n\ + or: tty7-updater install " + .to_string() + } + + fn next_path(args: &mut impl Iterator) -> Result { + args.next().map(PathBuf::from).ok_or_else(usage) + } + + fn next_string(args: &mut impl Iterator) -> Result { + args.next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage) + } + + fn reject_extra(mut args: impl Iterator) -> Result<(), String> { + if args.next().is_some() { + Err(usage()) + } else { + Ok(()) + } + } + + struct InstallPlan { + parent_pid: u32, + current: PathBuf, + stage: PathBuf, + expected_version: String, + log: PathBuf, + } + + fn install(plan: InstallPlan) -> Result<(), String> { + log_line(&plan.log, "re-verifying staged tty7 update"); + let replacement = plan.stage.join("unpacked/tty7.app"); + wait_for_exit(plan.parent_pid); + if let Err(error) = verify_update(&plan.current, &replacement, &plan.expected_version) { + log_line(&plan.log, &error); + let _ = fs::remove_dir_all(&plan.stage); + let _ = launch_app(&plan.current); + return Err(error); + } + log_line(&plan.log, &format!("replacing {}", plan.current.display())); + replace_and_relaunch(&plan.current, &replacement, &plan.stage, launch_app) + .inspect_err(|error| log_line(&plan.log, error)) + } + + fn verify_archive(archive: &Path, checksums: &Path, asset_name: &str) -> Result<(), String> { + let bytes = + fs::read(archive).map_err(|error| format!("reading {}: {error}", archive.display()))?; + let manifest = fs::read_to_string(checksums) + .map_err(|error| format!("reading {}: {error}", checksums.display()))?; + tty7_core::daemon::install::checksums::verify(&manifest, asset_name, &bytes) + .map_err(|error| error.to_string()) + } + + fn extract_archive(archive: &Path, stage: &Path) -> Result { + let unpacked = stage.join("unpacked"); + fs::create_dir(&unpacked) + .map_err(|error| format!("creating {}: {error}", unpacked.display()))?; + run_checked( + Command::new("/usr/bin/ditto") + .args(["-x", "-k"]) + .arg(archive) + .arg(&unpacked), + "extracting the update archive", + )?; + Ok(unpacked.join("tty7.app")) + } + + fn verify_update( + current: &Path, + replacement: &Path, + expected_version: &str, + ) -> Result<(), String> { + let executable = replacement.join("Contents/MacOS/tty7-app"); + let updater = replacement.join("Contents/MacOS/tty7-updater"); + if !replacement.is_dir() || !executable.is_file() || !updater.is_file() { + return Err( + "the staged bundle is missing tty7-app or tty7-updater under Contents/MacOS" + .to_string(), + ); + } + let actual_version = bundle_version(replacement)?; + if actual_version != expected_version { + return Err(format!( + "the staged app reports version {actual_version}, expected {expected_version}" + )); + } + run_checked( + Command::new("/usr/bin/codesign") + .args(["--verify", "--deep", "--strict"]) + .arg(replacement), + "verifying the staged app's code signature", + )?; + let current_requirement = signing_requirement(current)?; + let replacement_requirement = signing_requirement(replacement)?; + if current_requirement != replacement_requirement { + return Err(format!( + "the staged app has a different designated requirement: current \ + {current_requirement:?}, staged {replacement_requirement:?}" + )); + } + Ok(()) + } + + fn bundle_version(app: &Path) -> Result { + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", "Print :CFBundleShortVersionString"]) + .arg(app.join("Contents/Info.plist")) + .output() + .map_err(|error| format!("reading the staged app version: {error}"))?; + if !output.status.success() { + return Err(format!( + "reading the staged app version: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + fn signing_requirement(app: &Path) -> Result { + let output = Command::new("/usr/bin/codesign") + .args(["-d", "-r-"]) + .arg(app) + .output() + .map_err(|error| { + format!( + "reading the code-signing requirement for {}: {error}", + app.display() + ) + })?; + if !output.status.success() { + return Err(format!( + "reading the code-signing requirement for {}: {}", + app.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8_lossy(&output.stderr) + .lines() + .find_map(|line| line.strip_prefix("designated => ").map(str::to_string)) + .ok_or_else(|| "codesign did not report a designated requirement".to_string()) + } + + fn replace_and_relaunch( + current: &Path, + replacement: &Path, + stage: &Path, + launch: impl Fn(&Path) -> Result<(), String>, + ) -> Result<(), String> { + let name = current + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("tty7.app"); + let backup = current.with_file_name(format!(".{name}.tty7-update-backup")); + remove_path(&backup)?; + fs::rename(current, &backup) + .map_err(|error| format!("moving the current app aside: {error}"))?; + + if let Err(error) = fs::rename(replacement, current) { + let _ = fs::rename(&backup, current); + let _ = fs::remove_dir_all(stage); + return Err(format!("putting the staged app in place: {error}")); + } + + match launch(current) { + Ok(()) => { + let _ = remove_path(&backup); + let _ = fs::remove_dir_all(stage); + Ok(()) + } + Err(error) => { + let _ = remove_path(current); + fs::rename(&backup, current) + .map_err(|restore| format!("{error}; restoring the previous app: {restore}"))?; + let _ = fs::remove_dir_all(stage); + let _ = launch(current); + Err(error) + } + } + } + + fn launch_app(app: &Path) -> Result<(), String> { + let executable = app.join("Contents/MacOS/tty7-app"); + let mut child = Command::new(&executable) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("launching {}: {error}", executable.display()))?; + healthy_after_grace(&mut child) + } + + fn healthy_after_grace(child: &mut Child) -> Result<(), String> { + thread::sleep(LAUNCH_GRACE); + match child + .try_wait() + .map_err(|error| format!("checking the relaunched app: {error}"))? + { + None => Ok(()), + Some(status) => Err(format!( + "the relaunched app exited immediately with {status}" + )), + } + } + + fn wait_for_exit(pid: u32) { + while process_alive(pid) { + thread::sleep(PARENT_POLL); + } + } + + fn process_alive(pid: u32) -> bool { + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } + } + + fn remove_path(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } + .map_err(|error| format!("removing {}: {error}", path.display())) + } + + fn run_checked(command: &mut Command, context: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|error| format!("{context}: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "{context}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } + } + + fn log_line(path: &Path, message: &str) { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{message}"); + } + } + + #[cfg(test)] + mod tests { + use super::*; + + fn bundle(path: &Path, marker: &str) { + fs::create_dir_all(path.join("Contents/MacOS")).unwrap(); + fs::write(path.join("marker"), marker).unwrap(); + } + + #[test] + fn successful_launch_commits_the_replacement() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.app"); + let stage = root.path().join("stage"); + let replacement = stage.join("tty7.app"); + bundle(¤t, "old"); + bundle(&replacement, "new"); + + replace_and_relaunch(¤t, &replacement, &stage, |_| Ok(())).unwrap(); + + assert_eq!(fs::read_to_string(current.join("marker")).unwrap(), "new"); + assert!(!stage.exists()); + assert!(!root.path().join(".tty7.app.tty7-update-backup").exists()); + } + + #[test] + fn failed_launch_restores_and_relaunches_the_previous_app() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.app"); + let stage = root.path().join("stage"); + let replacement = stage.join("tty7.app"); + bundle(¤t, "old"); + bundle(&replacement, "new"); + let launches = std::cell::Cell::new(0); + + let error = replace_and_relaunch(¤t, &replacement, &stage, |_| { + launches.set(launches.get() + 1); + if launches.get() == 1 { + Err("new app failed".to_string()) + } else { + Ok(()) + } + }) + .unwrap_err(); + + assert_eq!(error, "new app failed"); + assert_eq!(launches.get(), 2); + assert_eq!(fs::read_to_string(current.join("marker")).unwrap(), "old"); + assert!(!stage.exists()); + } + + #[test] + fn verify_rejects_a_bundle_without_the_helper() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("current.app"); + let replacement = root.path().join("replacement.app"); + bundle(¤t, "old"); + fs::create_dir_all(replacement.join("Contents/MacOS")).unwrap(); + fs::write(replacement.join("Contents/MacOS/tty7-app"), b"app").unwrap(); + + let error = verify_update(¤t, &replacement, "1.0.0").unwrap_err(); + assert!( + error.contains("missing tty7-app or tty7-updater"), + "{error}" + ); + } + + #[test] + fn archive_verification_rejects_bytes_that_do_not_match_the_manifest() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("tty7.zip"); + let manifest = root.path().join("checksums.txt"); + fs::write(&archive, b"downloaded bytes").unwrap(); + fs::write( + &manifest, + format!( + "{} tty7.zip\n", + tty7_core::daemon::install::checksums::hex( + &tty7_core::daemon::install::checksums::sha256(b"published bytes") + ) + ), + ) + .unwrap(); + + let error = verify_archive(&archive, &manifest, "tty7.zip").unwrap_err(); + assert!(error.contains("failed sha256 verification"), "{error}"); + } + } +} + +#[cfg(target_os = "macos")] +fn main() { + if let Err(error) = macos::run() { + eprintln!("tty7-updater: {error}"); + std::process::exit(1); + } +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("tty7-updater is only available on macOS"); + std::process::exit(1); +} diff --git a/src/core/update.rs b/src/core/update.rs index 52dd2c30..2ee09982 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -4,7 +4,10 @@ use gpui::{AnyWindowHandle, App, AsyncApp, Global, PromptLevel, Window, http_cli use reqwest_client::ReqwestClient; use smol::future::FutureExt as _; use smol::io::AsyncReadExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::time::Duration; +use tty7_core::daemon::install::AssetFetcher as _; use crate::core::config::Config; @@ -17,11 +20,26 @@ const CHECK_TIMEOUT: Duration = Duration::from_secs(15); #[derive(Clone, Debug, PartialEq, Eq)] pub struct AvailableUpdate { pub version: String, + pub installable: bool, + pub install_hint: Option, + asset: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum UpdatePhase { + #[default] + Idle, + Checking, + UpToDate, + Downloading, + Installing, + Failed(String), } #[derive(Clone, Debug, Default)] pub struct UpdateStatus { pub available: Option, + pub phase: UpdatePhase, } impl Global for UpdateStatus {} @@ -30,13 +48,36 @@ pub fn spawn_check(cx: &mut App) { if !cx.global::().check_for_updates { return; } - spawn_check_forced(cx); + spawn_check_inner(false, cx); } pub fn spawn_check_forced(cx: &mut App) { + spawn_check_inner(true, cx); +} + +fn spawn_check_inner(report_failure: bool, cx: &mut App) { + if cx.try_global::().is_some_and(|status| { + matches!( + status.phase, + UpdatePhase::Checking | UpdatePhase::Downloading | UpdatePhase::Installing + ) + }) { + return; + } + let previous_available = cx + .try_global::() + .and_then(|status| status.available.clone()); + set_status( + UpdateStatus { + available: previous_available.clone(), + phase: UpdatePhase::Checking, + }, + cx, + ); + cx.spawn(async move |cx| { let current = env!("CARGO_PKG_VERSION"); - let latest = match fetch_latest_version() + let release = match fetch_latest_release() .or(async { cx.background_executor().timer(CHECK_TIMEOUT).await; Err(anyhow::anyhow!("timed out after {CHECK_TIMEOUT:?}")) @@ -46,25 +87,59 @@ pub fn spawn_check_forced(cx: &mut App) { Ok(v) => v, Err(e) => { log::debug!("update check skipped: {e:#}"); + if report_failure { + let message = format!("Could not check for updates: {e:#}"); + cx.update(|cx| { + set_status( + UpdateStatus { + available: previous_available, + phase: UpdatePhase::Failed(message), + }, + cx, + ) + }); + } else { + cx.update(|cx| set_status(UpdateStatus::default(), cx)); + } return; } }; - if !is_update_available(&latest, current) { - log::debug!("update check: up to date (latest {latest}, running {current})"); + if !is_update_available(&release.tag_name, current) { + log::debug!( + "update check: up to date (latest {}, running {current})", + release.tag_name + ); + cx.update(|cx| { + set_status( + UpdateStatus { + available: None, + phase: UpdatePhase::UpToDate, + }, + cx, + ) + }); return; } - let version = latest.trim_start_matches('v').to_string(); + let version = release.tag_name.trim_start_matches('v').to_string(); + let selection = select_release_asset(&version, &release.assets); + let available = AvailableUpdate { + version: version.clone(), + installable: selection.asset.is_some(), + install_hint: selection.reason, + asset: selection.asset, + }; log::info!("update available: {version} (running {current})"); cx.update(|cx| { - cx.set_global(UpdateStatus { - available: Some(AvailableUpdate { - version: version.clone(), - }), - }); - cx.refresh_windows(); + set_status( + UpdateStatus { + available: Some(available.clone()), + phase: UpdatePhase::Idle, + }, + cx, + ) }); if UpdateState::load().last_prompted.as_deref() == Some(version.as_str()) { @@ -76,7 +151,9 @@ pub fn spawn_check_forced(cx: &mut App) { }; let shown = cx.update(|cx| { window - .update(cx, |_root, window, cx| prompt_update(&version, window, cx)) + .update(cx, |_root, window, cx| { + prompt_update(&available, window, cx) + }) .is_ok() }); @@ -90,6 +167,11 @@ pub fn spawn_check_forced(cx: &mut App) { .detach(); } +fn set_status(status: UpdateStatus, cx: &mut App) { + cx.set_global(status); + cx.refresh_windows(); +} + async fn wait_for_window(cx: &mut AsyncApp) -> Option { for _ in 0..50 { if let Some(handle) = cx.update(|cx| cx.windows().first().copied()) { @@ -102,21 +184,139 @@ async fn wait_for_window(cx: &mut AsyncApp) -> Option { None } -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") - ); +fn prompt_update(update: &AvailableUpdate, window: &mut Window, cx: &mut App) { + let detail = if update.installable { + let note = update + .install_hint + .as_deref() + .map(|note| format!(" {note}")) + .unwrap_or_default(); + format!( + "tty7 {} is available — you're on {}. tty7 can download the verified update, install \ + it, and restart the app.{note}", + update.version, + env!("CARGO_PKG_VERSION") + ) + } else { + format!( + "tty7 {} is available — you're on {}. {}", + update.version, + env!("CARGO_PKG_VERSION"), + update + .install_hint + .as_deref() + .unwrap_or("This installation cannot update itself.") + ) + }; + let action = if update.installable { + "Update and Relaunch" + } else { + "View Release" + }; let answer = window.prompt( PromptLevel::Info, "Update available", Some(&detail), - &["Later", "Download"], + &["Later", action], cx, ); - cx.spawn(async move |_cx| { + let update = update.clone(); + cx.spawn(async move |cx| { if let Ok(1) = answer.await { - open_releases_page(); + if update.installable { + let _ = cx.update(|cx| install(update, cx)); + } else { + open_releases_page(); + } + } + }) + .detach(); +} + +pub fn install_available(cx: &mut App) { + let Some(update) = cx + .try_global::() + .and_then(|status| status.available.clone()) + else { + return; + }; + if update.installable { + install(update, cx); + } else { + open_releases_page(); + } +} + +fn install(update: AvailableUpdate, cx: &mut App) { + if cx.try_global::().is_some_and(|status| { + matches!( + status.phase, + UpdatePhase::Downloading | UpdatePhase::Installing + ) + }) { + return; + } + let Some(asset) = update.asset.clone() else { + open_releases_page(); + return; + }; + set_status( + UpdateStatus { + available: Some(update.clone()), + phase: UpdatePhase::Downloading, + }, + cx, + ); + + let version = update.version.clone(); + let task = cx + .background_executor() + .spawn(smol::unblock(move || prepare_update(&version, &asset))); + cx.spawn(async move |cx| { + let prepared = match task.await { + Ok(prepared) => prepared, + Err(error) => { + let message = format!("Update failed: {error:#}"); + log::error!("{message}"); + cx.update(|cx| { + set_status( + UpdateStatus { + available: Some(update), + phase: UpdatePhase::Failed(message), + }, + cx, + ) + }); + return; + } + }; + + cx.update(|cx| { + set_status( + UpdateStatus { + available: Some(update.clone()), + phase: UpdatePhase::Installing, + }, + cx, + ) + }); + match prepared.launch() { + Ok(()) => { + let _ = cx.update(|cx| cx.quit()); + } + Err(error) => { + let message = format!("Could not start the installer: {error:#}"); + log::error!("{message}"); + cx.update(|cx| { + set_status( + UpdateStatus { + available: Some(update), + phase: UpdatePhase::Failed(message), + }, + cx, + ) + }); + } } }) .detach(); @@ -176,12 +376,19 @@ impl UpdateState { } } -#[derive(serde::Deserialize)] +#[derive(Clone, Debug, serde::Deserialize)] struct LatestRelease { tag_name: String, + assets: Vec, } -async fn fetch_latest_version() -> Result { +#[derive(Clone, Debug, serde::Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +async fn fetch_latest_release() -> Result { let client = ReqwestClient::user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION"))) .context("building HTTP client")?; @@ -210,7 +417,267 @@ async fn fetch_latest_version() -> Result { .context("reading response body")?; let release: LatestRelease = serde_json::from_slice(&body).context("parsing release JSON")?; - Ok(release.tag_name) + Ok(release) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReleaseAsset { + name: String, + url: String, + checksums_url: String, +} + +struct AssetSelection { + asset: Option, + reason: Option, +} + +fn select_release_asset(version: &str, assets: &[GitHubAsset]) -> AssetSelection { + select_release_asset_for(package_for_current_install(version), assets) +} + +fn select_release_asset_for(package: Option, assets: &[GitHubAsset]) -> AssetSelection { + let Some(name) = package else { + return AssetSelection { + asset: None, + reason: Some(unsupported_install_reason()), + }; + }; + let Some(asset) = assets.iter().find(|asset| asset.name == name) else { + return AssetSelection { + asset: None, + reason: Some(format!( + "The release has no {name} package for this installation. Open the release page \ + to choose another package." + )), + }; + }; + let Some(checksums) = assets.iter().find(|asset| asset.name == "checksums.txt") else { + return AssetSelection { + asset: None, + reason: Some( + "The release has no checksums.txt, so tty7 refuses to install it automatically." + .to_string(), + ), + }; + }; + AssetSelection { + asset: Some(ReleaseAsset { + name, + url: asset.browser_download_url.clone(), + checksums_url: checksums.browser_download_url.clone(), + }), + reason: None, + } +} + +fn package_for_current_install(version: &str) -> Option { + #[cfg(target_os = "macos")] + { + let app = current_macos_app_bundle()?; + if !is_macos_update_writable(&app) || bundled_updater().is_none() { + return None; + } + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else if cfg!(target_arch = "x86_64") { + "x86_64" + } else { + return None; + }; + return Some(format!("tty7-{version}-macos-{arch}.zip")); + } + #[allow(unreachable_code)] + None +} + +fn unsupported_install_reason() -> String { + #[cfg(target_os = "macos")] + { + return "This copy is not running from a writable tty7.app bundle, so replacing it would be \ + unsafe. Move tty7 to Applications or another writable folder, or open the release \ + page to install the update." + .to_string(); + } + #[cfg(target_os = "linux")] + { + return "The first in-app updater supports packaged macOS app bundles. Use the release page \ + or your package manager to update this Linux installation." + .to_string(); + } + #[cfg(target_os = "windows")] + { + return "The first in-app updater supports packaged macOS app bundles. Open the release page \ + to update this Windows installation." + .to_string(); + } + #[allow(unreachable_code)] + "Automatic installation is not available on this platform. Open the release page.".to_string() +} + +fn prepare_update(version: &str, asset: &ReleaseAsset) -> Result { + let fetcher = tty7_core::daemon::install::download::HttpsFetcher::default(); + let checksums = fetcher + .get(&asset.checksums_url) + .map_err(anyhow::Error::msg) + .context("downloading checksums.txt")?; + let archive = fetcher + .get(&asset.url) + .map_err(anyhow::Error::msg) + .with_context(|| format!("downloading {}", asset.name))?; + prepare_macos_update(version, &asset.name, &archive, &checksums) +} + +#[derive(Debug)] +struct PreparedUpdate { + updater: PathBuf, + args: Vec, + config_dir: Option, + stage: PathBuf, +} + +impl PreparedUpdate { + fn launch(self) -> Result<()> { + let stage = self.stage; + let mut command = Command::new(self.updater); + command.args(self.args); + if let Some(config_dir) = self.config_dir { + command.env("TTY7_CONFIG_DIR", config_dir); + } + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .inspect_err(|_| { + let _ = std::fs::remove_dir_all(&stage); + }) + .context("launching tty7-updater")?; + Ok(()) + } +} + +fn update_staging_dir(parent: &Path) -> Result { + tempfile::Builder::new() + .prefix(".tty7-update-") + .tempdir_in(parent) + .context("creating update staging directory") +} + +fn write_staged_asset(dir: &Path, name: &str, bytes: &[u8]) -> Result { + let path = dir.join(name); + std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +#[cfg(target_os = "macos")] +fn prepare_macos_update( + version: &str, + asset_name: &str, + archive: &[u8], + checksums: &[u8], +) -> Result { + let current = + current_macos_app_bundle().context("tty7 is not running from an application bundle")?; + let parent = current + .parent() + .context("tty7.app has no parent directory")?; + let updater = bundled_updater().context("tty7-updater is not bundled with this app")?; + let staging = update_staging_dir(parent)?; + let dir = staging.path().to_path_buf(); + let archive = write_staged_asset(&dir, asset_name, archive)?; + let checksums = write_staged_asset(&dir, "checksums.txt", checksums)?; + run_updater( + &updater, + [ + PathBuf::from("verify"), + current.clone(), + archive, + checksums, + PathBuf::from(asset_name), + dir.clone(), + PathBuf::from(version), + ], + )?; + let log = + crate::core::config::config_path("update.log").unwrap_or_else(|| dir.join("update.log")); + if let Some(parent) = log.parent() { + std::fs::create_dir_all(parent).context("creating the update log directory")?; + } + let dir = staging.keep(); + Ok(PreparedUpdate { + updater, + args: vec![ + PathBuf::from("install"), + std::process::id().to_string().into(), + current, + dir.clone(), + PathBuf::from(version), + log, + ], + config_dir: crate::core::config::config_dir_path(), + stage: dir, + }) +} + +#[cfg(not(target_os = "macos"))] +fn prepare_macos_update( + _version: &str, + _asset_name: &str, + _archive: &[u8], + _checksums: &[u8], +) -> Result { + anyhow::bail!("the first in-app updater only supports macOS") +} + +#[cfg(target_os = "macos")] +fn current_macos_app_bundle() -> Option { + std::env::current_exe().ok()?.ancestors().find_map(|path| { + (path.extension().and_then(|ext| ext.to_str()) == Some("app")).then(|| path.to_path_buf()) + }) +} + +#[cfg(target_os = "macos")] +fn is_macos_update_writable(app: &Path) -> bool { + app.parent().is_some_and(can_stage_replacement_in) +} + +#[cfg(target_os = "macos")] +fn bundled_updater() -> Option { + let updater = current_macos_app_bundle()?.join("Contents/MacOS/tty7-updater"); + updater.is_file().then_some(updater) +} + +#[cfg(not(target_os = "macos"))] +fn bundled_updater() -> Option { + None +} + +#[cfg(not(target_os = "macos"))] +fn current_macos_app_bundle() -> Option { + None +} + +#[cfg(target_os = "macos")] +fn can_stage_replacement_in(dir: &Path) -> bool { + tempfile::Builder::new() + .prefix(".tty7-update-write-test-") + .tempfile_in(dir) + .is_ok() +} + +fn run_updater(updater: &Path, args: impl IntoIterator) -> Result<()> { + let output = Command::new(updater) + .args(args) + .output() + .context("running tty7-updater verification")?; + if !output.status.success() { + anyhow::bail!( + "tty7-updater verification failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ) + } + Ok(()) } fn parse_version(s: &str) -> Option<(u64, u64, u64, bool)> { @@ -239,6 +706,60 @@ fn is_update_available(latest: &str, current: &str) -> bool { mod tests { use super::*; + fn github_asset(name: &str) -> GitHubAsset { + GitHubAsset { + name: name.to_string(), + browser_download_url: format!("https://example.test/{name}"), + } + } + + #[test] + fn release_asset_requires_the_platform_package_and_checksums() { + let name = "tty7-27.1.0-macos-arm64.zip"; + let assets = [github_asset(name), github_asset("checksums.txt")]; + let selected = select_release_asset_for(Some(name.to_string()), &assets); + assert_eq!( + selected.asset, + Some(ReleaseAsset { + name: name.to_string(), + url: format!("https://example.test/{name}"), + checksums_url: "https://example.test/checksums.txt".to_string(), + }) + ); + assert_eq!(selected.reason, None); + } + + #[test] + fn release_without_checksums_is_never_installable() { + let name = "tty7-27.1.0-macos-arm64.zip"; + let selected = select_release_asset_for(Some(name.to_string()), &[github_asset(name)]); + assert!(selected.asset.is_none()); + assert!( + selected + .reason + .as_deref() + .is_some_and(|reason| reason.contains("checksums.txt")) + ); + } + + #[test] + fn release_without_the_exact_platform_package_is_never_guessed() { + let selected = select_release_asset_for( + Some("tty7-27.1.0-macos-arm64.zip".to_string()), + &[ + github_asset("tty7-27.1.0-macos-x86_64.zip"), + github_asset("checksums.txt"), + ], + ); + assert!(selected.asset.is_none()); + assert!( + selected + .reason + .as_deref() + .is_some_and(|reason| reason.contains("macos-arm64")) + ); + } + #[test] fn parses_versions_with_and_without_prefix() { assert_eq!(parse_version("v0.3.1"), Some((0, 3, 1, true))); diff --git a/src/ui/app.rs b/src/ui/app.rs index 7d79a9c9..58a23119 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4598,10 +4598,6 @@ impl Tty7App { log::warn!("failed to open {}: {e}", path.display()); } } - - pub(crate) fn open_releases_page(&self) { - crate::core::update::open_releases_page(); - } } #[cfg(test)] diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 0bbb185d..7d9005bf 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -4489,9 +4489,31 @@ impl Tty7App { let theme = cx.theme(); let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground); - let update = cx + let update_status = cx .try_global::() - .and_then(|s| s.available.clone()); + .cloned() + .unwrap_or_default(); + let update = update_status.available.clone(); + let update_busy = matches!( + update_status.phase, + crate::core::update::UpdatePhase::Checking + | crate::core::update::UpdatePhase::Downloading + | crate::core::update::UpdatePhase::Installing + ); + let phase_text = match &update_status.phase { + crate::core::update::UpdatePhase::Idle => None, + crate::core::update::UpdatePhase::Checking => Some("Checking for updates…".to_string()), + crate::core::update::UpdatePhase::UpToDate => { + Some("You're running the latest version.".to_string()) + } + crate::core::update::UpdatePhase::Downloading => { + Some("Downloading and verifying the update…".to_string()) + } + crate::core::update::UpdatePhase::Installing => { + Some("Relaunching with the update…".to_string()) + } + crate::core::update::UpdatePhase::Failed(message) => Some(message.clone()), + }; let check_for_updates = cx.global::().check_for_updates; let install_cli_on_path = cx.global::().install_cli_on_path; @@ -4562,26 +4584,60 @@ impl Tty7App { .child("Updates"), ) .when_some(update, |this, upd| { + let button_label = if upd.installable { + "Update and Relaunch" + } else { + "View Release" + }; this.child( - h_flex() - .gap_3() - .items_center() - .child(div().text_sm().text_color(foreground).child( - format!("Version {} is available.", upd.version), - )) + v_flex() + .gap_1() .child( - Button::new("download-update") - .label("Download") - .small() - .on_click(cx.listener(|this, _, _w, _cx| { - this.open_releases_page() - })), - ), + h_flex() + .gap_3() + .items_center() + .child(div().text_sm().text_color(foreground).child( + format!("Version {} is available.", upd.version), + )) + .child( + Button::new("install-update") + .label(button_label) + .small() + .disabled(update_busy) + .on_click(cx.listener(|_, _, _window, cx| { + crate::core::update::install_available(cx) + })), + ), + ) + .when_some(upd.install_hint, |this, hint| { + this.child(div().text_xs().text_color(muted_fg).child(hint)) + }), ) }) + .when_some(phase_text, |this, text| { + this.child(div().text_sm().text_color(muted_fg).child(text)) + }) .child(div().text_sm().text_color(muted_fg).child( - "Check GitHub for a newer release on launch and show it here. tty7 never updates itself — downloading happens on the Releases page.", + "tty7 checks stable releases and can update packaged macOS app bundles without opening a browser. A dedicated helper verifies checksums, version, and code signing before replacement, then relaunches the GUI. Compatible servers and shells stay running; if the wire protocol changed, tty7 asks whether to restart the server after relaunch. Other platforms and unsupported layouts fall back to the release page.", )) + .child( + h_flex().child( + Button::new("check-update-now") + .label(if matches!( + update_status.phase, + crate::core::update::UpdatePhase::Checking + ) { + "Checking…" + } else { + "Check Now" + }) + .small() + .disabled(update_busy) + .on_click(cx.listener(|_, _, _window, cx| { + crate::core::update::spawn_check_forced(cx) + })), + ), + ) .child( h_flex() .gap_2()