authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-19 04:04:04-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-19 04:04:04-08:00
log48bf123d0c44707f328039954a03e93488b3a70e
tree9e21fedac7d446ff738c091ba09da2db7ac6aa7c
parentf8883d0495aa6fb3162077667827b187220c7a7e
signature Commit is signed but in an unrecognized format.

prod builds


7 files changed, 596 insertions(+), 11 deletions(-)

.gitignore+1
......@@ -10,3 +10,4 @@ packages/*/dist
1010.env.local
1111build/
1212.logs/
13release/
README.md+14
......@@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop
5656bun run build
5757bun run start
5858
59# Build a shareable macOS .dmg (arm64 by default)
60bun run dist:desktop:dmg
61
5962# Or from any project directory after publishing:
6063npx t3
6164```
......@@ -69,6 +72,17 @@ npx t3
6972- `bun run build` — Builds contracts, web app, and server through Turbo.
7073- `bun run typecheck` — Strict TypeScript checks for all packages.
7174- `bun run test` — Runs workspace tests.
75- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.
76- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.
77
78### Desktop `.dmg` packaging notes
79
80- Default build is unsigned/not notarized for local sharing.
81- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source.
82- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL).
83- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic.
84- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch.
85- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage`
7286
7387### Running multiple dev instances
7488
apps/desktop/src/main.ts+127-11
......@@ -12,6 +12,7 @@ import {
1212 ipcMain,
1313 Menu,
1414 type MenuItemConstructorOptions,
15 protocol,
1516 shell,
1617} from "electron";
1718
......@@ -25,10 +26,9 @@ const CONFIRM_CHANNEL = "desktop:confirm";
2526const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
2627const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
2728const MENU_ACTION_CHANNEL = "desktop:menu-action";
28const ROOT_DIR = path.resolve(__dirname, "../../..");
29const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
30const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
3129const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
30const DESKTOP_SCHEME = "t3";
31const ROOT_DIR = path.resolve(__dirname, "../../..");
3232const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
3333const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
3434const APP_USER_MODEL_ID = "com.t3tools.t3code";
......@@ -41,6 +41,123 @@ let backendWsUrl = "";
4141let restartAttempt = 0;
4242let restartTimer: ReturnType<typeof setTimeout> | null = null;
4343let isQuitting = false;
44let desktopProtocolRegistered = false;
45
46protocol.registerSchemesAsPrivileged([
47 {
48 scheme: DESKTOP_SCHEME,
49 privileges: {
50 standard: true,
51 secure: true,
52 supportFetchAPI: true,
53 corsEnabled: true,
54 },
55 },
56]);
57
58function resolveAppRoot(): string {
59 if (!app.isPackaged) {
60 return ROOT_DIR;
61 }
62 return app.getAppPath();
63}
64
65function resolveBackendEntry(): string {
66 return path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
67}
68
69function resolveBackendCwd(): string {
70 if (!app.isPackaged) {
71 return resolveAppRoot();
72 }
73 return os.homedir();
74}
75
76function resolveDesktopStaticDir(): string | null {
77 const appRoot = resolveAppRoot();
78 const candidates = [
79 path.join(appRoot, "apps/server/dist/client"),
80 path.join(appRoot, "apps/web/dist"),
81 ];
82
83 for (const candidate of candidates) {
84 if (fs.existsSync(path.join(candidate, "index.html"))) {
85 return candidate;
86 }
87 }
88
89 return null;
90}
91
92function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {
93 const url = new URL(requestUrl);
94 const rawPath = decodeURIComponent(url.pathname);
95 const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, "");
96 if (normalizedPath.includes("..")) {
97 return path.join(staticRoot, "index.html");
98 }
99
100 const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html";
101 const resolvedPath = path.join(staticRoot, requestedPath);
102
103 if (path.extname(resolvedPath)) {
104 return resolvedPath;
105 }
106
107 const nestedIndex = path.join(resolvedPath, "index.html");
108 if (fs.existsSync(nestedIndex)) {
109 return nestedIndex;
110 }
111
112 return path.join(staticRoot, "index.html");
113}
114
115function isStaticAssetRequest(requestUrl: string): boolean {
116 try {
117 const url = new URL(requestUrl);
118 return path.extname(url.pathname).length > 0;
119 } catch {
120 return false;
121 }
122}
123
124function registerDesktopProtocol(): void {
125 if (isDevelopment || desktopProtocolRegistered) return;
126
127 const staticRoot = resolveDesktopStaticDir();
128 if (!staticRoot) {
129 throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first.");
130 }
131
132 const staticRootResolved = path.resolve(staticRoot);
133 const staticRootPrefix = `${staticRootResolved}${path.sep}`;
134 const fallbackIndex = path.join(staticRootResolved, "index.html");
135
136 protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {
137 try {
138 const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);
139 const resolvedCandidate = path.resolve(candidate);
140 const isInRoot =
141 resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
142 const isAssetRequest = isStaticAssetRequest(request.url);
143
144 if (!isInRoot || !fs.existsSync(resolvedCandidate)) {
145 if (isAssetRequest) {
146 callback({ error: -6 });
147 return;
148 }
149 callback({ path: fallbackIndex });
150 return;
151 }
152
153 callback({ path: resolvedCandidate });
154 } catch {
155 callback({ path: fallbackIndex });
156 }
157 });
158
159 desktopProtocolRegistered = true;
160}
44161
45162function dispatchMenuAction(action: string): void {
46163 const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
......@@ -199,13 +316,14 @@ function scheduleBackendRestart(reason: string): void {
199316function startBackend(): void {
200317 if (isQuitting || backendProcess) return;
201318
202 if (!fs.existsSync(BACKEND_ENTRY)) {
203 scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`);
319 const backendEntry = resolveBackendEntry();
320 if (!fs.existsSync(backendEntry)) {
321 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
204322 return;
205323 }
206324
207 const child = spawn(process.execPath, [BACKEND_ENTRY], {
208 cwd: ROOT_DIR,
325 const child = spawn(process.execPath, [backendEntry], {
326 cwd: resolveBackendCwd(),
209327 // In Electron main, process.execPath points to the Electron binary.
210328 // Run the child in Node mode so this backend process does not become a GUI app instance.
211329 env: {
......@@ -370,10 +488,7 @@ function createWindow(): BrowserWindow {
370488 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
371489 window.webContents.openDevTools({ mode: "detach" });
372490 } else {
373 if (!fs.existsSync(WEB_ENTRY)) {
374 throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
375 }
376 void window.loadFile(WEB_ENTRY);
491 void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`);
377492 }
378493
379494 window.on("closed", () => {
......@@ -406,6 +521,7 @@ app.on("before-quit", () => {
406521app.whenReady().then(() => {
407522 configureAppIdentity();
408523 configureApplicationMenu();
524 registerDesktopProtocol();
409525 void bootstrap();
410526
411527 app.on("activate", () => {
apps/server/src/codexAppServerManager.test.ts+91
......@@ -350,3 +350,94 @@ describe("thread checkpoint control", () => {
350350 });
351351 });
352352});
353
354describe("notification forwarding", () => {
355 it("suppresses streamed assistant message notifications", () => {
356 const manager = new CodexAppServerManager();
357 const emittedMethods: string[] = [];
358 manager.on("event", (event) => {
359 emittedMethods.push(event.method);
360 });
361
362 const context = {
363 session: {
364 sessionId: "sess_1",
365 provider: "codex",
366 status: "running",
367 threadId: "thread_1",
368 createdAt: "2026-02-10T00:00:00.000Z",
369 updatedAt: "2026-02-10T00:00:00.000Z",
370 },
371 };
372
373 const handleServerNotification = (
374 manager as unknown as {
375 handleServerNotification: (
376 context: unknown,
377 notification: { method: string; params?: unknown },
378 ) => void;
379 }
380 ).handleServerNotification.bind(manager);
381
382 handleServerNotification(context, {
383 method: "item/agentMessage/delta",
384 params: {
385 itemId: "item_msg_1",
386 delta: "Partial text",
387 },
388 });
389 handleServerNotification(context, {
390 method: "item/started",
391 params: {
392 item: {
393 id: "item_msg_1",
394 type: "agentMessage",
395 text: "Working...",
396 },
397 },
398 });
399
400 expect(emittedMethods).toEqual([]);
401 });
402
403 it("keeps completed assistant message notifications", () => {
404 const manager = new CodexAppServerManager();
405 const emittedMethods: string[] = [];
406 manager.on("event", (event) => {
407 emittedMethods.push(event.method);
408 });
409
410 const context = {
411 session: {
412 sessionId: "sess_1",
413 provider: "codex",
414 status: "running",
415 threadId: "thread_1",
416 createdAt: "2026-02-10T00:00:00.000Z",
417 updatedAt: "2026-02-10T00:00:00.000Z",
418 },
419 };
420
421 const handleServerNotification = (
422 manager as unknown as {
423 handleServerNotification: (
424 context: unknown,
425 notification: { method: string; params?: unknown },
426 ) => void;
427 }
428 ).handleServerNotification.bind(manager);
429
430 handleServerNotification(context, {
431 method: "item/completed",
432 params: {
433 item: {
434 id: "item_msg_1",
435 type: "agentMessage",
436 text: "Final response",
437 },
438 },
439 });
440
441 expect(emittedMethods).toEqual(["item/completed"]);
442 });
443});
apps/server/src/codexAppServerManager.ts+17
......@@ -584,6 +584,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
584584 context: CodexSessionContext,
585585 notification: JsonRpcNotification,
586586 ): void {
587 if (this.shouldSuppressNotification(notification)) {
588 return;
589 }
590
587591 const route = this.readRouteFields(notification.params);
588592 const textDelta =
589593 notification.method === "item/agentMessage/delta"
......@@ -644,6 +648,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
644648 }
645649 }
646650
651 private shouldSuppressNotification(notification: JsonRpcNotification): boolean {
652 if (notification.method === "item/agentMessage/delta") {
653 return true;
654 }
655
656 if (notification.method !== "item/started") {
657 return false;
658 }
659
660 const itemType = this.readString(this.readObject(notification.params, "item"), "type");
661 return itemType === "agentMessage";
662 }
663
647664 private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void {
648665 const route = this.readRouteFields(request.params);
649666 const requestKind = this.requestKindForMethod(request.method);
package.json+3
......@@ -20,6 +20,9 @@
2020 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
2121 "fmt": "oxfmt",
2222 "build:contracts": "turbo run build --filter=@t3tools/contracts",
23 "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs",
24 "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64",
25 "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64",
2326 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
2427 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
2528 },
scripts/build-desktop-dmg.mjs created+343
......@@ -0,0 +1,343 @@
1#!/usr/bin/env node
2
3import { spawn } from "node:child_process";
4import fs from "node:fs/promises";
5import os from "node:os";
6import path from "node:path";
7import { fileURLToPath } from "node:url";
8
9const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const repoRoot = path.resolve(__dirname, "..");
11const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png");
12
13const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]);
14
15function printUsage() {
16 console.log(`Build a shareable macOS .dmg for T3 Code desktop.
17
18Usage:
19 node scripts/build-desktop-dmg.mjs [options]
20
21Options:
22 --arch <arm64|x64|universal> Target arch (default: host arch if supported)
23 --version <semver> App version in DMG metadata (default: apps/server version)
24 --output-dir <path> Output folder for final artifacts (default: release)
25 --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts)
26 --keep-stage Keep temporary staging directory
27 --signed Allow automatic code signing discovery
28 --help Show this message
29`);
30}
31
32function fail(message) {
33 console.error(`[desktop-dmg] ${message}`);
34 process.exit(1);
35}
36
37function parseArgs(argv) {
38 const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64";
39 const options = {
40 arch: defaultArch,
41 version: null,
42 skipBuild: false,
43 keepStage: false,
44 signed: false,
45 outputDir: path.join(repoRoot, "release"),
46 };
47
48 for (let i = 0; i < argv.length; i += 1) {
49 const arg = argv[i];
50 if (arg === "--help") {
51 options.help = true;
52 continue;
53 }
54 if (arg === "--skip-build") {
55 options.skipBuild = true;
56 continue;
57 }
58 if (arg === "--keep-stage") {
59 options.keepStage = true;
60 continue;
61 }
62 if (arg === "--signed") {
63 options.signed = true;
64 continue;
65 }
66 if (arg === "--arch" || arg === "--version" || arg === "--output-dir") {
67 const value = argv[i + 1];
68 if (!value || value.startsWith("--")) {
69 fail(`Missing value for ${arg}`);
70 }
71 i += 1;
72 if (arg === "--arch") options.arch = value;
73 if (arg === "--version") options.version = value;
74 if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value);
75 continue;
76 }
77 fail(`Unknown argument: ${arg}`);
78 }
79
80 if (!ARCH_CHOICES.has(options.arch)) {
81 fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`);
82 }
83
84 return options;
85}
86
87async function readJson(filePath) {
88 const raw = await fs.readFile(filePath, "utf8");
89 return JSON.parse(raw);
90}
91
92async function exists(filePath) {
93 try {
94 await fs.access(filePath);
95 return true;
96 } catch {
97 return false;
98 }
99}
100
101function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) {
102 return new Promise((resolve, reject) => {
103 const child = spawn(command, args, {
104 cwd,
105 env,
106 stdio,
107 });
108
109 child.on("error", reject);
110 child.on("exit", (code, signal) => {
111 if (signal) {
112 reject(new Error(`${command} exited via signal ${signal}`));
113 return;
114 }
115 if (code !== 0) {
116 reject(new Error(`${command} exited with code ${code}`));
117 return;
118 }
119 resolve();
120 });
121 });
122}
123
124async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) {
125 const iconsetDir = path.join(tmpRoot, "icon.iconset");
126 await fs.mkdir(iconsetDir, { recursive: true });
127
128 const iconSizes = [16, 32, 128, 256, 512];
129 for (const size of iconSizes) {
130 await run(
131 "sips",
132 ["-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`)],
133 { stdio: "ignore" },
134 );
135 const retinaSize = size * 2;
136 await run(
137 "sips",
138 [
139 "-z",
140 String(retinaSize),
141 String(retinaSize),
142 sourcePng,
143 "--out",
144 path.join(iconsetDir, `icon_${size}x${size}@2x.png`),
145 ],
146 { stdio: "ignore" },
147 );
148 }
149
150 await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" });
151}
152
153async function stageProductionIcons(stageResourcesDir) {
154 if (!(await exists(PRODUCTION_ICON_SOURCE))) {
155 fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`);
156 }
157
158 const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-"));
159 try {
160 const iconPngPath = path.join(stageResourcesDir, "icon.png");
161 const iconIcnsPath = path.join(stageResourcesDir, "icon.icns");
162 await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], {
163 stdio: "ignore",
164 });
165 await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot);
166 } finally {
167 await fs.rm(tmpRoot, { recursive: true, force: true });
168 }
169}
170
171async function validateBundledClientAssets(clientDir) {
172 const indexPath = path.join(clientDir, "index.html");
173 const indexHtml = await fs.readFile(indexPath, "utf8");
174 const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]);
175 const missing = [];
176
177 for (const ref of refs) {
178 const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? "";
179 if (!normalizedRef) continue;
180 if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue;
181 if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue;
182
183 const ext = path.extname(normalizedRef);
184 if (!ext) continue;
185
186 const relativePath = normalizedRef.replace(/^\/+/, "");
187 const assetPath = path.join(clientDir, relativePath);
188 if (!(await exists(assetPath))) {
189 missing.push(normalizedRef);
190 }
191 }
192
193 if (missing.length > 0) {
194 const preview = missing.slice(0, 6).join(", ");
195 const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : "";
196 fail(
197 `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`,
198 );
199 }
200}
201
202async function main() {
203 const options = parseArgs(process.argv.slice(2));
204 if (options.help) {
205 printUsage();
206 return;
207 }
208
209 if (process.platform !== "darwin") {
210 fail("This script only builds macOS .dmg artifacts and must run on macOS.");
211 }
212
213 const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json"));
214 const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json"));
215
216 const electronVersion = desktopPkg.dependencies?.electron;
217 if (typeof electronVersion !== "string" || electronVersion.length === 0) {
218 fail("Could not resolve apps/desktop electron dependency version.");
219 }
220
221 const dependencies = serverPkg.dependencies;
222 if (!dependencies || Object.keys(dependencies).length === 0) {
223 fail("Could not resolve production dependencies from apps/server/package.json.");
224 }
225
226 const appVersion = options.version ?? serverPkg.version ?? "0.1.0";
227 const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage");
228 const stageAppDir = path.join(stageRoot, "app");
229 const distDirs = {
230 desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"),
231 desktopResources: path.join(repoRoot, "apps/desktop/resources"),
232 serverDist: path.join(repoRoot, "apps/server/dist"),
233 };
234 const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html");
235
236 if (!options.skipBuild) {
237 console.log("[desktop-dmg] Building desktop/server/web artifacts...");
238 await run("bun", ["run", "build:desktop"], { cwd: repoRoot });
239 }
240
241 for (const [label, dir] of Object.entries(distDirs)) {
242 if (!(await exists(dir))) {
243 fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`);
244 }
245 }
246 if (!(await exists(bundledClientEntry))) {
247 fail(
248 `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`,
249 );
250 }
251 await validateBundledClientAssets(path.dirname(bundledClientEntry));
252
253 await fs.rm(stageRoot, { recursive: true, force: true });
254 await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true });
255 await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true });
256
257 console.log("[desktop-dmg] Staging release app...");
258 await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), {
259 recursive: true,
260 });
261 await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), {
262 recursive: true,
263 });
264 await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true });
265 await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources"));
266
267 const stagePackageJson = {
268 name: "t3-code-desktop",
269 version: appVersion,
270 private: true,
271 description: "T3 Code desktop build",
272 author: "T3 Tools",
273 main: "apps/desktop/dist-electron/main.js",
274 build: {
275 appId: "com.t3tools.t3code",
276 productName: desktopPkg.productName ?? "T3 Code",
277 artifactName: "T3-Code-${version}-${arch}.${ext}",
278 directories: {
279 buildResources: "apps/desktop/resources",
280 },
281 mac: {
282 target: ["dmg"],
283 icon: "icon.icns",
284 category: "public.app-category.developer-tools",
285 },
286 },
287 dependencies,
288 devDependencies: {
289 electron: electronVersion,
290 },
291 };
292
293 await fs.writeFile(
294 path.join(stageAppDir, "package.json"),
295 `${JSON.stringify(stagePackageJson, null, 2)}\n`,
296 "utf8",
297 );
298
299 console.log("[desktop-dmg] Installing staged production dependencies...");
300 await run("bun", ["install", "--production"], { cwd: stageAppDir });
301
302 const buildEnv = {
303 ...process.env,
304 };
305 if (!options.signed) {
306 buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
307 }
308
309 const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`];
310 console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`);
311 await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv });
312
313 const stageDistDir = path.join(stageAppDir, "dist");
314 const stageFiles = await fs.readdir(stageDistDir);
315 const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg"));
316 if (dmgFiles.length === 0) {
317 fail(`Build completed but no .dmg found in ${stageDistDir}`);
318 }
319
320 await fs.mkdir(options.outputDir, { recursive: true });
321 const copiedArtifacts = [];
322 for (const file of stageFiles) {
323 if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue;
324 const from = path.join(stageDistDir, file);
325 const to = path.join(options.outputDir, file);
326 await fs.copyFile(from, to);
327 copiedArtifacts.push(to);
328 }
329
330 if (!options.keepStage) {
331 await fs.rm(stageRoot, { recursive: true, force: true });
332 }
333
334 console.log("[desktop-dmg] Done. Artifacts:");
335 for (const artifact of copiedArtifacts) {
336 console.log(` - ${artifact}`);
337 }
338}
339
340main().catch((error) => {
341 const message = error instanceof Error ? error.message : String(error);
342 fail(message);
343});