feat(update): install verified macOS releases in app

This commit is contained in:
ayamir
2026-08-02 22:25:10 +08:00
parent da6df709cd
commit a6754b28bc
11 changed files with 1095 additions and 48 deletions
+33 -2
View File
@@ -1,7 +1,8 @@
#!/bin/bash
# Usage: bundle-macos.sh <target-triple> <arch-label>
# Package the release binary into dist/tty7.app and wrap it in a
# drag-to-Applications DMG: dist/tty7-<version>-macos-<arch>.dmg.
# Package the release binary into dist/tty7.app, then publish both:
# dist/tty7-<version>-macos-<arch>.zip (in-app updater)
# dist/tty7-<version>-macos-<arch>.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"
+5
View File
@@ -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.
+8 -2
View File
@@ -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]
+11
View File
@@ -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
Generated
+1
View File
@@ -9682,6 +9682,7 @@ dependencies = [
"serde_yaml",
"smallvec",
"smol",
"tempfile",
"tray-icon",
"tty7-core",
"uuid",
+10
View File
@@ -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]
@@ -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"
),
}
}
+410
View File
@@ -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(&current, &replacement, &expected_version)
}
"install" => {
let parent_pid = next_string(&mut args)?
.parse::<u32>()
.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 <current.app> <archive.zip> <checksums.txt> \
<asset-name> <stage-dir> <version>\n\
or: tty7-updater install <parent-pid> <current.app> <stage-dir> <version> <log-path>"
.to_string()
}
fn next_path(args: &mut impl Iterator<Item = std::ffi::OsString>) -> Result<PathBuf, String> {
args.next().map(PathBuf::from).ok_or_else(usage)
}
fn next_string(args: &mut impl Iterator<Item = std::ffi::OsString>) -> Result<String, String> {
args.next()
.and_then(|arg| arg.into_string().ok())
.ok_or_else(usage)
}
fn reject_extra(mut args: impl Iterator<Item = std::ffi::OsString>) -> 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<PathBuf, String> {
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<String, String> {
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<String, String> {
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(&current, "old");
bundle(&replacement, "new");
replace_and_relaunch(&current, &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(&current, "old");
bundle(&replacement, "new");
let launches = std::cell::Cell::new(0);
let error = replace_and_relaunch(&current, &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(&current, "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(&current, &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);
}
+544 -23
View File
@@ -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<String>,
asset: Option<ReleaseAsset>,
}
#[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<AvailableUpdate>,
pub phase: UpdatePhase,
}
impl Global for UpdateStatus {}
@@ -30,13 +48,36 @@ pub fn spawn_check(cx: &mut App) {
if !cx.global::<Config>().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::<UpdateStatus>().is_some_and(|status| {
matches!(
status.phase,
UpdatePhase::Checking | UpdatePhase::Downloading | UpdatePhase::Installing
)
}) {
return;
}
let previous_available = cx
.try_global::<UpdateStatus>()
.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<AnyWindowHandle> {
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<AnyWindowHandle> {
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::<UpdateStatus>()
.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::<UpdateStatus>().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<GitHubAsset>,
}
async fn fetch_latest_version() -> Result<String> {
#[derive(Clone, Debug, serde::Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}
async fn fetch_latest_release() -> Result<LatestRelease> {
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<String> {
.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<ReleaseAsset>,
reason: Option<String>,
}
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<String>, 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<String> {
#[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<PreparedUpdate> {
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<PathBuf>,
config_dir: Option<PathBuf>,
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::TempDir> {
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<PathBuf> {
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<PreparedUpdate> {
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<PreparedUpdate> {
anyhow::bail!("the first in-app updater only supports macOS")
}
#[cfg(target_os = "macos")]
fn current_macos_app_bundle() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
None
}
#[cfg(not(target_os = "macos"))]
fn current_macos_app_bundle() -> Option<PathBuf> {
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<Item = PathBuf>) -> 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)));
-4
View File
@@ -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)]
+72 -16
View File
@@ -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::<crate::core::update::UpdateStatus>()
.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::<Config>().check_for_updates;
let install_cli_on_path = cx.global::<Config>().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()