mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
feat(update): install verified macOS releases in app
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -9682,6 +9682,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"tray-icon",
|
||||
"tty7-core",
|
||||
"uuid",
|
||||
|
||||
+10
@@ -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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<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(¤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);
|
||||
}
|
||||
+544
-23
@@ -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)));
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user