feat(update): install verified Linux AppImage releases in app (#306) (#652)

The last platform from #306: a Linux install running as an AppImage can
now download, verify, and apply a release from inside the app, through
the same tty7-updater helper the macOS (#309) and Windows (#330) paths
use. Tarball and distro installs are deliberately untouched — they keep
the named-package hint and the release page, because replacing a file a
package manager may own is not this code's call to make.

The installed artifact is one file, the path $APPIMAGE names, so the
install is the simplest of the three platforms: stage the download
beside the image (two renames only stay atomic on one filesystem),
verify, swap, relaunch, and restore the preserved previous image if the
new one does not survive its launch grace. What is Linux-shaped about
it is the mount: the image the GUI runs from is FUSE-mounted by the
AppImage runtime and torn down when the app exits, which is the moment
the installer starts working — so the GUI copies the helper out of the
mount into staging and runs the copy, the way the Windows path runs a
private copy because Setup replaces the installed one. The daemon is
left running throughout, as on macOS: nothing on Linux locks a running
executable's file, and the panes it serves are the reason the update
restarts only the GUI. The swap also carries the installed image's own
mode onto its replacement, so a 0700 image stays private and the
download's missing execute bit never reaches the installation.

Verification holds the issue's requirements with what an unsigned ELF
can offer: the bytes must match the release's checksums.txt, the file
must actually be a type-2 AppImage — a mis-published asset fails with a
name instead of at launch — and the image must state the version it
claims. That statement is new: bundle-appimage.sh stamps
X-AppImage-Version into the desktop entry, and the updater reads it
back with one --appimage-extract, answered by the runtime before any
application code and without FUSE. The same pass requires the new image
to bundle its own tty7-updater, because an image without one would
install fine and then be the last version that ever could.

release.yml and nightly.yml now build the updater on the Linux leg and
bundle it into the AppImage, and both check the packaged image for the
same facts the updater checks on a user's machine — helper present,
version stamped — so a packaging mistake fails the workflow instead of
the update. The first release carrying this can only bootstrap: images
already installed predate the helper and keep the manual hint, so the
first complete in-app update is the release after it.
This commit is contained in:
webdev
2026-08-16 17:43:52 +08:00
committed by GitHub
parent 0df604054d
commit ac3c95a647
6 changed files with 969 additions and 30 deletions
+12
View File
@@ -62,6 +62,12 @@ chmod +x "$APPDIR/usr/bin/tty7-app"
cp "target/${TARGET}/release/tty7" "$APPDIR/usr/bin/tty7"
chmod +x "$APPDIR/usr/bin/tty7"
# The in-app updater, beside the GUI the way every platform ships it. The GUI
# copies it out of the mount into its staging directory before use — the mount
# is gone by the time an install runs (see src/bin/tty7-updater.rs).
cp "target/${TARGET}/release/tty7-updater" "$APPDIR/usr/bin/tty7-updater"
chmod +x "$APPDIR/usr/bin/tty7-updater"
# A desktop entry + icon are mandatory AppImage metadata; linuxdeploy places
# them and generates AppRun. Icon basename must match the desktop's Icon= key.
cat > "$TOOLS/tty7.desktop" <<'DESKTOP'
@@ -75,6 +81,12 @@ Categories=System;TerminalEmulator;
Terminal=false
StartupWMClass=tty7
DESKTOP
# The release version, stamped where the in-app updater can read it back with
# one `--appimage-extract` and no mount: a downloaded image must state the
# version it claims before it may replace the installed one (`verify_update`
# in src/bin/tty7-updater.rs). X-AppImage-Version is the AppImage convention
# for exactly this. Appended outside the heredoc, which is quoted on purpose.
echo "X-AppImage-Version=${VERSION}" >> "$TOOLS/tty7.desktop"
# linuxdeploy only accepts fixed icon resolutions (…256, 384, 512 — NOT the
# source's 1024), so downscale to 256×256.
convert assets/app-icon.png -resize 256x256 "$TOOLS/tty7.png"
-1
View File
@@ -128,7 +128,6 @@ jobs:
run: cargo test --locked --target ${{ matrix.target }}
- name: Test desktop updater
if: runner.os == 'macOS' || runner.os == 'Windows'
timeout-minutes: 10
run: cargo test --locked --features updater --bin tty7-updater --target ${{ matrix.target }}
+24 -4
View File
@@ -122,15 +122,15 @@ jobs:
with:
workspaces: tty7
# The updater ships on every GUI platform now: inside the macOS bundle,
# beside the Windows app, and inside the Linux AppImage.
- name: Build
working-directory: tty7
shell: bash
run: |
cargo build --release --target "${{ matrix.target }}"
if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then
cargo build --release --features updater \
--bin tty7-updater --target "${{ matrix.target }}"
fi
cargo build --release --features updater \
--bin tty7-updater --target "${{ matrix.target }}"
- name: Bundle macOS DMG
if: matrix.os == 'macos'
@@ -174,6 +174,26 @@ jobs:
working-directory: tty7
run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}"
# The same facts the in-app updater checks on the user's machine,
# checked here so a packaging mistake fails the nightly instead. See
# the twin step in release.yml.
- name: Verify Linux AppImage update package
if: matrix.os == 'linux'
working-directory: tty7
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.plan.outputs.version }}"
IMAGE="$PWD/dist/tty7-${VERSION}-linux-${{ matrix.arch }}.AppImage"
VERIFY_ROOT="$RUNNER_TEMP/tty7-appimage-update-verify"
rm -rf "$VERIFY_ROOT"
mkdir -p "$VERIFY_ROOT"
(cd "$VERIFY_ROOT" && "$IMAGE" --appimage-extract >/dev/null)
test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-app"
test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-updater"
grep -Fxq "X-AppImage-Version=${VERSION}" \
"$VERIFY_ROOT/squashfs-root/usr/share/applications/tty7.desktop"
# See release.yml for why this is best-effort rather than required.
- name: Fetch the bundled Linux server
if: matrix.os == 'windows'
+24 -4
View File
@@ -74,15 +74,15 @@ jobs:
# recorded, not whatever cargo would re-resolve at build time. Safe here
# (unlike nightly) precisely because nothing rewrites Cargo.toml: this is
# a plain checkout of the tagged commit.
# The updater ships on every GUI platform now: inside the macOS bundle,
# beside the Windows app, and inside the Linux AppImage.
- name: Build
working-directory: tty7
shell: bash
run: |
cargo build --release --locked --target "${{ matrix.target }}"
if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then
cargo build --release --locked --features updater \
--bin tty7-updater --target "${{ matrix.target }}"
fi
cargo build --release --locked --features updater \
--bin tty7-updater --target "${{ matrix.target }}"
# ---- Packaging: one step per OS ----------------------------------------
# macOS gets a signed + notarized drag-to-Applications DMG. Windows gets
@@ -117,6 +117,26 @@ jobs:
working-directory: tty7
run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}"
# The in-app updater refuses an image whose bundled helper or stamped
# version is wrong — on the user's machine, after the download. Check the
# same facts here so a packaging mistake fails the release instead.
- name: Verify Linux AppImage update package
if: matrix.os == 'linux'
working-directory: tty7
shell: bash
run: |
set -euo pipefail
VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
IMAGE="$PWD/dist/tty7-${VERSION}-linux-${{ matrix.arch }}.AppImage"
VERIFY_ROOT="$RUNNER_TEMP/tty7-appimage-update-verify"
rm -rf "$VERIFY_ROOT"
mkdir -p "$VERIFY_ROOT"
(cd "$VERIFY_ROOT" && "$IMAGE" --appimage-extract >/dev/null)
test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-app"
test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-updater"
grep -Fxq "X-AppImage-Version=${VERSION}" \
"$VERIFY_ROOT/squashfs-root/usr/share/applications/tty7.desktop"
# The bundled server for WSL. `continue-on-error` mirrors `server-musl`'s
# own probe step: if there is no server asset, the release still ships and
# `bundle-windows.ps1` warns. It is not silent at runtime either — a WSL
+700 -3
View File
@@ -2,7 +2,10 @@
all(target_os = "windows", not(debug_assertions)),
windows_subsystem = "windows"
)]
#![cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
#![cfg_attr(
not(any(target_os = "macos", target_os = "windows", target_os = "linux")),
allow(dead_code)
)]
#[cfg(target_os = "macos")]
mod macos {
@@ -590,6 +593,692 @@ mod macos {
}
}
/// The Linux half serves exactly one installation shape: an AppImage. The
/// installed artifact is a single file (the path `$APPIMAGE` names), so the
/// whole install is one atomic swap — move the running image aside, rename
/// the verified download into its place, and start it. Tarball and distro
/// installs never reach this program; `package_for_current_install` in
/// `core::update` hands them the release page instead.
///
/// One Linux-specific constraint shapes the code: the image the GUI runs
/// from is a FUSE mount the AppImage runtime tears down when the app exits —
/// which is the moment `install` starts working. The GUI therefore copies
/// this helper out of the mount into the staging directory and runs the
/// copy, the same way the Windows path runs a private copy because Setup
/// replaces the installed one.
#[cfg(target_os = "linux")]
mod linux {
use std::fs::{self, OpenOptions};
use std::io::{Read as _, Write as _};
use std::os::unix::fs::PermissionsExt 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);
/// Where `bundle-appimage.sh` installs the desktop entry inside the
/// image. The root-level `tty7.desktop` is linuxdeploy's symlink to this
/// file, and extracting a symlink alone yields a dangling link — so the
/// real path is the one asked for.
const DESKTOP_ENTRY: &str = "usr/share/applications/tty7.desktop";
/// The helper inside the image, beside the app the way every platform
/// ships it.
const BUNDLED_UPDATER: &str = "usr/bin/tty7-updater";
/// The desktop-entry key `bundle-appimage.sh` stamps the release version
/// into — the AppImage convention for stating a version where tools can
/// read it without running the app.
const VERSION_KEY: &str = "X-AppImage-Version=";
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 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)?;
verify_update(&archive, &stage, &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 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)?;
let log = next_path(&mut args)?;
let options = tail_options(args)?;
options.apply();
install(InstallPlan {
parent_pid,
current,
archive,
checksums,
asset_name,
stage,
expected_version,
log,
result_file: options.result_file,
})
}
_ => Err(usage()),
}
}
fn usage() -> String {
"usage: tty7-updater verify <archive.AppImage> <checksums.txt> \
<asset-name> <stage-dir> <version>\n\
or: tty7-updater install <parent-pid> <current.AppImage> <archive.AppImage> \
<checksums.txt> <asset-name> <stage-dir> <version> <log-path> \
[--config-dir <dir>] [--result-file <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(())
}
}
/// The named options an install verb takes after its positional
/// arguments. See the Windows half of this file for why these are
/// arguments and not the environment.
#[derive(Default)]
struct TailOptions {
config_dir: Option<PathBuf>,
result_file: Option<PathBuf>,
}
fn tail_options(
mut args: impl Iterator<Item = std::ffi::OsString>,
) -> Result<TailOptions, String> {
let mut options = TailOptions::default();
while let Some(arg) = args.next() {
match arg.to_str() {
Some("--config-dir") => options.config_dir = Some(next_path(&mut args)?),
Some("--result-file") => options.result_file = Some(next_path(&mut args)?),
_ => return Err(usage()),
}
}
Ok(options)
}
impl TailOptions {
fn apply(&self) {
let Some(dir) = &self.config_dir else { return };
tty7_core::core::config::set_config_dir(dir.clone());
// Re-exported so the relaunched app — a child of this process —
// keeps answering for the same config directory. Safe here:
// argument parsing runs before any thread exists.
unsafe { std::env::set_var("TTY7_CONFIG_DIR", dir) };
}
}
/// The terminal outcome of the attempt, for the next GUI launch to merge
/// into the update state (#540). Best-effort, like every log line here.
fn report_outcome(
result_file: Option<&Path>,
log: &Path,
version: &str,
result: &Result<(), String>,
) {
let Some(path) = result_file else { return };
let outcome = tty7_core::daemon::install::outcome::UpdateOutcome {
version: version.to_string(),
ok: result.is_ok(),
detail: result.as_ref().err().cloned(),
};
if let Err(error) = tty7_core::daemon::install::outcome::write_outcome(path, &outcome) {
log_line(
log,
&format!(
"could not record the update outcome at {}: {error}",
path.display()
),
);
}
}
struct InstallPlan {
parent_pid: u32,
current: PathBuf,
archive: PathBuf,
checksums: PathBuf,
asset_name: String,
stage: PathBuf,
expected_version: String,
log: PathBuf,
result_file: Option<PathBuf>,
}
fn install(plan: InstallPlan) -> Result<(), String> {
install_inner(&plan)
}
// The daemon is deliberately left running, exactly as on macOS: nothing
// locks a running executable's file on Linux, and the daemon serves its
// panes from the old mount until the user chooses to restart it — that
// is what keeps their shells alive across the update.
fn install_inner(plan: &InstallPlan) -> Result<(), String> {
wait_for_exit(plan.parent_pid);
log_line(&plan.log, "re-verifying the staged tty7 update");
let verification = verify_archive(&plan.archive, &plan.checksums, &plan.asset_name)
.and_then(|()| verify_update(&plan.archive, &plan.stage, &plan.expected_version));
if let Err(error) = verification {
log_line(&plan.log, &error);
let _ = fs::remove_dir_all(&plan.stage);
let result = Err(error);
// The outcome lands before the old app does: the relaunched GUI
// merges it at startup, and a write afterward races that merge
// (#540).
report_outcome(
plan.result_file.as_deref(),
&plan.log,
&plan.expected_version,
&result,
);
let _ = launch_app(&plan.current);
return result;
}
log_line(&plan.log, &format!("replacing {}", plan.current.display()));
let report = |result: &Result<(), String>| {
report_outcome(
plan.result_file.as_deref(),
&plan.log,
&plan.expected_version,
result,
);
};
replace_and_relaunch(
&plan.current,
&plan.archive,
&plan.stage,
launch_app,
report,
)
.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())
}
/// What the downloaded file has to prove before it may become the
/// installation: it is a type-2 AppImage at all, it states the version
/// this update was for, and it carries its own updater — an image
/// without one would install fine and then be the last version that
/// ever could. Runs only after `verify_archive` has pinned the bytes to
/// the release's checksums.txt; from there, running the image's own
/// `--appimage-extract` is running the released code, which is exactly
/// what the swap is about to do anyway.
fn verify_update(staged: &Path, stage: &Path, expected_version: &str) -> Result<(), String> {
if !is_appimage(&read_header(staged)?) {
return Err(format!("{} is not a type-2 AppImage", staged.display()));
}
// Downloaded bytes land without the execute bit; extraction needs the
// runtime to run. The definitive mode is set again at swap time, taken
// from the file being replaced.
make_executable(staged)?;
let desktop = extract_entry(staged, stage, DESKTOP_ENTRY)?;
let text = fs::read_to_string(&desktop)
.map_err(|error| format!("reading {}: {error}", desktop.display()))?;
let actual = version_from_desktop_entry(&text).ok_or_else(|| {
format!("the staged AppImage's desktop entry carries no {VERSION_KEY}")
})?;
if actual != expected_version {
return Err(format!(
"the staged AppImage reports version {actual}, expected {expected_version}"
));
}
extract_entry(staged, stage, BUNDLED_UPDATER)?;
Ok(())
}
/// ELF with the AppImage type-2 marker (`AI\x02` at offset 8). The
/// runtime the swap is about to spawn only exists behind this shape; a
/// wrongly published asset — a tarball under the AppImage name, an HTML
/// error page — fails here with a name instead of at launch.
fn is_appimage(header: &[u8]) -> bool {
header.len() >= 11
&& header[..4] == [0x7f, b'E', b'L', b'F']
&& header[8..11] == [b'A', b'I', 0x02]
}
fn read_header(path: &Path) -> Result<Vec<u8>, String> {
let mut file =
fs::File::open(path).map_err(|error| format!("reading {}: {error}", path.display()))?;
let mut header = [0u8; 16];
let read = file
.read(&mut header)
.map_err(|error| format!("reading {}: {error}", path.display()))?;
Ok(header[..read].to_vec())
}
fn make_executable(path: &Path) -> Result<(), String> {
let mode = fs::metadata(path)
.map_err(|error| format!("reading the mode of {}: {error}", path.display()))?
.permissions()
.mode();
fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o755))
.map_err(|error| format!("marking {} executable: {error}", path.display()))
}
/// Unpacks one entry of the staged image into `<stage>/squashfs-root/`
/// and returns the extracted file's path.
///
/// `--appimage-extract` is answered by the AppImage runtime itself,
/// before any application code, and unpacks without mounting — so it
/// works on machines whose FUSE setup the eventual launch will need but
/// this verification should not. Run from the staging directory so the
/// `squashfs-root` it creates lands beside the package and is removed
/// with it. The runtime exits zero even when nothing matched, which is
/// why the answer is the extracted file's existence rather than the
/// exit status.
fn extract_entry(appimage: &Path, stage: &Path, entry: &str) -> Result<PathBuf, String> {
// Anchored before the spawn: exec resolves a relative program path
// against the child's working directory, which the line below moves —
// a hand-run `tty7-updater verify ./pkg.AppImage …` would otherwise
// fail with a bare "No such file or directory".
let appimage = std::path::absolute(appimage)
.map_err(|error| format!("resolving {}: {error}", appimage.display()))?;
run_checked(
Command::new(&appimage)
.args(["--appimage-extract", entry])
.current_dir(stage)
.stdout(Stdio::null()),
"extracting from the staged AppImage",
)?;
let extracted = stage.join("squashfs-root").join(entry);
if !extracted.is_file() {
return Err(format!("the staged AppImage carries no {entry}"));
}
Ok(extracted)
}
fn version_from_desktop_entry(text: &str) -> Option<String> {
text.lines()
.find_map(|line| line.strip_prefix(VERSION_KEY))
.map(|version| version.trim().to_string())
.filter(|version| !version.is_empty())
}
fn replace_and_relaunch(
current: &Path,
replacement: &Path,
stage: &Path,
launch: impl Fn(&Path) -> Result<(), String>,
report: impl Fn(&Result<(), String>),
) -> Result<(), String> {
// The staging directory is a fresh TempDir created beside the current
// image, so a backup here stays on the same filesystem without using a
// predictable sibling path. In particular, never delete a fixed-name
// path beside the image: it may be a recovery copy left by an
// interrupted update (or simply an unrelated user-owned path).
let backup = stage.join("previous.AppImage");
if backup.exists() {
let result = Err(format!(
"the update staging backup already exists: {}",
backup.display()
));
report(&result);
return result;
}
// The replacement wears the current image's own mode: a rename keeps
// the staged file's permissions, which are the download's, and the
// user's choice of who may run their tty7 is not this program's to
// revise. Owner execute is guaranteed on top — without it nothing can
// relaunch — and grants nobody else anything.
if let Err(error) = carry_mode(current, replacement) {
report(&Err(error.clone()));
return Err(error);
}
if let Err(error) = fs::rename(current, &backup) {
let result = Err(format!("moving the current AppImage aside: {error}"));
report(&result);
return result;
}
if let Err(error) = fs::rename(replacement, current) {
let _ = fs::rename(&backup, current);
let _ = fs::remove_dir_all(stage);
let result = Err(format!("putting the staged AppImage in place: {error}"));
report(&result);
return result;
}
match launch(current) {
Ok(()) => {
let _ = remove_path(&backup);
let _ = fs::remove_dir_all(stage);
let result = Ok(());
report(&result);
result
}
Err(error) => {
let _ = remove_path(current);
let (result, relaunch) = match fs::rename(&backup, current) {
Ok(()) => {
let _ = fs::remove_dir_all(stage);
(Err(error), true)
}
Err(restore) => (
Err(format!("{error}; restoring the previous image: {restore}")),
false,
),
};
// The outcome lands before the old app does: the relaunched
// GUI merges it at startup, and a write afterward races that
// merge (#540).
report(&result);
if relaunch {
let _ = launch(current);
}
result
}
}
}
/// Puts the mode of the file being replaced onto its replacement,
/// with owner execute assured. Falls back to plain 0o755 when the
/// current image cannot answer — it is about to be renamed away, not
/// consulted as an authority.
fn carry_mode(current: &Path, replacement: &Path) -> Result<(), String> {
let mode = fs::metadata(current)
.map(|meta| meta.permissions().mode())
.unwrap_or(0o755);
fs::set_permissions(replacement, fs::Permissions::from_mode(mode | 0o700))
.map_err(|error| format!("setting the mode of {}: {error}", replacement.display()))
}
/// Starts the image at its installed path. The runtime sets `$APPIMAGE`
/// and `$APPDIR` for the process it mounts, overwriting the stale pair
/// this process inherited from the app that spawned it.
fn launch_app(appimage: &Path) -> Result<(), String> {
let mut child = Command::new(appimage)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| format!("launching {}: {error}", appimage.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) {
// The updater is spawned directly by the app it waits for, so while
// that app lives it *is* this process's parent, and the kernel
// reparents us the moment it exits. Watching getppid() is therefore
// immune to pid reuse, which `kill(pid, 0)` is not: a recycled pid
// keeps answering 0 forever. Same reasoning as the macos module;
// Linux reparents to init or the nearest subreaper, and either way
// the answer stops being `pid`.
let pid = pid as libc::pid_t;
if unsafe { libc::getppid() } == pid {
while unsafe { libc::getppid() } == pid {
thread::sleep(PARENT_POLL);
}
return;
}
// Not our parent — a hand-run updater. The polling fallback keeps
// that invocation working, pid-reuse caveat and all.
while process_alive(pid) {
thread::sleep(PARENT_POLL);
}
}
fn process_alive(pid: libc::pid_t) -> bool {
unsafe { libc::kill(pid, 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::*;
#[test]
fn successful_launch_commits_the_replacement() {
let root = tempfile::tempdir().unwrap();
let current = root.path().join("tty7.AppImage");
let stage = root.path().join("stage");
fs::create_dir(&stage).unwrap();
let replacement = stage.join("tty7-new.AppImage");
fs::write(&current, b"old image").unwrap();
fs::write(&replacement, b"new image").unwrap();
replace_and_relaunch(&current, &replacement, &stage, |_| Ok(()), |_| ()).unwrap();
assert_eq!(fs::read(&current).unwrap(), b"new image");
assert!(!stage.exists());
}
#[test]
fn failed_launch_restores_and_relaunches_the_previous_image() {
let root = tempfile::tempdir().unwrap();
let current = root.path().join("tty7.AppImage");
let stage = root.path().join("stage");
fs::create_dir(&stage).unwrap();
let replacement = stage.join("tty7-new.AppImage");
fs::write(&current, b"old image").unwrap();
fs::write(&replacement, b"new image").unwrap();
let launches = std::cell::Cell::new(0);
let reported_after_launches = std::cell::Cell::new(usize::MAX);
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(())
}
},
|_| reported_after_launches.set(launches.get()),
)
.unwrap_err();
assert_eq!(error, "new app failed");
assert_eq!(launches.get(), 2);
// The outcome is reported after the failed first launch but before
// the old app comes back — the relaunched GUI must find it already
// on disk at startup (#540).
assert_eq!(reported_after_launches.get(), 1);
assert_eq!(fs::read(&current).unwrap(), b"old image");
assert!(!stage.exists());
}
/// The installed image keeps the mode the user gave it — a 0700
/// image stays private — while the download's missing execute bit
/// never survives into the installation.
#[test]
fn the_replacement_wears_the_current_images_mode() {
let root = tempfile::tempdir().unwrap();
let current = root.path().join("tty7.AppImage");
let stage = root.path().join("stage");
fs::create_dir(&stage).unwrap();
let replacement = stage.join("tty7-new.AppImage");
fs::write(&current, b"old image").unwrap();
fs::write(&replacement, b"new image").unwrap();
fs::set_permissions(&current, fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(&replacement, fs::Permissions::from_mode(0o644)).unwrap();
replace_and_relaunch(&current, &replacement, &stage, |_| Ok(()), |_| ()).unwrap();
let mode = fs::metadata(&current).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "mode {mode:o}");
}
/// A leftover backup means an earlier attempt stopped between its two
/// renames; installing over it would overwrite the one copy of the
/// previous version.
#[test]
fn an_existing_backup_stops_the_replacement() {
let root = tempfile::tempdir().unwrap();
let current = root.path().join("tty7.AppImage");
let stage = root.path().join("stage");
fs::create_dir(&stage).unwrap();
let replacement = stage.join("tty7-new.AppImage");
fs::write(&current, b"old image").unwrap();
fs::write(&replacement, b"new image").unwrap();
fs::write(stage.join("previous.AppImage"), b"earlier backup").unwrap();
let error = replace_and_relaunch(
&current,
&replacement,
&stage,
|_| panic!("nothing may launch when the backup path is taken"),
|_| (),
)
.unwrap_err();
assert!(error.contains("backup already exists"), "{error}");
assert_eq!(fs::read(&current).unwrap(), b"old image");
}
#[test]
fn archive_verification_rejects_bytes_that_do_not_match_the_manifest() {
let root = tempfile::tempdir().unwrap();
let archive = root.path().join("tty7.AppImage");
let manifest = root.path().join("checksums.txt");
fs::write(&archive, b"downloaded bytes").unwrap();
fs::write(
&manifest,
format!(
"{} tty7.AppImage\n",
tty7_core::daemon::install::checksums::hex(
&tty7_core::daemon::install::checksums::sha256(b"published bytes")
)
),
)
.unwrap();
let error = verify_archive(&archive, &manifest, "tty7.AppImage").unwrap_err();
assert!(error.contains("failed sha256 verification"), "{error}");
}
/// The magic check runs before anything executes the download, so a
/// mis-published asset is named without being run.
#[test]
fn verification_rejects_a_file_that_is_not_an_appimage() {
let mut elf_with_marker = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0, b'A', b'I', 0x02];
elf_with_marker.resize(16, 0);
assert!(is_appimage(&elf_with_marker));
// A plain ELF — the tarball's binary, say — is not an AppImage.
let mut bare_elf = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0, 0, 0, 0];
bare_elf.resize(16, 0);
assert!(!is_appimage(&bare_elf));
assert!(!is_appimage(b"<html>Not Found</html>"));
assert!(!is_appimage(b""));
assert!(!is_appimage(&[0x7f, b'E', b'L', b'F']));
let root = tempfile::tempdir().unwrap();
let staged = root.path().join("tty7.AppImage");
fs::write(&staged, b"<html>Not Found</html>").unwrap();
let error = verify_update(&staged, root.path(), "27.1.0").unwrap_err();
assert!(error.contains("not a type-2 AppImage"), "{error}");
}
#[test]
fn the_desktop_entry_states_the_version() {
let text = "[Desktop Entry]\nType=Application\nName=tty7\nExec=tty7-app\n\
Icon=tty7\nX-AppImage-Version=26.8.4\n";
assert_eq!(version_from_desktop_entry(text).as_deref(), Some("26.8.4"));
// The nightly stamp survives whole — the identity the GUI
// compares against carries the prerelease tail.
assert_eq!(
version_from_desktop_entry("X-AppImage-Version=26.8.4-nightly.202608140200\n")
.as_deref(),
Some("26.8.4-nightly.202608140200")
);
assert_eq!(
version_from_desktop_entry("[Desktop Entry]\nName=tty7\n"),
None
);
// A stated nothing is not a version.
assert_eq!(version_from_desktop_entry("X-AppImage-Version=\n"), None);
assert_eq!(version_from_desktop_entry("X-AppImage-Version= \n"), None);
}
}
}
#[cfg(target_os = "windows")]
mod windows {
use std::collections::HashSet;
@@ -3366,8 +4055,16 @@ fn main() {
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[cfg(target_os = "linux")]
fn main() {
eprintln!("tty7-updater is only available on macOS and Windows");
if let Err(error) = linux::run() {
eprintln!("tty7-updater: {error}");
std::process::exit(1);
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn main() {
eprintln!("tty7-updater is only available on macOS, Windows and Linux");
std::process::exit(1);
}
+209 -18
View File
@@ -132,8 +132,10 @@ pub enum UpdateInstallHint {
UnsupportedMacos,
#[cfg(target_os = "linux")]
UnsupportedLinux,
/// Linux updates by hand, but "use the release page" leaves the user to
/// work out which of five files is theirs. This names it.
/// The Linux shapes that still update by hand — a tarball install, or an
/// AppImage that cannot replace itself (read-only directory, no bundled
/// helper). "Use the release page" would leave the user to work out which
/// of five files is theirs; this names it.
#[cfg(target_os = "linux")]
LinuxManualPackage(String),
#[cfg(target_os = "windows")]
@@ -1170,9 +1172,10 @@ fn sweep_orphaned_stages(keep: Option<PathBuf>) {
}
}
/// Where each platform's staging directories are created — beside the bundle on
/// macOS (it has to be on the app's own volume to rename into place), and the
/// per-user temp directory on Windows.
/// Where each platform's staging directories are created — beside the bundle
/// on macOS and beside the image on Linux (both have to be on the installed
/// file's own volume to rename into place), and the per-user temp directory
/// on Windows.
// The `return`s are what let one cfg block win per platform; clippy sees only
// the surviving one and reads it as redundant.
#[allow(clippy::needless_return)]
@@ -1184,11 +1187,18 @@ fn stage_roots() -> Vec<PathBuf> {
.into_iter()
.collect();
}
#[cfg(target_os = "linux")]
{
return current_appimage()
.and_then(|appimage| appimage.parent().map(Path::to_path_buf))
.into_iter()
.collect();
}
#[cfg(target_os = "windows")]
{
return vec![std::env::temp_dir()];
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Vec::new()
}
@@ -1970,6 +1980,7 @@ fn select_release_asset_for(
/// The release package this installation can replace itself with. Split from
/// the bare filename so the Windows Inno layout can carry "yes, but the
/// install needs a UAC prompt" alongside it (#504).
#[derive(Debug)]
struct PackageOffer {
name: String,
#[cfg(target_os = "windows")]
@@ -2017,14 +2028,16 @@ fn package_for_current_install(version: &str) -> Result<PackageOffer, UpdateInst
// there is no filename worth naming.
return Err(UpdateInstallHint::UnsupportedLinux);
};
// The AppImage runtime exports this, pointing at the image it mounted.
// Nothing else identifies which of the two Linux packages is installed.
let name = if std::env::var_os("APPIMAGE").is_some() {
format!("tty7-{version}-linux-{arch}.AppImage")
} else {
format!("tty7-{version}-linux-{arch}.tar.gz")
};
return Err(UpdateInstallHint::LinuxManualPackage(name));
let appimage = current_appimage();
// An AppImage can replace itself only if a staging directory fits
// beside it — the swap is two renames, which need one filesystem —
// and only if it carries the helper that performs the swap after
// this process exits. Images from before the helper shipped fail the
// second check and keep the manual path they always had.
let can_self_update = appimage.as_deref().is_some_and(|appimage| {
is_appimage_update_writable(appimage) && bundled_updater().is_some()
});
return linux_package_for(version, arch, appimage.is_some(), can_self_update);
}
#[cfg(target_os = "windows")]
{
@@ -2118,7 +2131,11 @@ fn prepare_update(
{
return prepare_windows_update(version, &asset.name, &archive, &checksums);
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[cfg(target_os = "linux")]
{
return prepare_linux_update(version, &asset.name, &archive, &checksums);
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
anyhow::bail!("automatic installation is not supported on this platform")
}
@@ -2228,7 +2245,7 @@ fn updater_tail_args(config_dir: Option<&Path>, outcome: Option<&Path>) -> Vec<s
args
}
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn update_staging_dir(parent: &Path) -> Result<tempfile::TempDir> {
tempfile::Builder::new()
.prefix(".tty7-update-")
@@ -2304,6 +2321,70 @@ fn prepare_macos_update(
})
}
/// Stages a downloaded AppImage beside the installed one and has the bundled
/// updater verify it while this process is still around to show a failure.
///
/// Staging lives in the image's own directory for the same reason macOS
/// stages beside the bundle: the swap is two renames, and renames only stay
/// atomic on one filesystem. The helper that performs them is *copied* into
/// staging rather than run from the image — the image is a FUSE mount the
/// runtime tears down when the GUI exits, which is precisely the moment the
/// installer starts working.
#[cfg(target_os = "linux")]
fn prepare_linux_update(
version: &str,
asset_name: &str,
archive: &[u8],
checksums: &[u8],
) -> Result<PreparedUpdate> {
let current = current_appimage().context("tty7 is not running from an AppImage")?;
let parent = current
.parent()
.context("the AppImage has no parent directory")?;
let bundled = bundled_updater().context("tty7-updater is not bundled with this image")?;
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(
&bundled,
[
PathBuf::from("verify"),
archive.clone(),
checksums.clone(),
PathBuf::from(asset_name),
dir.clone(),
PathBuf::from(version),
],
)?;
let updater = dir.join("tty7-updater");
std::fs::copy(&bundled, &updater)
.with_context(|| format!("copying the updater to {}", updater.display()))?;
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,
command: "install".to_string(),
rest: vec![
current,
archive,
checksums,
PathBuf::from(asset_name),
dir.clone(),
PathBuf::from(version),
log,
],
config_dir: crate::core::config::config_dir_path(),
stage: dir,
needs_elevation: false,
expected_sha256: None,
})
}
#[cfg(target_os = "windows")]
fn prepare_windows_update(
version: &str,
@@ -2441,11 +2522,63 @@ fn bundled_updater() -> Option<PathBuf> {
updater.is_file().then_some(updater)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
/// The updater shipped inside the mounted image, beside this executable at
/// `usr/bin`. Resolved through `current_exe` rather than `$APPDIR` so a
/// stale or hand-set variable cannot point the update machinery at a binary
/// that is not the one this process actually runs beside.
#[cfg(target_os = "linux")]
fn bundled_updater() -> Option<PathBuf> {
let updater = std::env::current_exe().ok()?.parent()?.join("tty7-updater");
updater.is_file().then_some(updater)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn bundled_updater() -> Option<PathBuf> {
None
}
/// The image this process is running from. The AppImage runtime exports
/// `$APPIMAGE`, pointing at the file it mounted; nothing else identifies
/// which of the Linux packages is installed. Required to name an existing
/// absolute path — the variable outlives moves and deletions, and every
/// answer given here is a file the updater will later rename.
#[cfg(target_os = "linux")]
fn current_appimage() -> Option<PathBuf> {
let appimage = PathBuf::from(std::env::var_os("APPIMAGE")?);
(appimage.is_absolute() && appimage.is_file()).then_some(appimage)
}
#[cfg(target_os = "linux")]
fn is_appimage_update_writable(appimage: &Path) -> bool {
appimage.parent().is_some_and(can_stage_replacement_in)
}
/// The Linux answer, split from the probing so the policy is testable
/// without an AppImage runtime setting variables. Only an AppImage that can
/// stage and swap itself is offered an install; every other shape keeps the
/// named-package hint it always had — a tarball unpacks wherever the user
/// chose, a distro package belongs to its package manager, and guessing at
/// either is exactly what `package_for_current_install` must never do.
#[cfg(target_os = "linux")]
fn linux_package_for(
version: &str,
arch: &str,
is_appimage: bool,
can_self_update: bool,
) -> Result<PackageOffer, UpdateInstallHint> {
if !is_appimage {
return Err(UpdateInstallHint::LinuxManualPackage(format!(
"tty7-{version}-linux-{arch}.tar.gz"
)));
}
let name = format!("tty7-{version}-linux-{arch}.AppImage");
if can_self_update {
Ok(PackageOffer::plain(name))
} else {
Err(UpdateInstallHint::LinuxManualPackage(name))
}
}
#[cfg(target_os = "windows")]
#[derive(Clone, Debug, PartialEq, Eq)]
enum WindowsUpdateLayout {
@@ -2685,7 +2818,7 @@ fn windows_all_users_install_path() -> Option<PathBuf> {
(!value.is_empty()).then(|| PathBuf::from(std::ffi::OsString::from_wide(&value)))
}
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn can_stage_replacement_in(dir: &Path) -> bool {
tempfile::Builder::new()
.prefix(".tty7-update-write-test-")
@@ -2816,6 +2949,64 @@ mod tests {
);
}
/// The one Linux shape that installs itself: an AppImage whose directory
/// takes a staging directory and whose image carries the helper.
#[cfg(target_os = "linux")]
#[test]
fn an_appimage_that_can_replace_itself_is_offered_the_appimage() {
let offer = linux_package_for("27.1.0", "x86_64", true, true)
.expect("a self-updating AppImage yields an offer");
assert_eq!(offer.name, "tty7-27.1.0-linux-x86_64.AppImage");
}
/// Everything else keeps the manual hint, and the hint names the exact
/// package for the installation shape rather than pointing at a release
/// page with five files on it.
#[cfg(target_os = "linux")]
#[test]
fn linux_installs_that_cannot_self_update_name_their_package() {
// An AppImage in a read-only directory, or one from before the
// helper shipped: still an AppImage, still updated by hand.
assert_eq!(
linux_package_for("27.1.0", "x86_64", true, false).unwrap_err(),
UpdateInstallHint::LinuxManualPackage("tty7-27.1.0-linux-x86_64.AppImage".to_string())
);
// A tarball (or distro-packaged) install is never guessed at.
assert_eq!(
linux_package_for("27.1.0", "x86_64", false, false).unwrap_err(),
UpdateInstallHint::LinuxManualPackage("tty7-27.1.0-linux-x86_64.tar.gz".to_string())
);
// `can_self_update` without an AppImage cannot happen (the probe is
// gated on the variable), but the policy must not invent an offer if
// it ever does.
assert!(linux_package_for("27.1.0", "x86_64", false, true).is_err());
}
/// The offered AppImage name must match what the release actually
/// publishes, checksums manifest included — the same end-to-end shape the
/// macOS selection test pins.
#[cfg(target_os = "linux")]
#[test]
fn appimage_selection_matches_the_published_asset_names() {
let name = "tty7-27.1.0-linux-x86_64.AppImage";
let offer = linux_package_for("27.1.0", "x86_64", true, true).unwrap();
let assets = [
github_asset("tty7-27.1.0-linux-x86_64.tar.gz"),
github_asset(name),
github_asset("checksums.txt"),
];
let selected = select_release_asset_for(Ok(offer), &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);
}
#[cfg(target_os = "windows")]
#[test]
fn portable_backup_scan_separates_interrupted_from_finished() {