Support existing app identities and repository-prefixed update tags (#970)

* fix(update): support existing bundle identities and repository-prefixed tags

* fix(update): recognize established Tauri installer asset names
This commit is contained in:
Dylan M. Taylor
2026-11-11 20:09:55 -05:00
committed by GitHub
parent 8cdcdce326
commit 2a24c10817
4 changed files with 128 additions and 7 deletions
+24 -3
View File
@@ -23,6 +23,8 @@ pub struct App {
/// Who makes it, shown as "by <developer>" 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.<id>` 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.<id>`: macOS bundle id, Flatpak id and Linux desktop-file id.
/// macOS bundle id, Flatpak id and Linux desktop-file id; defaults to `ai.storyteller.<id>`.
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::<Vec<_>>(), ["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 =
+62 -2
View File
@@ -191,7 +191,7 @@ pub struct AssetName {
pub kind: PackageKind,
}
/// Reads `<stem>-<version>-<os>-<arch>[-portable].<ext>`; `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<AssetName> {
const KINDS: [PackageKind; 9] = [
@@ -216,7 +216,48 @@ pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option<AssetName> {
}
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: `<Product>_<version>_universal.dmg`
/// or `<Product>_<version>_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<AssetName> {
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(),
+32 -2
View File
@@ -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::Version> {
version::parse(tag).or_else(|| {
let name = repo.rsplit('/').next()?;
let suffix = tag.strip_prefix(name)?.strip_prefix('-')?;
version::parse(suffix)
})
}
/// Reads `GET /repos/<repo>/releases/latest` (or one element of `/releases`).
pub fn parse_github_release(json: &str, repo: &str) -> Result<Release, String> {
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<Release, Stri
})
.unwrap_or_default();
Ok(Release {
version: version::parse(&tag),
version: tag_version(&tag, repo),
name: v["name"].as_str().map(|n| truncate(n, 120)).filter(|n| !n.is_empty()).unwrap_or_else(|| tag.clone()),
tag,
published_at: v["published_at"].as_str().and_then(crate::time::parse_rfc3339),
@@ -262,7 +272,7 @@ pub fn release_from_sums(repo: &str, tag: &str, sums: &[(String, String)]) -> Re
let assets: Vec<Asset> = 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");
+10
View File
@@ -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() {