Files
craft-libs/crates/update/src/platform.rs
T
Dylan M. Taylor fe61243ebf feat(update): add craft-update crate for release checking, install, and self-updating (#311)
* feat(update): add craft-update crate for release checking, install, and self-updating

* fix(update): provide non-windows stub for finish

* fix(update): address CodeRabbit review feedback on craft-update

* feat(dialog,self_update): enhance prompt styling, support standalone self-updates

* fix(update): make startup prompt modal with Escape, skip non-file tar entries
2026-10-25 09:32:22 -04:00

424 lines
18 KiB
Rust

//! Platforms, package formats, and choosing the right release asset.
//!
//! Every Craft App release names its files `<app>-<version>-<os>-<arch>[-portable].<ext>`, for
//! example `photocraft-0.5.0-windows-x64.msi` or `photocraft-0.5.0-linux-aarch64.AppImage`.
//!
//! Labels are English. The UI translates [`PackageKind::label`]; OS and architecture names are
//! shown as they are.
use serde::{Deserialize, Serialize};
use crate::release::{Asset, Release};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Os {
Windows,
MacOs,
Linux,
FreeBsd,
}
impl Os {
pub fn label(self) -> &'static str {
match self {
Self::Windows => "Windows",
Self::MacOs => "macOS",
Self::Linux => "Linux",
Self::FreeBsd => "FreeBSD",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arch {
X64,
X86,
Arm64,
Riscv64,
}
impl Arch {
pub fn label(self) -> &'static str {
match self {
Self::X64 => "x64",
Self::X86 => "x86",
Self::Arm64 => "ARM64",
Self::Riscv64 => "RISC-V",
}
}
}
/// A package format a release ships.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PackageKind {
/// Windows Installer package (installs for all users into Program Files).
Msi,
/// Windows zip with the exe at the top level.
PortableZip,
/// macOS disk image with the `.app` bundle.
Dmg,
AppImage,
Deb,
Rpm,
/// Arch Linux package (`.pkg.tar.zst`).
ArchPkg,
Flatpak,
/// Linux or FreeBSD tarball (`bin/<app>`, `share/…`).
TarGz,
}
impl PackageKind {
pub fn label(self) -> &'static str {
match self {
Self::Msi => "Installer (.msi)",
Self::PortableZip => "Portable (.zip)",
Self::Dmg => "Disk image (.dmg)",
Self::AppImage => "AppImage",
Self::Deb => "Debian package (.deb)",
Self::Rpm => "RPM package (.rpm)",
Self::ArchPkg => "Arch package (.pkg.tar.zst)",
Self::Flatpak => "Flatpak bundle",
Self::TarGz => "Tarball (.tar.gz)",
}
}
fn suffix(self) -> &'static str {
match self {
Self::Msi => ".msi",
Self::PortableZip => "-portable.zip",
Self::Dmg => ".dmg",
Self::AppImage => ".AppImage",
Self::Deb => ".deb",
Self::Rpm => ".rpm",
Self::ArchPkg => ".pkg.tar.zst",
Self::Flatpak => ".flatpak",
Self::TarGz => ".tar.gz",
}
}
}
/// Where the launcher is running (or, on the web, the visitor's machine).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Platform {
pub os: Os,
pub arch: Arch,
}
impl Platform {
/// The platform this binary was built for, or `None` for wasm and unsupported targets.
pub fn current() -> Option<Self> {
let os = if cfg!(target_os = "windows") {
Os::Windows
} else if cfg!(target_os = "macos") {
Os::MacOs
} else if cfg!(target_os = "linux") {
Os::Linux
} else if cfg!(target_os = "freebsd") {
Os::FreeBsd
} else {
return None;
};
let arch = if cfg!(target_arch = "x86_64") {
Arch::X64
} else if cfg!(target_arch = "x86") {
Arch::X86
} else if cfg!(target_arch = "aarch64") {
Arch::Arm64
} else if cfg!(target_arch = "riscv64") {
Arch::Riscv64
} else {
return None;
};
Some(Self { os, arch })
}
pub fn label(self) -> String {
match self.os {
Os::MacOs => "macOS".into(),
os => format!("{} {}", os.label(), self.arch.label()),
}
}
/// Asset platform tokens to try, best first. Windows on ARM and 64-bit Windows can fall back
/// to emulated builds; macOS prefers universal, then native builds. Apple silicon can fall
/// back to Intel builds, but Intel Macs cannot run ARM builds.
fn tokens(self) -> &'static [&'static str] {
match (self.os, self.arch) {
(Os::Windows, Arch::X64) => &["windows-x64", "windows-x86"],
(Os::Windows, Arch::X86) => &["windows-x86"],
(Os::Windows, Arch::Arm64) => &["windows-arm64", "windows-x64", "windows-x86"],
(Os::Windows, _) => &[],
(Os::MacOs, Arch::X64) => &["macos-universal", "macos-x86_64"],
(Os::MacOs, Arch::Arm64) => &["macos-universal", "macos-aarch64", "macos-arm64", "macos-x86_64"],
(Os::MacOs, _) => &[],
(Os::Linux, Arch::X64) => &["linux-x86_64"],
(Os::Linux, Arch::Arm64) => &["linux-aarch64"],
(Os::Linux, Arch::Riscv64) => &["linux-riscv64"],
(Os::Linux, Arch::X86) => &[],
(Os::FreeBsd, Arch::X64) => &["freebsd-x86_64"],
(Os::FreeBsd, Arch::Arm64) => &["freebsd-aarch64"],
(Os::FreeBsd, _) => &[],
}
}
/// Package formats the launcher installs on this OS, in order of preference.
pub fn default_kinds(self) -> &'static [PackageKind] {
match self.os {
Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip],
Os::MacOs => &[PackageKind::Dmg],
Os::Linux => &[PackageKind::AppImage, PackageKind::TarGz],
Os::FreeBsd => &[PackageKind::TarGz],
}
}
/// Every format offered as a manual download on this OS (the web build's download menu).
pub fn download_kinds(self) -> &'static [PackageKind] {
match self.os {
Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip],
Os::MacOs => &[PackageKind::Dmg],
Os::Linux => &[PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz],
Os::FreeBsd => &[PackageKind::TarGz],
}
}
}
/// What an asset name says about itself.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetName {
pub stem: String,
pub version: String,
pub token: String,
pub kind: PackageKind,
}
/// Reads `<stem>-<version>-<os>-<arch>[-portable].<ext>`; `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] = [
PackageKind::PortableZip,
PackageKind::TarGz,
PackageKind::Msi,
PackageKind::Dmg,
PackageKind::AppImage,
PackageKind::Deb,
PackageKind::Rpm,
PackageKind::ArchPkg,
PackageKind::Flatpak,
];
let (kind, base) = KINDS.iter().find_map(|k| name.strip_suffix(k.suffix()).map(|b| (*k, b)))?;
for stem in stems {
let Some(rest) = base.strip_prefix(stem).and_then(|r| r.strip_prefix('-')) else { continue };
// rest = <version>-<os>-<arch>; os and arch never contain '-', versions may.
let mut parts = rest.rsplitn(3, '-');
let (arch, os, version) = (parts.next()?, parts.next()?, parts.next()?);
if crate::version::parse(version).is_none() || !version.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
return Some(AssetName { stem: (*stem).to_string(), version: version.to_string(), token: format!("{os}-{arch}"), kind });
}
None
}
/// The best asset of `release` for `platform`, trying `kinds` in order and, within a kind, the
/// platform's tokens in order (native before emulated).
pub fn pick_asset<'a>(release: &'a Release, stems: &[&str], platform: Platform, kinds: &[PackageKind]) -> Option<(&'a Asset, PackageKind)> {
let parsed: Vec<(&Asset, AssetName)> = release.assets.iter().filter_map(|a| parse_asset_name(&a.name, stems).map(|n| (a, n))).collect();
for kind in kinds {
for token in platform.tokens() {
if let Some((asset, _)) = parsed.iter().find(|(_, n)| n.kind == *kind && n.token == *token) {
return Some((asset, *kind));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
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(),
..Release::default()
}
}
const PHOTO: &[&str] = &[
"photocraft-0.5.0-freebsd-x86_64.tar.gz",
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-aarch64.AppImage.zsync",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-riscv64.tar.gz",
"photocraft-0.5.0-linux-x86_64.AppImage",
"photocraft-0.5.0-linux-x86_64.tar.gz",
"photocraft-0.5.0-macos-universal.dmg",
"photocraft-0.5.0-windows-x64-portable.zip",
"photocraft-0.5.0-windows-x64.msi",
"photocraft-0.5.0-windows-x86.msi",
"photocraft-cli-0.5.0-macos-universal.zip",
"photocraft-web-0.5.0.zip",
"SHA256SUMS.txt",
];
#[test]
fn macos_prefers_the_published_universal_build() {
// PhotoCraft v0.9.0's GitHub metadata, trimmed to its desktop DMG.
let mut r = crate::release::parse_github_release(include_str!("../tests/fixtures/photocraft-release.json"), "storytold/photocraft").unwrap();
// Split builds must not outrank the published universal DMG, even when listed first.
r.assets.splice(0..0, release(&["photocraft-0.9.0-macos-arm64.dmg", "photocraft-0.9.0-macos-x86_64.dmg"]).assets);
for arch in [Arch::X64, Arch::Arm64] {
let (asset, kind) = pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).unwrap();
assert_eq!(asset.name, "photocraft-0.9.0-macos-universal.dmg");
assert_eq!(kind, PackageKind::Dmg);
}
}
#[test]
fn freebsd_picks_the_tarball_for_the_processor() {
// SoundCraft v0.10.0's GitHub metadata, trimmed to its FreeBSD tarballs.
let r = crate::release::parse_github_release(include_str!("../tests/fixtures/soundcraft-release.json"), "storytold/soundcraft").unwrap();
let pick = |arch| {
let platform = Platform { os: Os::FreeBsd, arch };
pick_asset(&r, &["soundcraft"], platform, platform.default_kinds()).map(|(a, k)| (a.name.as_str(), k))
};
assert_eq!(pick(Arch::X64), Some(("soundcraft-0.10.0-freebsd-x86_64.tar.gz", PackageKind::TarGz)));
assert_eq!(pick(Arch::Arm64), Some(("soundcraft-0.10.0-freebsd-aarch64.tar.gz", PackageKind::TarGz)));
for arch in [Arch::X86, Arch::Riscv64] {
assert_eq!(pick(arch), None);
}
// An x86_64 machine never takes the ARM tarball, nor an ARM machine the x86_64 one.
let only_arm = release(&["soundcraft-0.10.0-freebsd-aarch64.tar.gz"]);
assert_eq!(pick_asset(&only_arm, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::X64 }, &[PackageKind::TarGz]), None);
let only_x64 = release(&["soundcraft-0.10.0-freebsd-x86_64.tar.gz"]);
assert_eq!(pick_asset(&only_x64, &["soundcraft"], Platform { os: Os::FreeBsd, arch: Arch::Arm64 }, &[PackageKind::TarGz]), None);
}
#[test]
fn macos_split_builds_match_the_processor() {
let r = release(&["photocraft-0.5.0-macos-arm64.dmg", "photocraft-0.5.0-macos-x86_64.dmg"]);
let pick = |arch| pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str());
assert_eq!(pick(Arch::X64), Some("photocraft-0.5.0-macos-x86_64.dmg"));
assert_eq!(pick(Arch::Arm64), Some("photocraft-0.5.0-macos-arm64.dmg"));
}
#[test]
fn intel_macos_never_selects_an_arm_only_build() {
for token in ["macos-aarch64", "macos-arm64"] {
let name = format!("photocraft-0.5.0-{token}.dmg");
let r = release(&[&name]);
assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::X64 }, &[PackageKind::Dmg]), None);
assert_eq!(
pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()),
Some(name.as_str())
);
}
}
#[test]
fn apple_silicon_keeps_the_intel_fallback() {
let r = release(&["photocraft-0.5.0-macos-x86_64.dmg"]);
assert_eq!(
pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch: Arch::Arm64 }, &[PackageKind::Dmg]).map(|(a, _)| a.name.as_str()),
Some("photocraft-0.5.0-macos-x86_64.dmg")
);
for arch in [Arch::X86, Arch::Riscv64] {
assert_eq!(pick_asset(&r, &["photocraft"], Platform { os: Os::MacOs, arch }, &[PackageKind::Dmg]), None);
}
}
#[test]
fn reads_asset_names() {
let n = parse_asset_name("photocraft-0.2.0-rc.1-windows-arm64-portable.zip", &["photocraft"]).unwrap();
assert_eq!((n.version.as_str(), n.token.as_str(), n.kind), ("0.2.0-rc.1", "windows-arm64", PackageKind::PortableZip));
let n = parse_asset_name("printcraft-0.2.1-linux-x86_64.tar.gz", &["pdfcraft", "printcraft"]).unwrap();
assert_eq!((n.stem.as_str(), n.kind), ("printcraft", PackageKind::TarGz));
for other in
["photocraft-cli-0.5.0-macos-universal.zip", "photocraft-web-0.5.0.zip", "SHA256SUMS.txt", "photocraft-0.5.0-linux-x86_64.AppImage.zsync"]
{
assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}");
}
// FigCraft also ships its collaboration server; that tarball is not the app.
assert_eq!(parse_asset_name("figcraft-server-0.5.1-linux-x86_64.tar.gz", &["figcraft"]), None);
assert!(parse_asset_name("figcraft-0.5.1-linux-x86_64.tar.gz", &["figcraft"]).is_some());
}
#[test]
fn picks_the_native_build_first_and_falls_back_to_emulation() {
let r = release(PHOTO);
let pick = |os, arch, kinds: &[PackageKind]| pick_asset(&r, &["photocraft"], Platform { os, arch }, kinds).map(|(a, _)| a.name.clone());
assert_eq!(pick(Os::MacOs, Arch::Arm64, &[PackageKind::Dmg]).as_deref(), Some("photocraft-0.5.0-macos-universal.dmg"));
assert_eq!(pick(Os::Windows, Arch::X64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi"));
// No arm64 MSI in this release: Windows on ARM runs the x64 build.
assert_eq!(pick(Os::Windows, Arch::Arm64, &[PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x64.msi"));
assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::AppImage]).as_deref(), Some("photocraft-0.5.0-linux-aarch64.AppImage"));
assert_eq!(pick(Os::Linux, Arch::Arm64, &[PackageKind::TarGz]), None);
assert_eq!(pick(Os::Linux, Arch::Riscv64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-linux-riscv64.tar.gz"));
assert_eq!(pick(Os::FreeBsd, Arch::X64, &[PackageKind::TarGz]).as_deref(), Some("photocraft-0.5.0-freebsd-x86_64.tar.gz"));
// Preference order across kinds.
assert_eq!(
pick(Os::Windows, Arch::X64, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(),
Some("photocraft-0.5.0-windows-x64-portable.zip")
);
assert_eq!(pick(Os::Windows, Arch::X86, &[PackageKind::PortableZip, PackageKind::Msi]).as_deref(), Some("photocraft-0.5.0-windows-x86.msi"));
let p_riscv = Platform { os: Os::Linux, arch: Arch::Riscv64 };
assert_eq!(
pick_asset(&r, &["photocraft"], p_riscv, p_riscv.default_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.tar.gz")
);
assert_eq!(
pick_asset(&r, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.tar.gz")
);
let r_riscv_deb =
release(&["photocraft-0.5.0-linux-riscv64.tar.gz", "photocraft-0.5.0-linux-riscv64.deb", "photocraft-0.5.0-linux-riscv64.rpm"]);
assert_eq!(
pick_asset(&r_riscv_deb, &["photocraft"], p_riscv, p_riscv.download_kinds()).map(|(a, _)| a.name.clone()).as_deref(),
Some("photocraft-0.5.0-linux-riscv64.deb")
);
}
#[test]
fn strictly_matches_architecture_for_all_linux_package_formats() {
let multi_arch_release = release(&[
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-x86_64.AppImage",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-x86_64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-x86_64.rpm",
"photocraft-0.5.0-linux-aarch64.pkg.tar.zst",
"photocraft-0.5.0-linux-x86_64.pkg.tar.zst",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-x86_64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
"photocraft-0.5.0-linux-x86_64.tar.gz",
]);
let p_x64 = Platform { os: Os::Linux, arch: Arch::X64 };
let p_arm64 = Platform { os: Os::Linux, arch: Arch::Arm64 };
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {
let pick_x64 = pick_asset(&multi_arch_release, &["photocraft"], p_x64, &[kind]).map(|(a, _)| a.name.clone());
assert!(pick_x64.as_deref().unwrap().contains("linux-x86_64"), "Expected x86_64 asset for {kind:?}, got {pick_x64:?}");
let pick_arm64 = pick_asset(&multi_arch_release, &["photocraft"], p_arm64, &[kind]).map(|(a, _)| a.name.clone());
assert!(pick_arm64.as_deref().unwrap().contains("linux-aarch64"), "Expected aarch64 asset for {kind:?}, got {pick_arm64:?}");
}
// On x86_64 Linux, an aarch64-only asset must NEVER match or fall back
let arm_only_release = release(&[
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-aarch64.pkg.tar.zst",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
]);
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {
assert_eq!(pick_asset(&arm_only_release, &["photocraft"], p_x64, &[kind]), None, "x86_64 must never match an aarch64 asset for {kind:?}");
}
}
}