diff --git a/crates/update/src/app.rs b/crates/update/src/app.rs index 59146cf41..ef788bb5a 100644 --- a/crates/update/src/app.rs +++ b/crates/update/src/app.rs @@ -23,6 +23,8 @@ pub struct App { /// Who makes it, shown as "by " in the update prompt. [`DEVELOPER`] unless the app /// says otherwise. pub developer: &'static str, + /// Existing package identity when it differs from the suite's `ai.storyteller.` convention. + pub bundle_id_override: Option<&'static str>, } /// The developer of every app in the storytold organisation. @@ -31,12 +33,23 @@ pub const DEVELOPER: &str = "ArtCraft"; impl App { /// An app with no former names and no icon. pub const fn new(id: &'static str, name: &'static str, tagline: &'static str, repo: &'static str) -> Self { - Self { id, name, tagline, repo, former_ids: &[], former_names: &[], icon_png: None, accent: None, developer: DEVELOPER } + Self { + id, + name, + tagline, + repo, + former_ids: &[], + former_names: &[], + icon_png: None, + accent: None, + developer: DEVELOPER, + bundle_id_override: None, + } } - /// `ai.storyteller.`: macOS bundle id, Flatpak id and Linux desktop-file id. + /// macOS bundle id, Flatpak id and Linux desktop-file id; defaults to `ai.storyteller.`. pub fn bundle_id(&self) -> String { - format!("ai.storyteller.{}", self.id) + self.bundle_id_override.map_or_else(|| format!("ai.storyteller.{}", self.id), str::to_owned) } pub fn releases_url(&self) -> String { @@ -72,6 +85,14 @@ impl App { mod tests { use super::*; + #[test] + fn an_existing_package_identity_does_not_change_its_release_stem() { + let app = App { bundle_id_override: Some("ai.artcraft.app"), ..App::new("artcraft", "ArtCraft", "Create", "storytold/artcraft") }; + assert_eq!(app.bundle_id(), "ai.artcraft.app"); + assert_eq!(app.stems().collect::>(), ["artcraft"]); + assert_eq!(App::new("photocraft", "PhotoCraft", "Photos", "storytold/photocraft").bundle_id(), "ai.storyteller.photocraft"); + } + #[test] fn former_names_follow_the_current_one() { let pdf = diff --git a/crates/update/src/platform.rs b/crates/update/src/platform.rs index a16a852d5..17a31d056 100644 --- a/crates/update/src/platform.rs +++ b/crates/update/src/platform.rs @@ -191,7 +191,7 @@ pub struct AssetName { pub kind: PackageKind, } -/// Reads `---[-portable].`; `None` for checksums, zsync files, CLI +/// Reads suite package names or Tauri DMG/MSI names; `None` for checksums, zsync files, CLI /// and web bundles, or anything else. pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option { const KINDS: [PackageKind; 9] = [ @@ -216,7 +216,48 @@ pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option { } return Some(AssetName { stem: (*stem).to_string(), version: version.to_string(), token: format!("{os}-{arch}"), kind }); } - None + parse_tauri_asset_name(base, kind, stems) +} + +/// Tauri's established package names omit the OS: `__universal.dmg` +/// or `__x64_en-US.msi`. Only these installer formats use this fallback; +/// their suffix and explicit architecture still prevent selecting a different platform. +fn parse_tauri_asset_name(base: &str, kind: PackageKind, stems: &[&str]) -> Option { + if !matches!(kind, PackageKind::Msi | PackageKind::Dmg) { + return None; + } + stems.iter().find_map(|stem| { + if !base.get(..stem.len())?.eq_ignore_ascii_case(stem) { + return None; + } + let rest = base.get(stem.len()..)?.strip_prefix('_')?; + let (version, platform) = rest.split_once('_')?; + if crate::version::parse(version).is_none() || !version.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + let token = match kind { + PackageKind::Dmg => match platform { + "universal" => "macos-universal", + "aarch64" | "arm64" => "macos-arm64", + "x64" | "x86_64" => "macos-x86_64", + _ => return None, + }, + PackageKind::Msi => { + let (arch, locale) = platform.split_once('_')?; + if locale.len() > 16 || !locale.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphabetic())) { + return None; + } + match arch { + "x64" => "windows-x64", + "x86" => "windows-x86", + "arm64" => "windows-arm64", + _ => return None, + } + } + _ => return None, + }; + Some(AssetName { stem: (*stem).to_string(), version: version.to_string(), token: token.to_string(), kind }) + }) } /// The best asset of `release` for `platform`, trying `kinds` in order and, within a kind, the @@ -237,6 +278,25 @@ pub fn pick_asset<'a>(release: &'a Release, stems: &[&str], platform: Platform, mod tests { use super::*; + #[test] + fn tauri_package_names_keep_platform_and_architecture_boundaries() { + let r = release(&["ArtCraft_0.42.1_x64_en-US.msi", "ArtCraft_0.42.1_universal.dmg", "artcraft-0.42.1-linux-x86_64.flatpak"]); + for (platform, kind, expected) in [ + (Platform { os: Os::Windows, arch: Arch::X64 }, PackageKind::Msi, "ArtCraft_0.42.1_x64_en-US.msi"), + (Platform { os: Os::MacOs, arch: Arch::X64 }, PackageKind::Dmg, "ArtCraft_0.42.1_universal.dmg"), + (Platform { os: Os::MacOs, arch: Arch::Arm64 }, PackageKind::Dmg, "ArtCraft_0.42.1_universal.dmg"), + (Platform { os: Os::Linux, arch: Arch::X64 }, PackageKind::Flatpak, "artcraft-0.42.1-linux-x86_64.flatpak"), + ] { + assert_eq!(pick_asset(&r, &["artcraft"], platform, &[kind]).map(|(a, _)| a.name.as_str()), Some(expected)); + assert!(pick_asset(&r, &["other-app"], platform, &[kind]).is_none()); + } + let arm_only = release(&["ArtCraft_0.42.1_aarch64.dmg", "ArtCraft_0.42.1_arm64_en-US.msi"]); + assert!(pick_asset(&arm_only, &["artcraft"], Platform { os: Os::MacOs, arch: Arch::X64 }, &[PackageKind::Dmg]).is_none()); + assert!(pick_asset(&arm_only, &["artcraft"], Platform { os: Os::Windows, arch: Arch::X64 }, &[PackageKind::Msi]).is_none()); + assert!(parse_asset_name("ArtCraft_not-a-version_x64_en-US.msi", &["artcraft"]).is_none()); + assert!(parse_asset_name("ArtCraft_0.42.1_x64_extra_en-US.msi", &["artcraft"]).is_none()); + } + fn release(names: &[&str]) -> Release { Release { assets: names.iter().map(|n| Asset { name: (*n).into(), size: 1, url: format!("https://x/{n}"), sha256: None }).collect(), diff --git a/crates/update/src/release.rs b/crates/update/src/release.rs index d0a74ef7d..5d19ea622 100644 --- a/crates/update/src/release.rs +++ b/crates/update/src/release.rs @@ -186,6 +186,16 @@ fn contains_encoded_separator(rest: &str) -> bool { bytes.windows(3).any(|w| w[0] == b'%' && matches!(&w[1..].to_ascii_lowercase()[..], b"2f" | b"5c" | b"2e")) } +/// Also accepts a repository-qualified release tag (`artcraft-v0.42.1`). The prefix must +/// match this repository exactly; an unrelated app's tag is never treated as a release version. +fn tag_version(tag: &str, repo: &str) -> Option { + version::parse(tag).or_else(|| { + let name = repo.rsplit('/').next()?; + let suffix = tag.strip_prefix(name)?.strip_prefix('-')?; + version::parse(suffix) + }) +} + /// Reads `GET /repos//releases/latest` (or one element of `/releases`). pub fn parse_github_release(json: &str, repo: &str) -> Result { let v: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("unreadable answer from GitHub ({e})"))?; @@ -240,7 +250,7 @@ fn release_from_value(v: &serde_json::Value, repo: &str) -> Result Re let assets: Vec = sums.iter().filter_map(|(hash, name)| asset(name, Some(hash.clone()))).chain(asset("SHA256SUMS.txt", None)).collect(); Release { tag: tag.to_string(), - version: version::parse(tag), + version: tag_version(tag, repo), name: tag.to_string(), published_at: None, html_url: format!("https://github.com/{repo}/releases/tag/{tag}"), @@ -352,6 +362,26 @@ mod tests { const REPO: &str = "storytold/photocraft"; + #[test] + fn repository_prefixed_tags_are_ordered_without_changing_download_links() { + let repo = "storytold/artcraft"; + let json = r#"[{"tag_name":"artcraft-v0.41.1","draft":false,"prerelease":false,"assets":[]}, + {"tag_name":"artcraft-v0.42.1","draft":false,"prerelease":false,"assets":[]}, + {"tag_name":"other-app-v99.0.0","draft":false,"prerelease":false,"assets":[]}, + {"tag_name":"artcraft-v99.0.0","draft":true,"prerelease":false,"assets":[]}]"#; + let release = parse_github_release_list(json, repo, false).expect("valid releases").expect("published release"); + assert_eq!(release.tag, "artcraft-v0.42.1"); + assert_eq!(release.version, version::parse("0.42.1")); + let hash = "a".repeat(64); + let sums = release_from_sums(repo, &release.tag, &[(hash, "artcraft-0.42.1-linux-x86_64.flatpak".into())]); + assert_eq!(sums.version, release.version); + assert_eq!( + sums.assets[0].url, + "https://github.com/storytold/artcraft/releases/download/artcraft-v0.42.1/artcraft-0.42.1-linux-x86_64.flatpak" + ); + assert!(parse_github_release(r#"{"tag_name":"other-v0.42.1","assets":[]}"#, repo).expect("valid JSON").version.is_none()); + } + #[test] fn truncate_keeps_whole_characters() { assert_eq!(truncate("short", 10), "short"); diff --git a/crates/update/src/self_update.rs b/crates/update/src/self_update.rs index 87ae16d9b..702bc9cfb 100644 --- a/crates/update/src/self_update.rs +++ b/crates/update/src/self_update.rs @@ -3307,6 +3307,16 @@ mod tests { } } + #[cfg(target_os = "linux")] + #[test] + fn a_custom_flatpak_identity_is_used_for_the_restart() { + let app = App { bundle_id_override: Some("ai.artcraft.app"), ..App::new("artcraft", "ArtCraft", "Create", "storytold/artcraft") }; + let cmd = flatpak_restart_command(&app, "exec \"$@\"", "123"); + let args: Vec<_> = cmd.get_args().map(|v| v.to_string_lossy().into_owned()).collect(); + assert_eq!(args.last().map(String::as_str), Some("ai.artcraft.app")); + assert!(!args.iter().any(|arg| arg == "ai.storyteller.artcraft")); + } + #[cfg(target_os = "linux")] #[test] fn flatpak_restart_unsets_persistent_pretend_version_overrides() {