// Adds the update bundle `tauri build` just made to latest.json, the manifest // installed apps poll for updates, and says what to publish where. // // The URLs point at the GitHub release for the version, which must exist // with the bundles attached before the manifest lands on main. import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { arch, platform } from "node:process"; const here = dirname(fileURLToPath(import.meta.url)); const manifestPath = join(here, "latest.json"); const metadata = JSON.parse( execFileSync("cargo", ["metadata", "--format-version", "1", "--no-deps", "--manifest-path", join(here, "src-tauri/Cargo.toml")], { encoding: "utf8" }), ); const version = metadata.packages.find((p) => p.name === "wispers-access-desktop").version; const bundleDir = join(metadata.target_directory, "release", "bundle"); const config = JSON.parse(readFileSync(join(here, "src-tauri", "tauri.conf.json"), "utf8")); // The base config names the staging app; release.mjs builds with the // production overlay, whose product name the bundles carry. const productName = JSON.parse(readFileSync(join(here, "src-tauri", "tauri.prod.conf.json"), "utf8")).productName; /** The bundle in the bundle directory's `dir` whose name ends in `suffix`. * `tauri build` leaves older builds' bundles in the same directory, and * `0.1.1` and `1.0.0-rc1` sort before `1.0.0`, so a bundle's name must * contain the version being released. The macOS tarball carries no version * in its name, so for it the newest file wins. */ function find(dir, suffix) { const path = join(bundleDir, dir); if (!existsSync(path)) return undefined; const candidates = readdirSync(path) .filter((f) => f.endsWith(suffix) && (f === `${productName}${suffix}` || f.includes(`_${version}_`))) .map((f) => join(path, f)) .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); return candidates[0]; } /** The update bundles and the platform keys the updater looks them up under. * Linux has one per package format, since an installed app updates in its own * format: the updater looks for `linux--` first. */ function hostBundles() { const cpu = arch === "arm64" ? "aarch64" : "x86_64"; switch (platform) { case "darwin": return [{ key: `darwin-${cpu}`, bundle: find("macos", ".app.tar.gz") }]; case "win32": return [{ key: `windows-${cpu}`, bundle: find("nsis", "-setup.exe") }]; case "linux": return [ { key: `linux-${cpu}-appimage`, bundle: find("appimage", ".AppImage") }, { key: `linux-${cpu}-deb`, bundle: find("deb", ".deb") }, ]; default: return []; } } const hosts = hostBundles(); if (hosts.length === 0) { console.error(`no update bundles for ${platform}`); process.exit(1); } for (const host of hosts) { if (!host.bundle || !existsSync(`${host.bundle}.sig`)) { console.error(`no signed ${host.key} update bundle under ${bundleDir}; build with the updater key set`); process.exit(1); } // The bundler only warns when the private key isn't the public key's half, // and installed apps would reject the update. host.signature = readFileSync(`${host.bundle}.sig`, "utf8").trim(); if (keyId(host.signature) !== keyId(config.plugins.updater.pubkey)) { console.error(`${host.bundle}.sig is not by the key in tauri.conf.json; build with the updater key`); process.exit(1); } } /** The key ID in a base64'd minisign key or signature file. */ function keyId(file) { const line = Buffer.from(file, "base64").toString("utf8").split("\n")[1]; return Buffer.from(line, "base64").subarray(2, 10).toString("hex"); } const release = `desktop-v${version}`; // GitHub turns the spaces in asset names into dots. const asset = (bundle) => basename(bundle).replaceAll(" ", "."); const hostKeys = hosts.map((host) => host.key); let manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : {}; if (manifest.version !== version) { // Each platform's machine adds its own entry, so the other's must already // be in this checkout when it was first. console.warn(`latest.json is for ${manifest.version}, starting it over for ${version}. If another platform has released ${version} already, pull main and run this again.`); manifest = { version, platforms: {} }; } // The first platform's machine creates the release, the next ones add to it. const releaseExists = Object.keys(manifest.platforms).some((key) => !hostKeys.includes(key)); manifest.pub_date = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); for (const host of hosts) { manifest.platforms[host.key] = { url: `https://github.com/s-te-ch/wispers-access/releases/download/${release}/${asset(host.bundle)}`, signature: host.signature, }; } writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); const extra = platform === "darwin" ? readdirSync(join(bundleDir, "dmg")).filter((f) => f.endsWith(".dmg")).map((f) => join(bundleDir, "dmg", f)) : []; const files = [...extra, ...hosts.flatMap((host) => [host.bundle, `${host.bundle}.sig`])].map((f) => `"${f}"`).join(" "); const publish = releaseExists ? `gh release upload ${release} --clobber ${files}` : `gh release create ${release} --title "Wispers Access desktop ${version}" --generate-notes ${files}`; console.log(` Wrote desktop/latest.json for ${version} (${Object.keys(manifest.platforms).join(", ")}). To publish: ${publish} then commit desktop/latest.json to main. `);