diff --git a/.gitignore b/.gitignore index 3ac5a88fdfab3c0e19a87842855744a7b7640c3f..08ba9a8a1e32370fc37f9fbfd03607d43e812f78 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ packages/*/dist .env.local build/ .logs/ +release/ diff --git a/README.md b/README.md index ac73a427082d88fe755a14b4e66c3605fb64d783..425a70e4041018b3793af34d548c358597bad03d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop bun run build bun run start +# Build a shareable macOS .dmg (arm64 by default) +bun run dist:desktop:dmg + # Or from any project directory after publishing: npx t3 ``` @@ -69,6 +72,17 @@ npx t3 - `bun run build` — Builds contracts, web app, and server through Turbo. - `bun run typecheck` — Strict TypeScript checks for all packages. - `bun run test` — Runs workspace tests. +- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. +- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. + +### Desktop `.dmg` packaging notes + +- Default build is unsigned/not notarized for local sharing. +- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. +- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL). +- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. +- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. +- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` ### Running multiple dev instances diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c741deec8aeabf9aeb66827c034a37df8b9b1db0..792e78811ecf87fb1c5915145ac75ea6d9b7aa73 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,6 +12,7 @@ import { ipcMain, Menu, type MenuItemConstructorOptions, + protocol, shell, } from "electron"; @@ -25,10 +26,9 @@ const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; -const ROOT_DIR = path.resolve(__dirname, "../../.."); -const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); -const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); +const DESKTOP_SCHEME = "t3"; +const ROOT_DIR = path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const APP_USER_MODEL_ID = "com.t3tools.t3code"; @@ -41,6 +41,123 @@ let backendWsUrl = ""; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; +let desktopProtocolRegistered = false; + +protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, +]); + +function resolveAppRoot(): string { + if (!app.isPackaged) { + return ROOT_DIR; + } + return app.getAppPath(); +} + +function resolveBackendEntry(): string { + return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); +} + +function resolveBackendCwd(): string { + if (!app.isPackaged) { + return resolveAppRoot(); + } + return os.homedir(); +} + +function resolveDesktopStaticDir(): string | null { + const appRoot = resolveAppRoot(); + const candidates = [ + path.join(appRoot, "apps/server/dist/client"), + path.join(appRoot, "apps/web/dist"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "index.html"))) { + return candidate; + } + } + + return null; +} + +function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { + const url = new URL(requestUrl); + const rawPath = decodeURIComponent(url.pathname); + const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, ""); + if (normalizedPath.includes("..")) { + return path.join(staticRoot, "index.html"); + } + + const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; + const resolvedPath = path.join(staticRoot, requestedPath); + + if (path.extname(resolvedPath)) { + return resolvedPath; + } + + const nestedIndex = path.join(resolvedPath, "index.html"); + if (fs.existsSync(nestedIndex)) { + return nestedIndex; + } + + return path.join(staticRoot, "index.html"); +} + +function isStaticAssetRequest(requestUrl: string): boolean { + try { + const url = new URL(requestUrl); + return path.extname(url.pathname).length > 0; + } catch { + return false; + } +} + +function registerDesktopProtocol(): void { + if (isDevelopment || desktopProtocolRegistered) return; + + const staticRoot = resolveDesktopStaticDir(); + if (!staticRoot) { + throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); + } + + const staticRootResolved = path.resolve(staticRoot); + const staticRootPrefix = `${staticRootResolved}${path.sep}`; + const fallbackIndex = path.join(staticRootResolved, "index.html"); + + protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { + try { + const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); + const resolvedCandidate = path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url); + + if (!isInRoot || !fs.existsSync(resolvedCandidate)) { + if (isAssetRequest) { + callback({ error: -6 }); + return; + } + callback({ path: fallbackIndex }); + return; + } + + callback({ path: resolvedCandidate }); + } catch { + callback({ path: fallbackIndex }); + } + }); + + desktopProtocolRegistered = true; +} function dispatchMenuAction(action: string): void { const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; @@ -199,13 +316,14 @@ function scheduleBackendRestart(reason: string): void { function startBackend(): void { if (isQuitting || backendProcess) return; - if (!fs.existsSync(BACKEND_ENTRY)) { - scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); + const backendEntry = resolveBackendEntry(); + if (!fs.existsSync(backendEntry)) { + scheduleBackendRestart(`missing server entry at ${backendEntry}`); return; } - const child = spawn(process.execPath, [BACKEND_ENTRY], { - cwd: ROOT_DIR, + const child = spawn(process.execPath, [backendEntry], { + cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. env: { @@ -370,10 +488,7 @@ function createWindow(): BrowserWindow { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); window.webContents.openDevTools({ mode: "detach" }); } else { - if (!fs.existsSync(WEB_ENTRY)) { - throw new Error(`Web bundle missing at ${WEB_ENTRY}`); - } - void window.loadFile(WEB_ENTRY); + void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); } window.on("closed", () => { @@ -406,6 +521,7 @@ app.on("before-quit", () => { app.whenReady().then(() => { configureAppIdentity(); configureApplicationMenu(); + registerDesktopProtocol(); void bootstrap(); app.on("activate", () => { diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index afb23d48a768f7c1a52b386659e1e919625682f1..65a0687fbd527a0648bdb076dd4beedc6fef0e24 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -350,3 +350,94 @@ describe("thread checkpoint control", () => { }); }); }); + +describe("notification forwarding", () => { + it("suppresses streamed assistant message notifications", () => { + const manager = new CodexAppServerManager(); + const emittedMethods: string[] = []; + manager.on("event", (event) => { + emittedMethods.push(event.method); + }); + + const context = { + session: { + sessionId: "sess_1", + provider: "codex", + status: "running", + threadId: "thread_1", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + const handleServerNotification = ( + manager as unknown as { + handleServerNotification: ( + context: unknown, + notification: { method: string; params?: unknown }, + ) => void; + } + ).handleServerNotification.bind(manager); + + handleServerNotification(context, { + method: "item/agentMessage/delta", + params: { + itemId: "item_msg_1", + delta: "Partial text", + }, + }); + handleServerNotification(context, { + method: "item/started", + params: { + item: { + id: "item_msg_1", + type: "agentMessage", + text: "Working...", + }, + }, + }); + + expect(emittedMethods).toEqual([]); + }); + + it("keeps completed assistant message notifications", () => { + const manager = new CodexAppServerManager(); + const emittedMethods: string[] = []; + manager.on("event", (event) => { + emittedMethods.push(event.method); + }); + + const context = { + session: { + sessionId: "sess_1", + provider: "codex", + status: "running", + threadId: "thread_1", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + const handleServerNotification = ( + manager as unknown as { + handleServerNotification: ( + context: unknown, + notification: { method: string; params?: unknown }, + ) => void; + } + ).handleServerNotification.bind(manager); + + handleServerNotification(context, { + method: "item/completed", + params: { + item: { + id: "item_msg_1", + type: "agentMessage", + text: "Final response", + }, + }, + }); + + expect(emittedMethods).toEqual(["item/completed"]); + }); +}); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 912f9b2db867aabd380a17fa2e83ab348dc97dd7..b6cd09956f8299f248b3dfce933b8e116b8892c5 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -584,6 +584,10 @@ export class CodexAppServerManager extends EventEmitter 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); +});