diff --git a/Jenkinsfile b/Jenkinsfile index 97b6301..0357bc6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -122,6 +122,10 @@ def buildOnLabel(label, buildCommand) { } } +def buildMacOnLabel(label, targetArch) { + return buildOnLabel(label, "ELECTROBUN_TARGET_ARCH=${targetArch} bun run build:app:mac") +} + def setBuildNameFromPackageVersion() { node('ubuntu') { stage('Set Build Name') { @@ -145,7 +149,8 @@ try { parallel( 'Windows Build': buildOnLabel('windows', 'bun run build:app'), - 'MacOS Build': buildOnLabel('macos', 'bun run build:app:mac'), + 'MacOS x64 Build': buildMacOnLabel('macos', 'x64'), + 'MacOS arm64 Build': buildMacOnLabel('macos-arm64', 'arm64'), 'Ubuntu Build': { buildLinux() } ) diff --git a/electrobun.config.ts b/electrobun.config.ts index 2069335..3c1070e 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; const packageJson = JSON.parse(readFileSync("./package.json", "utf8")); const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "dev"; const isStable = buildEnv === "stable"; +const canCodesign = Boolean(process.env.ELECTROBUN_DEVELOPER_ID); const nativeExternals = [ "canvas", @@ -41,8 +42,8 @@ export default { watchIgnore: ["dist/**", "build/**", "app_dist/**"], mac: { bundleCEF: false, - codesign: isStable, - notarize: isStable, + codesign: isStable && canCodesign, + notarize: isStable && canCodesign, }, linux: { bundleCEF: false, diff --git a/package.json b/package.json index 7da12b2..aea54eb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev:renderer": "vite src/app --port 5287 --no-open", "build:app": "electrobun build --env=stable", "build:app:mac": "bun scripts/build-macos.mjs", + "postinstall": "bun scripts/patch-electrobun-src.mjs", "build:linux": "bun scripts/build-linux.mjs", "clean": "bun scripts/clean-build.mjs" }, diff --git a/readme.md b/readme.md index ad5c98a..4d9b2f8 100644 --- a/readme.md +++ b/readme.md @@ -46,6 +46,10 @@ Commands: - Desktop app (current platform): `bun run build:app` - macOS universal (arm64 + x64): `bun run build:app:mac` +- macOS single arch: `ELECTROBUN_TARGET_ARCH=arm64 bun run build:app:mac` +- macOS arm64 on Intel: cross-builds via patched Electrobun CLI (`ELECTROBUN_TARGET_ARCH=arm64`) + +Universal macOS builds use split CI agents (`macos` for x64, `macos-arm64` for arm64). Locally on Intel, arm64 builds work via `ELECTROBUN_TARGET_ARCH=arm64`. On Apple Silicon, x64 uses Rosetta (`arch -x86_64`). - Linux headless packages: `bun run build:linux` - Clean build outputs: `bun run clean` diff --git a/scripts/build-macos.mjs b/scripts/build-macos.mjs index 37a6e2c..4bbca3f 100644 --- a/scripts/build-macos.mjs +++ b/scripts/build-macos.mjs @@ -1,10 +1,21 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import path from "node:path"; +import { getReleaseArch } from "./release-artifact-utils.mjs"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const hostArch = process.arch === "arm64" ? "arm64" : "x64"; -const targetArchs = ["arm64", "x64"]; +const hostArch = getReleaseArch(process.arch); +const targetArchs = process.env.ELECTROBUN_TARGET_ARCH + ? [getReleaseArch(process.env.ELECTROBUN_TARGET_ARCH)] + : ["arm64", "x64"]; + +function canRunUnderArch(archFlag) { + const result = spawnSync("arch", [archFlag, "true"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return result.status === 0; +} function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -19,37 +30,82 @@ function run(command, args, options = {}) { } } -function getElectrobunInvocation(targetArch) { - const electrobunArgs = ["electrobun", "build", "--env=stable"]; - const env = { +function buildElectrobunEnv(targetArch) { + return { ...process.env, + ELECTROBUN_BUILD_ENV: "stable", + ELECTROBUN_OS: "macos", ELECTROBUN_ARCH: targetArch, + ELECTROBUN_FORCE_ARCH: targetArch, + ELECTROBUN_TARGET_ARCH: targetArch, + NODE_ENV: "production", }; +} + +function getInvocation(targetArch) { + const env = buildElectrobunEnv(targetArch); + const runScript = path.join(rootDir, "scripts/run-electrobun-build.mjs"); if (targetArch === hostArch) { - return { command: "bun", args: electrobunArgs, env }; + return { command: "bun", args: [runScript], env }; } - if (hostArch === "arm64" && targetArch === "x64") { - return { command: "arch", args: ["-x86_64", "bun", ...electrobunArgs], env }; + // Apple Silicon can build x64 under Rosetta; Electrobun sees an x64 host. + if (targetArch === "x64" && hostArch === "arm64" && canRunUnderArch("-x86_64")) { + return { + command: "arch", + args: ["-x86_64", "bun", runScript], + env, + }; } - if (hostArch === "x64" && targetArch === "arm64") { - return { command: "arch", args: ["-arm64", "bun", ...electrobunArgs], env }; + // Intel (and any host) can cross-build arm64 via the patched Electrobun Bun CLI. + if (targetArch === "arm64" && hostArch === "x64") { + return { command: "bun", args: [runScript], env }; } return null; } -for (const targetArch of targetArchs) { - const invocation = getElectrobunInvocation(targetArch); +const orderedTargets = [...targetArchs].sort((a, b) => { + if (a === hostArch) return -1; + if (b === hostArch) return 1; + return 0; +}); + +console.log(`Host architecture: ${hostArch}`); +console.log(`Build order: ${orderedTargets.join(", ")}`); + +let builtCount = 0; + +for (const targetArch of orderedTargets) { + const invocation = getInvocation(targetArch); if (!invocation) { - console.warn( - `Skipping macOS ${targetArch} build: unsupported cross-compile from ${hostArch}.`, + console.error( + `Cannot build macOS ${targetArch} from host ${hostArch}.`, ); continue; } - console.log(`\n=== Building macOS ${targetArch} ===\n`); + console.log(`\n=== Building macOS ${targetArch} (host ${hostArch}) ===`); + console.log( + `ELECTROBUN_BUILD_ENV=stable ELECTROBUN_OS=macos ELECTROBUN_ARCH=${targetArch}\n`, + ); + run(invocation.command, invocation.args, { env: invocation.env }); + builtCount += 1; } + +if (builtCount === 0) { + console.error("No macOS architectures were built."); + process.exit(1); +} + +if (builtCount < targetArchs.length) { + console.error( + `Built ${builtCount}/${targetArchs.length} macOS architectures.`, + ); + process.exit(1); +} + +console.log(`\nBuilt macOS architectures: ${orderedTargets.join(", ")}`); diff --git a/scripts/ensure-electrobun-core.mjs b/scripts/ensure-electrobun-core.mjs new file mode 100644 index 0000000..cff2837 --- /dev/null +++ b/scripts/ensure-electrobun-core.mjs @@ -0,0 +1,123 @@ +import { + createWriteStream, + existsSync, + mkdirSync, + readdirSync, + unlinkSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getReleaseArch } from "./release-artifact-utils.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const electrobunDir = path.join(rootDir, "node_modules/electrobun"); +const electrobunVersion = JSON.parse( + await Bun.file(path.join(electrobunDir, "package.json")).text(), +).version; + +function getPlatformPaths(targetOS, targetArch) { + const binExt = targetOS === "win" ? ".exe" : ""; + const platformDistDir = path.join( + electrobunDir, + `dist-${targetOS}-${targetArch}`, + ); + + return { + platformDistDir, + bunBinary: path.join(platformDistDir, "bun") + binExt, + bsdiff: path.join(platformDistDir, "bsdiff") + binExt, + bspatch: path.join(platformDistDir, "bspatch") + binExt, + launcher: path.join(platformDistDir, "launcher") + binExt, + nativeWrapperMacos: path.join(platformDistDir, "libNativeWrapper.dylib"), + }; +} + +async function downloadFile(url, destination) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); + } + + mkdirSync(path.dirname(destination), { recursive: true }); + const fileStream = createWriteStream(destination); + const reader = response.body?.getReader(); + if (!reader) { + throw new Error(`No response body for ${url}`); + } + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + fileStream.write(Buffer.from(value)); + } + + await new Promise((resolve, reject) => { + fileStream.end((error) => (error ? reject(error) : resolve())); + }); +} + +function extractTarGz(tarPath, destination) { + mkdirSync(destination, { recursive: true }); + const result = spawnSync("tar", ["-xzf", tarPath, "-C", destination], { + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`tar extraction failed for ${tarPath}`); + } +} + +async function ensureCoreDependencies(targetOS, targetArch) { + const paths = getPlatformPaths(targetOS, targetArch); + const required = [ + paths.bunBinary, + paths.bsdiff, + paths.bspatch, + paths.launcher, + paths.nativeWrapperMacos, + ]; + + if (required.every((filePath) => existsSync(filePath))) { + console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} already present`); + return; + } + + const platformName = + targetOS === "macos" ? "darwin" : targetOS === "win" ? "win" : "linux"; + const url = `https://github.com/blackboardsh/electrobun/releases/download/v${electrobunVersion}/electrobun-core-${platformName}-${targetArch}.tar.gz`; + const tempFile = path.join( + electrobunDir, + `core-${targetOS}-${targetArch}-temp.tar.gz`, + ); + + console.log(`ensure-electrobun-core: downloading ${targetOS}-${targetArch}`); + console.log(url); + + await downloadFile(url, tempFile); + extractTarGz(tempFile, paths.platformDistDir); + + if (existsSync(tempFile)) { + unlinkSync(tempFile); + } + + const missing = required.filter((filePath) => !existsSync(filePath)); + if (missing.length > 0) { + const extracted = existsSync(paths.platformDistDir) + ? readdirSync(paths.platformDistDir) + : []; + throw new Error( + `Missing ${targetOS}-${targetArch} binaries after extract: ${missing.join(", ")}; extracted=${extracted.join(", ")}`, + ); + } + + console.log(`ensure-electrobun-core: ${targetOS}-${targetArch} ready`); +} + +const targetOS = "macos"; +const targetArch = getReleaseArch( + process.env.ELECTROBUN_TARGET_ARCH || + process.env.ELECTROBUN_FORCE_ARCH || + process.arch, +); + +await ensureCoreDependencies(targetOS, targetArch); diff --git a/scripts/patch-electrobun-src.mjs b/scripts/patch-electrobun-src.mjs new file mode 100644 index 0000000..8e384b6 --- /dev/null +++ b/scripts/patch-electrobun-src.mjs @@ -0,0 +1,159 @@ +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const electrobunDir = path.join(rootDir, "node_modules/electrobun"); +const sharedSrcDir = path.join(electrobunDir, "src/shared"); +const sharedDistDir = path.join(electrobunDir, "dist/api/shared"); + +if (!existsSync(sharedDistDir)) { + console.error("patch-electrobun-src: electrobun dist/api/shared not found"); + process.exit(1); +} + +mkdirSync(sharedSrcDir, { recursive: true }); + +for (const fileName of [ + "cef-version.ts", + "bun-version.ts", + "electrobun-version.ts", + "naming.ts", + "rpc.ts", +]) { + const source = path.join(sharedDistDir, fileName); + const destination = path.join(sharedSrcDir, fileName); + if (existsSync(source)) { + cpSync(source, destination); + } +} + +const platformPatch = `import { platform, arch } from "os"; + +export type SupportedOS = "macos" | "win" | "linux"; +export type SupportedArch = "arm64" | "x64"; + +const platformName = platform(); +const archName = arch(); + +export const OS: SupportedOS = (() => { + switch (platformName) { + case "win32": + return "win"; + case "darwin": + return "macos"; + case "linux": + return "linux"; + default: + throw new Error(\`Unsupported platform: \${platformName}\`); + } +})(); + +function normalizeForcedArch(value) { + if (value === "arm64" || value === "aarch64") { + return "arm64"; + } + if (value === "x64" || value === "amd64") { + return "x64"; + } + return null; +} + +export const ARCH: SupportedArch = (() => { + const forcedArch = normalizeForcedArch( + process.env.ELECTROBUN_FORCE_ARCH || process.env.ELECTROBUN_TARGET_ARCH, + ); + if (forcedArch) { + return forcedArch; + } + + if (OS === "win") { + return "x64"; + } + + switch (archName) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + throw new Error(\`Unsupported architecture: \${archName}\`); + } +})(); + +export const HOST_ARCH: SupportedArch = (() => { + if (OS === "win") { + return "x64"; + } + + switch (archName) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + throw new Error(\`Unsupported architecture: \${archName}\`); + } +})(); + +export function getPlatformOS(): SupportedOS { + return OS; +} + +export function getPlatformArch(): SupportedArch { + return ARCH; +} +`; + +writeFileSync(path.join(sharedSrcDir, "platform.ts"), platformPatch); + +const templatesDir = path.join(electrobunDir, "src/cli/templates"); +mkdirSync(templatesDir, { recursive: true }); +writeFileSync( + path.join(templatesDir, "embedded.ts"), + `export function getTemplateNames() { + return []; +} + +export function getTemplate(name: string) { + throw new Error(\`Template not available in patched electrobun CLI: \${name}\`); +} +`, +); + +const cliPath = path.join(electrobunDir, "src/cli/index.ts"); +let cliSource = readFileSync(cliPath, "utf8"); +if (!cliSource.includes("HOST_ARCH")) { + cliSource = cliSource.replace( + 'import { OS, ARCH } from "../shared/platform";', + 'import { OS, ARCH, HOST_ARCH } from "../shared/platform";', + ); + cliSource = cliSource.replace( + "const hostPaths = getPlatformPaths(OS, ARCH);", + "const hostPaths = getPlatformPaths(OS, HOST_ARCH);", + ); +} +if (!cliSource.includes("toolPaths")) { + cliSource = cliSource.replace( + "const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);", + "const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);\n\t\tconst toolPaths = getPlatformPaths(currentTarget.os, ARCH !== HOST_ARCH ? HOST_ARCH : ARCH);", + ); + cliSource = cliSource.replaceAll("const zstdPath = targetPaths.ZSTD;", "const zstdPath = toolPaths.ZSTD;"); + cliSource = cliSource.replaceAll("const bsdiffpath = targetPaths.BSDIFF;", "const bsdiffpath = toolPaths.BSDIFF;"); + cliSource = cliSource.replace( + "zigAsarCli = join(targetPaths.BSPATCH).replace(\"bspatch\", \"zig-asar\");", + "zigAsarCli = join(toolPaths.BSPATCH).replace(\"bspatch\", \"zig-asar\");", + ); + writeFileSync(cliPath, cliSource); +} + +const markerPath = path.join(electrobunDir, ".farmcontrol-electrobun-patched"); +writeFileSync(markerPath, `patched-at=${new Date().toISOString()}\n`); + +console.log("patch-electrobun-src: electrobun src/shared ready for cross-arch builds"); diff --git a/scripts/run-electrobun-build.mjs b/scripts/run-electrobun-build.mjs new file mode 100644 index 0000000..280f537 --- /dev/null +++ b/scripts/run-electrobun-build.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getReleaseArch } from "./release-artifact-utils.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const electrobunCli = path.join( + rootDir, + "node_modules/electrobun/src/cli/index.ts", +); + +const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable"; +const targetArch = getReleaseArch( + process.env.ELECTROBUN_TARGET_ARCH || + process.env.ELECTROBUN_FORCE_ARCH || + process.arch, +); + +const patchResult = spawnSync("bun", [path.join(rootDir, "scripts/patch-electrobun-src.mjs")], { + cwd: rootDir, + stdio: "inherit", + env: process.env, +}); + +if (patchResult.status !== 0) { + process.exit(patchResult.status ?? 1); +} + +const ensureCoreEnv = { + ...process.env, + ELECTROBUN_TARGET_ARCH: targetArch, + ELECTROBUN_FORCE_ARCH: targetArch, +}; + +const ensureCoreResult = spawnSync( + "bun", + [path.join(rootDir, "scripts/ensure-electrobun-core.mjs")], + { + cwd: rootDir, + stdio: "inherit", + env: ensureCoreEnv, + }, +); + +if (ensureCoreResult.status !== 0) { + process.exit(ensureCoreResult.status ?? 1); +} + +const env = { + ...process.env, + ELECTROBUN_BUILD_ENV: buildEnv, + ELECTROBUN_OS: "macos", + ELECTROBUN_ARCH: targetArch, + ELECTROBUN_FORCE_ARCH: targetArch, + ELECTROBUN_TARGET_ARCH: targetArch, + NODE_ENV: process.env.NODE_ENV || "production", +}; + +console.log( + `run-electrobun-build: env=${buildEnv} os=macos arch=${targetArch} (host ${getReleaseArch(process.arch)})`, +); + +const result = spawnSync( + "bun", + [electrobunCli, "build", `--env=${buildEnv}`], + { + cwd: rootDir, + stdio: "inherit", + env, + }, +); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +}