diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 9d31075862..1e01cfca1f 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -622,7 +622,11 @@ jobs: needs.publish-relay-image.result == 'success' runs-on: ubuntu-latest env: - REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64 + # Bundle-type Linux keys are load-bearing: tauri-plugin-updater on a deb + # (or rpm) install rejects every non-deb (non-rpm) payload with + # "invalid updater binary format", so the manifest MUST carry + # linux-*-deb / linux-*-rpm entries alongside the AppImage keys. + REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64,linux-x86_64-deb,linux-aarch64-deb,linux-x86_64-rpm,linux-aarch64-rpm steps: - name: Checkout diff --git a/scripts/collect-tauri-updater-assets.mjs b/scripts/collect-tauri-updater-assets.mjs index 51330bf7f8..1e78f00571 100644 --- a/scripts/collect-tauri-updater-assets.mjs +++ b/scripts/collect-tauri-updater-assets.mjs @@ -122,7 +122,9 @@ function isUpdaterBundle(file) { lower.endsWith('.app.tar.gz') || lower.endsWith('.tar.gz') || lower.endsWith('.zip') || - lower.endsWith('.exe') + lower.endsWith('.exe') || + lower.endsWith('.deb') || + lower.endsWith('.rpm') ); } @@ -140,6 +142,14 @@ function updaterOutputName(file, version, platform) { if (lower.endsWith('.exe')) { return `OpenBitFun_${version}_${platform}-setup.exe`; } + if (lower.endsWith('.deb')) { + // The bundle-type platform key is part of the name, so the renamed copy + // never collides with the raw bundler deb staged from release-assets. + return `OpenBitFun_${version}_${platform}.deb`; + } + if (lower.endsWith('.rpm')) { + return `OpenBitFun_${version}_${platform}.rpm`; + } if (lower.endsWith('.tar.gz')) { return `OpenBitFun_${version}_${platform}.tar.gz`; } @@ -162,6 +172,16 @@ function inferPlatform(file) { if (lower.includes('.appimage.tar.gz')) { return `linux-${arch}`; } + if (lower.endsWith('.deb')) { + // Bundle-type key: tauri-plugin-updater installs a deb payload via dpkg only + // when the running install is itself a deb, so these clients must receive a + // dedicated `linux--deb` entry instead of the AppImage the bare key + // serves. + return `linux-${arch}-deb`; + } + if (lower.endsWith('.rpm')) { + return `linux-${arch}-rpm`; + } if (lower.includes('.app.tar.gz')) { return `darwin-${arch}`; } diff --git a/scripts/generate-tauri-latest-json.mjs b/scripts/generate-tauri-latest-json.mjs index 12c33d253c..6e57696740 100644 --- a/scripts/generate-tauri-latest-json.mjs +++ b/scripts/generate-tauri-latest-json.mjs @@ -42,7 +42,7 @@ for (const sigPath of walkFiles(assetsDir).filter((file) => file.endsWith('.sig' const platformNames = Object.keys(platforms); if (platformNames.length === 0) { - fail('No signed updater artifacts were found. Expected .AppImage.sig, .app.tar.gz.sig, .tar.gz.sig, .zip.sig, or .exe.sig files.'); + fail('No signed updater artifacts were found. Expected .AppImage.sig, .app.tar.gz.sig, .tar.gz.sig, .zip.sig, .exe.sig, .deb.sig, or .rpm.sig files.'); } const missingPlatforms = requiredPlatforms.filter((platform) => !platforms[platform]); @@ -161,7 +161,9 @@ function isUpdaterBundle(file) { lower.endsWith('.app.tar.gz') || lower.endsWith('.tar.gz') || lower.endsWith('.zip') || - lower.endsWith('.exe') + lower.endsWith('.exe') || + lower.endsWith('.deb') || + lower.endsWith('.rpm') ); } @@ -184,6 +186,16 @@ function inferPlatform(file) { if (lower.includes('.appimage.tar.gz')) { return `linux-${arch}`; } + if (lower.endsWith('.deb')) { + // Bundle-type key: tauri-plugin-updater installs a deb payload via dpkg only + // when the running install is itself a deb, so these clients must receive a + // dedicated `linux--deb` entry instead of the AppImage the bare key + // serves. + return `linux-${arch}-deb`; + } + if (lower.endsWith('.rpm')) { + return `linux-${arch}-rpm`; + } if (lower.includes('.app.tar.gz')) { return `darwin-${arch}`; } diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs index 07574ea97c..10c3e17213 100644 --- a/scripts/tauri-release-manifest.test.mjs +++ b/scripts/tauri-release-manifest.test.mjs @@ -154,6 +154,84 @@ test('manifest declares the signed macOS .dmg installers next to the .app.tar.gz assert.match(absent.stderr, /Missing macOS installer OpenBitFun_1\.2\.3_x64\.dmg/); }); +// deb/rpm installs reject every payload that is not their own package format +// (tauri-plugin-updater: install_deb/install_rpm -> InvalidUpdaterFormat), so the +// feed must carry bundle-type keys. Regression: the 1.0.1 feed served the +// AppImage under the bare linux-x86_64 key and every deb install failed in-app +// updates with "invalid updater binary format". +test('deb and rpm installs get bundle-type Linux updater keys through collect + generate', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-deb-updater-')); + const collected = path.join(temp, 'collected'); + const out = path.join(temp, 'latest-v1.json'); + const assets = [ + ['OpenBitFun_1.2.3_amd64.AppImage', 'appimage'], + ['OpenBitFun_1.2.3_amd64.deb', 'deb'], + ['OpenBitFun-1.2.3-1.x86_64.rpm', 'rpm'], + ['OpenBitFun_1.2.3_arm64.deb', 'deb-arm64'], + ['OpenBitFun-1.2.3-1.aarch64.rpm', 'rpm-arm64'], + ]; + for (const [name, body] of assets) { + fs.writeFileSync(path.join(temp, name), body); + fs.writeFileSync(path.join(temp, `${name}.sig`), `sig ${name}`); + } + + const staging = run('scripts/collect-tauri-updater-assets.mjs', [ + '--assets-dir', temp, + '--version', '1.2.3', + '--out-dir', collected, + '--required-platforms', 'linux-x86_64,linux-x86_64-deb,linux-x86_64-rpm,linux-aarch64-deb,linux-aarch64-rpm', + ]); + assert.equal(staging.status, 0, staging.stderr); + assert.deepEqual(fs.readdirSync(collected).sort(), [ + 'OpenBitFun_1.2.3_linux-aarch64-deb.deb', + 'OpenBitFun_1.2.3_linux-aarch64-deb.deb.sig', + 'OpenBitFun_1.2.3_linux-aarch64-rpm.rpm', + 'OpenBitFun_1.2.3_linux-aarch64-rpm.rpm.sig', + 'OpenBitFun_1.2.3_linux-x86_64-deb.deb', + 'OpenBitFun_1.2.3_linux-x86_64-deb.deb.sig', + 'OpenBitFun_1.2.3_linux-x86_64-rpm.rpm', + 'OpenBitFun_1.2.3_linux-x86_64-rpm.rpm.sig', + 'OpenBitFun_1.2.3_linux-x86_64.AppImage', + 'OpenBitFun_1.2.3_linux-x86_64.AppImage.sig', + ]); + + const generated = run('scripts/generate-tauri-latest-json.mjs', [ + '--assets-dir', collected, + '--version', '1.2.3', + '--tag', 'v1.2.3', + '--repo', 'GCWing/OpenBitFun', + '--out', out, + '--required-platforms', 'linux-x86_64,linux-x86_64-deb,linux-x86_64-rpm,linux-aarch64-deb,linux-aarch64-rpm', + ]); + assert.equal(generated.status, 0, generated.stderr); + + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.match(manifest.platforms['linux-x86_64'].url, /OpenBitFun_1\.2\.3_linux-x86_64\.AppImage$/); + assert.match(manifest.platforms['linux-x86_64-deb'].url, /OpenBitFun_1\.2\.3_linux-x86_64-deb\.deb$/); + assert.match(manifest.platforms['linux-x86_64-rpm'].url, /OpenBitFun_1\.2\.3_linux-x86_64-rpm\.rpm$/); + assert.match(manifest.platforms['linux-aarch64-deb'].url, /OpenBitFun_1\.2\.3_linux-aarch64-deb\.deb$/); + assert.match(manifest.platforms['linux-aarch64-rpm'].url, /OpenBitFun_1\.2\.3_linux-aarch64-rpm\.rpm$/); + // collect renames the files but copies signatures byte-for-byte: minisign + // signatures cover the package bytes, not the asset name. + const renamedToRawSignature = { + 'linux-x86_64': 'sig OpenBitFun_1.2.3_amd64.AppImage', + 'linux-x86_64-deb': 'sig OpenBitFun_1.2.3_amd64.deb', + 'linux-x86_64-rpm': 'sig OpenBitFun-1.2.3-1.x86_64.rpm', + 'linux-aarch64-deb': 'sig OpenBitFun_1.2.3_arm64.deb', + 'linux-aarch64-rpm': 'sig OpenBitFun-1.2.3-1.aarch64.rpm', + }; + for (const [key, signature] of Object.entries(renamedToRawSignature)) { + assert.equal(manifest.platforms[key].signature, signature); + } + + const verified = run('scripts/verify-tauri-latest-json.mjs', [ + '--manifest', out, + '--version', '1.2.3', + '--required-platforms', 'linux-x86_64,linux-x86_64-deb,linux-x86_64-rpm,linux-aarch64-deb,linux-aarch64-rpm', + ]); + assert.equal(verified.status, 0, verified.stderr); +}); + test('stages GitHub release assets in a flat directory', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-release-assets-')); const first = path.join(temp, 'updater', 'latest.json'); diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index ee43d50e51..db1aff1c59 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -103,12 +103,33 @@ async fn updater_endpoints_by_policy() -> Vec { /// Tauri's `latest-v1.json` platform key for this host, e.g. `darwin-aarch64`. /// Mirrors `scripts/generate-tauri-latest-json.mjs`. +/// +/// Linux installs append a bundle-type suffix (`-deb` / `-rpm`) so the manifest +/// hands each install form the package its updater can actually install: +/// tauri-plugin-updater derives `dpkg -i` / `rpm -U` / AppImage rewrite from the +/// same [`tauri::utils::platform::bundle_type`] this key is derived from. A bare +/// `linux-*` key would feed AppImage bytes to a deb install, which the plugin +/// rejects as `invalid updater binary format`. fn updater_platform_key() -> String { let os = match std::env::consts::OS { "macos" => "darwin", other => other, }; - format!("{os}-{}", std::env::consts::ARCH) + format!( + "{os}-{}{}", + std::env::consts::ARCH, + updater_platform_key_suffix(tauri::utils::platform::bundle_type()) + ) +} + +/// Manifest-key suffix for the running install form, kept in step with +/// `scripts/generate-tauri-latest-json.mjs` (`linux--deb` / `linux--rpm`). +fn updater_platform_key_suffix(bundle: Option) -> &'static str { + match bundle { + Some(tauri::utils::config::BundleType::Deb) => "-deb", + Some(tauri::utils::config::BundleType::Rpm) => "-rpm", + _ => "", + } } /// Read one updater manifest and return the download URL it advertises for this @@ -1115,7 +1136,13 @@ mod tests { #[test] fn updater_platform_key_matches_latest_json_convention() { let key = super::updater_platform_key(); - let (os, arch) = key.split_once('-').expect("os-arch shape"); + // Optional bundle-type suffix, mirroring the script's linux--deb / + // linux--rpm keys. + let base = key + .strip_suffix("-deb") + .or_else(|| key.strip_suffix("-rpm")) + .unwrap_or(&key); + let (os, arch) = base.split_once('-').expect("os-arch shape"); assert!( matches!(os, "darwin" | "linux" | "windows"), "unexpected updater os segment: {os}" @@ -1129,6 +1156,45 @@ mod tests { key.starts_with("darwin-"), "macOS must map to darwin, got {key}" ); + #[cfg(target_os = "linux")] + assert!( + key.starts_with("linux-"), + "Linux keys must stay under the linux- prefix, got {key}" + ); + } + + /// The suffix must mirror the plugin's installer selection exactly: the same + /// `bundle_type()` that makes tauri-plugin-updater run `dpkg -i` / `rpm -U` + /// must ask the manifest for the `-deb` / `-rpm` payload, and every other + /// bundle type must keep the bare `os-arch` key (AppImage rewrite path). + #[test] + fn updater_platform_key_suffix_matches_plugin_installer_selection() { + use tauri::utils::config::BundleType; + assert_eq!( + super::updater_platform_key_suffix(Some(BundleType::Deb)), + "-deb" + ); + assert_eq!( + super::updater_platform_key_suffix(Some(BundleType::Rpm)), + "-rpm" + ); + assert_eq!( + super::updater_platform_key_suffix(Some(BundleType::AppImage)), + "" + ); + assert_eq!( + super::updater_platform_key_suffix(Some(BundleType::Msi)), + "" + ); + assert_eq!( + super::updater_platform_key_suffix(Some(BundleType::Nsis)), + "" + ); + assert_eq!( + super::updater_platform_key_suffix(Some(BundleType::App)), + "" + ); + assert_eq!(super::updater_platform_key_suffix(None), ""); } #[test]