#!/usr/bin/env node import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); function printUsage() { console.log(`Build a shareable macOS .dmg for T3 Code desktop. Usage: node scripts/build-desktop-dmg.mjs [options] Options: --arch Target arch (default: host arch if supported) --version App version in DMG metadata (default: apps/server version) --output-dir Output folder for final artifacts (default: release) --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) --keep-stage Keep temporary staging directory --signed Allow automatic code signing discovery --help Show this message `); } function fail(message) { console.error(`[desktop-dmg] ${message}`); process.exit(1); } function parseArgs(argv) { const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64"; const options = { arch: defaultArch, version: null, skipBuild: false, keepStage: false, signed: false, outputDir: path.join(repoRoot, "release"), }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--help") { options.help = true; continue; } if (arg === "--skip-build") { options.skipBuild = true; continue; } if (arg === "--keep-stage") { options.keepStage = true; continue; } if (arg === "--signed") { options.signed = true; continue; } if (arg === "--arch" || arg === "--version" || arg === "--output-dir") { const value = argv[i + 1]; if (!value || value.startsWith("--")) { fail(`Missing value for ${arg}`); } i += 1; if (arg === "--arch") options.arch = value; if (arg === "--version") options.version = value; if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value); continue; } fail(`Unknown argument: ${arg}`); } if (!ARCH_CHOICES.has(options.arch)) { fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`); } return options; } async function readJson(filePath) { const raw = await fs.readFile(filePath, "utf8"); return JSON.parse(raw); } async function exists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env, stdio, }); child.on("error", reject); child.on("exit", (code, signal) => { if (signal) { reject(new Error(`${command} exited via signal ${signal}`)); return; } if (code !== 0) { reject(new Error(`${command} exited with code ${code}`)); return; } resolve(); }); }); } async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { const iconsetDir = path.join(tmpRoot, "icon.iconset"); await fs.mkdir(iconsetDir, { recursive: true }); const iconSizes = [16, 32, 128, 256, 512]; for (const size of iconSizes) { await run( "sips", [ "-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`), ], { stdio: "ignore" }, ); const retinaSize = size * 2; await run( "sips", [ "-z", String(retinaSize), String(retinaSize), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}@2x.png`), ], { stdio: "ignore" }, ); } await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); } async function stageProductionIcons(stageResourcesDir) { if (!(await exists(PRODUCTION_ICON_SOURCE))) { fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); } const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); try { const iconPngPath = path.join(stageResourcesDir, "icon.png"); const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { stdio: "ignore", }); await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); } finally { await fs.rm(tmpRoot, { recursive: true, force: true }); } } async function validateBundledClientAssets(clientDir) { const indexPath = path.join(clientDir, "index.html"); const indexHtml = await fs.readFile(indexPath, "utf8"); const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); const missing = []; for (const ref of refs) { const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; if (!normalizedRef) continue; if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; const ext = path.extname(normalizedRef); if (!ext) continue; const relativePath = normalizedRef.replace(/^\/+/, ""); const assetPath = path.join(clientDir, relativePath); if (!(await exists(assetPath))) { missing.push(normalizedRef); } } if (missing.length > 0) { const preview = missing.slice(0, 6).join(", "); const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; fail( `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, ); } } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printUsage(); return; } if (process.platform !== "darwin") { fail("This script only builds macOS .dmg artifacts and must run on macOS."); } const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); const electronVersion = desktopPkg.dependencies?.electron; if (typeof electronVersion !== "string" || electronVersion.length === 0) { fail("Could not resolve apps/desktop electron dependency version."); } const dependencies = serverPkg.dependencies; if (!dependencies || Object.keys(dependencies).length === 0) { fail("Could not resolve production dependencies from apps/server/package.json."); } const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); const stageAppDir = path.join(stageRoot, "app"); const distDirs = { desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), desktopResources: path.join(repoRoot, "apps/desktop/resources"), serverDist: path.join(repoRoot, "apps/server/dist"), }; const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); if (!options.skipBuild) { console.log("[desktop-dmg] Building desktop/server/web artifacts..."); await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); } for (const [label, dir] of Object.entries(distDirs)) { if (!(await exists(dir))) { fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); } } if (!(await exists(bundledClientEntry))) { fail( `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, ); } await validateBundledClientAssets(path.dirname(bundledClientEntry)); await fs.rm(stageRoot, { recursive: true, force: true }); await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); console.log("[desktop-dmg] Staging release app..."); await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { recursive: true, }); await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), { recursive: true, }); await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources")); const stagePackageJson = { name: "t3-code-desktop", version: appVersion, private: true, description: "T3 Code desktop build", author: "T3 Tools", main: "apps/desktop/dist-electron/main.js", build: { appId: "com.t3tools.t3code", productName: desktopPkg.productName ?? "T3 Code", artifactName: "T3-Code-${version}-${arch}.${ext}", directories: { buildResources: "apps/desktop/resources", }, mac: { target: ["dmg"], icon: "icon.icns", category: "public.app-category.developer-tools", }, }, dependencies, devDependencies: { electron: electronVersion, }, }; await fs.writeFile( path.join(stageAppDir, "package.json"), `${JSON.stringify(stagePackageJson, null, 2)}\n`, "utf8", ); console.log("[desktop-dmg] Installing staged production dependencies..."); await run("bun", ["install", "--production"], { cwd: stageAppDir }); const buildEnv = { ...process.env, }; if (!options.signed) { buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; } const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`]; console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`); await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); const stageDistDir = path.join(stageAppDir, "dist"); const stageFiles = await fs.readdir(stageDistDir); const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg")); if (dmgFiles.length === 0) { fail(`Build completed but no .dmg found in ${stageDistDir}`); } await fs.mkdir(options.outputDir, { recursive: true }); const copiedArtifacts = []; for (const file of stageFiles) { if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue; const from = path.join(stageDistDir, file); const to = path.join(options.outputDir, file); await fs.copyFile(from, to); copiedArtifacts.push(to); } if (!options.keepStage) { await fs.rm(stageRoot, { recursive: true, force: true }); } console.log("[desktop-dmg] Done. Artifacts:"); for (const artifact of copiedArtifacts) { console.log(` - ${artifact}`); } } main().catch((error) => { const message = error instanceof Error ? error.message : String(error); fail(message); });