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/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`) diff --git a/Cargo.lock b/Cargo.lock index d7838dfc4..ce3c9a2b8 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" @@ -1655,6 +1675,7 @@ dependencies = [ "agent_core", "agent_orchestration", "anyhow", + "app_update", "async-channel", "async-trait", "axum 0.7.9", @@ -1697,6 +1718,7 @@ dependencies = [ "transmutation", "urlencoding", "web", + "zip", ] [[package]] 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"]); + } +} 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/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/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/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/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..04f59f1a3 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/update_control.rs @@ -0,0 +1,340 @@ +//! 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 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; +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, +} + +/// 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, + mark: Mark, + 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."), + mark: Mark::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 + ), + mark: Mark::Icon("icons/arrow_down.svg"), + failed: false, + action: Some(Action::Download), + }, + } + } + 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}"), + (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, + mark: Mark::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}"), + mark: Mark::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(); + 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") + .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(mark) + .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_fills_a_ring_and_takes_no_click() { + let chip = chip(&state(UpdateStatus::Downloading { + version: v("0.2.19"), + received: 42, + total: 100, + })) + .unwrap(); + // 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()); + } + + #[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( 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}'