fix: auto updater #48

Merged
reya merged 3 commits from fix-updater into master 2026-09-16 02:53:17 +00:00
3 changed files with 13 additions and 81 deletions
Showing only changes of commit 98f09830f3 - Show all commits
Generated
+1 -2
View File
@@ -2754,8 +2754,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-updater-core" name = "gpui-updater-core"
version = "0.1.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/AprilNEA/gpui-updater#a622818f581eb8663a3a19d65f18b962f665946c"
checksum = "49f3ec64b674794b6b8b6fb36d40974d3506bd254212c3d5f65fcc9eac6542cb"
dependencies = [ dependencies = [
"minisign-verify", "minisign-verify",
"semver", "semver",
+1 -1
View File
@@ -12,4 +12,4 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
ureq.workspace = true ureq.workspace = true
gpui-updater-core = "0.1" gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
+11 -78
View File
@@ -5,33 +5,26 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version}; use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::{Duration, Instant}; use instant::Duration;
use crate::source::{AssetFilter, GiteaSource, asset_filter_for}; use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
mod source; mod source;
/// Observable auto-update status — re-exported from
/// [`gpui_updater_core::UpdateStatus`].
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus; pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
/// Coop publishes its releases to its own Gitea instance rather than GitHub, so
/// release metadata is read from the Gitea API.
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1"; const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
const GITEA_REPO_OWNER: &str = "reya"; const GITEA_REPO_OWNER: &str = "reya";
const GITEA_REPO_NAME: &str = "coop"; const GITEA_REPO_NAME: &str = "coop";
/// Delay before the automatic check that runs on startup. /// Delay before the automatic check that runs on startup.
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120); const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
/// How long a failure stays visible before the status reverts to "Up to date". /// How long a failure stays visible before the status reverts to "Up to date".
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5); const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION"; const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE"; const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
/// 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 { fn uses_managed_updates() -> bool {
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox. // The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
std::env::var("FLATPAK_ID").is_ok() std::env::var("FLATPAK_ID").is_ok()
@@ -42,11 +35,6 @@ fn uses_managed_updates() -> bool {
} }
/// Initialize the auto-update system. /// Initialize the auto-update system.
///
/// Skips initialization when updates are handled by an external distribution
/// channel (Flatpak/Snap) or when nothing installable is published for this
/// target. 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) { pub fn init(window: &mut Window, cx: &mut App) {
if uses_managed_updates() { if uses_managed_updates() {
log::info!( log::info!(
@@ -56,6 +44,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
} }
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH); let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
let Some(filter) = asset_filter_for(os, arch) else { let Some(filter) = asset_filter_for(os, arch) else {
log::info!( log::info!(
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}" "Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
@@ -81,12 +70,6 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {} impl Global for GlobalAutoUpdater {}
/// The global auto-updater entity.
///
/// Wraps the blocking update engine from `gpui-updater-core` with Coop's
/// release source, verification policy and delayed auto-check.
///
/// Retrieve the global instance via [`AutoUpdater::global`].
pub struct AutoUpdater { pub struct AutoUpdater {
/// The blocking engine, driven on the background executor. /// The blocking engine, driven on the background executor.
engine: Arc<UpdateEngine<GiteaSource>>, engine: Arc<UpdateEngine<GiteaSource>>,
@@ -97,17 +80,10 @@ pub struct AutoUpdater {
pub version: Version, pub version: Version,
/// The in-flight check or download, if any. /// The in-flight check or download, if any.
task: Option<Task<()>>, task: Option<Task<()>>,
/// When the last error was recorded, so we can reset to idle after 5s.
error_time: Option<Instant>,
} }
impl AutoUpdater { impl AutoUpdater {
/// Whether auto-update is available for this installation. /// Whether auto-update is available for this installation.
///
/// Returns `false` on managed distribution channels (Flatpak/Snap) and on
/// targets without an installable artifact (see
/// [`asset_filter_for`](crate::source::asset_filter_for)), where no
/// updater is created at all.
pub fn is_available(cx: &App) -> bool { pub fn is_available(cx: &App) -> bool {
cx.try_global::<GlobalAutoUpdater>().is_some() cx.try_global::<GlobalAutoUpdater>().is_some()
} }
@@ -119,12 +95,6 @@ impl AutoUpdater {
} }
/// Retrieve the global auto updater instance. /// 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<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone() cx.global::<GlobalAutoUpdater>().0.clone()
} }
@@ -140,16 +110,10 @@ impl AutoUpdater {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter); let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
// Artifacts come from our own host, but a truncated or substituted
// download would otherwise be installed silently, so require the
// published SHA-256 to match. The release workflow publishes a
// `SHA256SUMS` asset for every release.
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum); let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config)); let engine = Arc::new(UpdateEngine::new(source, config));
// Schedule an auto-check after a 2-minute delay (deferred to run at the // Schedule an auto-check after a 2-minute delay
// end of the current frame so the window is fully set up).
cx.defer_in(window, |_this, _window, cx| { cx.defer_in(window, |_this, _window, cx| {
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
cx.background_executor().timer(AUTO_CHECK_DELAY).await; cx.background_executor().timer(AUTO_CHECK_DELAY).await;
@@ -164,17 +128,12 @@ impl AutoUpdater {
available: None, available: None,
version, version,
task: None, task: None,
error_time: None,
} }
} }
/// Whether nothing is happening, so the UI can hide the status line. /// Whether nothing is happening, so the UI can hide the status line.
pub fn idle(&self) -> bool { pub fn idle(&self) -> bool {
match self.status { matches!(self.status, UpdateStatus::Idle)
UpdateStatus::Idle => true,
UpdateStatus::Errored(_) => self.error_is_stale(),
_ => false,
}
} }
/// Whether a verified update is installed and waiting for a restart. /// Whether a verified update is installed and waiting for a restart.
@@ -200,18 +159,11 @@ impl AutoUpdater {
UpdateStatus::Staged(version) => { UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into() format!("Version {version} ready — restart to apply").into()
} }
UpdateStatus::Errored(message) => { UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
if self.error_is_stale() {
"Up to date".into()
} else {
format!("Update failed: {message}").into()
}
}
} }
} }
/// Check the release host for a newer version, then download and install it /// Check the release host for a newer version, then download and install it.
/// without further prompting. No-op while a check or install is in flight.
pub fn check(&mut self, cx: &mut Context<Self>) { pub fn check(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() { if self.status.is_busy() {
return; return;
@@ -248,10 +200,6 @@ impl AutoUpdater {
} }
/// Download the available update, verify it, and swap it into place. /// Download the available update, verify it, and swap it into place.
///
/// No-op unless a newer release is available and no other operation is in
/// flight. On success the status becomes [`UpdateStatus::Staged`] and the
/// app's restart path is set to the new binary; call [`restart`](Self::restart).
fn download_and_install(&mut self, cx: &mut Context<Self>) { fn download_and_install(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() { if self.status.is_busy() {
return; return;
@@ -270,9 +218,6 @@ impl AutoUpdater {
); );
self.task = Some(cx.spawn(async move |this, cx| { self.task = Some(cx.spawn(async move |this, cx| {
// The blocking download runs on a background thread and reports
// progress into shared atomics; this foreground task polls them so
// the UI updates live without a notification per 64 KiB chunk.
let downloaded = Arc::new(AtomicU64::new(0)); let downloaded = Arc::new(AtomicU64::new(0));
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
let done = Arc::new(AtomicBool::new(false)); let done = Arc::new(AtomicBool::new(false));
@@ -324,8 +269,7 @@ impl AutoUpdater {
} }
}; };
this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx)) let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
.ok();
let installed = { let installed = {
let engine = engine.clone(); let engine = engine.clone();
@@ -345,7 +289,6 @@ impl AutoUpdater {
this.set_status(UpdateStatus::Staged(version), cx); this.set_status(UpdateStatus::Staged(version), cx);
} }
Err(error) => { Err(error) => {
log::warn!("Update install failed: {error}");
this.set_status(UpdateStatus::Errored(error.to_string()), cx); this.set_status(UpdateStatus::Errored(error.to_string()), cx);
} }
} }
@@ -355,9 +298,6 @@ impl AutoUpdater {
} }
/// Relaunch into the staged update. /// Relaunch into the staged update.
///
/// On macOS and Linux the new files are already in place, so this is what
/// starts running them. No-op unless an update is [`staged`](Self::staged).
pub fn restart(&mut self, cx: &mut Context<Self>) { pub fn restart(&mut self, cx: &mut Context<Self>) {
if !self.staged() { if !self.staged() {
log::warn!("Ignoring restart request: no update is staged"); log::warn!("Ignoring restart request: no update is staged");
@@ -371,23 +311,16 @@ impl AutoUpdater {
self.status = status; self.status = status;
if errored { if errored {
self.error_time = Some(Instant::now());
// Re-render once the error has been on screen long enough to make
// way for "Up to date".
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await; cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
this.update(cx, |_this, cx| cx.notify()).ok(); this.update(cx, |this, cx| {
this.set_status(UpdateStatus::Idle, cx);
})
.ok();
}) })
.detach(); .detach();
} else {
self.error_time = None;
} }
cx.notify(); cx.notify();
} }
fn error_is_stale(&self) -> bool {
self.error_time
.is_some_and(|time| time.elapsed() >= ERROR_DISPLAY_DURATION)
}
} }