|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Has Apple notarize the app and the disk image `pnpm desktop:bundle` made, |
| 4 | + * and staples the tickets to them, so that both open on any Mac without a |
| 5 | + * warning, even offline: |
| 6 | + * |
| 7 | + * 1. the app goes to Apple's notary service and gets its ticket stapled; |
| 8 | + * 2. the disk image Tauri made is opened, the app inside it gets the same |
| 9 | + * ticket, and the image is compressed and signed again; |
| 10 | + * 3. the disk image goes to the notary service and gets its ticket stapled; |
| 11 | + * 4. both are checked the way Gatekeeper checks them, and the image's |
| 12 | + * SHA-256 is written next to it. |
| 13 | + * |
| 14 | + * Why the app is stapled as well as the image: the app's own ticket goes |
| 15 | + * wherever the app is copied, the image's stays with the image. The image is |
| 16 | + * opened rather than made again so that it keeps Tauri's layout. |
| 17 | + * |
| 18 | + * The bundle must have been built with a Developer ID in |
| 19 | + * APPLE_SIGNING_IDENTITY: Apple refuses to notarize an app signed ad hoc. |
| 20 | + * |
| 21 | + * Environment: |
| 22 | + * AGENTOS_NOTARY_PROFILE the notarytool keychain profile to send with |
| 23 | + * (made once with `xcrun notarytool store-credentials`) |
| 24 | + * APPLE_SIGNING_IDENTITY the identity that signs the disk image; default: |
| 25 | + * the one that signed the app |
| 26 | + */ |
| 27 | +import { spawnSync } from "node:child_process"; |
| 28 | +import { createHash } from "node:crypto"; |
| 29 | +import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 30 | +import { basename, dirname, join } from "node:path"; |
| 31 | +import { fileURLToPath } from "node:url"; |
| 32 | + |
| 33 | +const desktop = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 34 | +const config = JSON.parse(readFileSync(join(desktop, "src-tauri/tauri.conf.json"), "utf8")); |
| 35 | +const bundle = join(desktop, "src-tauri/target/release/bundle"); |
| 36 | +// The names Tauri gives what it makes. |
| 37 | +const arch = { arm64: "aarch64", x64: "x64" }[process.arch]; |
| 38 | +const app = join(bundle, "macos", `${config.productName}.app`); |
| 39 | +const dmg = join(bundle, "dmg", `${config.productName}_${config.version}_${arch}.dmg`); |
| 40 | +// Work files stay in the build folder, which is not part of the repository. |
| 41 | +const work = join(bundle, "notarize"); |
| 42 | + |
| 43 | +const say = (line) => console.log(`notarize: ${line}`); |
| 44 | + |
| 45 | +/** Stops the release with a message. Thrown, so that disk images opened on the way are closed again. */ |
| 46 | +function fail(message) { |
| 47 | + throw Object.assign(new Error(message), { expected: true }); |
| 48 | +} |
| 49 | + |
| 50 | +/** Runs a command and returns what it printed; fails when the command does, unless told not to. */ |
| 51 | +function run(command, args, { check = true } = {}) { |
| 52 | + const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); |
| 53 | + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); |
| 54 | + if (check && result.status !== 0) fail(`${command} ${args.join(" ")} failed:\n${output || result.error?.message}`); |
| 55 | + return { stdout: result.stdout ?? "", output }; |
| 56 | +} |
| 57 | + |
| 58 | +/** Sends a file to Apple's notary service and waits for its answer; prints Apple's log when it says no. */ |
| 59 | +function notarize(file, profile) { |
| 60 | + say(`sending ${basename(file)} to Apple, which takes a few minutes`); |
| 61 | + const sent = run("xcrun", ["notarytool", "submit", file, "--keychain-profile", profile, "--wait", "--output-format", "json"], { check: false }); |
| 62 | + const answer = sent.stdout |
| 63 | + .trim() |
| 64 | + .split("\n") |
| 65 | + .reverse() |
| 66 | + .map((line) => { |
| 67 | + try { |
| 68 | + return JSON.parse(line); |
| 69 | + } catch { |
| 70 | + return null; |
| 71 | + } |
| 72 | + }) |
| 73 | + .find((parsed) => parsed?.status); |
| 74 | + if (!answer) fail(`notarytool did not answer as expected:\n${sent.output}`); |
| 75 | + if (answer.status !== "Accepted") { |
| 76 | + const log = answer.id ? run("xcrun", ["notarytool", "log", answer.id, "--keychain-profile", profile], { check: false }).output : ""; |
| 77 | + fail(`Apple did not accept ${basename(file)}: ${answer.status}, ${answer.message ?? ""}\n${log}`); |
| 78 | + } |
| 79 | + say(`Apple accepted ${basename(file)} (submission ${answer.id})`); |
| 80 | +} |
| 81 | + |
| 82 | +/** Opens a disk image where only this script sees it (not in the Finder), runs `use`, and closes it again. */ |
| 83 | +function withImage(image, mountpoint, options, use) { |
| 84 | + mkdirSync(mountpoint, { recursive: true }); |
| 85 | + run("hdiutil", ["attach", image, "-nobrowse", "-noautoopen", "-mountpoint", mountpoint, "-quiet", ...options]); |
| 86 | + try { |
| 87 | + use(mountpoint); |
| 88 | + } finally { |
| 89 | + run("hdiutil", ["detach", mountpoint, "-quiet"], { check: false }); |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +/** Runs one of Gatekeeper's checks and prints its answer, which must say Apple's ticket was found. */ |
| 94 | +function check(label, command, args) { |
| 95 | + const { output } = run(command, args); |
| 96 | + // spctl accepts any Developer ID; only "Notarized" means it found Apple's ticket. |
| 97 | + if (command === "spctl" && !output.includes("source=Notarized Developer ID")) fail(`${label} is accepted, but not as notarized:\n${output}`); |
| 98 | + say(`${label}: ${output.split("\n").filter((line) => !line.startsWith("Processing")).join(" | ")}`); |
| 99 | +} |
| 100 | + |
| 101 | +async function release() { |
| 102 | + if (process.platform !== "darwin") fail("notarizing is for macOS builds, made on a Mac."); |
| 103 | + const profile = process.env.AGENTOS_NOTARY_PROFILE || fail("set AGENTOS_NOTARY_PROFILE to your notarytool keychain profile (xcrun notarytool store-credentials)."); |
| 104 | + for (const made of [app, dmg]) if (!existsSync(made)) fail(`${made} is missing: run pnpm desktop:bundle first.`); |
| 105 | + |
| 106 | + const authority = /^Authority=(Developer ID Application: .+)$/m.exec(run("codesign", ["-dvv", app]).output)?.[1]; |
| 107 | + if (!authority) fail(`${basename(app)} is not signed with a Developer ID: build it again with APPLE_SIGNING_IDENTITY set.`); |
| 108 | + const identity = process.env.APPLE_SIGNING_IDENTITY || authority; |
| 109 | + run("codesign", ["--verify", "--deep", "--strict", app]); |
| 110 | + |
| 111 | + rmSync(work, { recursive: true, force: true }); |
| 112 | + mkdirSync(work, { recursive: true }); |
| 113 | + |
| 114 | + // 1. The app. |
| 115 | + const zip = join(work, `${config.productName}.zip`); |
| 116 | + run("ditto", ["-c", "-k", "--keepParent", app, zip]); |
| 117 | + notarize(zip, profile); |
| 118 | + run("xcrun", ["stapler", "staple", app]); |
| 119 | + |
| 120 | + // 2. The app inside the image. |
| 121 | + const writable = join(work, "writable.dmg"); |
| 122 | + run("hdiutil", ["convert", dmg, "-format", "UDRW", "-o", writable, "-quiet"]); |
| 123 | + withImage(writable, join(work, "volume"), [], (volume) => { |
| 124 | + run("xcrun", ["stapler", "staple", join(volume, basename(app))]); |
| 125 | + // What macOS writes on a volume it opens writable does not ship. |
| 126 | + rmSync(join(volume, ".fseventsd"), { recursive: true, force: true }); |
| 127 | + }); |
| 128 | + const packed = join(work, basename(dmg)); |
| 129 | + // Compressed as Tauri compresses it. |
| 130 | + run("hdiutil", ["convert", writable, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", packed, "-quiet"]); |
| 131 | + run("codesign", ["--force", "--sign", identity, "--timestamp", packed]); |
| 132 | + renameSync(packed, dmg); |
| 133 | + |
| 134 | + // 3. The image. |
| 135 | + notarize(dmg, profile); |
| 136 | + run("xcrun", ["stapler", "staple", dmg]); |
| 137 | + |
| 138 | + // 4. As Gatekeeper sees them, the app inside the image included. |
| 139 | + check("app, signature", "codesign", ["--verify", "--deep", "--strict", "--verbose=2", app]); |
| 140 | + check("app, ticket", "xcrun", ["stapler", "validate", app]); |
| 141 | + check("app, Gatekeeper", "spctl", ["-a", "-vvv", "-t", "exec", app]); |
| 142 | + check("image, ticket", "xcrun", ["stapler", "validate", dmg]); |
| 143 | + check("image, Gatekeeper", "spctl", ["-a", "-vvv", "-t", "open", "--context", "context:primary-signature", dmg]); |
| 144 | + withImage(dmg, join(work, "shipped"), ["-readonly"], (volume) => { |
| 145 | + const inside = join(volume, basename(app)); |
| 146 | + check("app in the image, ticket", "xcrun", ["stapler", "validate", inside]); |
| 147 | + check("app in the image, Gatekeeper", "spctl", ["-a", "-vvv", "-t", "exec", inside]); |
| 148 | + }); |
| 149 | + |
| 150 | + const hash = createHash("sha256"); |
| 151 | + for await (const chunk of createReadStream(dmg)) hash.update(chunk); |
| 152 | + writeFileSync(`${dmg}.sha256`, `${hash.digest("hex")} ${basename(dmg)}\n`); |
| 153 | + rmSync(work, { recursive: true, force: true }); |
| 154 | + |
| 155 | + say(`done: ${dmg} (${(statSync(dmg).size / 1024 / 1024).toFixed(1)} MB), signed by ${authority}, notarized and stapled`); |
| 156 | + say(`its SHA-256 is in ${basename(dmg)}.sha256`); |
| 157 | +} |
| 158 | + |
| 159 | +try { |
| 160 | + await release(); |
| 161 | +} catch (error) { |
| 162 | + console.error(`notarize: ${error.expected ? error.message : error.stack}`); |
| 163 | + process.exitCode = 1; |
| 164 | +} |
0 commit comments