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/TODO.md b/TODO.md index e6ff5a6b7e4a04433c7bd84abe54fca6d56bb127..cf8656655e8eea82dc195d411a51a85cf2cc2150 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ ## Small things +- [ ] Plan mode +- [ ] Submitting new messages should scroll to bottom - [ ] 1 click copy in codeblocks - [x] Pasting/adding images to submit with prompts - [ ] Only show last 10 threads for a given project @@ -12,7 +14,7 @@ - [x] "Response" bar should ONLY appear on work completed - [x] "Response" bar should ONLY appear above the FINAL message in a series - [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc) -- [ ] Insta focus input on new thread creation +- [x] Insta focus input on new thread creation - [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it - [x] Making new thread should auto-focus input box - [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank) diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index acb4535b60e5107bc2fac12705d43e40b23c2d18..45b848af6524e510fc183d1ac21891d95444e769 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1 +1,2 @@ dist-electron/ +.electron-runtime/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d6afd21ce940cbbd337504fb545c19c59ed95e2c..65a23524587ac523734734ec9214e235c534d433 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,13 +2,14 @@ "name": "@t3tools/desktop", "version": "0.0.0", "private": true, + "productName": "T3 Code (Alpha)", "main": "dist-electron/main.js", "scripts": { "dev": "bun run --parallel dev:bundle dev:electron", "dev:bundle": "tsdown --watch", "dev:electron": "bun run scripts/dev-electron.mjs", "build": "tsdown", - "start": "electron dist-electron/main.js", + "start": "bun run scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/resources/icon.icns b/apps/desktop/resources/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..da16d12a0c7cb97f6a77c382f1a07c52a9457b16 Binary files /dev/null and b/apps/desktop/resources/icon.icns differ diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8298f70d8b3690d1103d712c37d13a1d17fcec8d Binary files /dev/null and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..37f3f756a553db1520e7bde5f928bc7a68cd1bdf Binary files /dev/null and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index be7d8380fb7172b0d35436d3e8b4a714f3d13810..03ac96b5a070e5e3a12acb02693ef27bc678c672 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,7 +1,10 @@ -import { spawn } from "node:child_process"; +import { spawnSync } from "node:child_process"; +import electronmon from "electronmon"; import waitOn from "wait-on"; +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); const devServerUrl = `http://localhost:${port}`; @@ -14,18 +17,57 @@ await waitOn({ ], }); -const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(command, ["dist-electron/main.js"], { - stdio: "inherit", +const monitor = await electronmon({ + cwd: desktopDir, + args: ["dist-electron/main.js"], env: { ...childEnv, VITE_DEV_SERVER_URL: devServerUrl, }, + electronPath: resolveElectronPath(), }); -child.on("exit", (code) => { - process.exit(code ?? 0); +let shuttingDown = false; + +function killChildTree(signal) { + if (process.platform === "win32") { + return; + } + + // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. + spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); +} + +async function shutdown(exitCode) { + if (shuttingDown) return; + shuttingDown = true; + + try { + await Promise.race([ + monitor.destroy(), + new Promise((resolve) => setTimeout(resolve, 1_500)), + ]); + } catch { + // Best effort only; fallback process kill below handles stubborn children. + } + + killChildTree("TERM"); + setTimeout(() => { + killChildTree("KILL"); + }, 1_200).unref(); + + process.exit(exitCode); +} + +process.once("SIGINT", () => { + void shutdown(130); +}); +process.once("SIGTERM", () => { + void shutdown(143); +}); +process.once("SIGHUP", () => { + void shutdown(129); }); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2a6d685dea2032cc4de322aa03ba9bb46fa5110a --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -0,0 +1,173 @@ +// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" + +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_BUNDLE_ID = "com.t3tools.t3code"; +const LAUNCHER_VERSION = 1; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const desktopDir = resolve(__dirname, ".."); + +function setPlistString(plistPath, key, value) { + const replaceResult = spawnSync( + "plutil", + ["-replace", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (replaceResult.status === 0) { + return; + } + + const insertResult = spawnSync( + "plutil", + ["-insert", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (insertResult.status === 0) { + return; + } + + const details = [replaceResult.stderr, insertResult.stderr] + .filter(Boolean) + .join("\n"); + throw new Error( + `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), + ); +} + +function patchMainBundleInfoPlist(appBundlePath, iconPath) { + const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); + setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); + + const resourcesDir = join(appBundlePath, "Contents", "Resources"); + copyFileSync(iconPath, join(resourcesDir, "icon.icns")); + copyFileSync(iconPath, join(resourcesDir, "electron.icns")); +} + +function patchHelperBundleInfoPlists(appBundlePath) { + const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); + if (!existsSync(frameworksDir)) { + return; + } + + for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.endsWith(".app")) { + continue; + } + if (!entry.name.startsWith("Electron Helper")) { + continue; + } + + const helperPlistPath = join( + frameworksDir, + entry.name, + "Contents", + "Info.plist", + ); + if (!existsSync(helperPlistPath)) { + continue; + } + + const suffix = entry.name + .replace("Electron Helper", "") + .replace(".app", "") + .trim(); + const helperName = suffix + ? `${APP_DISPLAY_NAME} Helper ${suffix}` + : `${APP_DISPLAY_NAME} Helper`; + const helperIdSuffix = suffix + .replace(/[()]/g, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-"); + const helperBundleId = helperIdSuffix + ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` + : `${APP_BUNDLE_ID}.helper`; + + setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); + setPlistString(helperPlistPath, "CFBundleName", helperName); + setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); + } +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +function buildMacLauncher(electronBinaryPath) { + const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); + const runtimeDir = join(desktopDir, ".electron-runtime"); + const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); + const targetBinaryPath = join( + targetAppBundlePath, + "Contents", + "MacOS", + "Electron", + ); + const iconPath = join(desktopDir, "resources", "icon.icns"); + const metadataPath = join(runtimeDir, "metadata.json"); + + mkdirSync(runtimeDir, { recursive: true }); + + const expectedMetadata = { + launcherVersion: LAUNCHER_VERSION, + sourceAppBundlePath, + sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, + iconMtimeMs: statSync(iconPath).mtimeMs, + }; + + const currentMetadata = readJson(metadataPath); + if ( + existsSync(targetBinaryPath) && + currentMetadata && + JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) + ) { + return targetBinaryPath; + } + + rmSync(targetAppBundlePath, { recursive: true, force: true }); + cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); + patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchHelperBundleInfoPlists(targetAppBundlePath); + writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + + return targetBinaryPath; +} + +export function resolveElectronPath() { + const require = createRequire(import.meta.url); + const electronBinaryPath = require("electron"); + + if (process.platform !== "darwin") { + return electronBinaryPath; + } + + return buildMacLauncher(electronBinaryPath); +} diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bf93adb6b0db0c2b5658faa08d5fd9ec3f6ee9e7 --- /dev/null +++ b/apps/desktop/scripts/start-electron.mjs @@ -0,0 +1,20 @@ +import { spawn } from "node:child_process"; + +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + +const childEnv = { ...process.env }; +delete childEnv.ELECTRON_RUN_AS_NODE; + +const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { + stdio: "inherit", + cwd: desktopDir, + env: childEnv, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a1f7a9bf21349271a570135693c45bae2b90d7e2..792e78811ecf87fb1c5915145ac75ea6d9b7aa73 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,7 +5,16 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + type MenuItemConstructorOptions, + protocol, + shell, +} from "electron"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; @@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -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 MENU_ACTION_CHANNEL = "desktop:menu-action"; 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"; let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess | null = null; @@ -30,6 +41,235 @@ 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]; + const targetWindow = existingWindow ?? createWindow(); + if (!existingWindow) { + mainWindow = targetWindow; + } + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); + if (!targetWindow.isVisible()) { + targetWindow.show(); + } + targetWindow.focus(); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); +} + +function configureApplicationMenu(): void { + const template: MenuItemConstructorOptions[] = []; + + if (process.platform === "darwin") { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings"), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + template.push( + { + label: "File", + submenu: [ + ...(process.platform === "darwin" + ? [] + : [ + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings"), + }, + { type: "separator" as const }, + ]), + { role: process.platform === "darwin" ? "close" : "quit" }, + ], + }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + ); + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +function resolveResourcePath(fileName: string): string | null { + const candidates = [ + path.join(__dirname, "../resources", fileName), + path.join(process.resourcesPath, "resources", fileName), + path.join(process.resourcesPath, fileName), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { + return resolveResourcePath(`icon.${ext}`); +} + +function configureAppIdentity(): void { + app.setName(APP_DISPLAY_NAME); + app.setAboutPanelOptions({ + applicationName: APP_DISPLAY_NAME, + }); + + if (process.platform === "win32") { + app.setAppUserModelId(APP_USER_MODEL_ID); + } + + if (process.platform === "darwin" && app.dock) { + const iconPath = resolveIconPath("png"); + if (iconPath) { + app.dock.setIcon(iconPath); + } + } +} async function reserveLoopbackPort(): Promise { return new Promise((resolve, reject) => { @@ -76,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: { @@ -124,10 +365,10 @@ function stopBackend(): void { backendProcess = null; if (!child) return; - if (!child.killed) { + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); setTimeout(() => { - if (!child.killed) { + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); } }, 2_000).unref(); @@ -204,6 +445,13 @@ function registerIpcHandlers(): void { }); } +function getIconOption(): { icon: string } | Record { + if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = process.platform === "win32" ? "ico" : "png"; + const iconPath = resolveIconPath(ext); + return iconPath ? { icon: iconPath } : {}; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -212,6 +460,8 @@ function createWindow(): BrowserWindow { minHeight: 620, show: false, autoHideMenuBar: true, + ...getIconOption(), + title: APP_DISPLAY_NAME, titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, webPreferences: { @@ -223,6 +473,13 @@ function createWindow(): BrowserWindow { }); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.on("page-title-updated", (event) => { + event.preventDefault(); + window.setTitle(APP_DISPLAY_NAME); + }); + window.webContents.on("did-finish-load", () => { + window.setTitle(APP_DISPLAY_NAME); + }); window.once("ready-to-show", () => { window.show(); }); @@ -231,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", () => { @@ -246,6 +500,8 @@ function createWindow(): BrowserWindow { return window; } +configureAppIdentity(); + async function bootstrap(): Promise { backendPort = await reserveLoopbackPort(); backendAuthToken = randomBytes(24).toString("hex"); @@ -263,6 +519,9 @@ app.on("before-quit", () => { }); app.whenReady().then(() => { + configureAppIdentity(); + configureApplicationMenu(); + registerDesktopProtocol(); void bootstrap(); app.on("activate", () => { @@ -277,3 +536,19 @@ app.on("window-all-closed", () => { app.quit(); } }); + +if (process.platform !== "win32") { + process.on("SIGINT", () => { + if (isQuitting) return; + isQuitting = true; + stopBackend(); + app.quit(); + }); + + process.on("SIGTERM", () => { + if (isQuitting) return; + isQuitting = true; + stopBackend(); + app.quit(); + }); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index fb90831fc0ded445d5ba9dcd85ef58c9b9c7a4c0..464832b33d307420860bfd9d90a6977d9b9d7247 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; 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 wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { @@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { showContextMenu: (items: { id: string; label: string }[]) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise, openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise, + onMenuAction: (listener: (action: string) => void) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { + if (typeof action !== "string") return; + listener(action); + }; + + ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); + }; + }, }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 6122ec601f61e1415abcba35662c7a831c1ffebd..912f9b2db867aabd380a17fa2e83ab348dc97dd7 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -163,9 +163,14 @@ export class CodexAppServerManager extends EventEmitter + + - T3 Code + T3 Code (Alpha)
diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..43dc5d21602cc217500121410827b603a6a6fe5e Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..1636b52162945be4ea63d866a9cb22ae68bb4ed0 Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b84a6ff27a093136a3511018cd15b6bb6bb9fff5 Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8298f70d8b3690d1103d712c37d13a1d17fcec8d Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..61b48db5af1a537cabab28a692c702b39a046e79 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd3da7224c7855b1c2cd446925bb5bcbe663e479 --- /dev/null +++ b/apps/web/src/appSettings.ts @@ -0,0 +1,109 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { z } from "zod"; + +const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; + +const appSettingsSchema = z.object({ + codexBinaryPath: z.string().trim().max(4096).default(""), + codexHomePath: z.string().trim().max(4096).default(""), + confirmThreadDelete: z.boolean().default(true), +}); + +export type AppSettings = z.infer; + +const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); + +let listeners: Array<() => void> = []; +let cachedRawSettings: string | null | undefined; +let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; + +function emitChange(): void { + for (const listener of listeners) { + listener(); + } +} + +function parsePersistedSettings(value: string | null): AppSettings { + if (!value) { + return DEFAULT_APP_SETTINGS; + } + + try { + const parsed = JSON.parse(value) as unknown; + return appSettingsSchema.parse(parsed); + } catch { + return DEFAULT_APP_SETTINGS; + } +} + +export function getAppSettingsSnapshot(): AppSettings { + if (typeof window === "undefined") { + return DEFAULT_APP_SETTINGS; + } + + const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); + if (raw === cachedRawSettings) { + return cachedSnapshot; + } + + cachedRawSettings = raw; + cachedSnapshot = parsePersistedSettings(raw); + return cachedSnapshot; +} + +function persistSettings(next: AppSettings): void { + if (typeof window === "undefined") return; + + const raw = JSON.stringify(next); + try { + if (raw !== cachedRawSettings) { + window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); + } + } catch { + // Best-effort persistence only. + } + + cachedRawSettings = raw; + cachedSnapshot = next; +} + +function subscribe(listener: () => void): () => void { + listeners.push(listener); + + const onStorage = (event: StorageEvent) => { + if (event.key === APP_SETTINGS_STORAGE_KEY) { + emitChange(); + } + }; + + window.addEventListener("storage", onStorage); + return () => { + listeners = listeners.filter((entry) => entry !== listener); + window.removeEventListener("storage", onStorage); + }; +} + +export function useAppSettings() { + const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); + + const updateSettings = useCallback((patch: Partial) => { + const next = appSettingsSchema.parse({ + ...getAppSettingsSnapshot(), + ...patch, + }); + persistSettings(next); + emitChange(); + }, []); + + const resetSettings = useCallback(() => { + persistSettings(DEFAULT_APP_SETTINGS); + emitChange(); + }, []); + + return { + settings, + updateSettings, + resetSettings, + defaults: DEFAULT_APP_SETTINGS, + } as const; +} diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts new file mode 100644 index 0000000000000000000000000000000000000000..4adc50bc1932e991c7c6d730c982305f5036fdc2 --- /dev/null +++ b/apps/web/src/branding.ts @@ -0,0 +1,3 @@ +export const APP_BASE_NAME = "T3 Code"; +export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; +export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4a374b6d67f08521cda82cdd6a053e1dca5e5a01..92a6a7392854bf12d7a1a033bb5dc2b57c3ccc11 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -37,6 +37,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useAppSettings } from "../appSettings"; import { buildBootstrapInput } from "../historyBootstrap"; import { type ComposerTriggerKind, @@ -380,6 +381,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const rawSearch = useSearch({ strict: false }); const api = useNativeApi(); const { resolvedTheme } = useTheme(); + const { settings: appSettings } = useAppSettings(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), @@ -651,6 +653,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; const runtimeSandboxMode = state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; + const codexBinaryPath = appSettings.codexBinaryPath.trim(); + const codexHomePath = appSettings.codexHomePath.trim(); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTrigger = useMemo( () => detectComposerTrigger(prompt, composerCursor), @@ -1524,6 +1528,8 @@ export default function ChatView({ threadId }: ChatViewProps) { cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, model: selectedModel || undefined, resumeThreadId: priorCodexThreadId ?? undefined, + ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), + ...(codexHomePath.length > 0 ? { codexHomePath } : {}), approvalPolicy: runtimeApprovalPolicy, sandboxMode: runtimeSandboxMode, }); @@ -1564,6 +1570,8 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, api, + codexBinaryPath, + codexHomePath, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 71a6e02ce87164ecbdfb4a91eaf977ce7455268a..9291843ea9d07e92cbfa44fc8782afe5a55e69dd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,10 +1,11 @@ -import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; +import { TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; -import { useTheme } from "../hooks/useTheme"; +import { APP_STAGE_LABEL } from "../branding"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; @@ -17,7 +18,6 @@ import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { createThread } from "../threadFactory"; -const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function formatRelativeTime(iso: string): string { @@ -116,6 +116,7 @@ export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); const navigate = useNavigate(); + const { settings: appSettings } = useAppSettings(); const params = useParams({ strict: false }); const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ @@ -126,7 +127,6 @@ export default function Sidebar() { const removeWorktreeMutation = useMutation( gitRemoveWorktreeMutationOptions({ api, queryClient }), ); - const { theme, setTheme } = useTheme(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); @@ -271,6 +271,17 @@ export default function Sidebar() { const thread = state.threads.find((t) => t.id === threadId); if (!thread) return; + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread \"${thread.title}\"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } const threadProject = state.projects.find((project) => project.id === thread.projectId); const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); const displayWorktreePath = orphanedWorktreePath @@ -348,7 +359,16 @@ export default function Sidebar() { }); } }, - [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads], + [ + api, + appSettings.confirmThreadDelete, + dispatch, + navigate, + removeWorktreeMutation, + routeThreadId, + state.projects, + state.threads, + ], ); const handleProjectContextMenu = useCallback( @@ -431,24 +451,14 @@ export default function Sidebar() {
- - T3 Code - - +
+ + T3 Code + + + {APP_STAGE_LABEL} + +
{/* New thread (global) */} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index baa3fea4f4a3866c9dd5e156980d89a357964e65..0de3ae3c4e7eaa62816048e3cf971aa30e84cea5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,12 +9,14 @@ import "@xterm/xterm/css/xterm.css"; import "highlight.js/styles/github-dark.css"; import "./index.css"; +import { APP_DISPLAY_NAME } from "./branding"; import { isElectron } from "./env"; import { routeTree } from "./routeTree.gen"; const history = isElectron ? createHashHistory() : createBrowserHistory(); const queryClient = new QueryClient(); +document.title = APP_DISPLAY_NAME; const router = createRouter({ routeTree, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a005a05e5ac23fb035c653563cd788382b8edc1f..880d5ef64ba441142945fdfe91d85a9e897d7dbe 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' const ChatRoute = ChatRouteImport.update({ @@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const ChatSettingsRoute = ChatSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ChatRoute, +} as any) const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ id: '/$threadId', path: '/$threadId', @@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/$threadId': typeof ChatThreadIdRoute + '/settings': typeof ChatSettingsRoute } export interface FileRoutesByTo { '/$threadId': typeof ChatThreadIdRoute + '/settings': typeof ChatSettingsRoute '/': typeof ChatIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/_chat/$threadId': typeof ChatThreadIdRoute + '/_chat/settings': typeof ChatSettingsRoute '/_chat/': typeof ChatIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$threadId' + fullPaths: '/' | '/$threadId' | '/settings' fileRoutesByTo: FileRoutesByTo - to: '/$threadId' | '/' - id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/' + to: '/$threadId' | '/settings' | '/' + id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -70,6 +79,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/_chat/settings': { + id: '/_chat/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof ChatSettingsRouteImport + parentRoute: typeof ChatRoute + } '/_chat/$threadId': { id: '/_chat/$threadId' path: '/$threadId' @@ -82,11 +98,13 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatThreadIdRoute: typeof ChatThreadIdRoute + ChatSettingsRoute: typeof ChatSettingsRoute ChatIndexRoute: typeof ChatIndexRoute } const ChatRouteChildren: ChatRouteChildren = { ChatThreadIdRoute: ChatThreadIdRoute, + ChatSettingsRoute: ChatSettingsRoute, ChatIndexRoute: ChatIndexRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index bfac06dff13a5ce399d898691c917c9d5f68d3db..78b887c6fec38cb6983a6fefa94925f80d1fc81b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -9,6 +9,7 @@ import { invalidateGitQueries } from "../lib/gitReactQuery"; import { DEFAULT_MODEL } from "../model-logic"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; +import { APP_DISPLAY_NAME } from "../branding"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -23,7 +24,9 @@ function RootRouteView() { return (
-

Connecting to T3 Code server...

+

+ Connecting to {APP_DISPLAY_NAME} server... +

); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..740707addd9e71f622f499025f2ce1e8e680736c --- /dev/null +++ b/apps/web/src/routes/_chat.settings.tsx @@ -0,0 +1,259 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { useAppSettings } from "../appSettings"; +import { isElectron } from "../env"; +import { useTheme } from "../hooks/useTheme"; +import { useStore } from "../store"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Switch } from "../components/ui/switch"; + +const THEME_OPTIONS = [ + { + value: "system", + label: "System", + description: "Match your OS appearance setting.", + }, + { + value: "light", + label: "Light", + description: "Always use the light theme.", + }, + { + value: "dark", + label: "Dark", + description: "Always use the dark theme.", + }, +] as const; + +const RUNTIME_MODE_OPTIONS = [ + { + value: "full-access", + label: "Full access", + description: "Allow commands to run without confirmation prompts.", + }, + { + value: "approval-required", + label: "Supervised", + description: "Require approval prompts before command execution.", + }, +] as const; + +function SettingsRouteView() { + const { theme, setTheme, resolvedTheme } = useTheme(); + const { state, dispatch } = useStore(); + const { settings, defaults, updateSettings } = useAppSettings(); + + const codexBinaryPath = settings.codexBinaryPath; + const codexHomePath = settings.codexHomePath; + + return ( +
+ {isElectron && ( +
+ Settings +
+ )} + +
+
+
+

Settings

+

+ Configure app-level preferences for this device. +

+
+ +
+
+

Appearance

+

+ Choose how T3 Code handles light and dark mode. +

+
+ +
+ {THEME_OPTIONS.map((option) => { + const selected = theme === option.value; + return ( + + ); + })} +
+ +

+ Active theme: {resolvedTheme} +

+
+ +
+
+

Codex App Server

+

+ These overrides apply to new sessions and let you use a non-default Codex install. +

+
+ +
+ + + + +
+

+ Binary source: {codexBinaryPath || "PATH"} +

+ +
+
+
+ +
+
+

Runtime Mode

+

+ Select the default execution policy for this client. +

+
+ +
+ {RUNTIME_MODE_OPTIONS.map((option) => { + const selected = state.runtimeMode === option.value; + return ( + + ); + })} +
+
+ +
+
+

Safety

+

+ Additional guardrails for destructive local actions. +

+
+ +
+
+

Confirm thread deletion

+

+ Ask for confirmation before deleting a thread and its chat history. +

+
+ + updateSettings({ + confirmThreadDelete: Boolean(checked), + }) + } + aria-label="Confirm thread deletion" + /> +
+ + {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +} + +export const Route = createFileRoute("/_chat/settings")({ + component: SettingsRouteView, +}); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 6f766e44f1db3a5f4d90a09e7f61586c89ebf339..36c14455986a5adbbb5309100b92eeb1e85d03a2 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,9 +1,28 @@ -import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import Sidebar from "../components/Sidebar"; function ChatRouteLayout() { + const navigate = useNavigate(); + + useEffect(() => { + const onMenuAction = window.desktopBridge?.onMenuAction; + if (typeof onMenuAction !== "function") { + return; + } + + const unsubscribe = onMenuAction((action) => { + if (action !== "open-settings") return; + void navigate({ to: "/settings" }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate]); + return (
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 3438d5d7b236ef604eb8111f1a531ddaefd8ea47..93076c718b369f48f230c54b31ed5d9ff033f994 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -8,6 +8,7 @@ interface DesktopBridge { confirm: (message: string) => Promise; showContextMenu: (items: readonly { id: string; label: string }[]) => Promise; openExternal: (url: string) => Promise; + onMenuAction: (listener: (action: string) => void) => () => void; } declare global { diff --git a/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..f01a7b8813dfa98e6c7eed39fe8e5d0eb923b97e Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png differ diff --git a/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8dba03e01fe1bb2c33d82836236899cd47016ca4 Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png differ diff --git a/assets/T3 Code logo.svg b/assets/T3 Code logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..61b48db5af1a537cabab28a692c702b39a046e79 --- /dev/null +++ b/assets/T3 Code logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/T3 Code logo@16x.png b/assets/T3 Code logo@16x.png new file mode 100644 index 0000000000000000000000000000000000000000..87d9f008c687041b72ccaab2833e0d17915b828c Binary files /dev/null and b/assets/T3 Code logo@16x.png differ diff --git a/assets/T3 Code logo@32x.png b/assets/T3 Code logo@32x.png new file mode 100644 index 0000000000000000000000000000000000000000..d75373862e46463a3990e86ff110e8b65dc1fa7d Binary files /dev/null and b/assets/T3 Code logo@32x.png differ diff --git a/assets/T3 Code logo@8x.png b/assets/T3 Code logo@8x.png new file mode 100644 index 0000000000000000000000000000000000000000..88317dd41fbabe19ea69d8ba0308ed08bca12887 Binary files /dev/null and b/assets/T3 Code logo@8x.png differ diff --git a/assets/T3 Code-macOS-Default-1024x1024@1x.png b/assets/T3 Code-macOS-Default-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..0a6e1cbfcff3433a3a96d97f3b7431292e1a96c1 Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@1x.png differ diff --git a/assets/T3 Code-macOS-Default-1024x1024@4x.png b/assets/T3 Code-macOS-Default-1024x1024@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..d426376b135c9ffaf9c3042d7b7392c8408438a0 Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@4x.png differ diff --git a/assets/logo-1024.png b/assets/logo-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..88317dd41fbabe19ea69d8ba0308ed08bca12887 Binary files /dev/null and b/assets/logo-1024.png differ diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..61b48db5af1a537cabab28a692c702b39a046e79 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/macos-icon-1024.png b/assets/macos-icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..0a6e1cbfcff3433a3a96d97f3b7431292e1a96c1 Binary files /dev/null and b/assets/macos-icon-1024.png differ diff --git a/package.json b/package.json index de8469e2c903b885521f05ddd0b55071f9a2cbc5..23e401648c212b9763e1aab2a821fad8951f10cb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", "build:contracts": "turbo run build --filter=@t3tools/contracts", + "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs", + "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64", + "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index df9c8e9b3d83f41dab157301e068cd6be519f516..15910a432b3bc51f29082749f2b51500f3a0f852 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => { }), ).toThrow(); }); + + it("accepts optional codex binary and home path overrides", () => { + const parsed = providerSessionStartInputSchema.parse({ + codexBinaryPath: "/opt/codex/bin/codex", + codexHomePath: "/Users/theo/.codex", + }); + expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex"); + expect(parsed.codexHomePath).toBe("/Users/theo/.codex"); + }); + + it("rejects blank codex overrides", () => { + expect(() => + providerSessionStartInputSchema.parse({ + codexBinaryPath: " ", + }), + ).toThrow(); + expect(() => + providerSessionStartInputSchema.parse({ + codexHomePath: " ", + }), + ).toThrow(); + }); }); describe("providerSendTurnInputSchema", () => { diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 65877a6a11fda30af49afad943263dcbe321bc23..06a7582a8b2de5d8ef3afde0e780b0130ccd56a1 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({ cwd: z.string().min(1).optional(), model: z.string().trim().min(1).optional(), resumeThreadId: z.string().trim().min(1).optional(), + codexBinaryPath: z.string().trim().min(1).optional(), + codexHomePath: z.string().trim().min(1).optional(), approvalPolicy: providerApprovalPolicySchema.default("never"), sandboxMode: providerSandboxModeSchema.default("workspace-write"), }); diff --git a/scripts/build-desktop-dmg.mjs b/scripts/build-desktop-dmg.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ffbd44dc44d17bc9fc48dd08decae699eec77d38 --- /dev/null +++ b/scripts/build-desktop-dmg.mjs @@ -0,0 +1,343 @@ +#!/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); +});