From a1e1133bdc038d0891ea086a7fc7525d6f24f11e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 3 Aug 2026 08:30:17 +0700 Subject: [PATCH] refactor --- Cargo.lock | 108 ++++++- crates/auto_update/Cargo.toml | 12 +- crates/auto_update/src/lib.rs | 555 ++++------------------------------ crates/workspace/src/lib.rs | 11 +- 4 files changed, 164 insertions(+), 522 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13d00cb..5e938eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -546,17 +546,10 @@ dependencies = [ name = "auto_update" version = "1.0.0-beta5" dependencies = [ - "anyhow", - "common", "gpui", + "gpui-updater", "instant", "log", - "semver", - "serde", - "serde_json", - "smallvec", - "smol", - "tempfile", ] [[package]] @@ -1375,6 +1368,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "coop" version = "1.0.0-beta5" @@ -2713,6 +2735,22 @@ dependencies = [ "zed-scap", ] +[[package]] +name = "gpui-updater" +version = "0.0.6" +source = "git+https://github.com/AprilNEA/gpui-updater?tag=v0.0.6#e8a85768793ac3238b0c1bc1f32d6a1544667ec3" +dependencies = [ + "gpui", + "minisign-verify", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", + "tracing", + "ureq", +] + [[package]] name = "gpui_linux" version = "0.1.0" @@ -4106,6 +4144,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -7137,6 +7181,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -7145,6 +7190,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -7718,6 +7773,37 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.9", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -7764,6 +7850,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 6c3767a..0e750d5 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -5,18 +5,8 @@ edition.workspace = true publish.workspace = true [dependencies] -common = { path = "../common" } - gpui.workspace = true instant.workspace = true -anyhow.workspace = true log.workspace = true -smallvec.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -semver = "1.0.27" -tempfile = "3.23.0" - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -smol.workspace = true +gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] } diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 8a46a73..4eb6216 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -1,24 +1,9 @@ #![cfg(not(target_arch = "wasm32"))] -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use anyhow::{Context as AnyhowContext, Error, anyhow}; -use gpui::http_client::{AsyncBody, HttpClient}; -use gpui::{ - App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task, - Window, -}; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window}; +use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version}; use instant::Duration; -use semver::Version; -use serde::Deserialize; -use smallvec::{SmallVec, smallvec}; -use smol::fs::File; -use smol::io::AsyncReadExt; -use smol::process::Command; -const GITHUB_API_URL: &str = "https://api.github.com"; const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION"; fn get_github_repo_owner() -> String { @@ -30,12 +15,15 @@ fn get_github_repo_name() -> String { } fn is_flatpak_installation() -> bool { - // Check if app is installed via Flatpak std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok() } +/// Initialize the auto-update system. +/// +/// Skips initialization when running as a Flatpak (updates are handled by the +/// Flatpak distribution channel). 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) { - // Skip auto-update initialization if installed via Flatpak if is_flatpak_installation() { log::info!("Skipping auto-update initialization: App is installed via Flatpak"); return; @@ -48,516 +36,81 @@ struct GlobalAutoUpdater(Entity); impl Global for GlobalAutoUpdater {} -#[cfg(not(target_os = "windows"))] -struct InstallerDir(tempfile::TempDir); +/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`]. +pub use gpui_updater::UpdateStatus as AutoUpdateStatus; -#[cfg(not(target_os = "windows"))] -impl InstallerDir { - async fn new() -> Result { - Ok(Self( - tempfile::Builder::new() - .prefix("coop-auto-update") - .tempdir()?, - )) - } - - fn path(&self) -> &Path { - self.0.path() - } -} - -#[cfg(target_os = "windows")] -struct InstallerDir(PathBuf); - -#[cfg(target_os = "windows")] -impl InstallerDir { - async fn new() -> Result { - let installer_dir = std::env::current_exe()? - .parent() - .context("No parent dir for Coop.exe")? - .join("updates"); - - if smol::fs::metadata(&installer_dir).await.is_ok() { - smol::fs::remove_dir_all(&installer_dir).await?; - } - - smol::fs::create_dir(&installer_dir).await?; - - Ok(Self(installer_dir)) - } - - fn path(&self) -> &Path { - self.0.as_path() - } -} - -struct MacOsUnmounter<'a> { - mount_path: PathBuf, - background_executor: &'a BackgroundExecutor, -} - -impl Drop for MacOsUnmounter<'_> { - fn drop(&mut self) { - let mount_path = std::mem::take(&mut self.mount_path); - - self.background_executor - .spawn(async move { - let unmount_output = Command::new("hdiutil") - .args(["detach", "-force"]) - .arg(&mount_path) - .output() - .await; - - match unmount_output { - Ok(output) if output.status.success() => { - log::info!("Successfully unmounted the disk image"); - } - Ok(output) => { - log::error!( - "Failed to unmount disk image: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - } - Err(error) => { - log::error!("Error while trying to unmount disk image: {:?}", error); - } - } - }) - .detach(); - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum AutoUpdateStatus { - Idle, - Checking, - Checked { download_url: String }, - Installing, - Updated, - Errored { msg: Box }, -} - -impl AsRef for AutoUpdateStatus { - fn as_ref(&self) -> &AutoUpdateStatus { - self - } -} - -impl AutoUpdateStatus { - pub fn is_updating(&self) -> bool { - matches!(self, Self::Checked { .. } | Self::Installing) - } - - pub fn is_updated(&self) -> bool { - matches!(self, Self::Updated) - } - - pub fn checked(download_url: String) -> Self { - Self::Checked { download_url } - } - - pub fn error(e: String) -> Self { - Self::Errored { msg: Box::new(e) } - } -} - -#[derive(Debug, Deserialize)] -pub struct GitHubRelease { - pub tag_name: String, - pub assets: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct GitHubAsset { - pub name: String, - pub browser_download_url: String, -} - -#[derive(Debug)] +/// 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 { - /// Current status of the auto updater - pub status: AutoUpdateStatus, - - /// Current version of the application + /// The underlying gpui-updater entity that does the heavy lifting. + pub updater: Entity, + /// Currently running app version. pub version: Version, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, - - /// Background tasks - tasks: Vec>>, + /// Keeps the observer subscription alive. + _subscription: Subscription, } impl AutoUpdater { - /// Retrieve the global auto updater instance + /// Retrieve the global auto updater instance. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } - /// Set the global auto updater instance fn set_global(state: Entity, cx: &mut App) { cx.set_global(GlobalAutoUpdater(state)); } fn new(window: &mut Window, cx: &mut Context) -> Self { let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); - let mut subscriptions = smallvec![]; - subscriptions.push( - // Observe the status - cx.observe_self(|this, cx| { - if let AutoUpdateStatus::Checked { download_url } = this.status.clone() { - this.download_and_install(&download_url, cx); - } - }), - ); - - // Run at the end of current cycle - cx.defer_in(window, |this, _window, cx| { - this.check(cx); - }); - - Self { - status: AutoUpdateStatus::Idle, - version, - tasks: vec![], - _subscriptions: subscriptions, - } - } - - fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context) { - self.status = status; - cx.notify(); - } - - fn check(&mut self, cx: &mut Context) { - let version = self.version.clone(); - let duration = Duration::from_secs(120); - let task = self.check_for_updates(version, cx); - - // Check for updates after 2 minutes - self.tasks.push(cx.spawn(async move |this, cx| { - cx.background_executor().timer(duration).await; - - // Update the status to checking - this.update(cx, |this, cx| { - this.set_status(AutoUpdateStatus::Checking, cx); - })?; - - match task.await { - Ok(download_url) => { - // Update the status to checked with download URL - this.update(cx, |this, cx| { - this.set_status(AutoUpdateStatus::checked(download_url), cx); - })?; - } - Err(e) => { - log::warn!("Failed to check for updates: {e}"); - this.update(cx, |this, cx| { - this.set_status(AutoUpdateStatus::Idle, cx); - })?; - } - } - - Ok(()) - })); - } - - fn check_for_updates(&self, version: Version, cx: &App) -> Task> { - let http_client = cx.http_client(); let repo_owner = get_github_repo_owner(); let repo_name = get_github_repo_name(); - cx.background_spawn(async move { - let url = format!( - "{}/repos/{}/{}/releases/latest", - GITHUB_API_URL, repo_owner, repo_name - ); + let source = + GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS { + "macos" => "macos", + "linux" => "linux", + _ => "", + }); - let async_body = AsyncBody::default(); - let mut body = Vec::new(); - let mut response = http_client.get(&url, async_body, false).await?; + let updater: Entity = + cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx)); - // Read the response body into a vector - response.body_mut().read_to_end(&mut body).await?; - - if !response.status().is_success() { - return Err(anyhow!("GitHub API returned error: {}", response.status())); + // 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); + }); } - - // Parse the response body as JSON - let release: GitHubRelease = serde_json::from_slice(&body)?; - - // Parse version from tag (remove 'v' prefix if present) - let tag_version = release.tag_name.trim_start_matches('v'); - let new_version = Version::parse(tag_version).context(format!( - "Failed to parse version from tag: {}", - release.tag_name - ))?; - - if new_version > version { - // Find the appropriate asset for the current platform - let current_os = std::env::consts::OS; - let asset_name = match current_os { - "macos" => "Coop.dmg", - "linux" => "coop.tar.gz", - "windows" => "Coop.exe", - _ => return Err(anyhow!("Unsupported OS: {}", current_os)), - }; - - let download_url = release - .assets - .iter() - .find(|asset| asset.name == asset_name) - .map(|asset| asset.browser_download_url.clone()) - .context(format!( - "No {} asset found in release {}", - asset_name, release.tag_name - ))?; - - Ok(download_url) - } else { - Err(anyhow!( - "No update available. Current: {}, Latest: {}", - version, - new_version - )) - } - }) - } - - fn download_and_install(&mut self, download_url: &str, cx: &mut Context) { - let http_client = cx.http_client(); - let download_url = download_url.to_string(); - - let task: Task> = cx.background_spawn(async move { - let installer_dir = InstallerDir::new().await?; - let target_path = Self::target_path(&installer_dir).await?; - - // Download the release - download(&download_url, &target_path, http_client).await?; - - Ok((installer_dir, target_path)) + cx.notify(); }); - self.tasks.push( - // Install the new release + // 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). + 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.set_status(AutoUpdateStatus::Installing, cx); - })?; + this.updater.update(cx, |updater, cx| { + updater.check(cx); + }); + }) + .ok(); + }) + .detach(); + }); - match task.await { - Ok((installer_dir, target_path)) => { - if Self::install(installer_dir, target_path, cx).await.is_ok() { - // Update the status to updated - this.update(cx, |this, cx| { - this.set_status(AutoUpdateStatus::Updated, cx); - })?; - } - } - Err(e) => { - // Update the status to error including the error message - this.update(cx, |this, cx| { - this.set_status(AutoUpdateStatus::error(e.to_string()), cx); - })?; - } - } - - Ok(()) - }), - ); - } - - async fn target_path(installer_dir: &InstallerDir) -> Result { - let filename = match std::env::consts::OS { - "macos" => anyhow::Ok("Coop.dmg"), - "linux" => Ok("coop.tar.gz"), - "windows" => Ok("Coop.exe"), - unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), - }?; - - Ok(installer_dir.path().join(filename)) - } - - async fn install( - installer_dir: InstallerDir, - target_path: PathBuf, - cx: &AsyncApp, - ) -> Result<(), Error> { - match std::env::consts::OS { - "macos" => install_release_macos(&installer_dir, target_path, cx).await, - "linux" => install_release_linux(&installer_dir, target_path, cx).await, - "windows" => install_release_windows(target_path).await, - unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"), + Self { + updater, + version, + _subscription: subscription, } } } - -async fn download( - url: &str, - target_path: &std::path::Path, - client: Arc, -) -> Result<(), Error> { - let body = AsyncBody::default(); - let mut target_file = File::create(&target_path).await?; - let mut response = client.get(url, body, true).await?; - - // Copy the response body to the target file - smol::io::copy(response.body_mut(), &mut target_file).await?; - - Ok(()) -} - -async fn install_release_macos( - temp_dir: &InstallerDir, - downloaded_dmg: PathBuf, - cx: &AsyncApp, -) -> Result<(), Error> { - let running_app_path = cx.update(|cx| cx.app_path())?; - let running_app_filename = running_app_path - .file_name() - .with_context(|| format!("invalid running app path {running_app_path:?}"))?; - - let mount_path = temp_dir.path().join("Coop"); - let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into(); - - mounted_app_path.push("/"); - - let output = Command::new("hdiutil") - .args(["attach", "-nobrowse"]) - .arg(&downloaded_dmg) - .arg("-mountroot") - .arg(temp_dir.path()) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "failed to mount: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - - // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits - let _unmounter = MacOsUnmounter { - mount_path: mount_path.clone(), - background_executor: cx.background_executor(), - }; - - let output = Command::new("rsync") - .args(["-av", "--delete"]) - .arg(&mounted_app_path) - .arg(&running_app_path) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "failed to copy app: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) -} - -async fn install_release_linux( - temp_dir: &InstallerDir, - downloaded_tar_gz: PathBuf, - cx: &AsyncApp, -) -> Result<(), Error> { - let running_app_path = cx.update(|cx| cx.app_path())?; - - // Extract the tar.gz file - let extracted = temp_dir.path().join("coop"); - smol::fs::create_dir_all(&extracted) - .await - .context("failed to create directory to extract update")?; - - let output = Command::new("tar") - .arg("-xzf") - .arg(&downloaded_tar_gz) - .arg("-C") - .arg(&extracted) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "failed to extract {:?} to {:?}: {:?}", - downloaded_tar_gz, - extracted, - String::from_utf8_lossy(&output.stderr) - ); - - // Find the extracted app directory - let mut entries = smol::fs::read_dir(&extracted).await?; - let mut app_dir = None; - - use smol::stream::StreamExt; - - while let Some(entry) = entries.next().await { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - app_dir = Some(path); - break; - } - } - - let from = app_dir.context("No app directory found in archive")?; - - // Copy to the current installation directory - let output = Command::new("rsync") - .args(["-av", "--delete"]) - .arg(&from) - .arg( - running_app_path - .parent() - .context("No parent directory for app")?, - ) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "failed to copy app from {:?} to {:?}: {:?}", - from, - running_app_path.parent(), - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) -} - -async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> { - //const CREATE_NO_WINDOW: u32 = 0x08000000; - - let system_root = std::env::var("SYSTEMROOT"); - let powershell_path = system_root.as_ref().map_or_else( - |_| "powershell.exe".to_string(), - |p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), - ); - - let mut installer_path = std::ffi::OsString::new(); - installer_path.push("\""); - installer_path.push(&downloaded_installer); - installer_path.push("\""); - - let output = Command::new(powershell_path) - //.creation_flags(CREATE_NO_WINDOW) - .args(["-NoProfile", "-WindowStyle", "Hidden"]) - .args(["Start-Process"]) - .arg(installer_path) - .arg("-ArgumentList") - .args(["/P", "/R"]) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "failed to start installer: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) -} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index cd26935..3cb0a4c 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -44,13 +44,12 @@ struct MsgRelayNotification; #[action(namespace = workspace, no_json)] enum Command { ToggleTheme, - + Update, RefreshMessagingRelays, BackupEncryption, ImportEncryption, RefreshEncryption, ResetEncryption, - ShowRelayList, ShowMessaging, ShowProfile, @@ -379,6 +378,9 @@ impl Workspace { Command::ImportEncryption => { self.import_encryption(window, cx); } + Command::Update => { + // + } } } @@ -586,6 +588,11 @@ impl Workspace { Box::new(Command::ToggleTheme), ) .separator() + .menu_with_icon( + "Check for Updates", + IconName::Device, + Box::new(Command::Update), + ) .menu_with_icon( "Settings", IconName::Settings,