| author | |
| committer | |
| log | 48bf123d0c44707f328039954a03e93488b3a70e |
| tree | 9e21fedac7d446ff738c091ba09da2db7ac6aa7c |
| parent | f8883d0495aa6fb3162077667827b187220c7a7e |
| signature | Commit is signed but in an unrecognized format. |
7 files changed, 596 insertions(+), 11 deletions(-)
.gitignore+1| ... | ... | @@ -10,3 +10,4 @@ packages/*/dist |
| 10 | 10 | .env.local |
| 11 | 11 | build/ |
| 12 | 12 | .logs/ |
| 13 | release/ |
README.md+14| ... | ... | @@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop |
| 56 | 56 | bun run build |
| 57 | 57 | bun run start |
| 58 | 58 | |
| 59 | # Build a shareable macOS .dmg (arm64 by default) | |
| 60 | bun run dist:desktop:dmg | |
| 61 | ||
| 59 | 62 | # Or from any project directory after publishing: |
| 60 | 63 | npx t3 |
| 61 | 64 | ``` |
| ... | ... | @@ -69,6 +72,17 @@ npx t3 |
| 69 | 72 | - `bun run build` — Builds contracts, web app, and server through Turbo. |
| 70 | 73 | - `bun run typecheck` — Strict TypeScript checks for all packages. |
| 71 | 74 | - `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` | |
| 72 | 86 | |
| 73 | 87 | ### Running multiple dev instances |
| 74 | 88 |
apps/desktop/src/main.ts+127-11| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | ipcMain, |
| 13 | 13 | Menu, |
| 14 | 14 | type MenuItemConstructorOptions, |
| 15 | protocol, | |
| 15 | 16 | shell, |
| 16 | 17 | } from "electron"; |
| 17 | 18 | |
| ... | ... | @@ -25,10 +26,9 @@ const CONFIRM_CHANNEL = "desktop:confirm"; |
| 25 | 26 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 26 | 27 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 27 | 28 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 28 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 29 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); | |
| 30 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); | |
| 31 | 29 | const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); |
| 30 | const DESKTOP_SCHEME = "t3"; | |
| 31 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 32 | 32 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 33 | 33 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 34 | 34 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; |
| ... | ... | @@ -41,6 +41,123 @@ let backendWsUrl = ""; |
| 41 | 41 | let restartAttempt = 0; |
| 42 | 42 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 43 | 43 | let isQuitting = false; |
| 44 | let desktopProtocolRegistered = false; | |
| 45 | ||
| 46 | protocol.registerSchemesAsPrivileged([ | |
| 47 | { | |
| 48 | scheme: DESKTOP_SCHEME, | |
| 49 | privileges: { | |
| 50 | standard: true, | |
| 51 | secure: true, | |
| 52 | supportFetchAPI: true, | |
| 53 | corsEnabled: true, | |
| 54 | }, | |
| 55 | }, | |
| 56 | ]); | |
| 57 | ||
| 58 | function resolveAppRoot(): string { | |
| 59 | if (!app.isPackaged) { | |
| 60 | return ROOT_DIR; | |
| 61 | } | |
| 62 | return app.getAppPath(); | |
| 63 | } | |
| 64 | ||
| 65 | function resolveBackendEntry(): string { | |
| 66 | return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); | |
| 67 | } | |
| 68 | ||
| 69 | function resolveBackendCwd(): string { | |
| 70 | if (!app.isPackaged) { | |
| 71 | return resolveAppRoot(); | |
| 72 | } | |
| 73 | return os.homedir(); | |
| 74 | } | |
| 75 | ||
| 76 | function 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 | ||
| 92 | function 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 | ||
| 115 | function 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 | ||
| 124 | function 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 | } | |
| 44 | 161 | |
| 45 | 162 | function dispatchMenuAction(action: string): void { |
| 46 | 163 | const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; |
| ... | ... | @@ -199,13 +316,14 @@ function scheduleBackendRestart(reason: string): void { |
| 199 | 316 | function startBackend(): void { |
| 200 | 317 | if (isQuitting || backendProcess) return; |
| 201 | 318 | |
| 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}`); | |
| 204 | 322 | return; |
| 205 | 323 | } |
| 206 | 324 | |
| 207 | const child = spawn(process.execPath, [BACKEND_ENTRY], { | |
| 208 | cwd: ROOT_DIR, | |
| 325 | const child = spawn(process.execPath, [backendEntry], { | |
| 326 | cwd: resolveBackendCwd(), | |
| 209 | 327 | // In Electron main, process.execPath points to the Electron binary. |
| 210 | 328 | // Run the child in Node mode so this backend process does not become a GUI app instance. |
| 211 | 329 | env: { |
| ... | ... | @@ -370,10 +488,7 @@ function createWindow(): BrowserWindow { |
| 370 | 488 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); |
| 371 | 489 | window.webContents.openDevTools({ mode: "detach" }); |
| 372 | 490 | } 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`); | |
| 377 | 492 | } |
| 378 | 493 | |
| 379 | 494 | window.on("closed", () => { |
| ... | ... | @@ -406,6 +521,7 @@ app.on("before-quit", () => { |
| 406 | 521 | app.whenReady().then(() => { |
| 407 | 522 | configureAppIdentity(); |
| 408 | 523 | configureApplicationMenu(); |
| 524 | registerDesktopProtocol(); | |
| 409 | 525 | void bootstrap(); |
| 410 | 526 | |
| 411 | 527 | app.on("activate", () => { |
apps/server/src/codexAppServerManager.test.ts+91| ... | ... | @@ -350,3 +350,94 @@ describe("thread checkpoint control", () => { |
| 350 | 350 | }); |
| 351 | 351 | }); |
| 352 | 352 | }); |
| 353 | ||
| 354 | describe("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 |
| 584 | 584 | context: CodexSessionContext, |
| 585 | 585 | notification: JsonRpcNotification, |
| 586 | 586 | ): void { |
| 587 | if (this.shouldSuppressNotification(notification)) { | |
| 588 | return; | |
| 589 | } | |
| 590 | ||
| 587 | 591 | const route = this.readRouteFields(notification.params); |
| 588 | 592 | const textDelta = |
| 589 | 593 | notification.method === "item/agentMessage/delta" |
| ... | ... | @@ -644,6 +648,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 644 | 648 | } |
| 645 | 649 | } |
| 646 | 650 | |
| 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 | ||
| 647 | 664 | private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { |
| 648 | 665 | const route = this.readRouteFields(request.params); |
| 649 | 666 | const requestKind = this.requestKindForMethod(request.method); |
package.json+3| ... | ... | @@ -20,6 +20,9 @@ |
| 20 | 20 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 21 | 21 | "fmt": "oxfmt", |
| 22 | 22 | "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", | |
| 23 | 26 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", |
| 24 | 27 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 25 | 28 | }, |
scripts/build-desktop-dmg.mjs created+343| ... | ... | @@ -0,0 +1,343 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import { spawn } from "node:child_process"; | |
| 4 | import fs from "node:fs/promises"; | |
| 5 | import os from "node:os"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | const repoRoot = path.resolve(__dirname, ".."); | |
| 11 | const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); | |
| 12 | ||
| 13 | const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); | |
| 14 | ||
| 15 | function printUsage() { | |
| 16 | console.log(`Build a shareable macOS .dmg for T3 Code desktop. | |
| 17 | ||
| 18 | Usage: | |
| 19 | node scripts/build-desktop-dmg.mjs [options] | |
| 20 | ||
| 21 | Options: | |
| 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 | ||
| 32 | function fail(message) { | |
| 33 | console.error(`[desktop-dmg] ${message}`); | |
| 34 | process.exit(1); | |
| 35 | } | |
| 36 | ||
| 37 | function 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 | ||
| 87 | async function readJson(filePath) { | |
| 88 | const raw = await fs.readFile(filePath, "utf8"); | |
| 89 | return JSON.parse(raw); | |
| 90 | } | |
| 91 | ||
| 92 | async function exists(filePath) { | |
| 93 | try { | |
| 94 | await fs.access(filePath); | |
| 95 | return true; | |
| 96 | } catch { | |
| 97 | return false; | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | function 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 | ||
| 124 | async 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 | ||
| 153 | async 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 | ||
| 171 | async 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 | ||
| 202 | async 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 | ||
| 340 | main().catch((error) => { | |
| 341 | const message = error instanceof Error ? error.message : String(error); | |
| 342 | fail(message); | |
| 343 | }); |