From 71689d8554608fbedabb23edc86b5246d56c8c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Fri, 30 Oct 2026 16:11:54 +0100 Subject: [PATCH 1/5] Describe self-update; publish an update manifest with every release The release workflow compiles the manifest URL into its builds (only those update themselves) and publishes update-manifest.json, listing URL, sha256 and size of every zip. --- .github/workflows/release.yml | 15 +- crates/code_assistant_core/build.rs | 6 + crates/code_assistant_core/src/version.rs | 6 + docs/self-update.md | 185 ++++++++++++++++++++++ scripts/update-manifest.sh | 36 +++++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 docs/self-update.md create mode 100755 scripts/update-manifest.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e7708fac..98653cd95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,6 +179,10 @@ jobs: - name: Build uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1.0.3 + env: + # Compiled in by code_assistant_core's build.rs: only builds that + # carry a feed update themselves (docs/self-update.md). + CODE_ASSISTANT_UPDATE_FEED: https://github.com/${{ github.repository }}/releases/latest/download/update-manifest.json with: command: build args: --locked --release --target ${{ matrix.target }} @@ -361,6 +365,13 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "Tagged $TAG at $TAG_SHA" + - name: Write update manifest + run: | + ./scripts/update-manifest.sh "$RELEASE_VERSION" \ + "https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}" \ + dist/*.zip > dist/update-manifest.json + cat dist/update-manifest.json + - name: Create GitHub release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: @@ -369,7 +380,9 @@ jobs: name: Release ${{ steps.tag.outputs.tag }} draft: false prerelease: false - files: dist/*.zip + files: | + dist/*.zip + dist/update-manifest.json - name: Bump to next development version run: | diff --git a/crates/code_assistant_core/build.rs b/crates/code_assistant_core/build.rs index 07694136a..ad04da2f5 100644 --- a/crates/code_assistant_core/build.rs +++ b/crates/code_assistant_core/build.rs @@ -54,6 +54,12 @@ fn main() { // Target triple the binary is being built for, e.g. "aarch64-apple-darwin". let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".to_string()); println!("cargo:rustc-env=BUILD_TARGET={target}"); + + // Update manifest URL; only the release workflow sets it, so only its + // builds update themselves (docs/self-update.md). Empty otherwise. + println!("cargo:rerun-if-env-changed=CODE_ASSISTANT_UPDATE_FEED"); + let feed = std::env::var("CODE_ASSISTANT_UPDATE_FEED").unwrap_or_default(); + println!("cargo:rustc-env=BUILD_UPDATE_FEED={feed}"); } /// Run a git subcommand and return its trimmed stdout on success. diff --git a/crates/code_assistant_core/src/version.rs b/crates/code_assistant_core/src/version.rs index ab9574c2c..62cdc9ce3 100644 --- a/crates/code_assistant_core/src/version.rs +++ b/crates/code_assistant_core/src/version.rs @@ -23,6 +23,12 @@ pub const RUSTC_VERSION: &str = env!("BUILD_RUSTC_VERSION"); /// Target triple, e.g. `aarch64-apple-darwin`. pub const TARGET: &str = env!("BUILD_TARGET"); +/// Update manifest URL compiled into release builds; `None` for every other +/// build, which never updates itself. +pub fn update_feed() -> Option<&'static str> { + Some(env!("BUILD_UPDATE_FEED")).filter(|feed| !feed.is_empty()) +} + /// `true` for optimized (non-debug) builds. pub fn is_release_build() -> bool { !cfg!(debug_assertions) diff --git a/docs/self-update.md b/docs/self-update.md new file mode 100644 index 000000000..668cc2976 --- /dev/null +++ b/docs/self-update.md @@ -0,0 +1,185 @@ +# Self-update + +A released app notices a newer release, downloads it when the user asks, +and replaces itself on *Restart to update*. Modelled on T3 Code's desktop +updater (electron-updater driven by a small state machine), without an +installer framework: our release artifacts are zips of a single binary or of +the macOS `.app` bundle, so the swap is ours to do, and GPUI's +`App::restart` already restarts the app on every platform. + +Code: `crates/app_update` (Layer 0: manifest, installation detection, +download, install, cleanup), `code_assistant_core::updates` (the +`UpdateService`, its state and poller), `ui_gpui` (title-bar pill, About +dialog). + +## Feed + +Every release carries an `update-manifest.json` asset, written by the +`publish` job of `.github/workflows/release.yml` from the zips it publishes: + +```json +{ + "version": "0.2.19", + "assets": { + "Code-Assistant-macos-aarch64.app.zip": { + "url": "https://github.com/stippi/code-assistant/releases/download/v0.2.19/Code-Assistant-macos-aarch64.app.zip", + "sha256": "…", + "size": 31457280 + }, + "code-assistant-linux-x86_64.zip": { "…": "…" } + } +} +``` + +The app reads it from +`https://github.com/stippi/code-assistant/releases/latest/download/update-manifest.json`, +not from the REST API: unauthenticated API calls are limited to 60 an hour +per IP, which users behind one company NAT would share. + +The manifest is keyed by asset file name. Which name an installation needs +is the app's business (`updates::asset_name`): the target triple it was +built for plus whether it runs from an `.app` bundle. An Intel build running +under Rosetta asks for the `aarch64` asset, so the next update moves it to +the native build (T3 does the same). + +The sha256 comes from the same release, so it protects the download, not +against a compromised release. On macOS the new bundle must in addition +carry the running app's code-signing team (below). Signing the manifest +(minisign, key compiled in) would close the gap; not done yet. + +## When updates are on + +Only builds of the release workflow update themselves: it sets +`CODE_ASSISTANT_UPDATE_FEED` while building, `build.rs` compiles the URL in +(`version::update_feed()`). Everything else — `cargo build`, the +`target/release` binary Zed starts over ACP — has no feed and never replaces +itself. The git tree state can not decide this: an explicit release version +is applied as a working-tree edit, so release builds are "dirty". + +The service reports *disabled* with a reason when + +- the build has no feed (runtime `CODE_ASSISTANT_UPDATE_FEED` sets one, for + testing against a local server), +- `CODE_ASSISTANT_DISABLE_AUTO_UPDATE` is set, +- macOS runs the app translocated (started from `~/Downloads` without moving + it; Gatekeeper runs it from a read-only random path) — *move it to + Applications*, +- the folder holding the bundle or binary is not writable (e.g. a binary in + `/usr/local/bin`), +- the platform has no release asset. + +Only the GPUI frontend runs the service; ACP, MCP-server and terminal modes +do not check for updates. + +## States + +T3 keeps one flat record with `status`, `errorContext` and `canRetry`; an +enum carries the same: + +| Status | Pill | Click | +| --- | --- | --- | +| `Disabled { reason }` | — | — | +| `Idle` / `Checking` / `UpToDate` | — | — | +| `Available { version }` | *Update 0.2.19* | download | +| `Downloading { version, received, total }` | progress | — | +| `Downloaded { version }` | *Restart to update* | install + restart | +| `Installing { version }` | spinner | — | + +Next to the status: the last error with its context (check, download, +install) and `checked_at`. Rules, as in T3: + +- A failed download goes back to `Available` with the error; the pill offers + the retry ("Download failed — retry"). +- A failed install goes back to `Downloaded` with the error. +- A failed check keeps the status it had. Unlike T3 the pill shows nothing + for it — a laptop offline for a day would otherwise carry a warning in its + title bar; the About dialog shows the error. A check asked for by hand + shows its result there. +- A check finding the version already downloaded (and verified) goes + straight to `Downloaded`. +- One action at a time: a check while downloading or installing is skipped. + +## Checking + +First check 15 seconds after start, then every 4 hours, and by hand from the +About dialog. Downloading starts only on a click (an *automatically +download* setting can come later). + +## Download + +`/code-assistant/updates//` (macOS: +`~/Library/Caches/…`). + +- Bytes go to `.part`. A new attempt asks for the rest with + `Range: bytes=-`; a `206` appends, a `200` starts over, a `416` drops + the part and starts over. +- Network errors, timeouts, `5xx`, `408` and `429` are retried with backoff + (5 attempts: 1 s, 2 s, 4 s, 8 s), every attempt resuming the part. Other + `4xx` fail at once. After the last attempt the state goes back to + `Available`; the part stays, so the user's retry resumes it, also after a + restart of the app. +- The finished part must have the manifest's size and sha256; then it is + renamed to ``. A mismatch deletes it and counts as a failed + attempt. +- Progress is published when the whole percentage changes. + +electron-updater (T3) does not resume: it retries server errors and, for +some targets, downloads only the blocks that changed (blockmaps). A single +binary changes almost entirely between releases, so blockmaps would not +help us; resuming does. + +## Install and restart + +*Restart to update* first asks when sessions are working (their turn is +cut off; T3 stops its backends the same way). Then, on the backend runtime: + +1. Extract next to the installation, in a hidden folder on the same volume, + so the swap is a `rename`. +2. Check: macOS: `codesign --verify --deep --strict` on the new bundle, and + when the running app is signed by a team, the new one must be signed by + the same team. Elsewhere: the binary is a regular file. +3. Write the install record (`updates/install.json`: + `{ from_version, target_version }`). +4. Swap: + - **macOS bundle**: running `.app` → hidden backup, new `.app` into place, + backup deleted. + - **Linux/macOS binary**: new file → `rename` over the old one. The + running process (and any ACP process Zed started) keeps the old inode. + - **Windows**: a running `.exe` can not be overwritten but can be + renamed: `code-assistant.exe` → `code-assistant.exe.old`, new file into + place. The `.old` file is deleted on the next start (or the one after, + while another process still runs it). + If the second rename fails, the first is undone. +5. GPUI calls `cx.restart()`: a script waits for the process to exit and + starts the same path again (`open` for the bundle). + +Any failure before the swap leaves the installation untouched and goes back +to `Downloaded` with the error. + +## Cleanup and "did not apply" + +At start the service + +- reads the install record: running `from_version` still means the update + did not apply — the state offers it again with that note (T3: "Update X + did not apply. Click to retry."); otherwise the record is deleted, +- deletes `updates/` folders for versions not newer than the + running one, +- deletes leftovers of an interrupted install next to the installation + (hidden staging folder, macOS backup, Windows `.old`). + +When a check finds a newer version than one partly or fully downloaded, the +older folder goes. + +## Release workflow + +- The build jobs set `CODE_ASSISTANT_UPDATE_FEED` for `cargo build`. +- The `publish` job writes `update-manifest.json` (`scripts/update-manifest.sh`: + name, URL, sha256 and size of every zip) and uploads it with the zips. + +## Not done yet + +- Release notes in the pill (the release text is "Release vX" for now). +- A setting to download automatically. +- A signed manifest. +- Channels (T3 has stable and nightly). diff --git a/scripts/update-manifest.sh b/scripts/update-manifest.sh new file mode 100755 index 000000000..98e5af09e --- /dev/null +++ b/scripts/update-manifest.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Write the update manifest the app's self-update reads (docs/self-update.md). +# +# Usage: +# ./scripts/update-manifest.sh VERSION DOWNLOAD_BASE_URL ASSET... > update-manifest.json +# +# DOWNLOAD_BASE_URL is the release's download folder, e.g. +# https://github.com/stippi/code-assistant/releases/download/v0.2.19 +# Every ASSET is listed under its file name with URL, sha256 and size. + +set -euo pipefail + +if [[ $# -lt 3 ]]; then + sed -n '2,9p' "$0" >&2 + exit 1 +fi + +VERSION="$1" +BASE_URL="${2%/}" +shift 2 + +assets='{}' +for file in "$@"; do + name="$(basename "$file")" + sha256="$(sha256sum "$file" | cut -d' ' -f1)" + size="$(wc -c < "$file" | tr -d ' ')" + assets="$(jq \ + --arg name "$name" \ + --arg url "$BASE_URL/$name" \ + --arg sha256 "$sha256" \ + --argjson size "$size" \ + '.[$name] = {url: $url, sha256: $sha256, size: $size}' <<< "$assets")" +done + +jq -n --arg version "$VERSION" --argjson assets "$assets" \ + '{version: $version, assets: $assets}' From eadb60fdb3243d2b540ad306095de3e858581391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Fri, 30 Oct 2026 16:19:36 +0100 Subject: [PATCH 2/5] app_update crate: manifest, download with resume and retries, install, cleanup Layer 0 mechanics of the self-update: an interrupted download resumes with Range, network and server errors are retried with backoff, the result is checked against the manifest's size and sha256. Installing extracts next to the installation and renames into place: the whole bundle on macOS (after codesign verification and a team check), the executable elsewhere (on Windows the running exe is renamed away first). --- Cargo.lock | 20 + Cargo.toml | 2 +- crates/app_update/Cargo.toml | 33 ++ crates/app_update/src/client.rs | 502 ++++++++++++++++++++++++++ crates/app_update/src/install.rs | 440 ++++++++++++++++++++++ crates/app_update/src/installation.rs | 176 +++++++++ crates/app_update/src/lib.rs | 19 + crates/app_update/src/manifest.rs | 69 ++++ crates/app_update/src/store.rs | 126 +++++++ 9 files changed, 1386 insertions(+), 1 deletion(-) create mode 100644 crates/app_update/Cargo.toml create mode 100644 crates/app_update/src/client.rs create mode 100644 crates/app_update/src/install.rs create mode 100644 crates/app_update/src/installation.rs create mode 100644 crates/app_update/src/lib.rs create mode 100644 crates/app_update/src/manifest.rs create mode 100644 crates/app_update/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index d7838dfc4..c19048b25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,6 +409,26 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "app_update" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum 0.7.9", + "bytes", + "futures", + "libc", + "reqwest", + "semver", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "tokio", + "tracing", + "zip", +] + [[package]] name = "approx" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 8511b055a..fd73790a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/agent_core", "crates/agent_orchestration", "crates/code_assistant", "crates/code_assistant_core", "crates/audio", "crates/ui_acp", "crates/ui_gpui", "crates/ui_terminal", "crates/command_executor", "crates/fs_explorer", "crates/git", "crates/github", "crates/llm", "crates/mcp_client", "crates/mcp_server", "crates/pty_session", "crates/sandbox", "crates/terminal", "crates/terminal_output", "crates/terminal_test_app", "crates/terminal_view", "crates/tools_core", "crates/web"] +members = ["crates/agent_core", "crates/app_update", "crates/agent_orchestration", "crates/code_assistant", "crates/code_assistant_core", "crates/audio", "crates/ui_acp", "crates/ui_gpui", "crates/ui_terminal", "crates/command_executor", "crates/fs_explorer", "crates/git", "crates/github", "crates/llm", "crates/mcp_client", "crates/mcp_server", "crates/pty_session", "crates/sandbox", "crates/terminal", "crates/terminal_output", "crates/terminal_test_app", "crates/terminal_view", "crates/tools_core", "crates/web"] resolver = "2" diff --git a/crates/app_update/Cargo.toml b/crates/app_update/Cargo.toml new file mode 100644 index 000000000..3c5e72b44 --- /dev/null +++ b/crates/app_update/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "app_update" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +anyhow = "1.0" +futures = "0.3" +reqwest = { version = "0.13", default-features = false, features = [ + "rustls", + "charset", + "http2", + "system-proxy", + "stream", +] } +semver = { version = "1.0", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.11.0" +tokio = { version = "1.48", features = ["fs", "io-util", "process", "time", "rt"] } +tracing = "0.1" +zip = { version = "8.6", default-features = false, features = ["deflate"] } + +[target.'cfg(target_os = "macos")'.dependencies] +# sysctlbyname("sysctl.proc_translated"): running under Rosetta +libc = "0.2" + +[dev-dependencies] +axum = "0.7" +bytes = "1.10" +tempfile = "3.23" +tokio = { version = "1.48", features = ["full"] } diff --git a/crates/app_update/src/client.rs b/crates/app_update/src/client.rs new file mode 100644 index 000000000..febffcc3a --- /dev/null +++ b/crates/app_update/src/client.rs @@ -0,0 +1,502 @@ +//! Fetching the manifest and downloading an asset: resumed with `Range` +//! after a broken connection, retried with backoff, verified against the +//! manifest's size and sha256. + +use crate::manifest::{Asset, Manifest}; +use anyhow::{Context, anyhow, bail}; +use futures::StreamExt; +use reqwest::StatusCode; +use reqwest::header::RANGE; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tracing::{debug, warn}; + +/// How often a download is tried before it fails, and how long to wait +/// before the second attempt (doubling after every further one). +#[derive(Debug, Clone, Copy)] +pub struct RetryPolicy { + pub attempts: u32, + pub first_delay: Duration, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + attempts: 5, + first_delay: Duration::from_secs(1), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Progress { + pub received: u64, + pub total: u64, +} + +#[derive(Clone)] +pub struct UpdateClient { + http: reqwest::Client, + retry: RetryPolicy, +} + +/// Why one download attempt failed: worth another attempt or not. +enum AttemptError { + Retry(anyhow::Error), + Fatal(anyhow::Error), +} + +impl UpdateClient { + pub fn new(user_agent: &str) -> anyhow::Result { + let http = reqwest::Client::builder() + .user_agent(user_agent) + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .build()?; + Ok(Self { + http, + retry: RetryPolicy::default(), + }) + } + + pub fn with_retry(mut self, retry: RetryPolicy) -> Self { + self.retry = retry; + self + } + + pub async fn fetch_manifest(&self, url: &str) -> anyhow::Result { + let response = self + .http + .get(url) + .timeout(Duration::from_secs(30)) + .send() + .await + .context("fetching the update manifest")?; + let status = response.status(); + if !status.is_success() { + bail!("fetching the update manifest: HTTP {status}"); + } + Manifest::parse(&response.bytes().await?) + } + + /// Downloads `asset` to `dest`, unless a verified copy is there already. + /// Bytes collect in `.part`, which survives a failure so the next + /// call resumes it. + pub async fn download( + &self, + asset: &Asset, + dest: &Path, + mut progress: impl FnMut(Progress) + Send, + ) -> anyhow::Result<()> { + if is_verified(dest, asset).await { + return Ok(()); + } + if let Some(dir) = dest.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let part = part_path(dest); + let mut delay = self.retry.first_delay; + let mut last_error = anyhow!("no download attempt"); + for attempt in 1..=self.retry.attempts.max(1) { + if attempt > 1 { + tokio::time::sleep(delay).await; + delay *= 2; + } + match self.attempt(asset, &part, &mut progress).await { + Ok(()) if is_verified(&part, asset).await => { + tokio::fs::rename(&part, dest).await?; + return Ok(()); + } + Ok(()) => { + let _ = tokio::fs::remove_file(&part).await; + last_error = anyhow!("the download does not match the release's checksum"); + } + Err(AttemptError::Fatal(e)) => return Err(e), + Err(AttemptError::Retry(e)) => last_error = e, + } + warn!("Update download attempt {attempt} failed: {last_error:#}"); + } + Err(last_error) + } + + /// One request for what `part` lacks; appends to it. + async fn attempt( + &self, + asset: &Asset, + part: &Path, + progress: &mut (impl FnMut(Progress) + Send), + ) -> Result<(), AttemptError> { + let mut offset = tokio::fs::metadata(part).await.map_or(0, |m| m.len()); + if offset > asset.size { + let _ = tokio::fs::remove_file(part).await; + offset = 0; + } + if offset == asset.size { + return Ok(()); + } + + let mut request = self.http.get(&asset.url); + if offset > 0 { + request = request.header(RANGE, format!("bytes={offset}-")); + } + let response = request + .send() + .await + .map_err(|e| AttemptError::Retry(e.into()))?; + let status = response.status(); + let append = match status { + StatusCode::PARTIAL_CONTENT if offset > 0 => true, + StatusCode::OK => false, + StatusCode::RANGE_NOT_SATISFIABLE => { + let _ = tokio::fs::remove_file(part).await; + return Err(AttemptError::Retry(anyhow!("HTTP {status}"))); + } + status if is_transient(status) => { + return Err(AttemptError::Retry(anyhow!("HTTP {status}"))); + } + status => return Err(AttemptError::Fatal(anyhow!("HTTP {status}"))), + }; + debug!(append, offset, "Downloading {}", asset.url); + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .append(append) + .truncate(!append) + .open(part) + .await + .map_err(|e| AttemptError::Fatal(e.into()))?; + let mut received = if append { offset } else { 0 }; + progress(Progress { + received, + total: asset.size, + }); + let mut body = response.bytes_stream(); + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(|e| AttemptError::Retry(e.into()))?; + file.write_all(&chunk) + .await + .map_err(|e| AttemptError::Fatal(e.into()))?; + received += chunk.len() as u64; + if received > asset.size { + drop(file); + let _ = tokio::fs::remove_file(part).await; + return Err(AttemptError::Retry(anyhow!( + "the download is larger than the release says" + ))); + } + progress(Progress { + received, + total: asset.size, + }); + } + file.flush() + .await + .map_err(|e| AttemptError::Fatal(e.into()))?; + Ok(()) + } +} + +/// Whether `path` holds exactly the asset. +pub async fn is_verified(path: &Path, asset: &Asset) -> bool { + let path = path.to_owned(); + let asset = asset.clone(); + tokio::task::spawn_blocking(move || { + std::fs::metadata(&path).is_ok_and(|m| m.len() == asset.size) + && sha256_hex(&path).is_ok_and(|hash| hash.eq_ignore_ascii_case(&asset.sha256)) + }) + .await + .unwrap_or(false) +} + +fn sha256_hex(path: &Path) -> std::io::Result { + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn part_path(dest: &Path) -> PathBuf { + let mut name = dest.file_name().unwrap_or_default().to_owned(); + name.push(".part"); + dest.with_file_name(name) +} + +fn is_transient(status: StatusCode) -> bool { + status.is_server_error() + || status == StatusCode::REQUEST_TIMEOUT + || status == StatusCode::TOO_MANY_REQUESTS +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::extract::State; + use axum::http::{HeaderMap, Response, StatusCode as AxumStatus, header}; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + /// What the test server does with the next request. + #[derive(Clone, Copy)] + enum Behavior { + /// Answers, honouring a `Range` request. + Serve, + /// Answers with the whole body, ignoring `Range`. + IgnoreRange, + /// Sends this many bytes of the answer, then breaks the connection. + CutAfter(usize), + Status(u16), + } + + struct Server { + body: Vec, + script: Mutex>, + /// The `Range` header of every request. + ranges: Mutex>>, + } + + async fn serve(State(server): State>, headers: HeaderMap) -> Response { + let range = headers + .get(header::RANGE) + .map(|value| value.to_str().unwrap().to_string()); + server.ranges.lock().unwrap().push(range.clone()); + let behavior = server + .script + .lock() + .unwrap() + .pop_front() + .unwrap_or(Behavior::Serve); + let offset = range + .as_deref() + .and_then(|r| r.strip_prefix("bytes=")) + .and_then(|r| r.strip_suffix('-')) + .map_or(0, |n| n.parse::().unwrap()); + let (status, offset) = match behavior { + Behavior::Status(code) => { + return Response::builder() + .status(code) + .body(Body::empty()) + .unwrap(); + } + Behavior::IgnoreRange => (AxumStatus::OK, 0), + _ if offset > 0 => (AxumStatus::PARTIAL_CONTENT, offset), + _ => (AxumStatus::OK, 0), + }; + let rest = bytes::Bytes::copy_from_slice(&server.body[offset..]); + let body = match behavior { + Behavior::CutAfter(n) => { + let sent = rest.slice(..n); + // The break comes a moment later, so hyper sends the bytes + // before it drops the connection. + let broken = futures::stream::once(async { + tokio::time::sleep(Duration::from_millis(50)).await; + Err(std::io::Error::other("connection lost")) + }); + Body::from_stream(futures::stream::iter([Ok(sent)]).chain(broken)) + } + _ => Body::from(rest.clone()), + }; + Response::builder() + .status(status) + .header(header::CONTENT_LENGTH, rest.len()) + .body(body) + .unwrap() + } + + struct Fixture { + server: Arc, + asset: Asset, + dir: tempfile::TempDir, + client: UpdateClient, + } + + impl Fixture { + async fn new(script: impl IntoIterator) -> Self { + let body: Vec = (0..100_000u32).map(|i| (i % 251) as u8).collect(); + let server = Arc::new(Server { + body: body.clone(), + script: Mutex::new(script.into_iter().collect()), + ranges: Mutex::default(), + }); + let router = axum::Router::new() + .route("/asset.zip", axum::routing::get(serve)) + .with_state(server.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + let asset = Asset { + url: format!("http://{address}/asset.zip"), + sha256: Sha256::digest(&body) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + size: body.len() as u64, + }; + let client = UpdateClient::new("test").unwrap().with_retry(RetryPolicy { + attempts: 3, + first_delay: Duration::ZERO, + }); + Self { + server, + asset, + dir: tempfile::tempdir().unwrap(), + client, + } + } + + fn dest(&self) -> PathBuf { + self.dir.path().join("0.2.19").join("asset.zip") + } + + async fn download(&self) -> anyhow::Result> { + let mut seen = Vec::new(); + self.client + .download(&self.asset, &self.dest(), |p| seen.push(p)) + .await + .map(|()| seen) + } + + fn ranges(&self) -> Vec> { + self.server.ranges.lock().unwrap().clone() + } + } + + #[tokio::test] + async fn downloads_and_verifies() { + let fixture = Fixture::new([]).await; + + let progress = fixture.download().await.unwrap(); + + assert_eq!(std::fs::read(fixture.dest()).unwrap(), fixture.server.body); + assert!(!part_path(&fixture.dest()).exists()); + assert_eq!(progress.last().unwrap().received, fixture.asset.size); + assert_eq!(fixture.ranges(), [None]); + } + + #[tokio::test] + async fn resumes_after_a_broken_connection() { + let fixture = Fixture::new([Behavior::CutAfter(30_000)]).await; + + fixture.download().await.unwrap(); + + assert_eq!(std::fs::read(fixture.dest()).unwrap(), fixture.server.body); + assert_eq!( + fixture.ranges(), + [None, Some("bytes=30000-".to_string())], + "the second attempt asks only for the rest" + ); + } + + #[tokio::test] + async fn starts_over_when_the_server_ignores_the_range() { + let fixture = Fixture::new([Behavior::CutAfter(30_000), Behavior::IgnoreRange]).await; + + fixture.download().await.unwrap(); + + assert_eq!(std::fs::read(fixture.dest()).unwrap(), fixture.server.body); + } + + #[tokio::test] + async fn retries_server_errors() { + let fixture = Fixture::new([Behavior::Status(503), Behavior::Status(429)]).await; + + fixture.download().await.unwrap(); + + assert_eq!(fixture.ranges().len(), 3); + } + + #[tokio::test] + async fn gives_up_at_once_on_a_missing_asset() { + let fixture = Fixture::new([Behavior::Status(404)]).await; + + let error = fixture.download().await.unwrap_err(); + + assert!(error.to_string().contains("404"), "{error:#}"); + assert_eq!(fixture.ranges().len(), 1); + } + + #[tokio::test] + async fn keeps_the_part_when_attempts_run_out_and_resumes_it_later() { + let fixture = Fixture::new([ + Behavior::CutAfter(10_000), + Behavior::Status(502), + Behavior::Status(502), + ]) + .await; + + assert!(fixture.download().await.is_err()); + assert_eq!( + std::fs::metadata(part_path(&fixture.dest())).unwrap().len(), + 10_000 + ); + + fixture.download().await.unwrap(); + assert_eq!( + fixture.ranges().last().unwrap().as_deref(), + Some("bytes=10000-") + ); + assert_eq!(std::fs::read(fixture.dest()).unwrap(), fixture.server.body); + } + + #[tokio::test] + async fn rejects_a_download_with_the_wrong_checksum() { + let mut fixture = Fixture::new([]).await; + fixture.asset.sha256 = "00".repeat(32); + + let error = fixture.download().await.unwrap_err(); + + assert!(error.to_string().contains("checksum"), "{error:#}"); + assert!(!fixture.dest().exists()); + assert!(!part_path(&fixture.dest()).exists()); + assert_eq!(fixture.ranges().len(), 3); + } + + #[tokio::test] + async fn reuses_a_verified_download() { + let fixture = Fixture::new([]).await; + fixture.download().await.unwrap(); + + fixture.download().await.unwrap(); + + assert_eq!(fixture.ranges().len(), 1); + } + + #[tokio::test] + async fn fetches_the_manifest() { + let app = axum::Router::new().route( + "/update-manifest.json", + axum::routing::get(|| async { r#"{"version": "1.2.3", "assets": {}}"# }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = UpdateClient::new("test").unwrap(); + + let manifest = client + .fetch_manifest(&format!("http://{address}/update-manifest.json")) + .await + .unwrap(); + assert_eq!(manifest.version, semver::Version::new(1, 2, 3)); + + let missing = client + .fetch_manifest(&format!("http://{address}/missing.json")) + .await; + assert!(missing.unwrap_err().to_string().contains("404")); + } +} diff --git a/crates/app_update/src/install.rs b/crates/app_update/src/install.rs new file mode 100644 index 000000000..1ce2a29aa --- /dev/null +++ b/crates/app_update/src/install.rs @@ -0,0 +1,440 @@ +//! Swapping a downloaded release in for the installation: extract next to +//! it, check what came out, rename it into place. + +use crate::installation::{Installation, InstallationKind}; +use anyhow::{Context, bail}; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{info, warn}; + +/// Replaces `installation` with the app in `archive`, a zip of the bundle or +/// of the executable. Nothing changes when this fails before the swap; a +/// failed swap is undone. +pub async fn install(installation: &Installation, archive: &Path) -> anyhow::Result<()> { + let installation = installation.clone(); + let archive = archive.to_owned(); + tokio::task::spawn_blocking(move || install_blocking(&installation, &archive)).await? +} + +/// Deletes what an interrupted install left next to the installation, and +/// on Windows the executables earlier installs renamed away. +pub fn remove_leftovers(installation: &Installation) { + let _ = fs::remove_dir_all(staging_dir(installation)); + let _ = fs::remove_dir_all(backup_path(installation)); + let Some(name) = file_name(&installation.path) else { + return; + }; + let Ok(entries) = fs::read_dir(installation.parent()) else { + return; + }; + for entry in entries.flatten() { + let Ok(entry_name) = entry.file_name().into_string() else { + continue; + }; + if entry_name.starts_with(&format!("{name}.")) && entry_name.ends_with(".old") { + // Fails while another process still runs it; next start then. + let _ = fs::remove_file(entry.path()); + } + } +} + +fn install_blocking(installation: &Installation, archive: &Path) -> anyhow::Result<()> { + let staging = staging_dir(installation); + let _ = fs::remove_dir_all(&staging); + fs::create_dir_all(&staging).with_context(|| format!("creating {}", staging.display()))?; + let result = extract(installation, archive, &staging) + .and_then(|new| check(installation, &new).map(|()| new)) + .and_then(|new| swap(installation, &new)); + let _ = fs::remove_dir_all(&staging); + if result.is_ok() { + info!("Installed the update at {}", installation.path.display()); + } + result +} + +fn file_name(path: &Path) -> Option { + path.file_name()?.to_str().map(str::to_owned) +} + +fn hidden_sibling(installation: &Installation, suffix: &str) -> PathBuf { + let name = file_name(&installation.path).unwrap_or_default(); + installation.parent().join(format!(".{name}.{suffix}")) +} + +fn staging_dir(installation: &Installation) -> PathBuf { + hidden_sibling(installation, "update") +} + +fn backup_path(installation: &Installation) -> PathBuf { + hidden_sibling(installation, "old") +} + +/// Unpacks the archive into `staging`; returns the new bundle or executable. +fn extract(installation: &Installation, archive: &Path, staging: &Path) -> anyhow::Result { + match installation.kind { + InstallationKind::MacBundle => extract_bundle(archive, staging), + InstallationKind::Executable => { + let name = file_name(&installation.path).context("installation has no file name")?; + extract_executable(archive, &staging.join(name)) + } + } +} + +#[cfg(target_os = "macos")] +fn extract_bundle(archive: &Path, staging: &Path) -> anyhow::Result { + // ditto keeps what a bundle needs and zip crates drop: symlinks, + // extended attributes, the signature's resource forks. + run( + "ditto", + &[ + "-x".as_ref(), + "-k".as_ref(), + archive.as_os_str(), + staging.as_os_str(), + ], + )?; + let mut bundles = fs::read_dir(staging)? + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "app")); + match (bundles.next(), bundles.next()) { + (Some(bundle), None) => Ok(bundle), + _ => bail!("the update does not contain exactly one app bundle"), + } +} + +#[cfg(not(target_os = "macos"))] +fn extract_bundle(_archive: &Path, _staging: &Path) -> anyhow::Result { + bail!("app bundles are updated on macOS only") +} + +fn extract_executable(archive: &Path, target: &Path) -> anyhow::Result { + let mut zip = zip::ZipArchive::new(fs::File::open(archive)?).context("reading the update")?; + let files: Vec = (0..zip.len()) + .filter(|&index| zip.by_index(index).is_ok_and(|entry| entry.is_file())) + .collect(); + let [index] = files[..] else { + bail!("the update does not contain exactly one file"); + }; + let mut entry = zip.by_index(index)?; + let mut out = fs::File::create(target)?; + std::io::copy(&mut entry, &mut out).context("unpacking the update")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(target, fs::Permissions::from_mode(0o755))?; + } + Ok(target.to_path_buf()) +} + +fn check(installation: &Installation, new: &Path) -> anyhow::Result<()> { + match installation.kind { + InstallationKind::MacBundle => check_bundle(&installation.path, new), + InstallationKind::Executable => { + if fs::metadata(new)?.len() == 0 { + bail!("the update's executable is empty"); + } + Ok(()) + } + } +} + +/// The new bundle's signature must be intact, and when the running app is +/// signed by a team, by the same team. +#[cfg(target_os = "macos")] +fn check_bundle(running: &Path, new: &Path) -> anyhow::Result<()> { + run( + "codesign", + &[ + "--verify".as_ref(), + "--deep".as_ref(), + "--strict".as_ref(), + new.as_os_str(), + ], + ) + .context("the update's code signature is invalid")?; + if let Some(team) = signing_team(running) { + let new_team = signing_team(new); + if new_team.as_deref() != Some(team.as_str()) { + bail!( + "the update is signed by {}, not by this app's team {team}", + new_team.as_deref().unwrap_or("no team") + ); + } + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn check_bundle(_running: &Path, _new: &Path) -> anyhow::Result<()> { + bail!("app bundles are updated on macOS only") +} + +#[cfg(target_os = "macos")] +fn signing_team(path: &Path) -> Option { + let output = std::process::Command::new("codesign") + .args(["-dv", "--verbose=2"]) + .arg(path) + .output() + .ok()?; + team_identifier(&String::from_utf8_lossy(&output.stderr)) +} + +/// The `TeamIdentifier` line of `codesign -dv` (which writes to stderr). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn team_identifier(codesign_output: &str) -> Option { + codesign_output + .lines() + .find_map(|line| line.strip_prefix("TeamIdentifier=")) + .map(str::trim) + .filter(|team| !team.is_empty() && *team != "not set") + .map(str::to_owned) +} + +#[cfg(target_os = "macos")] +fn run(program: &str, args: &[&std::ffi::OsStr]) -> anyhow::Result<()> { + let output = std::process::Command::new(program) + .args(args) + .output() + .with_context(|| format!("running {program}"))?; + if !output.status.success() { + bail!( + "{program} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +fn swap(installation: &Installation, new: &Path) -> anyhow::Result<()> { + let path = &installation.path; + let moved_away = match installation.kind { + // A directory can not be renamed over another: move the old one + // aside first. + InstallationKind::MacBundle => { + let backup = backup_path(installation); + let _ = fs::remove_dir_all(&backup); + Some(backup) + } + // A running .exe can not be replaced, but it can be renamed. + InstallationKind::Executable if cfg!(windows) => Some(old_executable_path(path)), + // The running process keeps the old inode. + InstallationKind::Executable => None, + }; + if let Some(aside) = &moved_away { + fs::rename(path, aside).with_context(|| format!("moving {} aside", path.display()))?; + } + if let Err(e) = fs::rename(new, path) { + if let Some(aside) = &moved_away + && let Err(undo) = fs::rename(aside, path) + { + warn!("Could not put {} back: {undo}", path.display()); + } + return Err(e).with_context(|| format!("moving the update to {}", path.display())); + } + if installation.kind == InstallationKind::MacBundle + && let Some(backup) = moved_away + { + let _ = fs::remove_dir_all(backup); + } + Ok(()) +} + +/// `..old`: unique, since an older one may still be running. +fn old_executable_path(path: &Path) -> PathBuf { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis()); + let name = file_name(path).unwrap_or_default(); + path.with_file_name(format!("{name}.{millis}.old")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn zip_files(archive: &Path, files: &[(&str, &[u8])]) { + let mut zip = zip::ZipWriter::new(fs::File::create(archive).unwrap()); + for (name, content) in files { + zip.start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(content).unwrap(); + } + zip.finish().unwrap(); + } + + fn listing(dir: &Path) -> Vec { + let mut names: Vec<_> = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + names.sort(); + names + } + + #[tokio::test] + async fn replaces_an_executable() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("code-assistant"); + fs::write(&exe, b"old").unwrap(); + let archive = dir.path().join("update.zip"); + // The archive's file name does not matter; the installed one stays. + zip_files(&archive, &[("code-assistant.exe", b"new")]); + let installation = Installation::from_executable(&exe); + + install(&installation, &archive).await.unwrap(); + + assert_eq!(fs::read(&exe).unwrap(), b"new"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&exe).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } + remove_leftovers(&installation); + assert_eq!(listing(dir.path()), ["code-assistant", "update.zip"]); + } + + #[tokio::test] + async fn refuses_an_archive_with_several_files_and_changes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("code-assistant"); + fs::write(&exe, b"old").unwrap(); + let archive = dir.path().join("update.zip"); + zip_files(&archive, &[("a", b"1"), ("b", b"2")]); + + let error = install(&Installation::from_executable(&exe), &archive) + .await + .unwrap_err(); + + assert!(error.to_string().contains("exactly one file"), "{error:#}"); + assert_eq!(fs::read(&exe).unwrap(), b"old"); + assert_eq!(listing(dir.path()), ["code-assistant", "update.zip"]); + } + + #[test] + fn removes_what_an_interrupted_install_left() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("code-assistant.exe"); + fs::write(&exe, b"current").unwrap(); + fs::create_dir(dir.path().join(".code-assistant.exe.update")).unwrap(); + fs::write(dir.path().join("code-assistant.exe.1700000000000.old"), b"").unwrap(); + fs::write(dir.path().join("other.old"), b"").unwrap(); + + remove_leftovers(&Installation::from_executable(&exe)); + + assert_eq!(listing(dir.path()), ["code-assistant.exe", "other.old"]); + } + + #[test] + fn reads_the_team_from_codesign_output() { + let signed = "Executable=/Applications/Code Assistant.app/Contents/MacOS/code-assistant\n\ + Authority=Developer ID Application: Someone (VX7AN5CM65)\n\ + TeamIdentifier=VX7AN5CM65\n"; + assert_eq!(team_identifier(signed).as_deref(), Some("VX7AN5CM65")); + assert_eq!( + team_identifier("Signature=adhoc\nTeamIdentifier=not set\n"), + None + ); + assert_eq!(team_identifier("code object is not signed at all"), None); + } + + /// An ad-hoc signed bundle whose resource says `marker`. + #[cfg(target_os = "macos")] + fn make_bundle(bundle: &Path, marker: &str) { + let contents = bundle.join("Contents"); + fs::create_dir_all(contents.join("MacOS")).unwrap(); + fs::create_dir_all(contents.join("Resources")).unwrap(); + fs::write( + contents.join("Info.plist"), + r#" + + +CFBundleExecutabletest-app +CFBundleIdentifierdev.stippi.app-update-test +CFBundlePackageTypeAPPL +"#, + ) + .unwrap(); + fs::copy("/usr/bin/true", contents.join("MacOS/test-app")).unwrap(); + fs::write(contents.join("Resources/marker"), marker).unwrap(); + run( + "codesign", + &[ + "--force".as_ref(), + "--deep".as_ref(), + "-s".as_ref(), + "-".as_ref(), + bundle.as_os_str(), + ], + ) + .unwrap(); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn replaces_a_bundle() { + let dir = tempfile::tempdir().unwrap(); + let apps = dir.path().join("Applications"); + let running = apps.join("Test App.app"); + make_bundle(&running, "old"); + let release = dir.path().join("release/Test App.app"); + make_bundle(&release, "new"); + let archive = dir.path().join("update.zip"); + run( + "ditto", + &[ + "-c".as_ref(), + "-k".as_ref(), + "--keepParent".as_ref(), + release.as_os_str(), + archive.as_os_str(), + ], + ) + .unwrap(); + let installation = Installation::from_executable(&running.join("Contents/MacOS/test-app")); + + install(&installation, &archive).await.unwrap(); + + assert_eq!( + fs::read_to_string(running.join("Contents/Resources/marker")).unwrap(), + "new" + ); + assert_eq!(listing(&apps), ["Test App.app"]); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn refuses_a_bundle_with_a_broken_signature() { + let dir = tempfile::tempdir().unwrap(); + let running = dir.path().join("Applications/Test App.app"); + make_bundle(&running, "old"); + let release = dir.path().join("release/Test App.app"); + make_bundle(&release, "new"); + fs::write(release.join("Contents/Resources/marker"), "tampered").unwrap(); + let archive = dir.path().join("update.zip"); + run( + "ditto", + &[ + "-c".as_ref(), + "-k".as_ref(), + "--keepParent".as_ref(), + release.as_os_str(), + archive.as_os_str(), + ], + ) + .unwrap(); + let installation = Installation::from_executable(&running.join("Contents/MacOS/test-app")); + + let error = install(&installation, &archive).await.unwrap_err(); + + assert!(error.to_string().contains("signature"), "{error:#}"); + assert_eq!( + fs::read_to_string(running.join("Contents/Resources/marker")).unwrap(), + "old" + ); + } +} diff --git a/crates/app_update/src/installation.rs b/crates/app_update/src/installation.rs new file mode 100644 index 000000000..aa8925ece --- /dev/null +++ b/crates/app_update/src/installation.rs @@ -0,0 +1,176 @@ +//! Where the running app lives, and whether it can replace itself there. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallationKind { + /// A macOS `.app` bundle, replaced as a whole. + MacBundle, + /// A bare executable. + Executable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Installation { + pub kind: InstallationKind, + /// The bundle folder or the executable. + pub path: PathBuf, +} + +impl Installation { + /// The running app, symlinks resolved (a link in `PATH` is not what an + /// update replaces). + pub fn current() -> std::io::Result { + let exe = std::env::current_exe()?; + Ok(Self::from_executable(&exe.canonicalize().unwrap_or(exe))) + } + + /// The installation an executable belongs to: the bundle when it sits in + /// `.app/Contents/MacOS/`, else the executable itself. + pub fn from_executable(exe: &Path) -> Self { + let bundle = exe + .parent() + .filter(|dir| dir.file_name().is_some_and(|name| name == "MacOS")) + .and_then(Path::parent) + .filter(|dir| dir.file_name().is_some_and(|name| name == "Contents")) + .and_then(Path::parent) + .filter(|dir| dir.extension().is_some_and(|ext| ext == "app")); + match bundle { + Some(bundle) => Self { + kind: InstallationKind::MacBundle, + path: bundle.to_path_buf(), + }, + None => Self { + kind: InstallationKind::Executable, + path: exe.to_path_buf(), + }, + } + } + + /// The folder holding the bundle or executable; staging happens there, + /// so the swap is a rename on one volume. + pub fn parent(&self) -> &Path { + self.path.parent().unwrap_or(Path::new(".")) + } + + /// Why this installation can not replace itself, if it can not. + pub fn unsupported_reason(&self) -> Option { + if is_translocated(&self.path) { + return Some( + "macOS runs this copy from a temporary read-only location. \ + Move the app to the Applications folder to get updates." + .to_string(), + ); + } + if !is_writable(self.parent()) { + return Some(format!( + "{} is not writable, so the app can not update itself there.", + self.parent().display() + )); + } + None + } +} + +/// Gatekeeper's App Translocation: a quarantined app started where it was +/// downloaded runs from a random read-only mount. +fn is_translocated(path: &Path) -> bool { + path.components() + .any(|component| component.as_os_str() == "AppTranslocation") +} + +/// Probes by creating a file: permission bits do not tell about ACLs, +/// read-only mounts or SIP. +fn is_writable(dir: &Path) -> bool { + let probe = dir.join(format!(".update-probe-{}", std::process::id())); + let created = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .is_ok(); + if created { + let _ = std::fs::remove_file(&probe); + } + created +} + +/// Whether this process is an Intel build running under Rosetta on Apple +/// silicon; its update should be the native build. +pub fn running_under_rosetta() -> bool { + #[cfg(target_os = "macos")] + { + let mut translated: libc::c_int = 0; + let mut size = std::mem::size_of::(); + // SAFETY: the name is NUL-terminated and `translated`/`size` + // describe a valid c_int buffer; no new value is written. + let result = unsafe { + libc::sysctlbyname( + c"sysctl.proc_translated".as_ptr(), + (&mut translated as *mut libc::c_int).cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + result == 0 && translated == 1 + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_executable_inside_a_bundle_is_the_bundle() { + let installation = Installation::from_executable(Path::new( + "/Applications/Code Assistant.app/Contents/MacOS/code-assistant", + )); + assert_eq!(installation.kind, InstallationKind::MacBundle); + assert_eq!( + installation.path, + Path::new("/Applications/Code Assistant.app") + ); + assert_eq!(installation.parent(), Path::new("/Applications")); + } + + #[test] + fn a_bare_executable_is_itself() { + for exe in [ + "/home/me/bin/code-assistant", + "/tmp/MacOS/code-assistant", + "/tmp/Contents/MacOS/code-assistant", + ] { + let installation = Installation::from_executable(Path::new(exe)); + assert_eq!(installation.kind, InstallationKind::Executable, "{exe}"); + assert_eq!(installation.path, Path::new(exe)); + } + } + + #[test] + fn translocated_apps_can_not_update() { + let installation = Installation::from_executable(Path::new( + "/private/var/folders/xy/T/AppTranslocation/1234/d/Code Assistant.app/Contents/MacOS/code-assistant", + )); + let reason = installation.unsupported_reason().unwrap(); + assert!(reason.contains("Applications"), "{reason}"); + } + + #[test] + fn needs_a_writable_folder() { + let dir = tempfile::tempdir().unwrap(); + let installation = Installation::from_executable(&dir.path().join("code-assistant")); + assert_eq!(installation.unsupported_reason(), None); + + let missing = Installation::from_executable(&dir.path().join("gone/code-assistant")); + assert!( + missing + .unsupported_reason() + .unwrap() + .contains("not writable") + ); + } +} diff --git a/crates/app_update/src/lib.rs b/crates/app_update/src/lib.rs new file mode 100644 index 000000000..3d856093f --- /dev/null +++ b/crates/app_update/src/lib.rs @@ -0,0 +1,19 @@ +//! Self-update mechanics for an app shipped as zips on a release page: the +//! update manifest, where the running installation lives, downloading an +//! asset (resumed, retried, verified), swapping it in and cleaning up after. +//! +//! When to check, what to download and the state users see belong to the +//! app (`code_assistant_core::updates`); see `docs/self-update.md`. + +mod client; +mod install; +mod installation; +mod manifest; +mod store; + +pub use client::{Progress, RetryPolicy, UpdateClient, is_verified}; +pub use install::{install, remove_leftovers}; +pub use installation::{Installation, InstallationKind, running_under_rosetta}; +pub use manifest::{Asset, Manifest}; +pub use semver::Version; +pub use store::{InstallRecord, UpdateStore}; diff --git a/crates/app_update/src/manifest.rs b/crates/app_update/src/manifest.rs new file mode 100644 index 000000000..f9358c0aa --- /dev/null +++ b/crates/app_update/src/manifest.rs @@ -0,0 +1,69 @@ +//! The release's `update-manifest.json` (written by +//! `scripts/update-manifest.sh`). + +use anyhow::Context; +use semver::Version; +use serde::Deserialize; +use std::collections::BTreeMap; + +/// The newest release and its downloads, keyed by file name. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Manifest { + pub version: Version, + pub assets: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Asset { + pub url: String, + /// Lower-case hex. + pub sha256: String, + pub size: u64, +} + +impl Manifest { + pub fn parse(bytes: &[u8]) -> anyhow::Result { + serde_json::from_slice(bytes).context("invalid update manifest") + } + + pub fn asset(&self, name: &str) -> Option<&Asset> { + self.assets.get(name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_what_the_release_script_writes() { + let manifest = Manifest::parse( + br#"{ + "version": "0.2.19", + "assets": { + "code-assistant-linux-x86_64.zip": { + "url": "https://example.com/v0.2.19/code-assistant-linux-x86_64.zip", + "sha256": "ab12", + "size": 42 + } + } + }"#, + ) + .unwrap(); + + assert_eq!(manifest.version, Version::new(0, 2, 19)); + let asset = manifest.asset("code-assistant-linux-x86_64.zip").unwrap(); + assert_eq!(asset.size, 42); + assert_eq!(asset.sha256, "ab12"); + assert!( + manifest + .asset("code-assistant-windows-x86_64.zip") + .is_none() + ); + } + + #[test] + fn rejects_a_version_that_is_not_semver() { + assert!(Manifest::parse(br#"{"version": "latest", "assets": {}}"#).is_err()); + } +} diff --git a/crates/app_update/src/store.rs b/crates/app_update/src/store.rs new file mode 100644 index 000000000..c77e7b308 --- /dev/null +++ b/crates/app_update/src/store.rs @@ -0,0 +1,126 @@ +//! The updates folder: one subfolder per downloaded version, and the record +//! of the last install. + +use anyhow::Context; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tracing::warn; + +const INSTALL_RECORD: &str = "install.json"; + +/// Written right before an install swaps the app: when the next start still +/// runs `from_version`, the update did not apply. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstallRecord { + pub from_version: Version, + pub target_version: Version, +} + +#[derive(Debug, Clone)] +pub struct UpdateStore { + root: PathBuf, +} + +impl UpdateStore { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// Where the download of `name` for `version` goes. + pub fn asset_path(&self, version: &Version, name: &str) -> PathBuf { + self.root.join(version.to_string()).join(name) + } + + pub fn read_install_record(&self) -> Option { + let text = std::fs::read_to_string(self.root.join(INSTALL_RECORD)).ok()?; + serde_json::from_str(&text) + .inspect_err(|e| warn!("Ignoring unreadable update install record: {e}")) + .ok() + } + + pub fn write_install_record(&self, record: &InstallRecord) -> anyhow::Result<()> { + std::fs::create_dir_all(&self.root)?; + let path = self.root.join(INSTALL_RECORD); + std::fs::write(&path, serde_json::to_vec(record)?) + .with_context(|| format!("writing {}", path.display())) + } + + pub fn remove_install_record(&self) { + let _ = std::fs::remove_file(self.root.join(INSTALL_RECORD)); + } + + /// Deletes the download folders of every version `keep` rejects, and + /// anything else that is not a version folder. + pub fn retain(&self, keep: impl Fn(&Version) -> bool) { + let Ok(entries) = std::fs::read_dir(&self.root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let version = entry + .file_name() + .to_str() + .and_then(|name| Version::parse(name).ok()); + if version.as_ref().is_some_and(&keep) { + continue; + } + if let Err(e) = std::fs::remove_dir_all(&path) { + warn!("Could not remove old update {}: {e}", path.display()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_record_round_trips_and_goes() { + let dir = tempfile::tempdir().unwrap(); + let store = UpdateStore::new(dir.path().join("updates")); + assert_eq!(store.read_install_record(), None); + + let record = InstallRecord { + from_version: Version::new(0, 2, 18), + target_version: Version::new(0, 2, 19), + }; + store.write_install_record(&record).unwrap(); + assert_eq!(store.read_install_record(), Some(record)); + + store.remove_install_record(); + assert_eq!(store.read_install_record(), None); + } + + #[test] + fn retain_keeps_only_accepted_versions_and_the_record() { + let dir = tempfile::tempdir().unwrap(); + let store = UpdateStore::new(dir.path()); + for folder in ["0.2.18", "0.2.19", "0.2.20", "junk"] { + std::fs::create_dir_all(dir.path().join(folder)).unwrap(); + } + store + .write_install_record(&InstallRecord { + from_version: Version::new(0, 2, 18), + target_version: Version::new(0, 2, 19), + }) + .unwrap(); + + store.retain(|version| *version > Version::new(0, 2, 18)); + + let mut left: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + left.sort(); + assert_eq!(left, ["0.2.19", "0.2.20", "install.json"]); + } +} From 64e8d702d9ac9665039e7f25e7811f3cb91e7583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Fri, 30 Oct 2026 16:30:23 +0100 Subject: [PATCH 3/5] Update service and title-bar chip: check, download on click, restart into it code_assistant_core::updates::UpdateService checks 15 s after start and every 4 hours, keeps the state (available, downloading, downloaded, installing, plus the last error per step) and publishes it as an app-scoped UiEvent. At start it removes downloads of versions not newer than the running one and notices an install that did not apply. The GPUI title bar shows a chip while a newer release is in sight: a click downloads it, then restarts into it (asking first while sessions are working). Failed checks stay out of the title bar; the About dialog shows the state and checks by hand. --- Cargo.lock | 2 + crates/code_assistant/src/app/gpui.rs | 19 + crates/code_assistant_core/Cargo.toml | 3 + crates/code_assistant_core/src/lib.rs | 1 + .../code_assistant_core/src/ui/ui_events.rs | 5 + crates/code_assistant_core/src/updates/mod.rs | 32 ++ .../src/updates/service.rs | 407 ++++++++++++++++ .../code_assistant_core/src/updates/state.rs | 105 +++++ .../code_assistant_core/src/updates/tests.rs | 434 ++++++++++++++++++ crates/ui_acp/src/ui.rs | 3 + crates/ui_gpui/src/app/event_loop.rs | 4 + crates/ui_gpui/src/app/mod.rs | 1 + crates/ui_gpui/src/app/updates.rs | 43 ++ crates/ui_gpui/src/lib.rs | 5 + .../ui_gpui/src/main_screen/about_dialog.rs | 102 +++- crates/ui_gpui/src/main_screen/mod.rs | 9 + .../ui_gpui/src/main_screen/update_control.rs | 317 +++++++++++++ crates/ui_gpui/src/sidebar/mod.rs | 8 + 18 files changed, 1499 insertions(+), 1 deletion(-) create mode 100644 crates/code_assistant_core/src/updates/mod.rs create mode 100644 crates/code_assistant_core/src/updates/service.rs create mode 100644 crates/code_assistant_core/src/updates/state.rs create mode 100644 crates/code_assistant_core/src/updates/tests.rs create mode 100644 crates/ui_gpui/src/app/updates.rs create mode 100644 crates/ui_gpui/src/main_screen/update_control.rs diff --git a/Cargo.lock b/Cargo.lock index c19048b25..ce3c9a2b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1675,6 +1675,7 @@ dependencies = [ "agent_core", "agent_orchestration", "anyhow", + "app_update", "async-channel", "async-trait", "axum 0.7.9", @@ -1717,6 +1718,7 @@ dependencies = [ "transmutation", "urlencoding", "web", + "zip", ] [[package]] diff --git a/crates/code_assistant/src/app/gpui.rs b/crates/code_assistant/src/app/gpui.rs index 555e15ae8..38b01bccc 100644 --- a/crates/code_assistant/src/app/gpui.rs +++ b/crates/code_assistant/src/app/gpui.rs @@ -70,6 +70,7 @@ pub fn run(config: AgentRunConfig) -> Result<()> { // broadcast stream (see ui_gpui's event bridge). #[cfg(feature = "voice")] let voice_events = events.clone(); + let update_events = events.clone(); let (service, service_worker) = SessionService::new( multi_session_manager, Arc::new(AgentRuntimeOptions { @@ -100,6 +101,21 @@ pub fn run(config: AgentRunConfig) -> Result<()> { worker }; + // Self-update: checks for newer releases on the backend runtime. Only + // release builds carry a feed (docs/self-update.md). + let update_worker = match code_assistant_core::updates::UpdateConfig::from_environment() { + Ok(config) => { + let (updates, worker) = + code_assistant_core::updates::UpdateService::new(config, update_events); + gui.set_update_service(updates); + Some(worker) + } + Err(e) => { + warn!("Self-update unavailable: {e:#}"); + None + } + }; + let gui_for_thread = gui.clone(); let task = config.task.clone(); @@ -166,6 +182,9 @@ pub fn run(config: AgentRunConfig) -> Result<()> { let worker = tokio::spawn(service_worker); #[cfg(feature = "voice")] tokio::spawn(voice_worker); + if let Some(update_worker) = update_worker { + tokio::spawn(update_worker); + } // Pre-warm the registry (connects configured MCP servers) in the // background: remote servers take seconds, and the sidebar's diff --git a/crates/code_assistant_core/Cargo.toml b/crates/code_assistant_core/Cargo.toml index 1101f71cb..29b27a891 100644 --- a/crates/code_assistant_core/Cargo.toml +++ b/crates/code_assistant_core/Cargo.toml @@ -14,6 +14,7 @@ test-utils = [] [dependencies] agent_core = { path = "../agent_core" } agent_orchestration = { path = "../agent_orchestration" } +app_update = { path = "../app_update" } command_executor = { path = "../command_executor" } fs_explorer = { path = "../fs_explorer" } git = { path = "../git" } @@ -96,6 +97,8 @@ chrono = "0.4" [dev-dependencies] axum = "0.7" +# The release archive the self-update tests serve +zip = { version = "8.6", default-features = false, features = ["deflate"] } # The scripted GitHub transport pull request tests read through github = { path = "../github", features = ["test-utils"] } # An in-process MCP server for the `:mcp:` scope tests diff --git a/crates/code_assistant_core/src/lib.rs b/crates/code_assistant_core/src/lib.rs index 0338f43c6..8d70e26a6 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -28,6 +28,7 @@ pub mod tool_dialects; pub mod tools; pub mod types; pub mod ui; +pub mod updates; pub mod utils; pub mod version; pub mod voice; diff --git a/crates/code_assistant_core/src/ui/ui_events.rs b/crates/code_assistant_core/src/ui/ui_events.rs index 7329effb6..f25273fdb 100644 --- a/crates/code_assistant_core/src/ui/ui_events.rs +++ b/crates/code_assistant_core/src/ui/ui_events.rs @@ -476,4 +476,9 @@ pub enum UiEvent { VoiceTranscript { entry: crate::voice::TranscriptEntry, }, + + // === Self-update (app-scoped) === + /// The self-update's state changed (a check, download progress, an + /// install). + UpdateStateChanged { state: crate::updates::UpdateState }, } diff --git a/crates/code_assistant_core/src/updates/mod.rs b/crates/code_assistant_core/src/updates/mod.rs new file mode 100644 index 000000000..584860c85 --- /dev/null +++ b/crates/code_assistant_core/src/updates/mod.rs @@ -0,0 +1,32 @@ +//! Self-update: notices a newer release, downloads it when the user asks, +//! swaps it in on *Restart to update*. The mechanics are the `app_update` +//! crate's; this module decides when to check, which asset this build +//! needs, and keeps the state the title bar shows. See +//! `docs/self-update.md`. + +mod service; +mod state; +#[cfg(test)] +mod tests; + +pub use app_update::{InstallationKind, Version}; +pub use service::{UpdateConfig, UpdateService}; +pub use state::{UpdateError, UpdateState, UpdateStatus, UpdateStep}; + +/// The release asset that updates a build for `target` installed as +/// `kind`; `None` for platforms the release does not build. An Intel build +/// under Rosetta gets the native one. +pub fn asset_name(target: &str, kind: InstallationKind, rosetta: bool) -> Option { + let platform = match target { + "aarch64-apple-darwin" => "macos-aarch64", + "x86_64-apple-darwin" if rosetta => "macos-aarch64", + "x86_64-apple-darwin" => "macos-x86_64", + "x86_64-unknown-linux-gnu" => "linux-x86_64", + "x86_64-pc-windows-msvc" => "windows-x86_64", + _ => return None, + }; + Some(match kind { + InstallationKind::MacBundle => format!("Code-Assistant-{platform}.app.zip"), + InstallationKind::Executable => format!("code-assistant-{platform}.zip"), + }) +} diff --git a/crates/code_assistant_core/src/updates/service.rs b/crates/code_assistant_core/src/updates/service.rs new file mode 100644 index 000000000..bb87ea9e5 --- /dev/null +++ b/crates/code_assistant_core/src/updates/service.rs @@ -0,0 +1,407 @@ +//! [`UpdateService`]: the frontends' handle to check for, download and +//! install a newer release. One worker does one step at a time and checks +//! on its own after a delay and then periodically. State changes arrive as +//! app-scoped [`UiEvent::UpdateStateChanged`] events. + +use super::state::{UpdateState, UpdateStatus, UpdateStep}; +use crate::session::event_stream::EventStream; +use crate::ui::UiEvent; +use crate::version; +use anyhow::{Context, anyhow, bail}; +use app_update::{ + Asset, InstallRecord, Installation, Manifest, RetryPolicy, UpdateClient, UpdateStore, Version, +}; +use chrono::Local; +use std::future::Future; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::Instant; +use tracing::{info, warn}; + +const FIRST_CHECK: Duration = Duration::from_secs(15); +const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60); + +pub struct UpdateConfig { + /// URL of the update manifest; without one the build never updates. + pub feed: Option, + pub disabled_by_env: bool, + pub current_version: Version, + pub installation: Installation, + /// The release asset this installation needs. + pub asset_name: Option, + pub store: UpdateStore, + pub retry: RetryPolicy, + pub first_check: Duration, + pub check_interval: Duration, +} + +impl UpdateConfig { + /// This build's: the feed the release workflow compiled in (or + /// `CODE_ASSISTANT_UPDATE_FEED`, for testing against a local server), + /// the running installation, downloads in the user's cache folder. + pub fn from_environment() -> anyhow::Result { + let feed = std::env::var("CODE_ASSISTANT_UPDATE_FEED") + .ok() + .filter(|feed| !feed.is_empty()) + .or_else(|| version::update_feed().map(str::to_owned)); + let installation = Installation::current().context("locating the running app")?; + let asset_name = super::asset_name( + version::TARGET, + installation.kind, + app_update::running_under_rosetta(), + ); + let updates_dir = dirs::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("code-assistant") + .join("updates"); + Ok(Self { + feed, + disabled_by_env: std::env::var_os("CODE_ASSISTANT_DISABLE_AUTO_UPDATE").is_some(), + current_version: Version::parse(version::VERSION)?, + installation, + asset_name, + store: UpdateStore::new(updates_dir), + retry: RetryPolicy::default(), + first_check: FIRST_CHECK, + check_interval: CHECK_INTERVAL, + }) + } + + fn disabled_reason(&self) -> Option { + if self.feed.is_none() { + return Some( + "This build does not update itself; the release builds from GitHub do.".into(), + ); + } + if self.disabled_by_env { + return Some("Updates are turned off by CODE_ASSISTANT_DISABLE_AUTO_UPDATE.".into()); + } + if self.asset_name.is_none() { + return Some("There is no release build for this platform.".into()); + } + self.installation.unsupported_reason() + } +} + +enum Command { + Check(oneshot::Sender<()>), + Download(oneshot::Sender<()>), + Install(oneshot::Sender>), +} + +/// Cloneable handle; the worker runs in the future returned by +/// [`UpdateService::new`], which the application spawns on its backend +/// runtime. +#[derive(Clone)] +pub struct UpdateService { + tx: mpsc::UnboundedSender, + state: Arc>, +} + +impl UpdateService { + /// Cleans up after earlier updates right away (removing downloads of + /// versions not newer than this one, noticing an install that did not + /// apply); checking starts with the worker. + pub fn new(config: UpdateConfig, events: EventStream) -> (Self, impl Future) { + let disabled = config.disabled_reason(); + let mut initial = UpdateState::new( + config.current_version.clone(), + match &disabled { + Some(reason) => UpdateStatus::Disabled { + reason: reason.clone(), + }, + None => UpdateStatus::Idle, + }, + ); + if disabled.is_none() { + initial.not_applied = clean_up(&config); + } + let state = Arc::new(Mutex::new(initial)); + let (tx, rx) = mpsc::unbounded_channel(); + let worker = Worker { + publisher: Publisher { + state: state.clone(), + events, + }, + config, + offer: None, + }; + (Self { tx, state }, worker.run(rx, disabled.is_none())) + } + + pub fn state(&self) -> UpdateState { + self.state.lock().unwrap().clone() + } + + /// Asks for the newest release now. + pub async fn check(&self) { + let (reply, done) = oneshot::channel(); + if self.tx.send(Command::Check(reply)).is_ok() { + let _ = done.await; + } + } + + /// Downloads the available release; failures end up in the state. + pub async fn download(&self) { + let (reply, done) = oneshot::channel(); + if self.tx.send(Command::Download(reply)).is_ok() { + let _ = done.await; + } + } + + /// Swaps the downloaded release in. Returns what to start instead of the + /// running app; the caller restarts right away. + pub async fn install(&self) -> anyhow::Result { + let (reply, done) = oneshot::channel(); + self.tx + .send(Command::Install(reply)) + .map_err(|_| anyhow!("the update service is not running"))?; + done.await + .map_err(|_| anyhow!("the update service stopped"))? + } +} + +/// Startup housekeeping; returns the version an install did not apply. +fn clean_up(config: &UpdateConfig) -> Option { + app_update::remove_leftovers(&config.installation); + let current = &config.current_version; + let not_applied = match config.store.read_install_record() { + Some(record) if record.from_version == *current && record.target_version != *current => { + warn!( + "The update to {} did not apply; still running {current}", + record.target_version + ); + Some(record.target_version) + } + Some(_) => { + config.store.remove_install_record(); + None + } + None => None, + }; + config.store.retain(|version| version > current); + not_applied +} + +/// Changes the shared state and tells the frontends. +#[derive(Clone)] +struct Publisher { + state: Arc>, + events: EventStream, +} + +impl Publisher { + fn status(&self) -> UpdateStatus { + self.state.lock().unwrap().status.clone() + } + + fn update(&self, change: impl FnOnce(&mut UpdateState)) { + let state = { + let mut state = self.state.lock().unwrap(); + change(&mut state); + state.clone() + }; + self.events + .publish_app(UiEvent::UpdateStateChanged { state }); + } +} + +struct Worker { + publisher: Publisher, + config: UpdateConfig, + /// The release the last check offered. + offer: Option<(Version, Asset)>, +} + +impl Worker { + async fn run(mut self, mut commands: mpsc::UnboundedReceiver, enabled: bool) { + let client = if enabled { + UpdateClient::new(&format!("code-assistant/{}", self.config.current_version)) + .map(|client| client.with_retry(self.config.retry)) + .inspect_err(|e| warn!("Updates are off: {e:#}")) + .ok() + } else { + None + }; + let mut next_check = Instant::now() + self.config.first_check; + loop { + tokio::select! { + command = commands.recv() => { + let Some(command) = command else { break }; + match command { + Command::Check(reply) => { + if let Some(client) = &client { + self.check(client).await; + next_check = Instant::now() + self.config.check_interval; + } + let _ = reply.send(()); + } + Command::Download(reply) => { + if let Some(client) = &client { + self.download(client).await; + } + let _ = reply.send(()); + } + Command::Install(reply) => { + let _ = reply.send(self.install().await); + } + } + } + () = tokio::time::sleep_until(next_check), if client.is_some() => { + if let Some(client) = &client { + self.check(client).await; + } + next_check = Instant::now() + self.config.check_interval; + } + } + } + } + + fn asset_name(&self) -> &str { + self.config.asset_name.as_deref().unwrap_or_default() + } + + async fn check(&mut self, client: &UpdateClient) { + let previous = self.publisher.status(); + self.publisher + .update(|state| state.status = UpdateStatus::Checking); + let feed = self.config.feed.clone().unwrap_or_default(); + let result = match client.fetch_manifest(&feed).await { + Ok(manifest) => self.offer(manifest).await, + Err(e) => Err(e), + }; + let checked_at = Local::now(); + self.publisher.update(|state| { + state.checked_at = Some(checked_at); + match result { + Ok(status) => { + state.status = status; + state.clear_error(UpdateStep::Check); + } + Err(e) => { + warn!("Checking for updates failed: {e:#}"); + state.status = previous; + state.fail(UpdateStep::Check, &e); + } + } + }); + } + + /// What the manifest offers this installation. + async fn offer(&mut self, manifest: Manifest) -> anyhow::Result { + let version = manifest.version.clone(); + if version <= self.config.current_version { + self.offer = None; + self.config.store.retain(|_| false); + return Ok(UpdateStatus::UpToDate); + } + let asset = manifest + .asset(self.asset_name()) + .with_context(|| format!("release {version} has no {}", self.asset_name()))? + .clone(); + self.config.store.retain(|kept| *kept == version); + let path = self.config.store.asset_path(&version, self.asset_name()); + let downloaded = app_update::is_verified(&path, &asset).await; + info!("Release {version} is available (downloaded: {downloaded})"); + self.offer = Some((version.clone(), asset)); + Ok(if downloaded { + UpdateStatus::Downloaded { version } + } else { + UpdateStatus::Available { version } + }) + } + + async fn download(&mut self, client: &UpdateClient) { + let Some((version, asset)) = self.offer.clone() else { + return; + }; + if !matches!(self.publisher.status(), UpdateStatus::Available { .. }) { + return; + } + let dest = self.config.store.asset_path(&version, self.asset_name()); + let total = asset.size; + self.publisher.update(|state| { + state.status = UpdateStatus::Downloading { + version: version.clone(), + received: 0, + total, + }; + state.clear_error(UpdateStep::Download); + }); + let publisher = self.publisher.clone(); + let mut last_percent = 0; + let progress_version = version.clone(); + let result = client + .download(&asset, &dest, move |progress| { + let percent = progress.received * 100 / progress.total.max(1); + if percent != last_percent { + last_percent = percent; + publisher.update(|state| { + state.status = UpdateStatus::Downloading { + version: progress_version.clone(), + received: progress.received, + total: progress.total, + }; + }); + } + }) + .await; + self.publisher.update(|state| match result { + Ok(()) => state.status = UpdateStatus::Downloaded { version }, + Err(e) => { + warn!("Downloading the update failed: {e:#}"); + state.status = UpdateStatus::Available { version }; + state.fail(UpdateStep::Download, &e); + } + }); + } + + async fn install(&mut self) -> anyhow::Result { + let UpdateStatus::Downloaded { version } = self.publisher.status() else { + bail!("no downloaded update to install"); + }; + let Some((_, asset)) = self.offer.clone() else { + bail!("no downloaded update to install"); + }; + let archive = self.config.store.asset_path(&version, self.asset_name()); + if !app_update::is_verified(&archive, &asset).await { + let error = anyhow!("the downloaded update is damaged; download it again"); + let _ = tokio::fs::remove_file(&archive).await; + self.publisher.update(|state| { + state.status = UpdateStatus::Available { + version: version.clone(), + }; + state.fail(UpdateStep::Install, &error); + }); + return Err(error); + } + + self.publisher.update(|state| { + state.status = UpdateStatus::Installing { + version: version.clone(), + }; + state.clear_error(UpdateStep::Install); + }); + let record = InstallRecord { + from_version: self.config.current_version.clone(), + target_version: version.clone(), + }; + if let Err(e) = self.config.store.write_install_record(&record) { + warn!("Could not record the update install: {e:#}"); + } + match app_update::install(&self.config.installation, &archive).await { + Ok(()) => Ok(self.config.installation.path.clone()), + Err(e) => { + warn!("Installing the update failed: {e:#}"); + self.config.store.remove_install_record(); + self.publisher.update(|state| { + state.status = UpdateStatus::Downloaded { version }; + state.fail(UpdateStep::Install, &e); + }); + Err(e) + } + } + } +} diff --git a/crates/code_assistant_core/src/updates/state.rs b/crates/code_assistant_core/src/updates/state.rs new file mode 100644 index 000000000..5373ec675 --- /dev/null +++ b/crates/code_assistant_core/src/updates/state.rs @@ -0,0 +1,105 @@ +//! What the frontends show of the self-update. + +use app_update::Version; +use chrono::{DateTime, Local}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateStatus { + /// This build does not update itself; `reason` says why. + Disabled { + reason: String, + }, + /// Not checked yet. + Idle, + Checking, + UpToDate, + Available { + version: Version, + }, + Downloading { + version: Version, + received: u64, + total: u64, + }, + Downloaded { + version: Version, + }, + Installing { + version: Version, + }, +} + +impl UpdateStatus { + /// The newer release this status is about, if any. + pub fn version(&self) -> Option<&Version> { + match self { + Self::Available { version } + | Self::Downloading { version, .. } + | Self::Downloaded { version } + | Self::Installing { version } => Some(version), + _ => None, + } + } +} + +/// The step an [`UpdateError`] happened in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateStep { + Check, + Download, + Install, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateError { + pub step: UpdateStep, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateState { + pub current_version: Version, + pub status: UpdateStatus, + /// The last step that failed; cleared when that step succeeds next. + pub error: Option, + pub checked_at: Option>, + /// The version the last install was meant to bring, when this start + /// still runs the old one. + pub not_applied: Option, +} + +impl UpdateState { + pub fn new(current_version: Version, status: UpdateStatus) -> Self { + Self { + current_version, + status, + error: None, + checked_at: None, + not_applied: None, + } + } + + /// Download progress in whole percent while downloading. + pub fn download_percent(&self) -> Option { + match self.status { + UpdateStatus::Downloading { + received, total, .. + } if total > 0 => Some((received.min(total) * 100 / total) as u8), + UpdateStatus::Downloading { .. } => Some(0), + _ => None, + } + } + + pub(super) fn fail(&mut self, step: UpdateStep, error: &anyhow::Error) { + self.error = Some(UpdateError { + step, + message: format!("{error:#}"), + }); + } + + pub(super) fn clear_error(&mut self, step: UpdateStep) { + if self.error.as_ref().is_some_and(|error| error.step == step) { + self.error = None; + } + } +} diff --git a/crates/code_assistant_core/src/updates/tests.rs b/crates/code_assistant_core/src/updates/tests.rs new file mode 100644 index 000000000..3997b78c2 --- /dev/null +++ b/crates/code_assistant_core/src/updates/tests.rs @@ -0,0 +1,434 @@ +use super::*; +use crate::session::event_stream::{EventPayload, EventStream, Subscription}; +use crate::ui::UiEvent; +use app_update::{InstallRecord, Installation, RetryPolicy, UpdateStore}; +use axum::extract::{Path as UrlPath, State}; +use axum::http::StatusCode; +use sha2::{Digest, Sha256}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const ASSET: &str = "code-assistant-test.zip"; +const WAIT: Duration = Duration::from_secs(10); + +fn v(text: &str) -> Version { + Version::parse(text).unwrap() +} + +/// What the fake release page serves. +#[derive(Default)] +struct Release { + /// `None` answers the manifest with a server error. + manifest: Mutex>, + /// `None` answers the asset with 404. + asset: Mutex>>, + asset_requests: AtomicUsize, +} + +async fn manifest(State(release): State>) -> Result { + release + .manifest + .lock() + .unwrap() + .clone() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn asset( + State(release): State>, + UrlPath(_name): UrlPath, +) -> Result, StatusCode> { + release.asset_requests.fetch_add(1, Ordering::SeqCst); + release + .asset + .lock() + .unwrap() + .clone() + .ok_or(StatusCode::NOT_FOUND) +} + +fn zip_of(content: &[u8]) -> Vec { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + zip.start_file("code-assistant", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(content).unwrap(); + zip.finish().unwrap().into_inner() +} + +struct Fixture { + dir: tempfile::TempDir, + base: String, + release: Arc, +} + +impl Fixture { + async fn new() -> Self { + let release = Arc::new(Release::default()); + let router = axum::Router::new() + .route("/update-manifest.json", axum::routing::get(manifest)) + .route("/download/:name", axum::routing::get(asset)) + .with_state(release.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("bin")).unwrap(); + std::fs::write(dir.path().join("bin/code-assistant"), b"0.2.18").unwrap(); + Self { dir, base, release } + } + + fn exe(&self) -> PathBuf { + self.dir.path().join("bin/code-assistant") + } + + fn store(&self) -> UpdateStore { + UpdateStore::new(self.dir.path().join("updates")) + } + + /// Publishes `version` with an executable saying `version`. + fn publish(&self, version: &str) { + let archive = zip_of(version.as_bytes()); + let sha256: String = Sha256::digest(&archive) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + *self.release.manifest.lock().unwrap() = Some( + serde_json::json!({ + "version": version, + "assets": { + ASSET: { + "url": format!("{}/download/{ASSET}", self.base), + "sha256": sha256, + "size": archive.len(), + } + } + }) + .to_string(), + ); + *self.release.asset.lock().unwrap() = Some(archive); + } + + fn config(&self) -> UpdateConfig { + UpdateConfig { + feed: Some(format!("{}/update-manifest.json", self.base)), + disabled_by_env: false, + current_version: v("0.2.18"), + installation: Installation::from_executable(&self.exe()), + asset_name: Some(ASSET.to_string()), + store: self.store(), + retry: RetryPolicy { + attempts: 2, + first_delay: Duration::ZERO, + }, + first_check: Duration::from_secs(3600), + check_interval: Duration::from_secs(3600), + } + } + + fn start_with(&self, config: UpdateConfig) -> (UpdateService, Subscription) { + let events = EventStream::new(); + let subscription = events.subscribe(); + let (service, worker) = UpdateService::new(config, events); + tokio::spawn(worker); + (service, subscription) + } + + fn start(&self) -> (UpdateService, Subscription) { + self.start_with(self.config()) + } +} + +async fn wait_for_state( + events: &mut Subscription, + pred: impl Fn(&UpdateState) -> bool, +) -> UpdateState { + tokio::time::timeout(WAIT, async { + loop { + if let Ok(event) = events.recv().await + && let EventPayload::Ui(UiEvent::UpdateStateChanged { state }) = event.payload + && pred(&state) + { + return state; + } + } + }) + .await + .expect("update state") +} + +#[test] +fn picks_the_asset_of_the_platform_and_packaging() { + use InstallationKind::{Executable, MacBundle}; + let cases = [ + ( + "aarch64-apple-darwin", + MacBundle, + false, + "Code-Assistant-macos-aarch64.app.zip", + ), + ( + "x86_64-apple-darwin", + MacBundle, + false, + "Code-Assistant-macos-x86_64.app.zip", + ), + ( + "x86_64-apple-darwin", + MacBundle, + true, + "Code-Assistant-macos-aarch64.app.zip", + ), + ( + "aarch64-apple-darwin", + Executable, + false, + "code-assistant-macos-aarch64.zip", + ), + ( + "x86_64-unknown-linux-gnu", + Executable, + false, + "code-assistant-linux-x86_64.zip", + ), + ( + "x86_64-pc-windows-msvc", + Executable, + false, + "code-assistant-windows-x86_64.zip", + ), + ]; + for (target, kind, rosetta, expected) in cases { + assert_eq!(asset_name(target, kind, rosetta).as_deref(), Some(expected)); + } + assert_eq!( + asset_name("aarch64-unknown-linux-gnu", Executable, false), + None + ); +} + +#[tokio::test] +async fn without_a_feed_the_build_never_updates() { + let fixture = Fixture::new().await; + fixture.publish("0.2.19"); + let (service, _events) = fixture.start_with(UpdateConfig { + feed: None, + ..fixture.config() + }); + + service.check().await; + + assert!(matches!( + service.state().status, + UpdateStatus::Disabled { .. } + )); + assert!(service.install().await.is_err()); + assert_eq!(std::fs::read(fixture.exe()).unwrap(), b"0.2.18"); +} + +#[tokio::test] +async fn finds_downloads_and_installs_a_newer_release() { + let fixture = Fixture::new().await; + fixture.publish("0.2.19"); + let (service, mut events) = fixture.start(); + + service.check().await; + let state = service.state(); + assert_eq!( + state.status, + UpdateStatus::Available { + version: v("0.2.19") + } + ); + assert!(state.checked_at.is_some()); + + service.download().await; + wait_for_state(&mut events, |state| { + matches!(state.status, UpdateStatus::Downloading { .. }) + }) + .await; + assert_eq!( + service.state().status, + UpdateStatus::Downloaded { + version: v("0.2.19") + } + ); + + let launch = service.install().await.unwrap(); + + assert_eq!(launch, fixture.exe()); + assert_eq!(std::fs::read(fixture.exe()).unwrap(), b"0.2.19"); + assert_eq!( + fixture.store().read_install_record(), + Some(InstallRecord { + from_version: v("0.2.18"), + target_version: v("0.2.19"), + }) + ); +} + +#[tokio::test] +async fn is_up_to_date_when_the_release_is_not_newer() { + let fixture = Fixture::new().await; + fixture.publish("0.2.18"); + let (service, _events) = fixture.start(); + + service.check().await; + + assert_eq!(service.state().status, UpdateStatus::UpToDate); +} + +#[tokio::test] +async fn a_failed_check_keeps_what_was_found_and_records_the_error() { + let fixture = Fixture::new().await; + fixture.publish("0.2.19"); + let (service, _events) = fixture.start(); + service.check().await; + + *fixture.release.manifest.lock().unwrap() = None; + service.check().await; + + let state = service.state(); + assert_eq!( + state.status, + UpdateStatus::Available { + version: v("0.2.19") + } + ); + assert_eq!(state.error.unwrap().step, UpdateStep::Check); +} + +#[tokio::test] +async fn a_failed_download_offers_the_download_again() { + let fixture = Fixture::new().await; + fixture.publish("0.2.19"); + *fixture.release.asset.lock().unwrap() = None; + let (service, _events) = fixture.start(); + service.check().await; + + service.download().await; + + let state = service.state(); + assert_eq!( + state.status, + UpdateStatus::Available { + version: v("0.2.19") + } + ); + let error = state.error.unwrap(); + assert_eq!(error.step, UpdateStep::Download); + assert!(error.message.contains("404"), "{}", error.message); + + fixture.publish("0.2.19"); + service.download().await; + let state = service.state(); + assert_eq!( + state.status, + UpdateStatus::Downloaded { + version: v("0.2.19") + } + ); + assert_eq!(state.error, None); +} + +#[tokio::test] +async fn a_later_start_finds_the_earlier_download() { + let fixture = Fixture::new().await; + fixture.publish("0.2.19"); + let (service, _events) = fixture.start(); + service.check().await; + service.download().await; + let requests = fixture.release.asset_requests.load(Ordering::SeqCst); + + let (restarted, _events) = fixture.start(); + restarted.check().await; + + assert_eq!( + restarted.state().status, + UpdateStatus::Downloaded { + version: v("0.2.19") + } + ); + assert_eq!( + fixture.release.asset_requests.load(Ordering::SeqCst), + requests + ); +} + +#[tokio::test] +async fn a_newer_release_replaces_an_older_download() { + let fixture = Fixture::new().await; + fixture.publish("0.2.19"); + let (service, _events) = fixture.start(); + service.check().await; + service.download().await; + + fixture.publish("0.2.20"); + service.check().await; + + assert_eq!( + service.state().status, + UpdateStatus::Available { + version: v("0.2.20") + } + ); + assert!(!fixture.dir.path().join("updates/0.2.19").exists()); +} + +#[tokio::test] +async fn notices_an_install_that_did_not_apply_and_cleans_up() { + let fixture = Fixture::new().await; + let store = fixture.store(); + for version in ["0.2.17", "0.2.18", "0.2.19"] { + std::fs::create_dir_all(store.root().join(version)).unwrap(); + } + store + .write_install_record(&InstallRecord { + from_version: v("0.2.18"), + target_version: v("0.2.19"), + }) + .unwrap(); + + let (service, _events) = fixture.start(); + + assert_eq!(service.state().not_applied, Some(v("0.2.19"))); + assert!(!store.root().join("0.2.17").exists()); + assert!(!store.root().join("0.2.18").exists()); + assert!(store.root().join("0.2.19").exists()); +} + +#[tokio::test] +async fn forgets_the_record_of_an_install_that_applied() { + let fixture = Fixture::new().await; + let store = fixture.store(); + store + .write_install_record(&InstallRecord { + from_version: v("0.2.17"), + target_version: v("0.2.18"), + }) + .unwrap(); + + let (service, _events) = fixture.start(); + + assert_eq!(service.state().not_applied, None); + assert_eq!(store.read_install_record(), None); +} + +#[tokio::test] +async fn checks_on_its_own() { + let fixture = Fixture::new().await; + fixture.publish("0.2.19"); + + let (_service, mut events) = fixture.start_with(UpdateConfig { + first_check: Duration::from_millis(10), + ..fixture.config() + }); + + wait_for_state(&mut events, |state| { + matches!(state.status, UpdateStatus::Available { .. }) + }) + .await; +} diff --git a/crates/ui_acp/src/ui.rs b/crates/ui_acp/src/ui.rs index bf8ed6242..422d45091 100644 --- a/crates/ui_acp/src/ui.rs +++ b/crates/ui_acp/src/ui.rs @@ -1032,6 +1032,9 @@ impl UserInterface for ACPUserUI { UiEvent::PullRequestChanged { .. } | UiEvent::PullRequestListChanged { .. } => { // The pull request view is GPUI-specific. } + UiEvent::UpdateStateChanged { .. } => { + // Only the GPUI app updates itself. + } } Ok(()) } diff --git a/crates/ui_gpui/src/app/event_loop.rs b/crates/ui_gpui/src/app/event_loop.rs index 5286f4733..e564a68b6 100644 --- a/crates/ui_gpui/src/app/event_loop.rs +++ b/crates/ui_gpui/src/app/event_loop.rs @@ -1209,6 +1209,10 @@ impl Gpui { self.append_voice_transcript(entry); cx.refresh(); } + UiEvent::UpdateStateChanged { state } => { + self.apply_update_state(state); + cx.refresh(); + } UiEvent::ConfigChanged => { debug!("UI: ConfigChanged event — config files modified on disk"); self.refresh_voice_configured(); diff --git a/crates/ui_gpui/src/app/mod.rs b/crates/ui_gpui/src/app/mod.rs index d8d6fa11b..23174c258 100644 --- a/crates/ui_gpui/src/app/mod.rs +++ b/crates/ui_gpui/src/app/mod.rs @@ -9,4 +9,5 @@ pub(super) mod commands; pub(super) mod drafts; pub(super) mod event_bridge; pub(super) mod event_loop; +pub(super) mod updates; pub(super) mod voice; diff --git a/crates/ui_gpui/src/app/updates.rs b/crates/ui_gpui/src/app/updates.rs new file mode 100644 index 000000000..e98ec36f1 --- /dev/null +++ b/crates/ui_gpui/src/app/updates.rs @@ -0,0 +1,43 @@ +//! Self-update state mirrored from the core, and the commands to drive it. + +use crate::Gpui; +use code_assistant_core::updates::{UpdateService, UpdateState}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub struct UpdatesState { + service: Arc>>, + state: Arc>>, +} + +impl Gpui { + /// Install the update service; without one the app shows no updates. + pub fn set_update_service(&self, service: UpdateService) { + *self.updates.state.lock().unwrap() = Some(service.state()); + *self.updates.service.lock().unwrap() = Some(service); + } + + pub(crate) fn update_service(&self) -> Option { + self.updates.service.lock().unwrap().clone() + } + + pub fn update_state(&self) -> Option { + self.updates.state.lock().unwrap().clone() + } + + pub(crate) fn apply_update_state(&self, state: UpdateState) { + *self.updates.state.lock().unwrap() = Some(state); + } + + pub(crate) fn cmd_check_for_update(&self) { + if let Some(service) = self.update_service() { + self.dispatch(async move { service.check().await }); + } + } + + pub(crate) fn cmd_download_update(&self) { + if let Some(service) = self.update_service() { + self.dispatch(async move { service.download().await }); + } + } +} diff --git a/crates/ui_gpui/src/lib.rs b/crates/ui_gpui/src/lib.rs index 8c7188bcb..d62066589 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -282,6 +282,10 @@ pub struct Gpui { /// Voice mode: the handle (absent when the build has no audio), its /// status and the transcript of the current voice conversation. voice: app::voice::VoiceState, + + /// Self-update: the handle (absent unless the app checks for updates) + /// and the last state it published. + updates: app::updates::UpdatesState, } /// State for a pending message edit @@ -603,6 +607,7 @@ impl Gpui { skills: Arc::new(Mutex::new(Vec::new())), voice: app::voice::VoiceState::default(), + updates: app::updates::UpdatesState::default(), } } diff --git a/crates/ui_gpui/src/main_screen/about_dialog.rs b/crates/ui_gpui/src/main_screen/about_dialog.rs index 24d0a8c77..5e3e5e1f0 100644 --- a/crates/ui_gpui/src/main_screen/about_dialog.rs +++ b/crates/ui_gpui/src/main_screen/about_dialog.rs @@ -2,6 +2,8 @@ //! (version, git commit + tree state, build timestamp, Rust toolchain and //! target triple). Modeled on [`super::project_dialog::NewProjectDialog`]. +use crate::Gpui; +use code_assistant_core::updates::{UpdateState, UpdateStatus, UpdateStep}; use code_assistant_core::version; use gpui_kit::component::{ActiveTheme, Icon, Sizable, Size, StyledExt}; use gpui_kit::{ @@ -63,6 +65,38 @@ impl AboutDialog { } } +/// One line on where the self-update stands. +fn update_summary(state: &UpdateState) -> String { + let status = match &state.status { + UpdateStatus::Disabled { reason } => return reason.clone(), + UpdateStatus::Idle => "not checked yet".to_string(), + UpdateStatus::Checking => "checking…".to_string(), + UpdateStatus::UpToDate => "up to date".to_string(), + UpdateStatus::Available { version } => format!("{version} available"), + UpdateStatus::Downloading { version, .. } => format!( + "downloading {version} ({}%)", + state.download_percent().unwrap_or(0) + ), + UpdateStatus::Downloaded { version } => { + format!("{version} downloaded, restart to update") + } + UpdateStatus::Installing { version } => format!("installing {version}…"), + }; + let checked = state + .checked_at + .map(|at| format!(", checked {}", at.format("%H:%M"))) + .unwrap_or_default(); + match &state.error { + Some(error) if error.step == UpdateStep::Check => { + format!( + "{status}{checked}; the last check failed: {}", + error.message + ) + } + _ => format!("{status}{checked}"), + } +} + impl EventEmitter for AboutDialog {} impl Focusable for AboutDialog { @@ -73,6 +107,16 @@ impl Focusable for AboutDialog { impl Render for AboutDialog { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let update_state = cx.try_global::().and_then(Gpui::update_state); + let can_check = update_state.as_ref().is_some_and(|state| { + !matches!( + state.status, + UpdateStatus::Disabled { .. } + | UpdateStatus::Checking + | UpdateStatus::Downloading { .. } + | UpdateStatus::Installing { .. } + ) + }); // Full-screen overlay with a backdrop div() .id("about-dialog-backdrop") @@ -160,7 +204,10 @@ impl Render for AboutDialog { "rust", format!("{} {}", version::RUSTC_VERSION, version::TARGET), cx, - )), + )) + .when_some(update_state.as_ref(), |el, state| { + el.child(Self::info_row("updates", update_summary(state), cx)) + }), ) // Buttons row .child( @@ -168,6 +215,30 @@ impl Render for AboutDialog { .flex() .justify_end() .gap_2() + .when(can_check, |el| { + el.child( + div() + .id("about-check-updates-btn") + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .border_1() + .border_color(cx.theme().border) + .hover(|s| s.bg(cx.theme().muted.opacity(0.5))) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Check for updates"), + ) + .on_click(|_, _, cx| { + if let Some(gpui) = cx.try_global::() { + gpui.cmd_check_for_update(); + } + }), + ) + }) // Copy button .child( div() @@ -209,3 +280,32 @@ impl Render for AboutDialog { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use code_assistant_core::updates::{UpdateError, Version}; + + #[test] + fn summarizes_the_update_state() { + let mut state = UpdateState::new(Version::new(0, 2, 18), UpdateStatus::UpToDate); + assert_eq!(update_summary(&state), "up to date"); + + state.status = UpdateStatus::Available { + version: Version::new(0, 2, 19), + }; + state.error = Some(UpdateError { + step: UpdateStep::Check, + message: "offline".into(), + }); + assert_eq!( + update_summary(&state), + "0.2.19 available; the last check failed: offline" + ); + + state.status = UpdateStatus::Disabled { + reason: "Not a release build.".into(), + }; + assert_eq!(update_summary(&state), "Not a release build."); + } +} diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index da698dedf..0b032d678 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -4,6 +4,7 @@ pub mod project_dialog; mod question_prompt; pub mod right_panel; mod status_popover; +mod update_control; mod voice_control; use crate::shared::collapse_animation::CollapseAnimator; @@ -112,6 +113,9 @@ pub struct MainScreen { /// Whether the voice transcript popover below the title bar is open. voice_transcript_open: bool, + /// Whether the restart-to-update confirmation below the title bar is + /// open (sessions are working). + update_confirm_open: bool, /// Whether the sidebar got the first session listing. Without it, an /// empty listing equals the initial `sessions` and never reaches the /// sidebar, which then shows no project folders. @@ -264,6 +268,7 @@ impl MainScreen { right_sidebar_width: px(initial_sidebar_width.round()), right_sidebar_resizing: false, voice_transcript_open: false, + update_confirm_open: false, sidebar_sessions_synced: false, resize_start_x: 0.0, resize_start_width: 0.0, @@ -2089,6 +2094,8 @@ impl Render for MainScreen { ), ) }) + // A newer release, when there is one + .children(self.render_update_control(cx)) // Voice mode, next to the right panel views .children(self.render_voice_controls(cx)) .child( @@ -2269,6 +2276,8 @@ impl Render for MainScreen { ) // Voice transcript popover below the title bar .children(self.render_voice_transcript(cx)) + // Restart-to-update confirmation below the title bar + .children(self.render_update_confirm(cx)) // Modal dialog overlay for new project creation .when_some(new_project_dialog, |el, dialog| el.child(dialog)) // Modal "About" dialog overlay diff --git a/crates/ui_gpui/src/main_screen/update_control.rs b/crates/ui_gpui/src/main_screen/update_control.rs new file mode 100644 index 000000000..7358dfc91 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/update_control.rs @@ -0,0 +1,317 @@ +//! Self-update in the title bar: a chip while a newer release is available, +//! downloading or downloaded; a click downloads it or restarts into it +//! (asking first while sessions are working). + +use super::MainScreen; +use crate::Gpui; +use code_assistant_core::updates::{UpdateState, UpdateStatus, UpdateStep}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::tooltip::Tooltip; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, Size}; +use gpui_kit::{AnyElement, Context, Hsla, SharedString, div, prelude::*, px}; +use tracing::warn; + +/// What the chip offers. +enum Action { + Download, + Restart, +} + +struct Chip { + label: String, + tooltip: String, + icon: &'static str, + failed: bool, + action: Option, +} + +/// The chip for `state`, or `None` when there is nothing to offer. Failed +/// checks show nothing here: offline is not worth a mark in the title bar. +fn chip(state: &UpdateState) -> Option { + let error = |step| { + state + .error + .as_ref() + .filter(|error| error.step == step) + .map(|error| error.message.clone()) + }; + Some(match &state.status { + UpdateStatus::Available { version } => { + match error(UpdateStep::Download).or_else(|| error(UpdateStep::Install)) { + Some(message) => Chip { + label: "Update failed".into(), + tooltip: format!("{message}\n\nClick to download {version} again."), + icon: "icons/circle_alert.svg", + failed: true, + action: Some(Action::Download), + }, + None => Chip { + label: format!("Update {version}"), + tooltip: format!( + "Version {version} is available (this is {}). Click to download it.", + state.current_version + ), + icon: "icons/arrow_down.svg", + failed: false, + action: Some(Action::Download), + }, + } + } + UpdateStatus::Downloading { version, .. } => Chip { + label: format!("Downloading {}%", state.download_percent().unwrap_or(0)), + tooltip: format!("Downloading version {version}"), + icon: "icons/arrow_down.svg", + failed: false, + action: None, + }, + UpdateStatus::Downloaded { version } => { + let tooltip = match (error(UpdateStep::Install), &state.not_applied) { + (Some(message), _) => format!("Installing {version} failed: {message}"), + (None, Some(not_applied)) if not_applied == version => { + format!("The update to {version} did not apply. Click to try again.") + } + _ => format!("Version {version} is downloaded. Click to restart into it."), + }; + Chip { + label: "Restart to update".into(), + tooltip, + icon: "icons/refresh_cw.svg", + failed: error(UpdateStep::Install).is_some(), + action: Some(Action::Restart), + } + } + UpdateStatus::Installing { version } => Chip { + label: "Installing…".into(), + tooltip: format!("Installing version {version}"), + icon: "icons/refresh_cw.svg", + failed: false, + action: None, + }, + _ => return None, + }) +} + +impl MainScreen { + /// The title-bar chip, or `None` when no update is in sight. + pub(super) fn render_update_control(&self, cx: &mut Context) -> Option { + let chip = chip(&cx.try_global::()?.update_state()?)?; + let color: Hsla = if chip.failed { + cx.theme().danger + } else { + cx.theme().primary + }; + let tooltip = SharedString::from(chip.tooltip); + let clickable = chip.action.is_some(); + Some( + div() + .id("update-chip") + .mr_1() + .h(px(24.)) + .px_2() + .rounded_md() + .flex() + .items_center() + .gap_1() + .text_xs() + .text_color(color) + .border_1() + .border_color(color.opacity(0.4)) + .when(clickable, |el| { + el.cursor_pointer().hover(|s| s.bg(cx.theme().muted)) + }) + .when(self.update_confirm_open, |el| el.bg(cx.theme().muted)) + .child( + Icon::default() + .path(SharedString::from(chip.icon)) + .with_size(Size::XSmall) + .text_color(color), + ) + .child(SharedString::from(chip.label)) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .when_some(chip.action, |el, action| { + el.on_click(cx.listener(move |this, _, _, cx| match action { + Action::Download => { + if let Some(gpui) = cx.try_global::() { + gpui.cmd_download_update(); + } + } + Action::Restart => { + if this.project_sidebar.read(cx).working_session_count() > 0 { + this.update_confirm_open = !this.update_confirm_open; + cx.notify(); + } else { + this.restart_to_update(cx); + } + } + })) + }) + .into_any_element(), + ) + } + + /// Below the chip: restarting stops the working sessions — go ahead? + pub(super) fn render_update_confirm(&self, cx: &mut Context) -> Option { + if !self.update_confirm_open { + return None; + } + let working = self.project_sidebar.read(cx).working_session_count(); + let message = match working { + 0 => "No session is working anymore.".to_string(), + 1 => "A session is working. Restarting stops it; its conversation is kept.".to_string(), + n => format!( + "{n} sessions are working. Restarting stops them; their conversations are kept." + ), + }; + Some( + div() + .absolute() + .top(px(52.)) + .right(px(16.)) + .w(px(320.)) + .p_3() + .flex() + .flex_col() + .gap_3() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded_lg() + .shadow_lg() + .child( + div() + .text_sm() + .text_color(cx.theme().foreground) + .child(SharedString::from(message)), + ) + .child( + div() + .flex() + .justify_end() + .gap_2() + .child( + Button::new("update-confirm-cancel") + .label("Cancel") + .small() + .ghost() + .on_click(cx.listener(|this, _, _, cx| { + this.update_confirm_open = false; + cx.notify(); + })), + ) + .child( + Button::new("update-confirm-restart") + .label("Restart now") + .small() + .primary() + .on_click(cx.listener(|this, _, _, cx| { + this.restart_to_update(cx); + })), + ), + ) + .into_any_element(), + ) + } + + /// Swaps the downloaded release in and restarts into it. A failure + /// shows on the chip (the state carries it). + fn restart_to_update(&mut self, cx: &mut Context) { + self.update_confirm_open = false; + cx.notify(); + let Some(service) = cx.try_global::().and_then(Gpui::update_service) else { + return; + }; + cx.spawn(async move |_, cx| { + let installed = cx + .background_spawn(async move { service.install().await }) + .await; + match installed { + Ok(launch) => cx.update(|cx| { + cx.set_restart_path(launch); + cx.restart(); + }), + Err(e) => warn!("Update not installed: {e:#}"), + } + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use code_assistant_core::updates::{UpdateError, Version}; + + fn state(status: UpdateStatus) -> UpdateState { + UpdateState::new(Version::new(0, 2, 18), status) + } + + fn v(text: &str) -> Version { + Version::parse(text).unwrap() + } + + #[test] + fn shows_nothing_without_a_newer_release() { + for status in [ + UpdateStatus::Idle, + UpdateStatus::Checking, + UpdateStatus::UpToDate, + UpdateStatus::Disabled { + reason: "dev build".into(), + }, + ] { + assert!(chip(&state(status)).is_none()); + } + } + + #[test] + fn a_failed_check_leaves_the_offer_alone() { + let mut available = state(UpdateStatus::Available { + version: v("0.2.19"), + }); + available.error = Some(UpdateError { + step: UpdateStep::Check, + message: "offline".into(), + }); + let chip = chip(&available).unwrap(); + assert_eq!(chip.label, "Update 0.2.19"); + assert!(!chip.failed); + } + + #[test] + fn a_failed_download_offers_a_retry() { + let mut available = state(UpdateStatus::Available { + version: v("0.2.19"), + }); + available.error = Some(UpdateError { + step: UpdateStep::Download, + message: "connection reset".into(), + }); + let chip = chip(&available).unwrap(); + assert!(chip.failed); + assert!(matches!(chip.action, Some(Action::Download))); + assert!(chip.tooltip.contains("connection reset")); + } + + #[test] + fn downloading_shows_progress_and_takes_no_click() { + let chip = chip(&state(UpdateStatus::Downloading { + version: v("0.2.19"), + received: 42, + total: 100, + })) + .unwrap(); + assert_eq!(chip.label, "Downloading 42%"); + assert!(chip.action.is_none()); + } + + #[test] + fn a_download_that_did_not_apply_says_so() { + let mut downloaded = state(UpdateStatus::Downloaded { + version: v("0.2.19"), + }); + downloaded.not_applied = Some(v("0.2.19")); + let chip = chip(&downloaded).unwrap(); + assert!(matches!(chip.action, Some(Action::Restart))); + assert!(chip.tooltip.contains("did not apply"), "{}", chip.tooltip); + } +} diff --git a/crates/ui_gpui/src/sidebar/mod.rs b/crates/ui_gpui/src/sidebar/mod.rs index e73de2e9f..39077d54f 100644 --- a/crates/ui_gpui/src/sidebar/mod.rs +++ b/crates/ui_gpui/src/sidebar/mod.rs @@ -307,6 +307,14 @@ impl SessionSidebar { cx.notify(); } + /// Sessions whose agent runs in this process. + pub fn working_session_count(&self) -> usize { + self.activity_states + .values() + .filter(|state| !state.is_terminal() && !state.is_running_externally()) + .count() + } + /// The status the row of `session_id` shows. #[cfg(test)] pub(crate) fn session_status( From 3b3cfe864e86328becf2900a3e2929e234e06bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Fri, 30 Oct 2026 16:35:33 +0100 Subject: [PATCH 4/5] AGENTS.md: self-update --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0fd05b58e..a6d3f0bff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,17 @@ Layer 5 (binary): code_assistant — CLI, config, feature-gated frontend worktree session) and stack rebases (`git::stack_rebase`, scratch repo) are git-crate work the core drives +### Self-update +- Release builds (the workflow compiles in `CODE_ASSISTANT_UPDATE_FEED`) + read `update-manifest.json` from the latest GitHub release; all other + builds never update themselves (`version::update_feed()`) +- Layer-0 `app_update`: manifest, download (resumed with `Range`, retried, + sha256-checked), swap next to the installation (macOS bundle after a + codesign/team check, bare executable, Windows rename trick), cleanup +- `code_assistant_core::updates::UpdateService`: checks after 15 s and every + 4 h, download on click, app-scoped `UiEvent::UpdateStateChanged`; the GPUI + title-bar chip installs and calls `cx.restart()`; see `docs/self-update.md` + ### Session Storage - One folder per session below `sessions/`, the ID is its path: `/-` (`persistence::layout`) From cb83b1e1e1e6e3a3698c3f9e2249379b64a8f1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Fri, 30 Oct 2026 17:26:31 +0100 Subject: [PATCH 5/5] Update chip: a filling ring instead of a percentage while downloading The label stays "Downloading", so the chip keeps its width; the ring is the context indicator's, the percentage moved to the tooltip. --- .../ui_gpui/src/main_screen/update_control.rs | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/ui_gpui/src/main_screen/update_control.rs b/crates/ui_gpui/src/main_screen/update_control.rs index 7358dfc91..04f59f1a3 100644 --- a/crates/ui_gpui/src/main_screen/update_control.rs +++ b/crates/ui_gpui/src/main_screen/update_control.rs @@ -4,6 +4,7 @@ use super::MainScreen; use crate::Gpui; +use crate::shared::context_indicator::ContextIndicator; use code_assistant_core::updates::{UpdateState, UpdateStatus, UpdateStep}; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::tooltip::Tooltip; @@ -17,10 +18,17 @@ enum Action { Restart, } +/// The chip's leading mark: an icon, or while downloading a ring that +/// fills up (like the context indicator), so the chip keeps its width. +enum Mark { + Icon(&'static str), + Progress(f32), +} + struct Chip { label: String, tooltip: String, - icon: &'static str, + mark: Mark, failed: bool, action: Option, } @@ -41,7 +49,7 @@ fn chip(state: &UpdateState) -> Option { Some(message) => Chip { label: "Update failed".into(), tooltip: format!("{message}\n\nClick to download {version} again."), - icon: "icons/circle_alert.svg", + mark: Mark::Icon("icons/circle_alert.svg"), failed: true, action: Some(Action::Download), }, @@ -51,19 +59,22 @@ fn chip(state: &UpdateState) -> Option { "Version {version} is available (this is {}). Click to download it.", state.current_version ), - icon: "icons/arrow_down.svg", + mark: Mark::Icon("icons/arrow_down.svg"), failed: false, action: Some(Action::Download), }, } } - UpdateStatus::Downloading { version, .. } => Chip { - label: format!("Downloading {}%", state.download_percent().unwrap_or(0)), - tooltip: format!("Downloading version {version}"), - icon: "icons/arrow_down.svg", - failed: false, - action: None, - }, + UpdateStatus::Downloading { version, .. } => { + let percent = state.download_percent().unwrap_or(0); + Chip { + label: "Downloading".into(), + tooltip: format!("Downloading version {version}: {percent}%"), + mark: Mark::Progress(f32::from(percent) / 100.0), + failed: false, + action: None, + } + } UpdateStatus::Downloaded { version } => { let tooltip = match (error(UpdateStep::Install), &state.not_applied) { (Some(message), _) => format!("Installing {version} failed: {message}"), @@ -75,7 +86,7 @@ fn chip(state: &UpdateState) -> Option { Chip { label: "Restart to update".into(), tooltip, - icon: "icons/refresh_cw.svg", + mark: Mark::Icon("icons/refresh_cw.svg"), failed: error(UpdateStep::Install).is_some(), action: Some(Action::Restart), } @@ -83,7 +94,7 @@ fn chip(state: &UpdateState) -> Option { UpdateStatus::Installing { version } => Chip { label: "Installing…".into(), tooltip: format!("Installing version {version}"), - icon: "icons/refresh_cw.svg", + mark: Mark::Icon("icons/refresh_cw.svg"), failed: false, action: None, }, @@ -102,6 +113,20 @@ impl MainScreen { }; let tooltip = SharedString::from(chip.tooltip); let clickable = chip.action.is_some(); + let scale = cx.theme().font_size / px(16.0); + let mark = match chip.mark { + Mark::Icon(path) => Icon::default() + .path(SharedString::from(path)) + .with_size(Size::XSmall) + .text_color(color) + .into_any_element(), + Mark::Progress(ratio) => ContextIndicator::new(ratio) + .size(px(12.0 * scale)) + .stroke_width(px(2.0 * scale)) + .bg_color(color.opacity(0.25)) + .progress_color(color) + .into_any_element(), + }; Some( div() .id("update-chip") @@ -120,12 +145,7 @@ impl MainScreen { el.cursor_pointer().hover(|s| s.bg(cx.theme().muted)) }) .when(self.update_confirm_open, |el| el.bg(cx.theme().muted)) - .child( - Icon::default() - .path(SharedString::from(chip.icon)) - .with_size(Size::XSmall) - .text_color(color), - ) + .child(mark) .child(SharedString::from(chip.label)) .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) .when_some(chip.action, |el, action| { @@ -293,14 +313,17 @@ mod tests { } #[test] - fn downloading_shows_progress_and_takes_no_click() { + fn downloading_fills_a_ring_and_takes_no_click() { let chip = chip(&state(UpdateStatus::Downloading { version: v("0.2.19"), received: 42, total: 100, })) .unwrap(); - assert_eq!(chip.label, "Downloading 42%"); + // The label stays the same, so the chip keeps its width. + assert_eq!(chip.label, "Downloading"); + assert!(matches!(chip.mark, Mark::Progress(ratio) if (ratio - 0.42).abs() < 1e-6)); + assert!(chip.tooltip.ends_with("42%"), "{}", chip.tooltip); assert!(chip.action.is_none()); }