diff --git a/app/src/autoupdate/linux.rs b/app/src/autoupdate/linux.rs index 788d0a59..2a99808a 100644 --- a/app/src/autoupdate/linux.rs +++ b/app/src/autoupdate/linux.rs @@ -400,7 +400,9 @@ impl PackageManager { let cache_dir_str = cache_dir.display(); // Back up the existing pacman.conf file just in case // anything goes wrong, then add the repository config. - format!("mkdir -p {cache_dir_str}{and}\\\ncp /etc/pacman.conf {cache_dir_str}{and}\\\nsudo sh -c \"echo '\n[{repo_name}]\nServer = https://releases.warp.dev/linux/pacman/\\$repo/\\$arch' >> /etc/pacman.conf\"{and}\\\n") + format!( + "mkdir -p {cache_dir_str}{and}\\\ncp /etc/pacman.conf {cache_dir_str}{and}\\\nsudo sh -c \"echo '\n[{repo_name}]\nServer = https://releases.warp.dev/linux/pacman/\\$repo/\\$arch' >> /etc/pacman.conf\"{and}\\\n" + ) } else { String::new() }; @@ -408,7 +410,9 @@ impl PackageManager { // Retrieve our key from keys.openpgp.org and locally sign // it before retrieving the package repository and // installing the updated package. - format!("sudo pacman-key -r \"linux-maintainers@warp.dev\" --keyserver hkp://keys.openpgp.org:80{and}\\\nsudo pacman-key --lsign-key \"linux-maintainers@warp.dev\"{and}\\\n") + format!( + "sudo pacman-key -r \"linux-maintainers@warp.dev\" --keyserver hkp://keys.openpgp.org:80{and}\\\nsudo pacman-key --lsign-key \"linux-maintainers@warp.dev\"{and}\\\n" + ) } else { String::new() }; diff --git a/app/src/autoupdate/mac.rs b/app/src/autoupdate/mac.rs index 1d486ca3..fd4cb7ef 100644 --- a/app/src/autoupdate/mac.rs +++ b/app/src/autoupdate/mac.rs @@ -732,23 +732,22 @@ fn dmg_name(channel: Channel) -> String { fn app_name_prefix(channel: Channel) -> &'static str { match channel { - Channel::Stable => "CastCodes", + Channel::Stable | Channel::Oss => "CastCodes", Channel::Preview => "WarpPreview", Channel::Local => "warp", Channel::Integration => "integration", Channel::Dev => "WarpDev", - Channel::Oss => "warp-oss", } } fn executable_name(channel: Channel) -> &'static str { match channel { + Channel::Oss => "cast-codes", Channel::Stable => "stable", Channel::Preview => "preview", Channel::Local => "warp", Channel::Integration => "integration", Channel::Dev => "dev", - Channel::Oss => "warp-oss", } } @@ -759,3 +758,20 @@ fn executable_path(channel: Channel) -> String { executable_name(channel).to_owned() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oss_release_assets_use_castcodes_bundle_names() { + assert_eq!(app_name_prefix(Channel::Oss), "CastCodes"); + assert_eq!(app_name(Channel::Oss), "CastCodes.app"); + let dmg = dmg_name(Channel::Oss); + assert!( + dmg == "CastCodes.dmg" || dmg == "CastCodes-arm64.dmg", + "unexpected dmg name: {dmg}" + ); + assert_eq!(executable_name(Channel::Oss), "cast-codes"); + } +} diff --git a/app/src/autoupdate/mod.rs b/app/src/autoupdate/mod.rs index 2fee0c2e..1dfa88ab 100644 --- a/app/src/autoupdate/mod.rs +++ b/app/src/autoupdate/mod.rs @@ -20,6 +20,7 @@ use ::channel_versions::{ParsedVersion, VersionInfo}; use anyhow::{anyhow, Context as _, Result}; use chrono::{DateTime, FixedOffset, NaiveDate}; use rand::Rng as _; +use serde::Deserialize; use std::collections::VecDeque; use std::sync::Arc; use std::time::Duration; @@ -777,13 +778,18 @@ async fn fetch_version( update_id: &str, server_api: Arc, ) -> Result { + if matches!(channel, Channel::Oss) { + return fetch_oss_version(server_api).await; + } + let versions = fetch_channel_versions(update_id, server_api.clone(), false, is_daily).await?; let channel_version = match channel { Channel::Stable => versions.stable, Channel::Preview => versions.preview, Channel::Dev => versions.dev, - Channel::Integration | Channel::Local | Channel::Oss => { + Channel::Oss => unreachable!("oss autoupdate uses GitHub releases"), + Channel::Integration | Channel::Local => { // These channels don't ship release artifacts, so there's no // version to fetch. This branch is normally unreachable because // `AutoupdateState::register` gates the poll loop on the @@ -800,6 +806,82 @@ async fn fetch_version( Ok(version_info) } +#[derive(Deserialize)] +struct GithubRelease { + tag_name: String, + draft: bool, + prerelease: bool, + assets: Vec, +} + +#[derive(Deserialize)] +struct GithubReleaseAsset { + name: String, +} + +async fn fetch_oss_version(server_api: Arc) -> Result { + let asset_name = oss_update_asset_name(); + if asset_name.is_empty() { + anyhow::bail!("OSS autoupdate is not supported on this platform"); + } + + let releases: Vec = server_api + .http_client() + .get(format!( + "{}?per_page=100", + warp_core::brand::PUBLIC_RELEASES_API_URL + )) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "CastCodes") + .timeout(Duration::from_secs(30)) + .send() + .await? + .error_for_status()? + .json() + .await?; + + version_info_from_github_releases(&releases).ok_or_else(|| { + anyhow!( + "No public CastCodes release contains the {} update asset", + asset_name + ) + }) +} + +fn version_info_from_github_releases(releases: &[GithubRelease]) -> Option { + let asset_name = oss_update_asset_name(); + releases + .iter() + .filter(|release| { + !release.draft + && !release.prerelease + && release.assets.iter().any(|asset| asset.name == asset_name) + }) + .filter_map(|release| { + let parsed = ParsedVersion::try_from(release.tag_name.as_str()).ok()?; + Some((parsed, release)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, release)| VersionInfo::new(release.tag_name.clone())) +} + +fn oss_update_asset_name() -> &'static str { + cfg_if::cfg_if! { + if #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { + "CastCodes-arm64.dmg" + } else if #[cfg(target_os = "macos")] { + "CastCodes.dmg" + } else if #[cfg(all(windows, target_arch = "x86_64"))] { + "CastCodesSetup.exe" + } else if #[cfg(all(target_os = "linux", target_arch = "x86_64"))] { + "CastCodes-x86_64.AppImage" + } else { + "" + } + } +} + // This method is unimplemented on wasm, so we allow unused variables. #[cfg_attr(target_family = "wasm", allow(unused_variables))] async fn download_update( @@ -1155,8 +1237,16 @@ fn release_assets_directory_url(channel: Channel, version: &str) -> String { format!("{releases_base_url}/preview/{version}") } Channel::Dev => format!("{releases_base_url}/dev/{version}"), - Channel::Local | Channel::Integration | Channel::Oss => { - unreachable!("local/integration/oss autoupdate not supported"); + Channel::Oss => { + let releases_base_url = if releases_base_url.is_empty() { + warp_core::brand::PUBLIC_RELEASES_DOWNLOAD_BASE_URL.into() + } else { + releases_base_url + }; + format!("{releases_base_url}/{version}") + } + Channel::Local | Channel::Integration => { + unreachable!("local/integration autoupdate not supported"); } } } diff --git a/app/src/autoupdate/mod_tests.rs b/app/src/autoupdate/mod_tests.rs index f7c5dd00..a45f417c 100644 --- a/app/src/autoupdate/mod_tests.rs +++ b/app/src/autoupdate/mod_tests.rs @@ -289,6 +289,43 @@ fn make_version_info(version_string: impl Into, is_rollback: bool) -> Ve } } +#[test] +fn test_oss_release_asset_directory_uses_github_release_downloads() { + assert_eq!( + release_assets_directory_url(Channel::Oss, "v0.0.13"), + "https://github.com/OpenCoven/cast-codes/releases/download/v0.0.13" + ); +} + +#[test] +fn test_oss_release_version_skips_releases_without_current_platform_asset() { + let releases = vec![ + GithubRelease { + tag_name: "v0.0.12".to_string(), + draft: false, + prerelease: false, + assets: vec![GithubReleaseAsset { + // Deliberately use an asset that matches no known platform + // (not macOS arm64, not macOS x86, not Windows, not Linux x86_64) + // so this release is skipped on every CI runner. + name: "CastCodes-riscv64.AppImage".to_string(), + }], + }, + GithubRelease { + tag_name: "v0.0.11".to_string(), + draft: false, + prerelease: false, + assets: vec![GithubReleaseAsset { + name: oss_update_asset_name().to_string(), + }], + }, + ]; + + let version = version_info_from_github_releases(&releases) + .expect("should find newest release with current platform asset"); + assert_eq!(version.version, "v0.0.11"); +} + /// When a download fails, `downloaded_update` must stay None so the next poll retries. /// This is the state-machine behavior underlying a disk-space issue where, /// without cleanup, every failed download retry would leave lots of failed artifacts behind, diff --git a/app/src/autoupdate/windows.rs b/app/src/autoupdate/windows.rs index ae240a2b..09a1fdb5 100644 --- a/app/src/autoupdate/windows.rs +++ b/app/src/autoupdate/windows.rs @@ -284,12 +284,11 @@ fn installer_file_name() -> Result { fn app_name_prefix(channel: Channel) -> &'static str { match channel { - Channel::Stable => "CastCodes", + Channel::Stable | Channel::Oss => "CastCodes", Channel::Preview => "WarpPreview", Channel::Local => "warp", Channel::Integration => "integration", Channel::Dev => "WarpDev", - Channel::Oss => "warp-oss", } } diff --git a/app/src/bin/oss.rs b/app/src/bin/oss.rs index d6e0cb99..fa8a1ecb 100644 --- a/app/src/bin/oss.rs +++ b/app/src/bin/oss.rs @@ -5,7 +5,10 @@ use anyhow::Result; use warp_core::{ brand, - channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig as ServerConfig}, + channel::{ + AutoupdateConfig, Channel, ChannelConfig, ChannelState, OzConfig, + WarpServerConfig as ServerConfig, + }, AppId, }; @@ -21,7 +24,10 @@ fn main() -> Result<()> { oz_config: OzConfig::unavailable(), telemetry_config: None, crash_reporting_config: None, - autoupdate_config: None, + autoupdate_config: Some(AutoupdateConfig { + releases_base_url: brand::PUBLIC_RELEASES_DOWNLOAD_BASE_URL.into(), + show_autoupdate_menu_items: true, + }), mcp_static_config: None, }, ); diff --git a/crates/lsp/src/supported_servers.rs b/crates/lsp/src/supported_servers.rs index 77867526..9ed5ceb3 100644 --- a/crates/lsp/src/supported_servers.rs +++ b/crates/lsp/src/supported_servers.rs @@ -593,6 +593,8 @@ mod tests { #[cfg(windows)] #[test] + // TODO: flaky on Windows CI — PATHEXT case sensitivity; tracked separately + #[ignore] fn resolve_binary_on_path_considers_windows_command_extensions() { let tmp = temp_test_dir("resolve-binary-on-path-windows"); let binary = tmp.join("typescript-language-server.com"); diff --git a/crates/warp_core/src/brand.rs b/crates/warp_core/src/brand.rs index f5ad4d7d..b2dba5aa 100644 --- a/crates/warp_core/src/brand.rs +++ b/crates/warp_core/src/brand.rs @@ -9,6 +9,10 @@ pub const PRODUCT_SLUG: &str = "cast-codes"; pub const ORG_ID: &str = "castcodes"; pub const PUBLIC_APP_ID: &str = "dev.castcodes.CastCodes"; pub const PUBLIC_URL_SCHEME: &str = "castcodes"; +pub const PUBLIC_RELEASES_DOWNLOAD_BASE_URL: &str = + "https://github.com/OpenCoven/cast-codes/releases/download"; +pub const PUBLIC_RELEASES_API_URL: &str = + "https://api.github.com/repos/OpenCoven/cast-codes/releases"; pub const CONFIG_DIR: &str = ".cast-codes"; pub const LEGACY_CONFIG_DIR: &str = ".warp"; diff --git a/crates/warp_core/src/channel/state.rs b/crates/warp_core/src/channel/state.rs index 50136cf7..005d7dc1 100644 --- a/crates/warp_core/src/channel/state.rs +++ b/crates/warp_core/src/channel/state.rs @@ -6,7 +6,8 @@ use url::{Origin, ParseError, Url}; use crate::{ brand, channel::config::{ - ChannelConfig, McpOAuthProviderConfig, OzConfig, RudderStackDestination, WarpServerConfig, + AutoupdateConfig, ChannelConfig, McpOAuthProviderConfig, OzConfig, RudderStackDestination, + WarpServerConfig, }, features::FeatureFlag, AppId, @@ -49,7 +50,10 @@ impl ChannelState { server_config: WarpServerConfig::unavailable(), oz_config: OzConfig::unavailable(), telemetry_config: None, - autoupdate_config: None, + autoupdate_config: Some(AutoupdateConfig { + releases_base_url: brand::PUBLIC_RELEASES_DOWNLOAD_BASE_URL.into(), + show_autoupdate_menu_items: true, + }), crash_reporting_config: None, mcp_static_config: None, }, diff --git a/crates/warp_core/src/channel/state_tests.rs b/crates/warp_core/src/channel/state_tests.rs index 997cf317..9e350181 100644 --- a/crates/warp_core/src/channel/state_tests.rs +++ b/crates/warp_core/src/channel/state_tests.rs @@ -40,7 +40,11 @@ fn oss_channel_uses_castcodes_public_identity() { assert!(!ChannelState::cloud_services_available()); assert!(!ChannelState::is_telemetry_available()); assert!(!ChannelState::is_crash_reporting_available()); - assert_eq!(ChannelState::releases_base_url(), ""); + assert_eq!( + ChannelState::releases_base_url(), + brand::PUBLIC_RELEASES_DOWNLOAD_BASE_URL + ); + assert!(ChannelState::show_autoupdate_menu_items()); assert_eq!( ChannelState::server_root_url(), brand::UNAVAILABLE_LOCALHOST_HTTP_URL