From 57c85f5b99acde48b9b8b515b467a83e60ac5c0e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 16 Sep 2026 02:53:16 +0000 Subject: [PATCH] fix: auto updater (#48) Reviewed-on: https://git.reya.info/reya/coop/pulls/48 --- .github/workflows/release.yml | 12 + Cargo.lock | 41 ++-- Cargo.toml | 3 +- crates/auto_update/Cargo.toml | 5 +- crates/auto_update/src/lib.rs | 362 +++++++++++++++++++----------- crates/auto_update/src/source.rs | 369 +++++++++++++++++++++++++++++++ crates/workspace/src/lib.rs | 34 ++- desktop/Cargo.toml | 2 +- 8 files changed, 671 insertions(+), 157 deletions(-) create mode 100644 crates/auto_update/src/source.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26536177..06b8ebc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,6 +152,18 @@ jobs: echo "Artifacts structure:" find artifacts -type f -exec ls -la {} \; + - name: Generate SHA256SUMS + run: | + # One ` ` line per artifact. The in-app updater reads + # this to verify a download before installing it, so it must be + # published alongside every release. Written outside the directory + # being hashed so the checksums file never includes itself. + find artifacts -type f ! -name SHA256SUMS -print0 \ + | sort -z \ + | xargs -0 sha256sum > SHA256SUMS.raw + mv SHA256SUMS.raw artifacts/SHA256SUMS + cat artifacts/SHA256SUMS + - name: Create draft release id: create_release uses: akkuman/gitea-release-action@v1 diff --git a/Cargo.lock b/Cargo.lock index 3c325347..bebad170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,7 +267,7 @@ dependencies = [ [[package]] name = "assets" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "gpui", @@ -529,12 +529,15 @@ dependencies = [ [[package]] name = "auto_update" -version = "1.0.1" +version = "1.0.2" dependencies = [ "gpui", - "gpui-updater", + "gpui-updater-core", "instant", "log", + "serde", + "serde_json", + "ureq", ] [[package]] @@ -1061,7 +1064,7 @@ dependencies = [ [[package]] name = "chat" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "common", @@ -1083,7 +1086,7 @@ dependencies = [ [[package]] name = "chat_ui" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "chat", @@ -1266,7 +1269,7 @@ dependencies = [ [[package]] name = "common" -version = "1.0.1" +version = "1.0.2" dependencies = [ "chrono", "dirs 5.0.1", @@ -1394,7 +1397,7 @@ dependencies = [ [[package]] name = "coop" -version = "1.0.1" +version = "1.0.2" dependencies = [ "assets", "auto_update", @@ -1420,7 +1423,7 @@ dependencies = [ [[package]] name = "coop_web" -version = "1.0.1" +version = "1.0.2" dependencies = [ "assets", "chat", @@ -1780,7 +1783,7 @@ dependencies = [ [[package]] name = "device" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "common", @@ -2749,11 +2752,10 @@ dependencies = [ ] [[package]] -name = "gpui-updater" -version = "0.0.6" -source = "git+https://github.com/AprilNEA/gpui-updater?tag=v0.0.6#e8a85768793ac3238b0c1bc1f32d6a1544667ec3" +name = "gpui-updater-core" +version = "0.1.0" +source = "git+https://github.com/AprilNEA/gpui-updater#a622818f581eb8663a3a19d65f18b962f665946c" dependencies = [ - "gpui", "minisign-verify", "semver", "serde", @@ -5066,7 +5068,7 @@ dependencies = [ [[package]] name = "person" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "common", @@ -6491,7 +6493,7 @@ dependencies = [ [[package]] name = "settings" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "common", @@ -6735,7 +6737,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "state" -version = "1.0.1" +version = "1.0.2" dependencies = [ "aes-gcm", "anyhow", @@ -7089,7 +7091,7 @@ dependencies = [ [[package]] name = "theme" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "gpui", @@ -7629,7 +7631,7 @@ dependencies = [ [[package]] name = "ui" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "common", @@ -7770,6 +7772,7 @@ dependencies = [ "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "ureq-proto", @@ -9026,7 +9029,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "workspace" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "auto_update", diff --git a/Cargo.toml b/Cargo.toml index 1cee8338..a50022e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"] default-members = ["desktop"] [workspace.package] -version = "1.0.1" +version = "1.0.2" edition = "2024" publish = false @@ -50,6 +50,7 @@ webbrowser = "1.0.4" tracing-subscriber = { version = "0.3.18", features = ["fmt"] } errno = { version = "0.3.14", default-features = false } instant = "0.1" +ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] } [patch.crates-io] # Use stacker's psm version which may have better WASM support diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 0e750d58..e58b814f 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -8,5 +8,8 @@ publish.workspace = true gpui.workspace = true instant.workspace = true log.workspace = true +serde.workspace = true +serde_json.workspace = true +ureq.workspace = true -gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] } +gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" } diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 1173d99d..ce962806 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -1,22 +1,30 @@ #![cfg(not(target_arch = "wasm32"))] -use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window}; -use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version}; -use instant::{Duration, Instant}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; +use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version}; +use instant::Duration; + +use crate::source::{AssetFilter, GiteaSource, asset_filter_for}; + +mod source; + +pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus; + +const GITEA_API_BASE: &str = "https://git.reya.info/api/v1"; +const GITEA_REPO_OWNER: &str = "reya"; +const GITEA_REPO_NAME: &str = "coop"; + +/// Delay before the automatic check that runs on startup. +const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120); +/// How long a failure stays visible before the status reverts to "Up to date". +const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5); const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION"; const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE"; -fn get_github_repo_owner() -> String { - std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string()) -} - -fn get_github_repo_name() -> String { - std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string()) -} - -/// Whether updates are managed by an external distribution channel -/// (Flatpak/Snap), in which case the in-app updater must not run. fn uses_managed_updates() -> bool { // The Flatpak runtime exports `FLATPAK_ID` inside the sandbox. std::env::var("FLATPAK_ID").is_ok() @@ -27,50 +35,55 @@ fn uses_managed_updates() -> bool { } /// Initialize the auto-update system. -/// -/// Skips initialization when updates are handled by an external distribution -/// channel (Flatpak/Snap). Otherwise creates the global [`AutoUpdater`] -/// entity and schedules a check for updates after a 2-minute delay. pub fn init(window: &mut Window, cx: &mut App) { if uses_managed_updates() { log::info!( - "Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)" + "Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)" ); return; } - AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx); + let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH); + + let Some(filter) = asset_filter_for(os, arch) else { + log::info!( + "Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}" + ); + return; + }; + + let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else { + log::error!( + "Skipping auto-update initialization: crate version {:?} is not valid semver", + env!("CARGO_PKG_VERSION") + ); + return; + }; + + AutoUpdater::set_global( + cx.new(|cx| AutoUpdater::new(window, version, filter, cx)), + cx, + ); } struct GlobalAutoUpdater(Entity); impl Global for GlobalAutoUpdater {} -/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`]. -pub use gpui_updater::UpdateStatus as AutoUpdateStatus; - -/// The global auto-updater entity. -/// -/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration -/// (GitHub repo, Flatpak detection, delayed auto-check). -/// -/// Retrieve the global instance via [`AutoUpdater::global`]. pub struct AutoUpdater { - /// The underlying gpui-updater entity that does the heavy lifting. - pub updater: Entity, + /// The blocking engine, driven on the background executor. + engine: Arc>, + status: UpdateStatus, + /// The newer release found by the last successful check, if any. + available: Option, /// Currently running app version. pub version: Version, - /// Keeps the observer subscription alive. - _subscription: Subscription, - /// When the last error was recorded, so we can reset to idle after 5s. - error_time: Option, + /// The in-flight check or download, if any. + task: Option>, } impl AutoUpdater { /// Whether auto-update is available for this installation. - /// - /// Returns `false` on managed distribution channels (Flatpak/Snap), where - /// updates are handled by the channel and no global updater is created. pub fn is_available(cx: &App) -> bool { cx.try_global::().is_some() } @@ -82,12 +95,6 @@ impl AutoUpdater { } /// Retrieve the global auto updater instance. - /// - /// # Panics - /// - /// Panics when auto-update is not available for this installation. Prefer - /// [`AutoUpdater::try_global`] when the installation type is not known at - /// compile time (e.g. Flatpak/Snap). pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -96,92 +103,49 @@ impl AutoUpdater { cx.set_global(GlobalAutoUpdater(state)); } - fn new(window: &mut Window, cx: &mut Context) -> Self { - let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); + fn new( + window: &mut Window, + version: Version, + filter: AssetFilter, + cx: &mut Context, + ) -> Self { + let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter); + let config = EngineConfig::new(version.clone()).verification(Verification::Checksum); + let engine = Arc::new(UpdateEngine::new(source, config)); - let repo_owner = get_github_repo_owner(); - let repo_name = get_github_repo_name(); - - let source = - GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS { - "macos" => "macos", - "linux" => "linux", - _ => "", - }); - - let updater: Entity = - cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx)); - - // When an update becomes available, automatically download and install it. - let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| { - let status = this.updater.read(cx).status().clone(); - - if matches!(status, UpdateStatus::Available(_)) { - this.updater.update(cx, |updater, cx| { - updater.download_and_install(cx); - }); - } - - if matches!(status, UpdateStatus::Errored(_)) { - this.error_time = Some(Instant::now()); - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(5)).await; - this.update(cx, |_this, cx| cx.notify()).ok(); - }) - .detach(); - } else { - this.error_time = None; - } - - cx.notify(); - }); - - // Schedule an auto-check after a 2-minute delay (deferred to run at the - // end of the current frame so the window is fully set up). + // Schedule an auto-check after a 2-minute delay cx.defer_in(window, |_this, _window, cx| { - let duration = Duration::from_secs(120); cx.spawn(async move |this, cx| { - cx.background_executor().timer(duration).await; - this.update(cx, |this, cx| { - this.updater.update(cx, |updater, cx| { - updater.check(cx); - }); - }) - .ok(); + cx.background_executor().timer(AUTO_CHECK_DELAY).await; + this.update(cx, |this, cx| this.check(cx)).ok(); }) .detach(); }); Self { - updater, + engine, + status: UpdateStatus::Idle, + available: None, version, - _subscription: subscription, - error_time: None, + task: None, } } - pub fn idle(&self, cx: &App) -> bool { - let status = self.updater.read(cx).status(); - if status == &UpdateStatus::Idle { - return true; - } - if matches!(status, UpdateStatus::Errored(_)) - && self - .error_time - .is_some_and(|t| t.elapsed() >= Duration::from_secs(5)) - { - return true; - } - false + /// Whether nothing is happening, so the UI can hide the status line. + pub fn idle(&self) -> bool { + matches!(self.status, UpdateStatus::Idle) } - pub fn status(&self, cx: &App) -> SharedString { - let status = self.updater.read(cx).status(); + /// Whether a verified update is installed and waiting for a restart. + pub fn staged(&self) -> bool { + matches!(self.status, UpdateStatus::Staged(_)) + } - match status { - UpdateStatus::Idle => "Up to date".into(), + /// A short, human-readable description of the current status. + pub fn status(&self) -> SharedString { + match &self.status { + UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(), UpdateStatus::Checking => "Checking for updates…".into(), - UpdateStatus::UpToDate => "Up to date".into(), UpdateStatus::Available(version) => format!("Version {version} available").into(), UpdateStatus::Downloading { downloaded, total } => { let total_mb = total.map(|t| t as f64 / 1_048_576.0); @@ -195,16 +159,168 @@ impl AutoUpdater { UpdateStatus::Staged(version) => { format!("Version {version} ready — restart to apply").into() } - UpdateStatus::Errored(msg) => { - if self - .error_time - .is_some_and(|t| t.elapsed() >= Duration::from_secs(5)) - { - "Up to date".into() - } else { - format!("Update failed: {msg}").into() - } - } + UpdateStatus::Errored(message) => format!("Update failed: {message}").into(), } } + + /// Check the release host for a newer version, then download and install it. + pub fn check(&mut self, cx: &mut Context) { + if self.status.is_busy() { + return; + } + self.set_status(UpdateStatus::Checking, cx); + + let engine = self.engine.clone(); + + self.task = Some(cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { engine.check() }) + .await; + + this.update(cx, |this, cx| { + this.task = None; + match result { + Ok(Some(release)) => { + log::info!("Update {} is available", release.version); + let version = release.version.clone(); + this.available = Some(release); + this.set_status(UpdateStatus::Available(version), cx); + this.download_and_install(cx); + } + Ok(None) => this.set_status(UpdateStatus::UpToDate, cx), + Err(error) => { + log::warn!("Update check failed: {error}"); + this.set_status(UpdateStatus::Errored(error.to_string()), cx); + } + } + }) + .ok(); + })); + } + + /// Download the available update, verify it, and swap it into place. + fn download_and_install(&mut self, cx: &mut Context) { + if self.status.is_busy() { + return; + } + let Some(release) = self.available.clone() else { + return; + }; + + let engine = self.engine.clone(); + self.set_status( + UpdateStatus::Downloading { + downloaded: 0, + total: None, + }, + cx, + ); + + self.task = Some(cx.spawn(async move |this, cx| { + let downloaded = Arc::new(AtomicU64::new(0)); + let total = Arc::new(AtomicU64::new(0)); // 0 = unknown + let done = Arc::new(AtomicBool::new(false)); + + let download_task = { + let (engine, release) = (engine.clone(), release.clone()); + let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone()); + cx.background_executor().spawn(async move { + let result = engine.download(&release, |got, expected| { + downloaded.store(got, Ordering::Relaxed); + total.store(expected.unwrap_or(0), Ordering::Relaxed); + }); + done.store(true, Ordering::Relaxed); + result + }) + }; + + loop { + let got = downloaded.load(Ordering::Relaxed); + let total = total.load(Ordering::Relaxed); + this.update(cx, |this, cx| { + this.set_status( + UpdateStatus::Downloading { + downloaded: got, + total: (total != 0).then_some(total), + }, + cx, + ); + }) + .ok(); + if done.load(Ordering::Relaxed) { + break; + } + cx.background_executor() + .timer(Duration::from_millis(120)) + .await; + } + + let artifact = match download_task.await { + Ok(artifact) => artifact, + Err(error) => { + log::warn!("Update download failed: {error}"); + this.update(cx, |this, cx| { + this.task = None; + this.set_status(UpdateStatus::Errored(error.to_string()), cx); + }) + .ok(); + return; + } + }; + + let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx)); + + let installed = { + let engine = engine.clone(); + cx.background_executor() + .spawn(async move { engine.install(&artifact) }) + .await + }; + + this.update(cx, |this, cx| { + this.task = None; + match installed { + Ok(installed) => { + if let Some(path) = installed.restart_path { + cx.set_restart_path(path); + } + let version = release.version.clone(); + this.set_status(UpdateStatus::Staged(version), cx); + } + Err(error) => { + this.set_status(UpdateStatus::Errored(error.to_string()), cx); + } + } + }) + .ok(); + })); + } + + /// Relaunch into the staged update. + pub fn restart(&mut self, cx: &mut Context) { + if !self.staged() { + log::warn!("Ignoring restart request: no update is staged"); + return; + } + cx.restart(); + } + + fn set_status(&mut self, status: UpdateStatus, cx: &mut Context) { + let errored = matches!(status, UpdateStatus::Errored(_)); + self.status = status; + + if errored { + cx.spawn(async move |this, cx| { + cx.background_executor().timer(ERROR_DISPLAY_DURATION).await; + this.update(cx, |this, cx| { + this.set_status(UpdateStatus::Idle, cx); + }) + .ok(); + }) + .detach(); + } + + cx.notify(); + } } diff --git a/crates/auto_update/src/source.rs b/crates/auto_update/src/source.rs new file mode 100644 index 00000000..318b01e7 --- /dev/null +++ b/crates/auto_update/src/source.rs @@ -0,0 +1,369 @@ +use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag}; +use serde::Deserialize; + +const CHECKSUMS_ASSET: &str = "SHA256SUMS"; +const RELEASE_PAGE_SIZE: usize = 20; + +/// Which published artifact belongs to a target platform. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AssetFilter { + extension: &'static str, + arch: &'static str, +} + +impl AssetFilter { + /// Whether `name` is the installable artifact for this target. + fn matches(&self, name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.ends_with(self.extension) && name.contains(self.arch) + } +} + +pub fn asset_filter_for(os: &str, arch: &str) -> Option { + let extension = match os { + "macos" => ".dmg", + "linux" => ".tar.gz", + _ => return None, + }; + + let arch = match (os, arch) { + // cargo-packager names the disk images `aarch64`/`x64`. + ("macos", "aarch64") => "aarch64", + ("macos", "x86_64") => "x64", + // `script/bundle-linux` names the tarballs `aarch64`/`x86_64`. + ("linux", "aarch64") => "aarch64", + ("linux", "x86_64") => "x86_64", + _ => return None, + }; + + Some(AssetFilter { extension, arch }) +} + +/// Reads releases from a Gitea repository's Releases. +pub struct GiteaSource { + api_base: String, + owner: String, + repo: String, + filter: AssetFilter, +} + +impl GiteaSource { + /// Build a source for `owner/repo` on the Gitea instance at `api_base` + /// (e.g. `https://git.reya.info/api/v1`). + pub fn new( + api_base: impl Into, + owner: impl Into, + repo: impl Into, + filter: AssetFilter, + ) -> Self { + Self { + api_base: api_base.into().trim_end_matches('/').to_string(), + owner: owner.into(), + repo: repo.into(), + filter, + } + } + + fn releases_url(&self) -> String { + format!( + "{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}", + self.api_base, self.owner, self.repo + ) + } +} + +impl UpdateSource for GiteaSource { + fn fetch_latest(&self) -> Result { + let releases: Vec = http::get_json(&self.releases_url())?; + let release = newest_published(&releases) + .ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?; + + let asset = release + .assets + .iter() + .find(|asset| self.filter.matches(&asset.name)) + .ok_or(Error::NoMatchingAsset { + target_os: std::env::consts::OS, + target_arch: std::env::consts::ARCH, + })?; + + // Resolve the published checksum so the engine can reject a truncated or substituted download. + let sha256 = release + .assets + .iter() + .find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET)) + .map(|sums| http::get_string(&sums.browser_download_url)) + .transpose()? + .and_then(|sums| sha256_for(&sums, &asset.name)); + + Ok(Release { + version: parse_tag(&release.tag_name)?, + notes: release + .body + .clone() + .filter(|body| !body.trim().is_empty()) + .or_else(|| release.name.clone()), + asset: Asset { + name: asset.name.clone(), + url: asset.browser_download_url.clone(), + size: asset.size, + }, + signature: None, + signature_url: None, + sha256, + }) + } +} + +fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> { + releases + .iter() + .filter(|release| !release.draft && !release.prerelease) + .filter_map(|release| { + parse_tag(&release.tag_name) + .ok() + .map(|version| (version, release)) + }) + .max_by(|(left, _), (right, _)| left.cmp(right)) + .map(|(_, release)| release) +} + +/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file. +fn sha256_for(sums: &str, asset_name: &str) -> Option { + sums.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let (hash, path) = (parts.next()?, parts.next()?); + let path = path.strip_prefix('*').unwrap_or(path); + let base = path.rsplit(['/', '\\']).next().unwrap_or(path); + (base == asset_name).then(|| hash.to_ascii_lowercase()) + }) +} + +/// A release as returned by the Gitea API. +#[derive(Debug, Deserialize)] +struct GiteaRelease { + tag_name: String, + #[serde(default)] + name: Option, + #[serde(default)] + body: Option, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, + #[serde(default)] + assets: Vec, +} + +/// A release asset as returned by the Gitea API. +#[derive(Debug, Deserialize)] +struct GiteaAsset { + name: String, + browser_download_url: String, + #[serde(default)] + size: u64, +} + +/// Blocking HTTP helpers for release metadata. +mod http { + use std::time::Duration; + + use gpui_updater_core::{Error, Result}; + use serde::de::DeserializeOwned; + use ureq::Agent; + use ureq::tls::{RootCerts, TlsConfig}; + + const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION")); + const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); + + fn agent() -> Agent { + Agent::config_builder() + .user_agent(USER_AGENT) + .tls_config( + TlsConfig::builder() + .root_certs(RootCerts::PlatformVerifier) + .build(), + ) + .timeout_resolve(Some(CONNECT_TIMEOUT)) + .timeout_connect(Some(CONNECT_TIMEOUT)) + .timeout_recv_response(Some(RESPONSE_TIMEOUT)) + .build() + .into() + } + + fn get_bytes(url: &str) -> Result> { + let mut response = agent().get(url).call().map_err(|error| match error { + ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")), + other => Error::Http(other.to_string()), + })?; + + response + .body_mut() + .read_to_vec() + .map_err(|error| Error::Http(format!("GET {url} -> {error}"))) + } + + pub(super) fn get_json(url: &str) -> Result { + serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string())) + } + + pub(super) fn get_string(url: &str) -> Result { + String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use gpui_updater_core::Version; + + use super::*; + + const PUBLISHED: &[&str] = &[ + "Coop_1.0.1_aarch64.dmg", + "Coop_1.0.1_x64.dmg", + "coop-linux-aarch64.tar.gz", + "coop-linux-x86_64.tar.gz", + "coop_1.0.1_aarch64.snap", + "coop_1.0.1_arm64-setup.exe", + "coop_1.0.1_x64-setup.exe", + "coop_1.0.1_x86_64.snap", + "su.reya.coop_aarch64.flatpak", + "su.reya.coop_x86_64.flatpak", + ]; + + fn selected(os: &str, arch: &str) -> Option<&'static str> { + let filter = asset_filter_for(os, arch)?; + PUBLISHED.iter().copied().find(|name| filter.matches(name)) + } + + #[test] + fn picks_the_artifact_matching_os_and_architecture() { + assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg")); + assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg")); + assert_eq!( + selected("linux", "aarch64"), + Some("coop-linux-aarch64.tar.gz") + ); + assert_eq!( + selected("linux", "x86_64"), + Some("coop-linux-x86_64.tar.gz") + ); + } + + #[test] + fn has_no_target_for_windows_or_unknown_platforms() { + assert_eq!(asset_filter_for("windows", "x86_64"), None); + assert_eq!(asset_filter_for("freebsd", "x86_64"), None); + assert_eq!(asset_filter_for("macos", "riscv64"), None); + } + + #[test] + fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() { + let macos = asset_filter_for("macos", "aarch64").unwrap(); + assert!(!macos.matches("coop_1.0.1_aarch64.snap")); + assert!(!macos.matches("su.reya.coop_aarch64.flatpak")); + assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig")); + + let linux = asset_filter_for("linux", "x86_64").unwrap(); + assert!(!linux.matches("coop_1.0.1_x64-setup.exe")); + assert!(!linux.matches("coop_1.0.1_x86_64.snap")); + } + + #[test] + fn reads_checksums_by_basename_ignoring_directory_prefix() { + let sums = "\ +abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg +123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz +789abc SHA256SUMS +"; + assert_eq!( + sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(), + Some("abcdef") + ); + assert_eq!( + sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(), + Some("123456") + ); + assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None); + } + + #[test] + fn newest_published_skips_drafts_prereleases_and_bad_tags() { + let releases: Vec = serde_json::from_str( + r#"[ + { + "tag_name": "v1.0.2", + "draft": true, + "assets": [] + }, + { + "tag_name": "v2.0.0-rc.1", + "prerelease": true, + "assets": [] + }, + { + "tag_name": "nightly", + "assets": [] + }, + { + "tag_name": "v1.0.0", + "assets": [ + { + "name": "coop-linux-x86_64.tar.gz", + "browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz", + "size": 26160329 + } + ] + }, + { + "tag_name": "v1.0.1", + "name": "v1.0.1", + "body": "Fixed app panic on flatpak installations", + "assets": [ + { + "name": "coop-linux-x86_64.tar.gz", + "browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz", + "size": 26160329 + } + ] + } + ]"#, + ) + .unwrap(); + + let newest = newest_published(&releases).unwrap(); + assert_eq!(newest.tag_name, "v1.0.1"); + assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1"); + assert_eq!(newest.assets.len(), 1); + assert_eq!(newest.assets[0].size, 26160329); + } + + #[test] + #[ignore = "requires network access to the release host"] + fn live_release_source_resolves_the_running_platform() { + let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH) + .expect("this platform should be supported"); + let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter); + + let release = source + .fetch_latest() + .expect("release lookup should succeed"); + + assert!( + release.version >= Version::new(1, 0, 0), + "unexpected version {}", + release.version + ); + assert!( + source.filter.matches(&release.asset.name), + "unexpected artifact {}", + release.asset.name + ); + assert!( + release.asset.url.starts_with("https://"), + "{} ", + release.asset.url + ); + } +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index c53f9fc5..f76ba639 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -375,14 +375,8 @@ impl Workspace { self.import_encryption(window, cx); } Command::Update => { - // No-op on managed distribution channels (Flatpak/Snap) where - // the in-app updater is never initialized. if let Some(auto_updater) = AutoUpdater::try_global(cx) { - auto_updater.update(cx, |this, cx| { - this.updater.update(cx, |updater, cx| { - updater.check(cx); - }); - }); + auto_updater.update(cx, |this, cx| this.check(cx)); } } } @@ -611,6 +605,7 @@ impl Workspace { } fn titlebar_right(&mut self, cx: &mut Context) -> impl IntoElement { + let auto_updater = AutoUpdater::try_global(cx); let chat = ChatRegistry::global(cx); let nip4e_enabled = AppSettings::get_nip4e(cx); let nostr = NostrRegistry::global(cx); @@ -623,20 +618,35 @@ impl Workspace { let profile = persons.read(cx).get(&public_key, cx); let announcement = profile.announcement(); - // Update status is only shown when auto-update is available. On - // managed distribution channels (Flatpak/Snap) no updater exists, so - // nothing is rendered. - let updater_status = AutoUpdater::try_global(cx).and_then(|updater| { + let updater_status = auto_updater.as_ref().and_then(|updater| { let updater = updater.read(cx); - (!updater.idle(cx)).then(|| updater.status(cx)) + (!updater.idle()).then(|| updater.status()) }); + let staged_update = auto_updater + .as_ref() + .is_some_and(|updater| updater.read(cx).staged()); + h_flex() .when(!cx.theme().platform.is_mac(), |this| this.pr_2()) .gap_2() .when_some(updater_status, |this, status| { this.child(div().text_xs().italic().child(status)) }) + .when(staged_update, |this| { + this.child( + Button::new("restart-to-update") + .label("Restart to Update") + .tooltip("Quit and relaunch into the installed update") + .small() + .ghost() + .on_click(cx.listener(|_this, _event, _window, cx| { + if let Some(auto_updater) = AutoUpdater::try_global(cx) { + auto_updater.update(cx, |this, cx| this.restart(cx)); + } + })), + ) + }) .when(nip4e_enabled, |this| { this.child( Button::new("key") diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c9e4763f..a18c4203 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -14,7 +14,7 @@ product-name = "Coop" description = "Chat Freely, Stay Private on Nostr" identifier = "su.reya.coop" category = "SocialNetworking" -version = "1.0.1" +version = "1.0.2" out-dir = "../dist" before-packaging-command = "cargo build --release" resources = ["Cargo.toml", "src"]