diff --git a/Cargo.lock b/Cargo.lock index 66118fc..d6738d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -542,15 +542,16 @@ version = "0.3.0" dependencies = [ "anyhow", "common", + "futures", "gpui", "gpui_tokio", "log", - "nostr-sdk", "reqwest", "semver", + "serde", + "serde_json", "smallvec", "smol", - "state", "tempfile", ] diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 9227179..39a37e3 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -6,16 +6,17 @@ publish.workspace = true [dependencies] common = { path = "../common" } -state = { path = "../state" } gpui.workspace = true gpui_tokio.workspace = true reqwest.workspace = true -nostr-sdk.workspace = true anyhow.workspace = true smol.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" +futures.workspace = true diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 3bb10a6..c81fc30 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -8,16 +8,35 @@ use gpui::http_client::{AsyncBody, HttpClient}; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task, }; -use nostr_sdk::prelude::*; use semver::Version; +use serde::Deserialize; use smallvec::{smallvec, SmallVec}; use smol::fs::File; use smol::process::Command; -use state::NostrRegistry; -const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q"; +const GITHUB_API_URL: &str = "https://api.github.com"; +const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION"; + +fn get_github_repo_owner() -> String { + std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string()) +} + +fn get_github_repo_name() -> String { + std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_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() +} pub fn init(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; + } + AutoUpdater::set_global(cx.new(AutoUpdater::new), cx); } @@ -108,7 +127,7 @@ impl Drop for MacOsUnmounter<'_> { pub enum AutoUpdateStatus { Idle, Checking, - Checked { files: Vec }, + Checked { download_url: String }, Installing, Updated, Errored { msg: Box }, @@ -129,8 +148,8 @@ impl AutoUpdateStatus { matches!(self, Self::Updated) } - pub fn checked(files: Vec) -> Self { - Self::Checked { files } + pub fn checked(download_url: String) -> Self { + Self::Checked { download_url } } pub fn error(e: String) -> Self { @@ -138,6 +157,18 @@ impl AutoUpdateStatus { } } +#[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)] pub struct AutoUpdater { /// Current status of the auto updater @@ -172,36 +203,32 @@ impl AutoUpdater { let mut tasks = smallvec![]; tasks.push( - // Subscribe to get the new update event in the bootstrap relays - Self::subscribe_to_updates(cx), - ); - - tasks.push( - // Subscribe to get the new update event in the bootstrap relays + // Check for updates after 2 minutes cx.spawn(async move |this, cx| { - // Check for updates after 2 minutes cx.background_executor() .timer(Duration::from_secs(120)) .await; // Update the status to checking - _ = this.update(cx, |this, cx| { + this.update(cx, |this, cx| { this.set_status(AutoUpdateStatus::Checking, cx); - }); + }) + .ok(); match Self::check_for_updates(async_version, cx).await { - Ok(ids) => { - // Update the status to downloading - _ = this.update(cx, |this, cx| { - this.set_status(AutoUpdateStatus::checked(ids), cx); - }); + Ok(download_url) => { + // Update the status to checked with download URL + this.update(cx, |this, cx| { + this.set_status(AutoUpdateStatus::checked(download_url), cx); + }) + .ok(); } Err(e) => { - _ = this.update(cx, |this, cx| { + log::warn!("Failed to check for updates: {e}"); + this.update(cx, |this, cx| { this.set_status(AutoUpdateStatus::Idle, cx); - }); - - log::warn!("{e}"); + }) + .ok(); } } }), @@ -210,8 +237,8 @@ impl AutoUpdater { subscriptions.push( // Observe the status cx.observe_self(|this, cx| { - if let AutoUpdateStatus::Checked { files } = this.status.clone() { - this.get_latest_release(&files, cx); + if let AutoUpdateStatus::Checked { download_url } = this.status.clone() { + this.download_and_install(&download_url, cx); } }), ); @@ -229,110 +256,82 @@ impl AutoUpdater { cx.notify(); } - fn subscribe_to_updates(cx: &App) -> Task<()> { - let nostr = NostrRegistry::global(cx); - let _client = nostr.read(cx).client(); - + fn check_for_updates(version: Version, cx: &AsyncApp) -> Task> { cx.background_spawn(async move { - let _opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); + let client = reqwest::Client::new(); + let repo_owner = get_github_repo_owner(); + let repo_name = get_github_repo_name(); + let url = format!( + "{}/repos/{}/{}/releases/latest", + GITHUB_API_URL, repo_owner, repo_name + ); - let _filter = Filter::new() - .kind(Kind::ReleaseArtifactSet) - .author(app_pubkey) - .limit(1); + let response = client + .get(&url) + .header("User-Agent", "Coop-Auto-Updater") + .send() + .await + .context("Failed to fetch GitHub releases")?; - // TODO - }) - } + if !response.status().is_success() { + return Err(anyhow!("GitHub API returned error: {}", response.status())); + } - fn check_for_updates(version: Version, cx: &AsyncApp) -> Task, Error>> { - let client = cx.update(|cx| { - let nostr = NostrRegistry::global(cx); - nostr.read(cx).client() - }); + let release: GitHubRelease = response + .json() + .await + .context("Failed to parse GitHub release")?; - cx.background_spawn(async move { - let _opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); + // 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 + ))?; - let filter = Filter::new() - .kind(Kind::ReleaseArtifactSet) - .author(app_pubkey) - .limit(1); + 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)), + }; - if let Some(event) = client.database().query(filter).await?.first_owned() { - let new_version: Version = event - .tags - .find(TagKind::d()) - .and_then(|tag| tag.content()) - .and_then(|content| content.split("@").last()) - .and_then(|content| Version::parse(content).ok()) - .context("Failed to parse version")?; + 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 + ))?; - if new_version > version { - // Get all file metadata event ids - let ids: Vec = event.tags.event_ids().copied().collect(); - - let _filter = Filter::new() - .kind(Kind::FileMetadata) - .author(app_pubkey) - .ids(ids.clone()); - - // TODO - - Ok(ids) - } else { - Err(anyhow!("No update available")) - } + Ok(download_url) } else { - Err(anyhow!("No update available")) + Err(anyhow!( + "No update available. Current: {}, Latest: {}", + version, + new_version + )) } }) } - fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); + fn download_and_install(&mut self, download_url: &str, cx: &mut Context) { let http_client = cx.http_client(); - let ids = ids.to_vec(); + let download_url = download_url.to_string(); let task: Task> = cx.background_spawn(async move { - let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); - let os = std::env::consts::OS; + let installer_dir = InstallerDir::new().await?; + let target_path = Self::target_path(&installer_dir).await?; - let filter = Filter::new() - .kind(Kind::FileMetadata) - .author(app_pubkey) - .ids(ids); + // Download the release + download(&download_url, &target_path, http_client).await?; - // Get all urls for this release - let events = client.database().query(filter).await?; - - for event in events.into_iter() { - // Only process events that match current platform - if event.content != os { - continue; - } - - // Parse the url - let url = event - .tags - .find(TagKind::Url) - .and_then(|tag| tag.content()) - .and_then(|content| Url::parse(content).ok()) - .context("Failed to parse url")?; - - let installer_dir = InstallerDir::new().await?; - let target_path = Self::target_path(&installer_dir).await?; - - // Download the release - download(url.as_str(), &target_path, http_client).await?; - - return Ok((installer_dir, target_path)); - } - - Err(anyhow!("Failed to get latest release")) + Ok((installer_dir, target_path)) }); self._tasks.push( @@ -365,6 +364,7 @@ impl AutoUpdater { 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}"), }?; @@ -379,6 +379,7 @@ impl AutoUpdater { ) -> 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}"), } @@ -451,6 +452,75 @@ async fn install_release_macos( 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; diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 14f69fa..02f72ff 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -122,7 +122,7 @@ fn load_embedded_fonts(cx: &App) { } scope.spawn(async { - let font_bytes = asset_source.load(font_path).unwrap().unwrap(); + let font_bytes = asset_source.load(font_path.as_str()).unwrap().unwrap(); embedded_fonts.lock().unwrap().push(font_bytes); }); }