mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 17:45:10 +00:00
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:
@@ -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 =
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user