| 1 | #!/usr/bin/env node |
| 2 | // Builds the UI for Electron, builds the shell and packages both with |
| 3 | // @electron/packager into desktop/build/electron/<os>-<arch>/. The Go binaries |
| 4 | // are added afterwards by scripts/desktop-build.sh, which owns signing and the |
| 5 | // per-platform artifacts. |
| 6 | // |
| 7 | // usage: node desktop/packaging/package.mjs <os/arch> <version> [channel] |
| 8 | import { defaultSanitizePackageJson, packager } from "@electron/packager"; |
| 9 | import { execFileSync } from "node:child_process"; |
| 10 | import { createHash } from "node:crypto"; |
| 11 | import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; |
| 12 | import { tmpdir } from "node:os"; |
| 13 | import { basename, dirname, join } from "node:path"; |
| 14 | import { fileURLToPath } from "node:url"; |
| 15 | import { |
| 16 | buildInfo, |
| 17 | nsisProjectDefines, |
| 18 | packagerOptions, |
| 19 | parseTarget, |
| 20 | PRODUCT, |
| 21 | readProductIdentity, |
| 22 | runBuildScript, |
| 23 | sanitizeShellPackageJson, |
| 24 | signingFileList, |
| 25 | versionTag, |
| 26 | walkFiles |
| 27 | } from "./lib.mjs"; |
| 28 | import { verifyFrontendArtifact } from "../frontend/scripts/artifact-identity.mjs"; |
| 29 | |
| 30 | const desktop = dirname(dirname(fileURLToPath(import.meta.url))); |
| 31 | const repo = dirname(desktop); |
| 32 | const [spec, version, channel = "stable"] = process.argv.slice(2); |
| 33 | if (!spec || !version) { |
| 34 | console.error("usage: package.mjs <os/arch> <version> [channel]"); |
| 35 | process.exit(2); |
| 36 | } |
| 37 | const target = parseTarget(spec); |
| 38 | versionTag(version); |
| 39 | const identity = readProductIdentity(); |
| 40 | const electronVersion = JSON.parse(readFileSync(join(desktop, "electron", "node_modules", "electron", "package.json"), "utf8")).version; |
| 41 | const commit = (process.env.REASONIX_COMMIT ?? "").trim() || gitCommit(); |
| 42 | const buildTime = (process.env.REASONIX_BUILD_TIME ?? "").trim() || new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); |
| 43 | |
| 44 | function gitCommit() { |
| 45 | try { |
| 46 | return execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf8", }).trim(); |
| 47 | } catch { |
| 48 | return "unknown"; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | function sha256File(path) { |
| 53 | return createHash("sha256").update(readFileSync(path)).digest("hex"); |
| 54 | } |
| 55 | |
| 56 | function require(path, what) { |
| 57 | if (!existsSync(path)) throw new Error(`${what} is missing: ${path}`); |
| 58 | } |
| 59 | |
| 60 | const frontendDist = join(desktop, "frontend", "dist"); |
| 61 | if (process.env.REASONIX_PACKAGE_REUSE_FRONTEND === "1") { |
| 62 | const pnpmVersion = (process.env.REASONIX_FRONTEND_PNPM_VERSION ?? "").trim(); |
| 63 | if (!pnpmVersion) throw new Error("REASONIX_FRONTEND_PNPM_VERSION is required when reusing a frontend artifact"); |
| 64 | verifyFrontendArtifact({ |
| 65 | root: repo, |
| 66 | dist: frontendDist, |
| 67 | manifest: process.env.REASONIX_FRONTEND_ARTIFACT_MANIFEST || join(desktop, "frontend", ".reasonix-frontend-artifact.json"), |
| 68 | shell: "electron", |
| 69 | channel, |
| 70 | sourceSHA: process.env.GITHUB_SHA || undefined, |
| 71 | runId: process.env.GITHUB_RUN_ID || undefined, |
| 72 | attempt: process.env.GITHUB_RUN_ATTEMPT || undefined, |
| 73 | pnpmVersion, |
| 74 | }); |
| 75 | console.log(`==> reusing ${frontendDist}`); |
| 76 | } else { |
| 77 | console.log(`==> frontend build:electron (channel ${channel})`); |
| 78 | runBuildScript(join(desktop, "frontend"), "build-for-shell.mjs", ["electron"], { REASONIX_CHANNEL: channel, REASONIX_COMMIT: commit }); |
| 79 | } |
| 80 | require(join(frontendDist, "index.html"), "frontend dist"); |
| 81 | |
| 82 | console.log("==> shell build"); |
| 83 | runBuildScript(join(desktop, "electron"), "build.mjs"); |
| 84 | const shellDist = join(desktop, "electron", "dist"); |
| 85 | for (const name of ["main.cjs", "preload.cjs"]) require(join(shellDist, name), "shell bundle"); |
| 86 | if (!existsSync(join(shellDist, "desktopContract.json")) && process.env.REASONIX_ELECTRON_ALLOW_MISSING_CONTRACT !== "1") { |
| 87 | throw new Error(`desktop contract is missing from ${shellDist}; run: cd desktop && go run . -emit-contract frontend/src/generated`); |
| 88 | } |
| 89 | |
| 90 | const sourceMapDir = join(desktop, "build", "sourcemaps", target.key); |
| 91 | rmSync(sourceMapDir, { recursive: true, force: true }); |
| 92 | mkdirSync(sourceMapDir, { recursive: true }); |
| 93 | for (const name of readdirSync(shellDist).filter((name) => name.endsWith(".map"))) { |
| 94 | cpSync(join(shellDist, name), join(sourceMapDir, name)); |
| 95 | } |
| 96 | const frontendMapArchive = join(desktop, "frontend", "sourcemaps", commit); |
| 97 | require(join(frontendMapArchive, "manifest.json"), "frontend source map archive manifest"); |
| 98 | const frontendMapManifest = JSON.parse(readFileSync(join(frontendMapArchive, "manifest.json"), "utf8")); |
| 99 | if (frontendMapManifest.commit !== commit) throw new Error(`frontend source map commit ${frontendMapManifest.commit} does not match ${commit}`); |
| 100 | if (!Array.isArray(frontendMapManifest.maps) || frontendMapManifest.maps.length === 0) throw new Error("frontend source map archive manifest has no maps"); |
| 101 | for (const record of frontendMapManifest.maps) { |
| 102 | if (!record || typeof record.archive !== "string" || typeof record.map !== "string" || typeof record.bundle !== "string") { |
| 103 | throw new Error("frontend source map archive manifest has an invalid map record"); |
| 104 | } |
| 105 | if ( |
| 106 | !record.map.endsWith(".map") || |
| 107 | record.map.startsWith("/") || |
| 108 | record.map.includes("\\") || |
| 109 | record.map.split("/").includes("..") || |
| 110 | record.archive.includes("/") || |
| 111 | record.archive.includes("\\") || |
| 112 | record.archive.includes("..") || |
| 113 | record.bundle !== record.map.slice(0, -4) |
| 114 | ) { |
| 115 | throw new Error(`frontend source map archive manifest has an unsafe map record: ${JSON.stringify(record)}`); |
| 116 | } |
| 117 | const archivedMap = join(frontendMapArchive, record.archive); |
| 118 | require(archivedMap, `archived frontend source map ${record.map}`); |
| 119 | const destination = join(sourceMapDir, "frontend", record.map); |
| 120 | mkdirSync(dirname(destination), { recursive: true }); |
| 121 | cpSync(archivedMap, destination); |
| 122 | } |
| 123 | |
| 124 | const sourceMapEntries = []; |
| 125 | for (const mapPath of walkFiles(sourceMapDir) |
| 126 | .filter((name) => name.endsWith(".map")) |
| 127 | .sort()) { |
| 128 | const shellMap = !mapPath.startsWith("frontend/"); |
| 129 | const bundleRelative = shellMap ? mapPath.slice(0, -4) : mapPath.slice("frontend/".length, -4); |
| 130 | if (!bundleRelative || bundleRelative.includes("..")) throw new Error(`source map has an invalid bundle path: ${mapPath}`); |
| 131 | const mapJSON = JSON.parse(readFileSync(join(sourceMapDir, mapPath), "utf8")); |
| 132 | if (mapJSON.file && String(mapJSON.file) !== basename(bundleRelative)) throw new Error(`source map bundle identity does not match ${mapPath}`); |
| 133 | const bundlePath = shellMap ? join(shellDist, bundleRelative) : join(frontendDist, bundleRelative); |
| 134 | require(bundlePath, `bundle for source map ${mapPath}`); |
| 135 | sourceMapEntries.push({ |
| 136 | bundle: shellMap ? `electron/${bundleRelative}` : bundleRelative, |
| 137 | bundleHash: `sha256:${sha256File(bundlePath)}`, |
| 138 | map: mapPath, |
| 139 | mapHash: `sha256:${sha256File(join(sourceMapDir, mapPath))}`, |
| 140 | }); |
| 141 | } |
| 142 | for (const requiredMap of ["main.cjs.map", "preload.cjs.map"]) { |
| 143 | if (!sourceMapEntries.some((entry) => entry.map === requiredMap)) throw new Error(`required Electron source map is missing: ${requiredMap}`); |
| 144 | } |
| 145 | if (!sourceMapEntries.some((entry) => entry.map.startsWith("frontend/"))) throw new Error("frontend source maps are missing"); |
| 146 | writeFileSync( |
| 147 | join(sourceMapDir, "manifest.json"), |
| 148 | JSON.stringify( |
| 149 | { |
| 150 | schemaVersion: 1, |
| 151 | commit: (process.env.GITHUB_SHA ?? "").trim() || gitCommit(), |
| 152 | target: target.spec, |
| 153 | channel, |
| 154 | createdAt: buildTime, |
| 155 | entries: sourceMapEntries, |
| 156 | }, |
| 157 | null, |
| 158 | 2, |
| 159 | ) + "\n", |
| 160 | ); |
| 161 | |
| 162 | const staging = mkdtempSync(join(tmpdir(), "reasonix-package-")); |
| 163 | const outDir = join(desktop, "build", "electron", target.key); |
| 164 | try { |
| 165 | cpSync(frontendDist, join(staging, "app"), { |
| 166 | recursive: true, |
| 167 | filter: (source) => !source.endsWith(".map"), |
| 168 | }); |
| 169 | mkdirSync(join(staging, "icons"), { recursive: true }); |
| 170 | cpSync(join(desktop, "build", "appicon.png"), join(staging, "icons", "appicon.png")); |
| 171 | // Packaged launches always read this identity, including the full version |
| 172 | // tag. Environment overrides belong only to the unpackaged development shell. |
| 173 | writeFileSync(join(staging, "build.json"), JSON.stringify(buildInfo({ version, channel, commit, electronVersion, target, buildTime, }), null, 2,) + "\n",); |
| 174 | |
| 175 | const icon = { darwin: join(desktop, "build", "darwin", "icon.icns"), win32: join(desktop, "build", "windows", "icon.ico"), }[target.packagerPlatform]; |
| 176 | if (icon) require(icon, "application icon"); |
| 177 | const options = packagerOptions({ |
| 178 | target, |
| 179 | version, |
| 180 | identity, |
| 181 | root: desktop, |
| 182 | electronVersion, |
| 183 | extraResources: [join(staging, "app"), join(staging, "icons"), join(staging, "build.json")], |
| 184 | icon, |
| 185 | }); |
| 186 | options.sanitizePackageJson = [defaultSanitizePackageJson, (pkg) => sanitizeShellPackageJson(pkg, { version, productName: PRODUCT.name })]; |
| 187 | rmSync(options.out, { recursive: true, force: true }); |
| 188 | rmSync(outDir, { recursive: true, force: true }); |
| 189 | |
| 190 | console.log(`==> packaging ${PRODUCT.name} ${version} for ${target.spec} with Electron ${electronVersion}`); |
| 191 | const [finalPath] = await packager(options); |
| 192 | mkdirSync(outDir, { recursive: true }); |
| 193 | const bundle = target.os === "darwin" ? join(outDir, `${PRODUCT.name}.app`) : join(outDir, "app"); |
| 194 | renameSync(target.os === "darwin" ? join(finalPath, `${PRODUCT.name}.app`) : finalPath, bundle); |
| 195 | // The packager stages the app tree in a mkdtemp directory (0700) and renames |
| 196 | // it into place; dpkg installs that mode as root:root, hiding app/ from users. |
| 197 | if (target.os !== "darwin") chmodSync(bundle, 0o755); |
| 198 | rmSync(options.out, { recursive: true, force: true }); |
| 199 | |
| 200 | if (target.os === "windows") { |
| 201 | const installer = join(desktop, "build", "windows", "installer"); |
| 202 | mkdirSync(installer, { recursive: true }); |
| 203 | writeFileSync(join(installer, "reasonix_project.nsh"), nsisProjectDefines(identity, version)); |
| 204 | const signing = signingFileList(walkFiles(bundle).map((name) => `app/${name}`)); |
| 205 | writeFileSync(join(outDir, "signing-files.txt"), signing.join("\n") + "\n"); |
| 206 | console.log(`==> ${signing.length} Electron PE files need Authenticode (${join(outDir, "signing-files.txt")})`); |
| 207 | } |
| 208 | writeFileSync(join(outDir, "summary.json"), JSON.stringify({ target: target.spec, version, channel, commit, electronVersion, bundle, }, null, 2,) + "\n",); |
| 209 | console.log(`==> packaged ${bundle}`); |
| 210 | } finally { |
| 211 | rmSync(staging, { recursive: true, force: true }); |
| 212 | } |
| 213 |