diff --git a/.gitignore b/.gitignore index 778aad55..6f457901 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ playwright-report/ coverage/ .channels/ +.codemux-dev/ continuous-analysis/ .settings.json diff --git a/README.md b/README.md index 7524192b..0ecabce9 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,36 @@ bun run update:cloudflared bun run dev ``` +### Isolated Electron Dev Instances + +Use isolated dev mode when you want to debug CodeMux from a separate git worktree without interrupting your normal running app session: + +```bash +bun run dev:isolated +``` + +This keeps `bun run dev` unchanged. The isolated command writes all Electron app state for that worktree under a gitignored `.codemux-dev/` directory: + +```text +.codemux-dev/ + userData/ + sessionData/ + logs/ + ports.json +``` + +Each worktree can have its own `.codemux-dev/` directory and port allocation. The first run automatically reserves a non-default port offset and stores it in `.codemux-dev/ports.json`; later runs reuse that saved offset so the same worktree stays stable. If the saved ports become stale or conflict with another process, stop the existing isolated instance, delete `.codemux-dev/ports.json` to reallocate, or set an explicit `CODEMUX_PORT_OFFSET`. + +Supported port overrides: + +- `CODEMUX_PORT_OFFSET` +- `CODEMUX_WEB_PORT` +- `CODEMUX_WEB_STANDALONE_PORT` +- `CODEMUX_GATEWAY_PORT` +- `CODEMUX_OPENCODE_PORT` +- `CODEMUX_AUTH_API_PORT` +- `CODEMUX_WEBHOOK_PORT` + ### Linux Server / Headless Dev On headless Linux hosts, `bun run dev` may exit early because Electron needs a virtual display, a DBus session, and a correctly configured `chrome-sandbox` helper. This repo includes Linux server helpers for that workflow: diff --git a/bun.lock b/bun.lock index 6ce7b285..d57c8f34 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "@types/ws": "^8.18.1", "@vitest/coverage-v8": "4.0.18", "bun-types": "latest", + "cross-env": "^10.1.0", "electron": "^40.6.1", "electron-builder": "^26.0.0", "electron-vite": "^3.1.0", @@ -144,6 +145,8 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -718,6 +721,8 @@ "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], + "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], @@ -1038,7 +1043,7 @@ "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], - "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -1578,7 +1583,7 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], @@ -1696,6 +1701,8 @@ "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1708,8 +1715,6 @@ "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -1736,6 +1741,8 @@ "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "path-scurry/lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], @@ -1806,6 +1813,8 @@ "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1814,8 +1823,6 @@ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -1834,6 +1841,8 @@ "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 8925d0a4..a2c1f096 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -6,6 +6,7 @@ import { resolve } from 'path'; import { createLogger } from 'vite'; import { createAuthProxyPlugin } from './scripts/auth-proxy-plugin'; import { tunnelManager } from './scripts/tunnel-manager'; +import { GATEWAY_PORT, OPENCODE_PORT, WEBHOOK_PORT, WEB_PORT } from './shared/ports'; // Custom logger that suppresses proxy errors during startup const logger = createLogger(); @@ -70,7 +71,7 @@ export default defineConfig({ // Proxy auth/device API requests to Electron's internal Auth API server createAuthProxyPlugin({ tunnelManager, - defaultPort: 8233, + defaultPort: WEB_PORT, }), ], resolve: { @@ -81,17 +82,17 @@ export default defineConfig({ server: { hmr: false, host: true, - port: 8233, + port: WEB_PORT, allowedHosts: true, proxy: { // Proxy Gateway WebSocket to the Gateway server '/ws': { - target: 'http://localhost:4200', + target: `http://localhost:${GATEWAY_PORT}`, ws: true, }, // Proxy OpenCode API requests to the OpenCode server '/opencode-api': { - target: 'http://localhost:4096', + target: `http://localhost:${OPENCODE_PORT}`, changeOrigin: true, rewrite: (path) => path.replace(/^\/opencode-api/, ''), // Handle SSE connections properly @@ -99,11 +100,11 @@ export default defineConfig({ }, // Proxy webhook endpoints to the WebhookServer '/api/messages': { - target: 'http://localhost:4098', + target: `http://localhost:${WEBHOOK_PORT}`, changeOrigin: true, }, '/webhook': { - target: 'http://localhost:4098', + target: `http://localhost:${WEBHOOK_PORT}`, changeOrigin: true, }, }, diff --git a/electron/main/app-main.ts b/electron/main/app-main.ts new file mode 100644 index 00000000..1a31a390 --- /dev/null +++ b/electron/main/app-main.ts @@ -0,0 +1,306 @@ +import { app, BrowserWindow } from "electron"; +import { shellEnvSync } from "shell-env"; +import { mainLog } from "./services/logger"; +import { unwatchAll } from "./services/file-service"; +// dev restart trigger + +// Load the user's full login-shell environment for packaged macOS/Linux apps. +// GUI-launched apps inherit a minimal environment from launchd, missing vars +// defined in ~/.zshrc / ~/.bashrc (e.g. PATH, ANTHROPIC_API_KEY). +// shell-env spawns a login shell to capture the complete env. +// On Windows this is a no-op (returns process.env as-is). +try { + const codemuxEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.startsWith("CODEMUX_")), + ); + Object.assign(process.env, shellEnvSync(), codemuxEnv); +} catch { + // Non-fatal: if shell-env fails (e.g. non-standard shell), continue with + // the existing environment. PATH and other vars may be incomplete. +} + +// Catch uncaught exceptions from child process stdio (EPIPE, etc.) +// Without this, Electron shows an error dialog and the app becomes unstable. +process.on("uncaughtException", (err) => { + // EPIPE occurs when writing to a child process whose stdin is already closed + // (e.g. engine CLI exits before SDK finishes writing). Safe to suppress. + if ((err as NodeJS.ErrnoException).code === "EPIPE") { + mainLog.warn("Suppressed EPIPE error:", err.message); + return; + } + mainLog.error("Uncaught exception:", err); + // Non-EPIPE uncaught exceptions leave the process in undefined state — exit gracefully + app.exit(1); +}); +import { createWindow, getMainWindow } from "./window-manager"; +import { registerIpcHandlers } from "./ipc-handlers"; +import { deviceStore } from "./services/device-store"; +import { conversationStore } from "./services/conversation-store"; +import { authApiServer } from "./services/auth-api-server"; +import { productionServer } from "./services/production-server"; +import { EngineManager } from "./gateway/engine-manager"; +import { GatewayServer } from "./gateway/ws-server"; +import { OpenCodeAdapter } from "./engines/opencode"; +import { CopilotSdkAdapter } from "./engines/copilot"; +import { ClaudeCodeAdapter } from "./engines/claude"; +import { CodexAdapter } from "./engines/codex"; +import { ChannelManager } from "./channels/channel-manager"; +import { WebhookServer } from "./channels/webhook-server"; +import { FeishuAdapter } from "./channels/feishu/feishu-adapter"; +import { DingTalkAdapter } from "./channels/dingtalk/dingtalk-adapter"; +import { TelegramAdapter } from "./channels/telegram/telegram-adapter"; +import { WeComAdapter } from "./channels/wecom/wecom-adapter"; +import { TeamsAdapter } from "./channels/teams/teams-adapter"; +import { WeixinIlinkAdapter } from "./channels/weixin-ilink/weixin-ilink-adapter"; +import { updateManager } from "./services/update-manager"; +import { trayManager } from "./services/tray-manager"; +import { scheduledTaskService } from "./services/scheduled-task-service"; +import { ensureDefaultWorkspace } from "./services/default-workspace"; +import { GATEWAY_PORT, OPENCODE_PORT, WEBHOOK_PORT, WEB_PORT } from "../../shared/ports"; + +// --- Gateway singleton instances --- +const engineManager = new EngineManager(); +const gatewayServer = new GatewayServer(engineManager); + +// Register engine adapters +const openCodeAdapter = new OpenCodeAdapter({ port: OPENCODE_PORT }); +const copilotAdapter = new CopilotSdkAdapter(); +const claudeAdapter = new ClaudeCodeAdapter(); +const codexAdapter = new CodexAdapter(); +engineManager.registerAdapter(openCodeAdapter); +engineManager.registerAdapter(copilotAdapter); +engineManager.registerAdapter(claudeAdapter); +engineManager.registerAdapter(codexAdapter); + +// Export for IPC handlers +export { engineManager, gatewayServer }; + +// --- Channel Manager --- +const channelManager = new ChannelManager(); +const webhookServer = new WebhookServer(WEBHOOK_PORT); +channelManager.setWebhookServer(webhookServer); + +// Register all channel adapters +channelManager.registerAdapter(new FeishuAdapter()); +channelManager.registerAdapter(new DingTalkAdapter()); +channelManager.registerAdapter(new TelegramAdapter()); +channelManager.registerAdapter(new WeComAdapter()); +channelManager.registerAdapter(new TeamsAdapter()); +channelManager.registerAdapter(new WeixinIlinkAdapter()); + +// Export for IPC handlers +export { channelManager }; + +// Gateway WS port — imported from shared/ports + +// Startup readiness tracking +let startupReady = false; +export function isStartupReady(): boolean { + return startupReady; +} + +// Single instance lock +const gotTheLock = app.requestSingleInstanceLock(); + +// Track if we're already quitting to prevent double cleanup +let isQuitting = false; + +if (!gotTheLock) { + app.quit(); +} else { + app.on("second-instance", () => { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow.isVisible()) mainWindow.show(); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); + + app.whenReady().then(async () => { + // Ensure default workspace directory exists + ensureDefaultWorkspace(); + + // Initialize DeviceStore (needs to be after app ready) + deviceStore.init(); + + // Initialize ConversationStore (needs to be after app ready, before engines start) + conversationStore.init(); + + // Rebuild engine routing tables from persisted ConversationStore data + engineManager.initFromStore(); + + // Initialize scheduled task service (persistent desktop-level scheduled tasks) + scheduledTaskService.init(engineManager); + + // Register IPC handlers + registerIpcHandlers(); + + // In dev mode, start internal Auth API server + // Vite middleware will proxy requests to this server + if (!app.isPackaged) { + try { + await authApiServer.start(); + } catch (err) { + mainLog.error("Failed to start Auth API server:", err); + } + } else { + // In production mode, start the production HTTP server + // This is required for Cloudflare Tunnel to work + try { + const port = await productionServer.start(WEB_PORT); + mainLog.info(`Production server started on port ${port}`); + } catch (err) { + mainLog.error("Failed to start Production server:", err); + } + } + + // Start Gateway WebSocket server + try { + if (app.isPackaged && productionServer.isRunning()) { + // In production: attach to production server for single-port access through Cloudflare Tunnel + const httpServer = productionServer.getServer(); + if (httpServer) { + gatewayServer.start({ server: httpServer, path: "/ws" }); + mainLog.info("Gateway server attached to production server at /ws"); + } else { + gatewayServer.start({ port: GATEWAY_PORT }); + mainLog.info(`Gateway server started on port ${GATEWAY_PORT}`); + } + } else { + // In dev: standalone port + gatewayServer.start({ port: GATEWAY_PORT }); + mainLog.info(`Gateway server started on port ${GATEWAY_PORT}`); + } + } catch (err) { + mainLog.error("Failed to start Gateway server:", err); + } + + // Start all engine adapters (non-blocking, don't delay window creation) + const enginePromises: Promise[] = []; + const engines = [ + ["OpenCode", openCodeAdapter], + ["Copilot", copilotAdapter], + ["Claude", claudeAdapter], + ["Codex", codexAdapter], + ] as const; + for (const [name, adapter] of engines) { + const p = (adapter as any).start().then( + () => mainLog.info(`${name} engine started successfully`), + (err: any) => mainLog.error(`${name} engine failed to start:`, err?.message ?? err), + ); + enginePromises.push(p); + } + + // Create main window + const isHiddenStart = process.argv.includes("--hidden"); + createWindow(isHiddenStart); + + // Initialize system tray + trayManager.init(); + + // Initialize auto-updater (only in packaged mode) + if (app.isPackaged) { + updateManager.init(); + } + + // Mark startup as ready once all engines have settled (success or failure) + Promise.allSettled(enginePromises).then(async () => { + mainLog.info("All engines settled"); + + const gatewayUrl = app.isPackaged && productionServer.isRunning() + ? `ws://127.0.0.1:${WEB_PORT}/ws` + : `ws://127.0.0.1:${GATEWAY_PORT}`; + + channelManager.setRuntimeOptions({ gatewayUrl }); + + try { + // Start the shared webhook HTTP server for channels that need it + // (Telegram, WeCom, Teams). Feishu and DingTalk use platform WSClient. + await webhookServer.start(); + mainLog.info(`Webhook server started on port ${webhookServer.serverPort}`); + } catch (err) { + mainLog.error("Failed to start channel webhook server:", err); + } + + // Initialize channels (after engines are ready and gateway is running) + try { + await channelManager.initFromConfig({ gatewayUrl }); + } catch (err) { + mainLog.error("Failed to initialize channels:", err); + } + + // Mark startup ready AFTER channels are initialized so the renderer + // sees final channel statuses when it (re-)polls on startup:ready. + startupReady = true; + mainLog.info("All engines and channels settled, startup ready"); + const win = getMainWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send("startup:ready"); + } + }); + + app.on("activate", () => { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show(); + mainWindow.focus(); + } else if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); + }); + + // On non-macOS platforms, quit when all windows are closed + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + // Cleanup before app quits + app.on("will-quit", async (event) => { + if (isQuitting) return; + isQuitting = true; + + // When installing an update, the updater needs the normal quit flow to + // complete (e.g. Squirrel.Mac swaps the app bundle and relaunches). + // Only do synchronous cleanup and let the quit proceed without + // preventDefault. + if (updateManager.isInstallingUpdate()) { + trayManager.destroy(); + await conversationStore.flushAll(); + await scheduledTaskService.shutdown(); + gatewayServer.stop(); + return; + } + + event.preventDefault(); + + try { + trayManager.destroy(); + + // Stop native file watchers early — @parcel/watcher uses NAPI threadsafe + // functions that must be torn down before Node.js module cleanup begins. + unwatchAll(); + + // Flush conversation store before quit + await conversationStore.flushAll(); + + await Promise.all([ + authApiServer.stop(), + channelManager.stopAll(), + webhookServer.stop(), + engineManager.stopAll(), + productionServer.stop(), + scheduledTaskService.shutdown(), + ]); + + gatewayServer.stop(); + } catch (err) { + mainLog.error("Cleanup error:", err); + } + + app.exit(0); + }); +} diff --git a/electron/main/channels/base-session-mapper.ts b/electron/main/channels/base-session-mapper.ts index 3deac653..34d63b95 100644 --- a/electron/main/channels/base-session-mapper.ts +++ b/electron/main/channels/base-session-mapper.ts @@ -6,10 +6,10 @@ import fs from "fs"; import path from "path"; -import { app } from "electron"; import type { EngineType, UnifiedProject, UnifiedSession } from "../../../src/types/unified"; import type { StreamingSession } from "./streaming/streaming-types"; import { channelLog } from "../services/logger"; +import { getChannelBindingsPath } from "../services/app-paths"; // --- Base Types (platform-agnostic) --- @@ -130,11 +130,9 @@ export class BaseSessionMapper< // --- Persistence --- private readonly channelType: string; - private readonly bindingsFileName: string; constructor(channelType: string, options?: { maxProcessedIds?: number }) { this.channelType = channelType; - this.bindingsFileName = `${channelType}-bindings.json`; this.maxProcessedIds = options?.maxProcessedIds ?? 1000; } @@ -143,8 +141,7 @@ export class BaseSessionMapper< // ========================================================================= private getBindingsFilePath(): string { - const dir = path.join(app.getPath("userData"), "channels"); - return path.join(dir, this.bindingsFileName); + return getChannelBindingsPath(this.channelType); } /** Convert a persisted binding to a runtime binding. Override for custom fields. */ diff --git a/electron/main/channels/channel-manager.ts b/electron/main/channels/channel-manager.ts index c4814343..5d8c788e 100644 --- a/electron/main/channels/channel-manager.ts +++ b/electron/main/channels/channel-manager.ts @@ -4,9 +4,8 @@ // ============================================================================ import fs from "fs"; -import path from "path"; -import { app } from "electron"; import { channelLog } from "../services/logger"; +import { getChannelConfigPath, getChannelsPath } from "../services/app-paths"; import { ChannelAdapter, type ChannelConfig, @@ -17,12 +16,11 @@ import type { WebhookServer } from "./webhook-server"; // --- Config persistence helpers --- function getChannelConfigDir(): string { - return path.join(app.getPath("userData"), "channels"); + return getChannelsPath(); } function loadConfig(channelType: string): ChannelConfig | null { - const dir = getChannelConfigDir(); - const filePath = path.join(dir, `${channelType}.json`); + const filePath = getChannelConfigPath(channelType); if (!fs.existsSync(filePath)) return null; try { const raw = fs.readFileSync(filePath, "utf-8"); @@ -37,7 +35,7 @@ function saveConfig(config: ChannelConfig): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - const filePath = path.join(dir, `${config.type}.json`); + const filePath = getChannelConfigPath(config.type); const tmpPath = `${filePath}.tmp`; // Exclude ephemeral runtime data (gatewayUrl) from persisted config diff --git a/electron/main/channels/feishu/feishu-session-mapper.ts b/electron/main/channels/feishu/feishu-session-mapper.ts index b1cbc2a9..ae9d013e 100644 --- a/electron/main/channels/feishu/feishu-session-mapper.ts +++ b/electron/main/channels/feishu/feishu-session-mapper.ts @@ -7,8 +7,8 @@ import fs from "fs"; import path from "path"; -import { app } from "electron"; import type { EngineType } from "../../../../src/types/unified"; +import { getChannelBindingsPath } from "../../services/app-paths"; import type { GroupBinding, P2PChatState, PendingQuestion, PendingSelection, StreamingSession, TempSession } from "./feishu-types"; import { feishuLog, type ScopedLogger } from "../../services/logger"; @@ -26,8 +26,7 @@ interface PersistedGroupBinding { } function getBindingsFilePath(): string { - const dir = path.join(app.getPath("userData"), "channels"); - return path.join(dir, "feishu-bindings.json"); + return getChannelBindingsPath("feishu"); } export class FeishuSessionMapper { diff --git a/electron/main/gateway/ws-server.ts b/electron/main/gateway/ws-server.ts index 30ba84dd..1d2d3d26 100644 --- a/electron/main/gateway/ws-server.ts +++ b/electron/main/gateway/ws-server.ts @@ -18,6 +18,7 @@ import log from "../services/logger"; import { conversationStore } from "../services/conversation-store"; import { scheduledTaskService } from "../services/scheduled-task-service"; import { orchestratorService } from "../services/orchestrator-service"; +import { getSettingsPath } from "../services/app-paths"; import { GatewayRequestType, GatewayNotificationType, @@ -244,11 +245,7 @@ export class GatewayServer { private isWorktreeEnabled(): boolean { try { - const settingsPath = require("path").join( - require("electron").app.getPath("userData"), - "settings.json", - ); - const raw = require("fs").readFileSync(settingsPath, "utf-8"); + const raw = require("fs").readFileSync(getSettingsPath(), "utf-8"); const settings = JSON.parse(raw); return settings.worktreeEnabled === true; } catch { diff --git a/electron/main/index.ts b/electron/main/index.ts index 32f897e3..8f5fef83 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,303 +1,5 @@ -import { app, BrowserWindow } from "electron"; -import { shellEnvSync } from "shell-env"; -import { mainLog } from "./services/logger"; -import { unwatchAll } from "./services/file-service"; -// dev restart trigger +import { configureDevIsolatedAppPaths } from "./services/app-paths"; -// Load the user's full login-shell environment for packaged macOS/Linux apps. -// GUI-launched apps inherit a minimal environment from launchd, missing vars -// defined in ~/.zshrc / ~/.bashrc (e.g. PATH, ANTHROPIC_API_KEY). -// shell-env spawns a login shell to capture the complete env. -// On Windows this is a no-op (returns process.env as-is). -try { - Object.assign(process.env, shellEnvSync()); -} catch { - // Non-fatal: if shell-env fails (e.g. non-standard shell), continue with - // the existing environment. PATH and other vars may be incomplete. -} +configureDevIsolatedAppPaths(); -// Catch uncaught exceptions from child process stdio (EPIPE, etc.) -// Without this, Electron shows an error dialog and the app becomes unstable. -process.on("uncaughtException", (err) => { - // EPIPE occurs when writing to a child process whose stdin is already closed - // (e.g. engine CLI exits before SDK finishes writing). Safe to suppress. - if ((err as NodeJS.ErrnoException).code === "EPIPE") { - mainLog.warn("Suppressed EPIPE error:", err.message); - return; - } - mainLog.error("Uncaught exception:", err); - // Non-EPIPE uncaught exceptions leave the process in undefined state — exit gracefully - app.exit(1); -}); -import { createWindow, getMainWindow } from "./window-manager"; -import { registerIpcHandlers } from "./ipc-handlers"; -import { deviceStore } from "./services/device-store"; -import { conversationStore } from "./services/conversation-store"; -import { authApiServer } from "./services/auth-api-server"; -import { productionServer } from "./services/production-server"; -import { EngineManager } from "./gateway/engine-manager"; -import { GatewayServer } from "./gateway/ws-server"; -import { OpenCodeAdapter } from "./engines/opencode"; -import { CopilotSdkAdapter } from "./engines/copilot"; -import { ClaudeCodeAdapter } from "./engines/claude"; -import { CodexAdapter } from "./engines/codex"; -import { ChannelManager } from "./channels/channel-manager"; -import { WebhookServer } from "./channels/webhook-server"; -import { FeishuAdapter } from "./channels/feishu/feishu-adapter"; -import { DingTalkAdapter } from "./channels/dingtalk/dingtalk-adapter"; -import { TelegramAdapter } from "./channels/telegram/telegram-adapter"; -import { WeComAdapter } from "./channels/wecom/wecom-adapter"; -import { TeamsAdapter } from "./channels/teams/teams-adapter"; -import { WeixinIlinkAdapter } from "./channels/weixin-ilink/weixin-ilink-adapter"; -import { updateManager } from "./services/update-manager"; -import { trayManager } from "./services/tray-manager"; -import { scheduledTaskService } from "./services/scheduled-task-service"; -import { ensureDefaultWorkspace } from "./services/default-workspace"; -import { GATEWAY_PORT, OPENCODE_PORT, WEBHOOK_PORT, WEB_PORT } from "../../shared/ports"; - -// --- Gateway singleton instances --- -const engineManager = new EngineManager(); -const gatewayServer = new GatewayServer(engineManager); - -// Register engine adapters -const openCodeAdapter = new OpenCodeAdapter({ port: OPENCODE_PORT }); -const copilotAdapter = new CopilotSdkAdapter(); -const claudeAdapter = new ClaudeCodeAdapter(); -const codexAdapter = new CodexAdapter(); -engineManager.registerAdapter(openCodeAdapter); -engineManager.registerAdapter(copilotAdapter); -engineManager.registerAdapter(claudeAdapter); -engineManager.registerAdapter(codexAdapter); - -// Export for IPC handlers -export { engineManager, gatewayServer }; - -// --- Channel Manager --- -const channelManager = new ChannelManager(); -const webhookServer = new WebhookServer(WEBHOOK_PORT); -channelManager.setWebhookServer(webhookServer); - -// Register all channel adapters -channelManager.registerAdapter(new FeishuAdapter()); -channelManager.registerAdapter(new DingTalkAdapter()); -channelManager.registerAdapter(new TelegramAdapter()); -channelManager.registerAdapter(new WeComAdapter()); -channelManager.registerAdapter(new TeamsAdapter()); -channelManager.registerAdapter(new WeixinIlinkAdapter()); - -// Export for IPC handlers -export { channelManager }; - -// Gateway WS port — imported from shared/ports - -// Startup readiness tracking -let startupReady = false; -export function isStartupReady(): boolean { - return startupReady; -} - -// Single instance lock -const gotTheLock = app.requestSingleInstanceLock(); - -// Track if we're already quitting to prevent double cleanup -let isQuitting = false; - -if (!gotTheLock) { - app.quit(); -} else { - app.on("second-instance", () => { - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - if (!mainWindow.isVisible()) mainWindow.show(); - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } - }); - - app.whenReady().then(async () => { - // Ensure default workspace directory exists - ensureDefaultWorkspace(); - - // Initialize DeviceStore (needs to be after app ready) - deviceStore.init(); - - // Initialize ConversationStore (needs to be after app ready, before engines start) - conversationStore.init(); - - // Rebuild engine routing tables from persisted ConversationStore data - engineManager.initFromStore(); - - // Initialize scheduled task service (persistent desktop-level scheduled tasks) - scheduledTaskService.init(engineManager); - - // Register IPC handlers - registerIpcHandlers(); - - // In dev mode, start internal Auth API server - // Vite middleware will proxy requests to this server - if (!app.isPackaged) { - try { - await authApiServer.start(); - } catch (err) { - mainLog.error("Failed to start Auth API server:", err); - } - } else { - // In production mode, start the production HTTP server - // This is required for Cloudflare Tunnel to work - try { - const port = await productionServer.start(WEB_PORT); - mainLog.info(`Production server started on port ${port}`); - } catch (err) { - mainLog.error("Failed to start Production server:", err); - } - } - - // Start Gateway WebSocket server - try { - if (app.isPackaged && productionServer.isRunning()) { - // In production: attach to production server for single-port access through Cloudflare Tunnel - const httpServer = productionServer.getServer(); - if (httpServer) { - gatewayServer.start({ server: httpServer, path: "/ws" }); - mainLog.info("Gateway server attached to production server at /ws"); - } else { - gatewayServer.start({ port: GATEWAY_PORT }); - mainLog.info(`Gateway server started on port ${GATEWAY_PORT}`); - } - } else { - // In dev: standalone port - gatewayServer.start({ port: GATEWAY_PORT }); - mainLog.info(`Gateway server started on port ${GATEWAY_PORT}`); - } - } catch (err) { - mainLog.error("Failed to start Gateway server:", err); - } - - // Start all engine adapters (non-blocking, don't delay window creation) - const enginePromises: Promise[] = []; - const engines = [ - ["OpenCode", openCodeAdapter], - ["Copilot", copilotAdapter], - ["Claude", claudeAdapter], - ["Codex", codexAdapter], - ] as const; - for (const [name, adapter] of engines) { - const p = (adapter as any).start().then( - () => mainLog.info(`${name} engine started successfully`), - (err: any) => mainLog.error(`${name} engine failed to start:`, err?.message ?? err), - ); - enginePromises.push(p); - } - - // Create main window - const isHiddenStart = process.argv.includes("--hidden"); - createWindow(isHiddenStart); - - // Initialize system tray - trayManager.init(); - - // Initialize auto-updater (only in packaged mode) - if (app.isPackaged) { - updateManager.init(); - } - - // Mark startup as ready once all engines have settled (success or failure) - Promise.allSettled(enginePromises).then(async () => { - mainLog.info("All engines settled"); - - const gatewayUrl = app.isPackaged && productionServer.isRunning() - ? `ws://127.0.0.1:${WEB_PORT}/ws` - : `ws://127.0.0.1:${GATEWAY_PORT}`; - - channelManager.setRuntimeOptions({ gatewayUrl }); - - try { - // Start the shared webhook HTTP server for channels that need it - // (Telegram, WeCom, Teams). Feishu and DingTalk use platform WSClient. - await webhookServer.start(); - mainLog.info(`Webhook server started on port ${webhookServer.serverPort}`); - } catch (err) { - mainLog.error("Failed to start channel webhook server:", err); - } - - // Initialize channels (after engines are ready and gateway is running) - try { - await channelManager.initFromConfig({ gatewayUrl }); - } catch (err) { - mainLog.error("Failed to initialize channels:", err); - } - - // Mark startup ready AFTER channels are initialized so the renderer - // sees final channel statuses when it (re-)polls on startup:ready. - startupReady = true; - mainLog.info("All engines and channels settled, startup ready"); - const win = getMainWindow(); - if (win && !win.isDestroyed()) { - win.webContents.send("startup:ready"); - } - }); - - app.on("activate", () => { - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.show(); - mainWindow.focus(); - } else if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); - }); - - // On non-macOS platforms, quit when all windows are closed - app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } - }); - - // Cleanup before app quits - app.on("will-quit", async (event) => { - if (isQuitting) return; - isQuitting = true; - - // When installing an update, the updater needs the normal quit flow to - // complete (e.g. Squirrel.Mac swaps the app bundle and relaunches). - // Only do synchronous cleanup and let the quit proceed without - // preventDefault. - if (updateManager.isInstallingUpdate()) { - trayManager.destroy(); - await conversationStore.flushAll(); - await scheduledTaskService.shutdown(); - gatewayServer.stop(); - return; - } - - event.preventDefault(); - - try { - trayManager.destroy(); - - // Stop native file watchers early — @parcel/watcher uses NAPI threadsafe - // functions that must be torn down before Node.js module cleanup begins. - unwatchAll(); - - // Flush conversation store before quit - await conversationStore.flushAll(); - - await Promise.all([ - authApiServer.stop(), - channelManager.stopAll(), - webhookServer.stop(), - engineManager.stopAll(), - productionServer.stop(), - scheduledTaskService.shutdown(), - ]); - - gatewayServer.stop(); - } catch (err) { - mainLog.error("Cleanup error:", err); - } - - app.exit(0); - }); -} +await import("./app-main"); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 6dd8de84..ec26b8c8 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -7,9 +7,10 @@ import { productionServer } from "./services/production-server"; import { updateManager } from "./services/update-manager"; import { trayManager } from "./services/tray-manager"; import { getLogFilePath, getFileLogLevel, setFileLogLevel, loadSettings, saveSettings } from "./services/logger"; -import { isStartupReady } from "./index"; -import { channelManager } from "./index"; +import { isStartupReady } from "./app-main"; +import { channelManager } from "./app-main"; import { GATEWAY_PORT } from "../../shared/ports"; +import { getUserDataPath } from "./services/app-paths"; import { getQrCode as ilinkGetQrCode, pollQrStatus as ilinkPollQrStatus } from "./channels/weixin-ilink/weixin-ilink-qr-flow"; export function registerIpcHandlers(): void { @@ -22,7 +23,7 @@ export function registerIpcHandlers(): void { platform: process.platform, arch: process.arch, version: app.getVersion(), - userDataPath: app.getPath("userData"), + userDataPath: getUserDataPath(), homePath: app.getPath("home"), isPackaged: app.isPackaged, }; @@ -196,7 +197,7 @@ export function registerIpcHandlers(): void { ipcMain.handle("gateway:getPort", async () => { // In packaged mode, GatewayServer attaches to the production HTTP server // at /ws path, so we must return the full WebSocket URL. - // In dev mode, GatewayServer listens on its own port (4200). + // In dev mode, GatewayServer listens on its configured standalone port. if (app.isPackaged && productionServer.isRunning()) { return `ws://127.0.0.1:${productionServer.getPort()}/ws`; } diff --git a/electron/main/services/app-paths.ts b/electron/main/services/app-paths.ts new file mode 100644 index 00000000..181291af --- /dev/null +++ b/electron/main/services/app-paths.ts @@ -0,0 +1,90 @@ +import { app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; + +export const DEV_ISOLATED_ENV = "CODEMUX_DEV_ISOLATED"; +export const DEV_ISOLATED_DIR = ".codemux-dev"; + +export function isDevIsolatedMode(): boolean { + return !app.isPackaged && process.env[DEV_ISOLATED_ENV] === "1"; +} + +export function configureDevIsolatedAppPaths(cwd = process.cwd()): void { + if (!isDevIsolatedMode()) return; + + const root = path.join(cwd, DEV_ISOLATED_DIR); + const userData = path.join(root, "userData"); + const sessionData = path.join(root, "sessionData"); + const logs = path.join(root, "logs"); + + fs.mkdirSync(userData, { recursive: true }); + fs.mkdirSync(sessionData, { recursive: true }); + fs.mkdirSync(logs, { recursive: true }); + + app.setPath("userData", userData); + app.setPath("sessionData", sessionData); + app.setPath("logs", logs); +} + +export function getUserDataPath(): string { + return app.getPath("userData"); +} + +export function getLogsPath(): string { + return app.getPath("logs"); +} + +export function getSettingsPath(): string { + return path.join(getUserDataPath(), "settings.json"); +} + +export function usesSharedDevDeviceStorePath(): boolean { + return !app.isPackaged && !isDevIsolatedMode(); +} + +export function getDevicesPath(): string { + if (usesSharedDevDeviceStorePath()) { + return path.join(process.cwd(), ".devices.json"); + } + return path.join(getUserDataPath(), "devices.json"); +} + +export function getConversationsPath(): string { + return path.join(getUserDataPath(), "conversations"); +} + +export function getWorktreesPath(): string { + return path.join(getUserDataPath(), "worktrees"); +} + +export function getWorktreeIndexPath(projectId: string): string { + return path.join(getWorktreesPath(), projectId, "index.json"); +} + +export function getScheduledTasksPath(): string { + return path.join(getUserDataPath(), "scheduled-tasks.json"); +} + +export function getOrchestrationsPath(): string { + return path.join(getUserDataPath(), "orchestrations.json"); +} + +export function getDefaultWorkspacePath(): string { + return path.join(getUserDataPath(), "workspace"); +} + +export function getCloudflaredConfigPath(): string { + return path.join(getUserDataPath(), "cloudflared-config.yml"); +} + +export function getChannelsPath(): string { + return path.join(getUserDataPath(), "channels"); +} + +export function getChannelConfigPath(channelType: string): string { + return path.join(getChannelsPath(), `${channelType}.json`); +} + +export function getChannelBindingsPath(channelType: string): string { + return path.join(getChannelsPath(), `${channelType}-bindings.json`); +} diff --git a/electron/main/services/conversation-store.ts b/electron/main/services/conversation-store.ts index 909e49a7..70df2562 100644 --- a/electron/main/services/conversation-store.ts +++ b/electron/main/services/conversation-store.ts @@ -21,8 +21,8 @@ import fs from "fs"; import fsp from "fs/promises"; import path from "path"; -import { app } from "electron"; import { conversationStoreLog } from "./logger"; +import { getConversationsPath } from "./app-paths"; import { timeId } from "../utils/id-gen"; import type { EngineType, @@ -84,7 +84,7 @@ class ConversationStore { init(): void { if (this.initialized) return; - this.basePath = path.join(app.getPath("userData"), "conversations"); + this.basePath = getConversationsPath(); this.ensureDirSync(this.basePath); this.loadIndex(); this.initialized = true; diff --git a/electron/main/services/default-workspace.ts b/electron/main/services/default-workspace.ts index d5b43e48..4cfa97b8 100644 --- a/electron/main/services/default-workspace.ts +++ b/electron/main/services/default-workspace.ts @@ -3,14 +3,10 @@ // so sessions can be created without first setting up a project. // ============================================================================ -import { app } from "electron"; -import path from "node:path"; import fs from "node:fs"; +import { getDefaultWorkspacePath } from "./app-paths"; -/** Returns the default workspace directory path (inside Electron userData) */ -export function getDefaultWorkspacePath(): string { - return path.join(app.getPath("userData"), "workspace"); -} +export { getDefaultWorkspacePath }; /** Ensures the default workspace directory exists. Call at app startup. */ export function ensureDefaultWorkspace(): string { diff --git a/electron/main/services/device-store.ts b/electron/main/services/device-store.ts index db639731..054a60e3 100644 --- a/electron/main/services/device-store.ts +++ b/electron/main/services/device-store.ts @@ -1,6 +1,5 @@ -import path from "path"; -import { app } from "electron"; import { DeviceStoreBase } from "../../../shared/device-store-base"; +import { getDevicesPath, usesSharedDevDeviceStorePath } from "./app-paths"; // Re-export shared types for existing consumers export type { DeviceInfo, PendingRequest, DeviceStoreData } from "../../../shared/device-store-types"; @@ -13,12 +12,7 @@ class ElectronDeviceStore extends DeviceStoreBase { private initialized = false; protected getFilePath(): string { - // In development, share .devices.json with scripts/ Vite plugin - if (!app.isPackaged) { - return path.join(process.cwd(), ".devices.json"); - } - // In production, use standard user data directory - return path.join(app.getPath("userData"), "devices.json"); + return getDevicesPath(); } /** @@ -38,7 +32,7 @@ class ElectronDeviceStore extends DeviceStoreBase { * immediately to the running auth API server. */ protected override beforeRead(): void { - if (!this.initialized || app.isPackaged) return; + if (!this.initialized || !usesSharedDevDeviceStorePath()) return; this.loadData(); } diff --git a/electron/main/services/logger.ts b/electron/main/services/logger.ts index 9db9d25a..a18c0207 100644 --- a/electron/main/services/logger.ts +++ b/electron/main/services/logger.ts @@ -3,6 +3,7 @@ import { app } from "electron"; import path from "node:path"; import fs from "node:fs"; import type { LevelOption } from "electron-log"; +import { getLogsPath, getSettingsPath, isDevIsolatedMode } from "./app-paths"; // Configure electron-log for the main process. // All logs (main + renderer forwarded via WebSocket) go to a single file. @@ -11,8 +12,8 @@ import type { LevelOption } from "electron-log"; log.transports.file.resolvePathFn = (variables) => { // Use Electron's standard logs directory when running as packaged app, // otherwise fallback to the default library directory. - const dir = app.isPackaged - ? app.getPath("logs") + const dir = app.isPackaged || isDevIsolatedMode() + ? getLogsPath() : variables.libraryDefaultDir; return path.join(dir, variables.fileName ?? "main.log"); }; @@ -24,10 +25,6 @@ log.transports.file.maxSize = 5 * 1024 * 1024; const VALID_LEVELS: LevelOption[] = ["error", "warn", "info", "verbose", "debug", "silly", false]; -function getSettingsPath(): string { - return path.join(app.getPath("userData"), "settings.json"); -} - function loadSettings(): Record { try { const raw = fs.readFileSync(getSettingsPath(), "utf-8"); diff --git a/electron/main/services/orchestrator-service.ts b/electron/main/services/orchestrator-service.ts index 1f485fa0..09aaaa3d 100644 --- a/electron/main/services/orchestrator-service.ts +++ b/electron/main/services/orchestrator-service.ts @@ -1,8 +1,7 @@ import { EventEmitter } from "node:events"; -import path from "node:path"; import fs from "node:fs"; -import { app } from "electron"; import log from "electron-log/main"; +import { getOrchestrationsPath } from "./app-paths"; import type { EngineManager } from "../gateway/engine-manager"; import type { OrchestrationRun, OrchestrationSubtask, EngineType, RoleEngineMapping } from "../../../src/types/unified"; @@ -494,7 +493,7 @@ Respond with ONLY the JSON array, no markdown fencing, no explanation.`; private getStorePath(): string { if (!this.persistPath) { - this.persistPath = path.join(app.getPath("userData"), "orchestrations.json"); + this.persistPath = getOrchestrationsPath(); } return this.persistPath; } diff --git a/electron/main/services/scheduled-task-service.ts b/electron/main/services/scheduled-task-service.ts index 808997a8..96493456 100644 --- a/electron/main/services/scheduled-task-service.ts +++ b/electron/main/services/scheduled-task-service.ts @@ -7,10 +7,11 @@ import { EventEmitter } from "events"; import { randomUUID } from "crypto"; -import { app, Notification } from "electron"; +import { Notification } from "electron"; import path from "node:path"; import fs from "node:fs"; import { scheduledTaskLog } from "./logger"; +import { getScheduledTasksPath } from "./app-paths"; import type { EngineManager } from "../gateway/engine-manager"; import type { ScheduledTask, @@ -498,7 +499,7 @@ export class ScheduledTaskService extends EventEmitter { // --- Persistence ---------------------------------------------------- private getFilePath(): string { - return path.join(app.getPath("userData"), "scheduled-tasks.json"); + return getScheduledTasksPath(); } private loadFromDisk(): void { diff --git a/electron/main/services/tunnel-manager.ts b/electron/main/services/tunnel-manager.ts index 212ab084..ca6575f4 100644 --- a/electron/main/services/tunnel-manager.ts +++ b/electron/main/services/tunnel-manager.ts @@ -4,6 +4,7 @@ import path from "path"; import fs from "fs"; import os from "os"; import { tunnelLog } from "./logger"; +import { getCloudflaredConfigPath } from "./app-paths"; export interface TunnelConfig { /** Custom hostname for named tunnel (e.g. "codemux.example.com") */ @@ -73,8 +74,7 @@ class TunnelManager { * ~/.cloudflared/config.yml with different ingress rules. */ private writeNamedTunnelConfig(tunnelId: string, hostname: string, port: number): string { - const userDataPath = app.getPath("userData"); - const configPath = path.join(userDataPath, "cloudflared-config.yml"); + const configPath = getCloudflaredConfigPath(); const credentialsPath = path.join(os.homedir(), ".cloudflared", `${tunnelId}.json`); const bareHostname = hostname.replace(/^https?:\/\//, ""); diff --git a/electron/main/services/worktree-manager.ts b/electron/main/services/worktree-manager.ts index b11684c2..4d0ac744 100644 --- a/electron/main/services/worktree-manager.ts +++ b/electron/main/services/worktree-manager.ts @@ -7,8 +7,8 @@ import { execFile } from "node:child_process"; import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { app } from "electron"; import log from "electron-log/main"; +import { getWorktreesPath } from "./app-paths"; import { createSlug, slugify } from "./slug"; import { worktreeStore, type WorktreeInfo } from "./worktree-store"; @@ -67,7 +67,7 @@ class WorktreeManager { private ensureInit(): void { if (this.initialized) return; - this.worktreeBase = path.join(app.getPath("userData"), "worktrees"); + this.worktreeBase = getWorktreesPath(); if (!fs.existsSync(this.worktreeBase)) { fs.mkdirSync(this.worktreeBase, { recursive: true }); } diff --git a/electron/main/services/worktree-store.ts b/electron/main/services/worktree-store.ts index 6672328b..a9ab974f 100644 --- a/electron/main/services/worktree-store.ts +++ b/electron/main/services/worktree-store.ts @@ -6,8 +6,8 @@ import fs from "fs"; import fsp from "fs/promises"; import path from "path"; -import { app } from "electron"; import log from "electron-log/main"; +import { getWorktreesPath } from "./app-paths"; const worktreeStoreLog = log.scope("WorktreeStore"); @@ -40,7 +40,7 @@ export class WorktreeStore { init(): void { if (this.initialized) return; - this.basePath = path.join(app.getPath("userData"), "worktrees"); + this.basePath = getWorktreesPath(); this.ensureDirSync(this.basePath); this.initialized = true; worktreeStoreLog.info(`Initialized at ${this.basePath}`); diff --git a/package.json b/package.json index cc85f8b4..0e829467 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "main": "out/main/index.mjs", "scripts": { "dev": "electron-vite dev", + "dev:isolated": "cross-env CODEMUX_DEV_ISOLATED=1 bun scripts/dev-isolated.ts", "build": "electron-vite build", "preview": "electron-vite preview", "pack": "electron-builder --dir", @@ -79,6 +80,7 @@ "@types/ws": "^8.18.1", "@vitest/coverage-v8": "4.0.18", "bun-types": "latest", + "cross-env": "^10.1.0", "electron": "^40.6.1", "electron-builder": "^26.0.0", "electron-vite": "^3.1.0", diff --git a/playwright.config.ts b/playwright.config.ts index 7e81d12b..19f3bea4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from "@playwright/test"; +import { WEB_PORT } from "./shared/ports"; export default defineConfig({ testDir: "./tests/e2e/specs", @@ -13,7 +14,7 @@ export default defineConfig({ globalTeardown: "./tests/e2e/global-teardown.ts", use: { - baseURL: process.env.TEST_BASE_URL || "http://127.0.0.1:8233", + baseURL: process.env.TEST_BASE_URL || `http://127.0.0.1:${WEB_PORT}`, trace: "on-first-retry", screenshot: "only-on-failure", }, diff --git a/scripts/check-incremental-coverage.ts b/scripts/check-incremental-coverage.ts index 2d5afec3..9d98f9cb 100644 --- a/scripts/check-incremental-coverage.ts +++ b/scripts/check-incremental-coverage.ts @@ -44,6 +44,8 @@ const EXCLUDE_PATTERNS = [ /^shared\/ports\.ts$/, /^shared\/settings-keys\.ts$/, /^electron\/main\/index\.ts$/, + // Main-process lifecycle wiring requires a real Electron app; new path logic is covered separately. + /^electron\/main\/app-main\.ts$/, /^electron\/main\/engines\/identity-prompt\.ts$/, /^electron\/preload\//, /^src\/components\/file-icons\//, diff --git a/scripts/dev-isolated.ts b/scripts/dev-isolated.ts new file mode 100644 index 00000000..04d1afaf --- /dev/null +++ b/scripts/dev-isolated.ts @@ -0,0 +1,323 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DEFAULT_AUTH_API_PORT, + DEFAULT_GATEWAY_PORT, + DEFAULT_OPENCODE_PORT, + DEFAULT_WEBHOOK_PORT, + DEFAULT_WEB_PORT, + DEFAULT_WEB_STANDALONE_PORT, + MAX_PORT_OFFSET, +} from "../shared/ports"; + +const DEV_ISOLATED_DIR = ".codemux-dev"; +const PORTS_FILE = "ports.json"; +const OFFSET_STEP = 100; +const MAX_OFFSET_ATTEMPTS = Math.floor(MAX_PORT_OFFSET / OFFSET_STEP); +const LOCK_DIR = path.join(os.tmpdir(), "codemux-dev-isolated-port-locks"); +const isWindows = process.platform === "win32"; +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +interface PortPlan { + portOffset: number; + ports: { + web: number; + webStandalone: number; + gateway: number; + opencode: number; + authApi: number; + webhook: number; + }; +} + +interface PortReservation { + plan: PortPlan; + release(): void; +} + +const PORT_LABELS: Record = { + web: "CODEMUX_WEB_PORT", + webStandalone: "CODEMUX_WEB_STANDALONE_PORT", + gateway: "CODEMUX_GATEWAY_PORT", + opencode: "CODEMUX_OPENCODE_PORT", + authApi: "CODEMUX_AUTH_API_PORT", + webhook: "CODEMUX_WEBHOOK_PORT", +}; + +function readNumberEnv(name: string, env: NodeJS.ProcessEnv): number | undefined { + const value = env[name]; + if (!value) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed)) { + throw new Error(`${name} must be an integer`); + } + return parsed; +} + +function resolvePort(envName: string, fallback: number, env: NodeJS.ProcessEnv): number { + const override = readNumberEnv(envName, env); + return override ?? fallback; +} + +function validatePortPlan(plan: PortPlan): void { + const seen = new Map(); + + for (const [key, port] of Object.entries(plan.ports) as Array<[keyof PortPlan["ports"], number]>) { + if (port < 1 || port > 65_535) { + throw new Error( + `${PORT_LABELS[key]} resolved to invalid port ${port}. ` + + `Check CODEMUX_PORT_OFFSET=${plan.portOffset} or override ${PORT_LABELS[key]} explicitly.`, + ); + } + + const duplicate = seen.get(port); + if (duplicate) { + throw new Error( + `${PORT_LABELS[key]} and ${PORT_LABELS[duplicate]} both resolve to port ${port}. ` + + "Set distinct CODEMUX_* port overrides.", + ); + } + seen.set(port, key); + } +} + +export function buildPortPlan(portOffset: number, env: NodeJS.ProcessEnv = process.env): PortPlan { + if (portOffset < 0 || portOffset > MAX_PORT_OFFSET) { + throw new Error(`CODEMUX_PORT_OFFSET must be between 0 and ${MAX_PORT_OFFSET}, got ${portOffset}`); + } + + const plan = { + portOffset, + ports: { + web: resolvePort("CODEMUX_WEB_PORT", DEFAULT_WEB_PORT + portOffset, env), + webStandalone: resolvePort("CODEMUX_WEB_STANDALONE_PORT", DEFAULT_WEB_STANDALONE_PORT + portOffset, env), + gateway: resolvePort("CODEMUX_GATEWAY_PORT", DEFAULT_GATEWAY_PORT + portOffset, env), + opencode: resolvePort("CODEMUX_OPENCODE_PORT", DEFAULT_OPENCODE_PORT + portOffset, env), + authApi: resolvePort("CODEMUX_AUTH_API_PORT", DEFAULT_AUTH_API_PORT + portOffset, env), + webhook: resolvePort("CODEMUX_WEBHOOK_PORT", DEFAULT_WEBHOOK_PORT + portOffset, env), + }, + }; + + validatePortPlan(plan); + return plan; +} + +function allPorts(plan: PortPlan): number[] { + return Object.values(plan.ports); +} + +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen({ port, host: "127.0.0.1" }, () => { + server.close(() => resolve(true)); + }); + }); +} + +async function unavailablePorts(plan: PortPlan): Promise { + const checks = await Promise.all( + allPorts(plan).map(async (port) => ({ port, available: await isPortAvailable(port) })), + ); + return checks.filter((check) => !check.available).map((check) => check.port); +} + +function readSavedOffset(devRoot: string): number | undefined { + try { + const raw = fs.readFileSync(path.join(devRoot, PORTS_FILE), "utf-8"); + const data = JSON.parse(raw) as { portOffset?: unknown }; + return typeof data.portOffset === "number" && data.portOffset >= 0 ? data.portOffset : undefined; + } catch { + return undefined; + } +} + +function candidateOffsets(): number[] { + const offsets = Array.from({ length: MAX_OFFSET_ATTEMPTS }, (_, index) => (index + 1) * OFFSET_STEP); + for (let i = offsets.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [offsets[i], offsets[j]] = [offsets[j], offsets[i]]; + } + return offsets; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function removeStaleLock(lockPath: string): boolean { + try { + const data = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as { pid?: unknown }; + if (typeof data.pid === "number" && isProcessAlive(data.pid)) return false; + fs.unlinkSync(lockPath); + return true; + } catch { + try { + fs.unlinkSync(lockPath); + return true; + } catch { + return false; + } + } +} + +function reservePlan(plan: PortPlan): PortReservation | null { + fs.mkdirSync(LOCK_DIR, { recursive: true }); + const lockPath = path.join(LOCK_DIR, `${plan.portOffset}.json`); + + let fd: number; + try { + fd = fs.openSync(lockPath, "wx"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST" && removeStaleLock(lockPath)) { + fd = fs.openSync(lockPath, "wx"); + } else { + return null; + } + } + + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, cwd: projectRoot, plan }, null, 2)); + + let released = false; + return { + plan, + release() { + if (released) return; + released = true; + try { + fs.closeSync(fd); + } catch { + // ignore + } + try { + fs.unlinkSync(lockPath); + } catch { + // ignore + } + }, + }; +} + +async function validateReservedPorts(reservation: PortReservation): Promise { + const unavailable = await unavailablePorts(reservation.plan); + if (unavailable.length > 0) { + reservation.release(); + throw new Error(`Port offset ${reservation.plan.portOffset} conflicts on ports: ${unavailable.join(", ")}`); + } +} + +async function reserveCheckedPlan(plan: PortPlan): Promise { + const reservation = reservePlan(plan); + if (!reservation) return null; + await validateReservedPorts(reservation); + return reservation; +} + +export async function allocatePortReservation(devRoot: string, env: NodeJS.ProcessEnv = process.env): Promise { + const explicitOffset = readNumberEnv("CODEMUX_PORT_OFFSET", env); + if (explicitOffset !== undefined) { + const plan = buildPortPlan(explicitOffset, env); + const reservation = await reserveCheckedPlan(plan); + if (!reservation) { + throw new Error(`CODEMUX_PORT_OFFSET=${explicitOffset} is already reserved by another isolated dev startup`); + } + return reservation; + } + + const savedOffset = readSavedOffset(devRoot); + if (savedOffset !== undefined) { + const plan = buildPortPlan(savedOffset, env); + const reservation = await reserveCheckedPlan(plan); + if (reservation) return reservation; + throw new Error( + `Saved isolated port offset ${savedOffset} is already reserved. ` + + "Stop the existing isolated instance, delete .codemux-dev/ports.json to reallocate, or set CODEMUX_PORT_OFFSET explicitly.", + ); + } + + for (const offset of candidateOffsets()) { + try { + const reservation = await reserveCheckedPlan(buildPortPlan(offset, env)); + if (reservation) return reservation; + } catch { + // Try the next candidate. + } + } + + throw new Error(`No available CodeMux isolated port offset found after ${MAX_OFFSET_ATTEMPTS} attempts`); +} + +export async function allocatePortPlan(devRoot: string, env: NodeJS.ProcessEnv = process.env): Promise { + const reservation = await allocatePortReservation(devRoot, env); + reservation.release(); + return reservation.plan; +} + +function writePortsFile(devRoot: string, plan: PortPlan): void { + fs.mkdirSync(devRoot, { recursive: true }); + const data = { + devIsolated: true, + updatedAt: new Date().toISOString(), + portOffset: plan.portOffset, + ports: plan.ports, + }; + fs.writeFileSync(path.join(devRoot, PORTS_FILE), `${JSON.stringify(data, null, 2)}\n`, "utf-8"); +} + +async function main(): Promise { + const devRoot = path.join(projectRoot, DEV_ISOLATED_DIR); + const reservation = await allocatePortReservation(devRoot); + const { plan } = reservation; + writePortsFile(devRoot, plan); + + process.on("exit", () => reservation.release()); + + console.log(`CodeMux isolated dev data: ${devRoot}`); + console.log(`CodeMux port offset: ${plan.portOffset}`); + console.log(`CodeMux web port: ${plan.ports.web}`); + + const child = spawn("bun", ["run", "dev"], { + cwd: projectRoot, + env: { + ...process.env, + CODEMUX_DEV_ISOLATED: "1", + CODEMUX_PORT_OFFSET: String(plan.portOffset), + }, + stdio: "inherit", + shell: isWindows, + }); + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => child.kill(signal)); + } + + child.on("close", (code) => { + reservation.release(); + process.exit(code ?? 1); + }); + + child.on("error", (error) => { + reservation.release(); + console.error(error); + process.exit(1); + }); +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/shared/ports.ts b/shared/ports.ts index d355a79f..31ed4f91 100644 --- a/shared/ports.ts +++ b/shared/ports.ts @@ -1,27 +1,103 @@ -/** - * Centralized port configuration for all CodeMux services. - * Change ports here — all code references this single source of truth. - * - * NOTE: A few build-tool config files (electron.vite.config.ts, vite.config.ts, - * playwright.config.ts) still hard-code these values because they are top-level - * static objects that rarely change. If you update a port here, grep for the old - * value in those files as well. - */ +/** Centralized port configuration for all CodeMux services. */ + +const MAX_TCP_PORT = 65_535; + +export const DEFAULT_WEB_PORT = 8233; +export const DEFAULT_WEB_STANDALONE_PORT = 8234; +export const DEFAULT_GATEWAY_PORT = 4200; +export const DEFAULT_OPENCODE_PORT = 4096; +export const DEFAULT_AUTH_API_PORT = 4097; +export const DEFAULT_WEBHOOK_PORT = 4098; + +const DEFAULT_PORTS = { + CODEMUX_WEB_PORT: DEFAULT_WEB_PORT, + CODEMUX_WEB_STANDALONE_PORT: DEFAULT_WEB_STANDALONE_PORT, + CODEMUX_GATEWAY_PORT: DEFAULT_GATEWAY_PORT, + CODEMUX_OPENCODE_PORT: DEFAULT_OPENCODE_PORT, + CODEMUX_AUTH_API_PORT: DEFAULT_AUTH_API_PORT, + CODEMUX_WEBHOOK_PORT: DEFAULT_WEBHOOK_PORT, +} as const; + +export const MAX_PORT_OFFSET = MAX_TCP_PORT - Math.max(...Object.values(DEFAULT_PORTS)); + +function readEnv(name: string): string | undefined { + if (typeof process === "undefined") return undefined; + return process.env?.[name]; +} + +function readNumberEnv(name: string): number | undefined { + const value = readEnv(name); + if (!value) return undefined; + + const parsed = Number(value); + if (!Number.isInteger(parsed)) { + throw new Error(`${name} must be an integer`); + } + return parsed; +} + +function validatePort(name: string, port: number): number { + if (port < 1 || port > MAX_TCP_PORT) { + throw new Error(`${name} must be between 1 and ${MAX_TCP_PORT}, got ${port}`); + } + return port; +} + +function readPortEnv(name: keyof typeof DEFAULT_PORTS, fallback: number): number { + const parsed = readNumberEnv(name); + return validatePort(name, parsed ?? fallback); +} + +function readPortOffset(): number { + const parsed = readNumberEnv("CODEMUX_PORT_OFFSET"); + if (parsed === undefined) return 0; + if (parsed < 0 || parsed > MAX_PORT_OFFSET) { + throw new Error( + `CODEMUX_PORT_OFFSET must be between 0 and ${MAX_PORT_OFFSET}, got ${parsed}. ` + + "Large offsets push default CodeMux ports outside the valid TCP port range.", + ); + } + return parsed; +} + +function validateDistinctPorts(ports: Record): void { + const seen = new Map(); + for (const [name, port] of Object.entries(ports)) { + const existing = seen.get(port); + if (existing) { + throw new Error(`${name} and ${existing} both resolve to port ${port}; CodeMux service ports must be distinct`); + } + seen.set(port, name); + } +} + +export const PORT_OFFSET = readPortOffset(); + +const resolvedPorts = { + WEB_PORT: readPortEnv("CODEMUX_WEB_PORT", DEFAULT_WEB_PORT + PORT_OFFSET), + WEB_STANDALONE_PORT: readPortEnv("CODEMUX_WEB_STANDALONE_PORT", DEFAULT_WEB_STANDALONE_PORT + PORT_OFFSET), + GATEWAY_PORT: readPortEnv("CODEMUX_GATEWAY_PORT", DEFAULT_GATEWAY_PORT + PORT_OFFSET), + OPENCODE_PORT: readPortEnv("CODEMUX_OPENCODE_PORT", DEFAULT_OPENCODE_PORT + PORT_OFFSET), + AUTH_API_PORT: readPortEnv("CODEMUX_AUTH_API_PORT", DEFAULT_AUTH_API_PORT + PORT_OFFSET), + WEBHOOK_PORT: readPortEnv("CODEMUX_WEBHOOK_PORT", DEFAULT_WEBHOOK_PORT + PORT_OFFSET), +}; + +validateDistinctPorts(resolvedPorts); /** Electron-Vite dev server / Production HTTP server */ -export const WEB_PORT = 8233; +export const WEB_PORT = resolvedPorts.WEB_PORT; /** Standalone Vite dev server (npm run dev:web) */ -export const WEB_STANDALONE_PORT = 8234; +export const WEB_STANDALONE_PORT = resolvedPorts.WEB_STANDALONE_PORT; /** Gateway WebSocket server */ -export const GATEWAY_PORT = 4200; +export const GATEWAY_PORT = resolvedPorts.GATEWAY_PORT; /** OpenCode engine API server */ -export const OPENCODE_PORT = 4096; +export const OPENCODE_PORT = resolvedPorts.OPENCODE_PORT; /** Auth API server (dev-only, not started in packaged mode) */ -export const AUTH_API_PORT = 4097; +export const AUTH_API_PORT = resolvedPorts.AUTH_API_PORT; /** Third-party webhook receiver (Telegram, WeCom, Teams, etc.) */ -export const WEBHOOK_PORT = 4098; +export const WEBHOOK_PORT = resolvedPorts.WEBHOOK_PORT; diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts index 4e20455e..7b9f41e1 100644 --- a/src/lib/gateway-client.ts +++ b/src/lib/gateway-client.ts @@ -171,13 +171,13 @@ export class GatewayClient { } else if (!this.wsUrl) { if (isElectron()) { // In Electron: get full WS URL from main process via IPC - // Dev mode: ws://127.0.0.1:4200 - // Packaged mode: ws://127.0.0.1:${WEB_PORT}/ws (attached to production server) + // Dev mode uses the configured standalone Gateway port. + // Packaged mode attaches Gateway to the production server at /ws. this.wsUrl = await gatewayAPI.getWsUrl(); } else { // In remote browser: derive WS URL from current page location // Production (Cloudflare Tunnel): wss://tunnel-host/ws - // Dev fallback: ws://localhost:4200 + // Dev fallback uses the current Vite host. const loc = window.location; const wsProtocol = loc.protocol === "https:" ? "wss:" : "ws:"; this.wsUrl = `${wsProtocol}//${loc.host}/ws`; diff --git a/tests/unit/electron/ipc-handlers.test.ts b/tests/unit/electron/ipc-handlers.test.ts index 0251b3cb..f0b801f8 100644 --- a/tests/unit/electron/ipc-handlers.test.ts +++ b/tests/unit/electron/ipc-handlers.test.ts @@ -134,7 +134,7 @@ vi.mock("../../../electron/main/services/logger", () => ({ saveSettings: mockSaveSettings, })); -vi.mock("../../../electron/main/index", () => ({ +vi.mock("../../../electron/main/app-main", () => ({ isStartupReady: mockIsStartupReady, channelManager: mockChannelManager, })); diff --git a/tests/unit/electron/services/app-paths.test.ts b/tests/unit/electron/services/app-paths.test.ts new file mode 100644 index 00000000..c066e7aa --- /dev/null +++ b/tests/unit/electron/services/app-paths.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { mockApp, mockPaths } = vi.hoisted(() => { + const mockPaths: Record = {}; + const mockApp = { + isPackaged: false, + getPath: vi.fn((name: string) => mockPaths[name] ?? `/mock/${name}`), + setPath: vi.fn((name: string, value: string) => { + mockPaths[name] = value; + }), + }; + return { mockApp, mockPaths }; +}); + +vi.mock("electron", () => ({ app: mockApp })); + +import { + configureDevIsolatedAppPaths, + DEV_ISOLATED_ENV, + getChannelsPath, + getDevicesPath, + getSettingsPath, + isDevIsolatedMode, + usesSharedDevDeviceStorePath, +} from "../../../../electron/main/services/app-paths"; + +let tmpDirs: string[] = []; + +function setUserDataPath(userDataPath: string): void { + mockPaths.userData = userDataPath; +} + +describe("app-paths", () => { + beforeEach(() => { + delete process.env[DEV_ISOLATED_ENV]; + mockApp.isPackaged = false; + mockApp.getPath.mockClear(); + mockApp.setPath.mockClear(); + for (const key of Object.keys(mockPaths)) delete mockPaths[key]; + setUserDataPath("/mock/userData"); + }); + + afterEach(() => { + delete process.env[DEV_ISOLATED_ENV]; + for (const dir of tmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tmpDirs = []; + }); + + it("does not change Electron app paths outside isolated dev mode", () => { + configureDevIsolatedAppPaths("/repo"); + + expect(isDevIsolatedMode()).toBe(false); + expect(mockApp.setPath).not.toHaveBeenCalled(); + }); + + it("configures userData, sessionData, and logs under .codemux-dev in isolated dev mode", () => { + process.env[DEV_ISOLATED_ENV] = "1"; + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codemux-app-paths-")); + tmpDirs.push(cwd); + + configureDevIsolatedAppPaths(cwd); + + const root = path.join(cwd, ".codemux-dev"); + expect(mockApp.setPath).toHaveBeenCalledWith("userData", path.join(root, "userData")); + expect(mockApp.setPath).toHaveBeenCalledWith("sessionData", path.join(root, "sessionData")); + expect(mockApp.setPath).toHaveBeenCalledWith("logs", path.join(root, "logs")); + expect(fs.existsSync(path.join(root, "userData"))).toBe(true); + expect(fs.existsSync(path.join(root, "sessionData"))).toBe(true); + expect(fs.existsSync(path.join(root, "logs"))).toBe(true); + }); + + it("keeps normal dev devices in the repo file and isolates them when requested", () => { + expect(usesSharedDevDeviceStorePath()).toBe(true); + expect(getDevicesPath()).toMatch(/\.devices\.json$/); + + process.env[DEV_ISOLATED_ENV] = "1"; + + expect(usesSharedDevDeviceStorePath()).toBe(false); + expect(getDevicesPath()).toBe(path.join("/mock/userData", "devices.json")); + }); + + it("uses userData for packaged paths and derived config files", () => { + mockApp.isPackaged = true; + + expect(getDevicesPath()).toBe(path.join("/mock/userData", "devices.json")); + expect(getSettingsPath()).toBe(path.join("/mock/userData", "settings.json")); + expect(getChannelsPath()).toBe(path.join("/mock/userData", "channels")); + }); +}); diff --git a/tests/unit/electron/services/device-store.test.ts b/tests/unit/electron/services/device-store.test.ts new file mode 100644 index 00000000..f54b428a --- /dev/null +++ b/tests/unit/electron/services/device-store.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; + +const { mockGetDevicesPath, mockUsesSharedDevDeviceStorePath } = vi.hoisted(() => ({ + mockGetDevicesPath: vi.fn(() => "/tmp/codemux-devices.json"), + mockUsesSharedDevDeviceStorePath: vi.fn(() => false), +})); + +vi.mock("fs"); +vi.mock("../../../../electron/main/services/app-paths", () => ({ + getDevicesPath: mockGetDevicesPath, + usesSharedDevDeviceStorePath: mockUsesSharedDevDeviceStorePath, +})); + +function deviceData(devices: Record = {}): string { + return JSON.stringify({ + devices, + pendingRequests: {}, + jwtSecret: "test-secret", + }); +} + +async function importDeviceStore() { + vi.resetModules(); + return import("../../../../electron/main/services/device-store"); +} + +describe("electron device store", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetDevicesPath.mockReturnValue("/tmp/codemux-devices.json"); + mockUsesSharedDevDeviceStorePath.mockReturnValue(false); + (fs.existsSync as any).mockReturnValue(true); + (fs.readFileSync as any).mockReturnValue(deviceData({ + d1: { id: "d1", name: "Initial", lastSeenAt: 1 }, + })); + }); + + it("lazily initializes once using the configured devices path", async () => { + const { deviceStore } = await importDeviceStore(); + + expect(fs.readFileSync).not.toHaveBeenCalled(); + + deviceStore.init(); + deviceStore.init(); + + expect(mockGetDevicesPath).toHaveBeenCalledTimes(1); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + expect(fs.readFileSync).toHaveBeenCalledWith("/tmp/codemux-devices.json", "utf-8"); + expect(deviceStore.getDevice("d1")?.name).toBe("Initial"); + }); + + it("only reloads after initialization", async () => { + const { deviceStore } = await importDeviceStore(); + + deviceStore.reload(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + + deviceStore.init(); + deviceStore.reload(); + + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + }); + + it("reloads before reads when using the shared dev devices file", async () => { + const { deviceStore } = await importDeviceStore(); + deviceStore.init(); + + mockUsesSharedDevDeviceStorePath.mockReturnValue(true); + (fs.readFileSync as any).mockReturnValue(deviceData({ + d2: { id: "d2", name: "Reloaded", lastSeenAt: 2 }, + })); + + expect(deviceStore.getDevice("d2")?.name).toBe("Reloaded"); + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + }); + + it("does not reload before reads in isolated device-store mode", async () => { + const { deviceStore } = await importDeviceStore(); + deviceStore.init(); + + (fs.readFileSync as any).mockReturnValue(deviceData({ + d2: { id: "d2", name: "Reloaded", lastSeenAt: 2 }, + })); + + expect(deviceStore.getDevice("d2")).toBeUndefined(); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + }); + + it("does not reload before reads when it has not been initialized", async () => { + const { deviceStore } = await importDeviceStore(); + mockUsesSharedDevDeviceStorePath.mockReturnValue(true); + + expect(() => deviceStore.getDevice("d1")).toThrow("DeviceStore not initialized"); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scripts/dev-isolated.test.ts b/tests/unit/scripts/dev-isolated.test.ts new file mode 100644 index 00000000..23291d96 --- /dev/null +++ b/tests/unit/scripts/dev-isolated.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { buildPortPlan } from "../../../scripts/dev-isolated"; + +describe("dev-isolated script", () => { + it("builds an offset port plan", () => { + const plan = buildPortPlan(200, {}); + + expect(plan).toEqual({ + portOffset: 200, + ports: { + web: 8433, + webStandalone: 8434, + gateway: 4400, + opencode: 4296, + authApi: 4297, + webhook: 4298, + }, + }); + }); + + it("keeps explicit port overrides in the plan", () => { + const plan = buildPortPlan(200, { + CODEMUX_WEB_PORT: "9100", + CODEMUX_OPENCODE_PORT: "9101", + }); + + expect(plan.ports.web).toBe(9100); + expect(plan.ports.opencode).toBe(9101); + expect(plan.ports.gateway).toBe(4400); + }); + + it("throws for offsets that would create invalid default ports", () => { + expect(() => buildPortPlan(60_000, {})).toThrow("CODEMUX_PORT_OFFSET must be between 0"); + }); + + it("throws when an explicit override duplicates another service port", () => { + expect(() => buildPortPlan(200, { + CODEMUX_WEB_PORT: "9100", + CODEMUX_GATEWAY_PORT: "9100", + })).toThrow("CODEMUX_GATEWAY_PORT and CODEMUX_WEB_PORT both resolve to port 9100"); + }); +}); diff --git a/tests/unit/shared/ports.test.ts b/tests/unit/shared/ports.test.ts new file mode 100644 index 00000000..d589b7f4 --- /dev/null +++ b/tests/unit/shared/ports.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const PORT_ENV_KEYS = [ + "CODEMUX_PORT_OFFSET", + "CODEMUX_WEB_PORT", + "CODEMUX_WEB_STANDALONE_PORT", + "CODEMUX_GATEWAY_PORT", + "CODEMUX_OPENCODE_PORT", + "CODEMUX_AUTH_API_PORT", + "CODEMUX_WEBHOOK_PORT", +] as const; + +const originalEnv = new Map(); +for (const key of PORT_ENV_KEYS) { + originalEnv.set(key, process.env[key]); +} + +async function importPorts(env: Partial> = {}) { + vi.resetModules(); + for (const key of PORT_ENV_KEYS) delete process.env[key]; + Object.assign(process.env, env); + return import("../../../shared/ports"); +} + +describe("shared ports", () => { + afterEach(() => { + vi.resetModules(); + for (const key of PORT_ENV_KEYS) { + const original = originalEnv.get(key); + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } + }); + + it("uses default ports without environment overrides", async () => { + const ports = await importPorts(); + + expect(ports.PORT_OFFSET).toBe(0); + expect(ports.WEB_PORT).toBe(8233); + expect(ports.WEB_STANDALONE_PORT).toBe(8234); + expect(ports.GATEWAY_PORT).toBe(4200); + expect(ports.OPENCODE_PORT).toBe(4096); + expect(ports.AUTH_API_PORT).toBe(4097); + expect(ports.WEBHOOK_PORT).toBe(4098); + }); + + it("applies CODEMUX_PORT_OFFSET to every default port", async () => { + const ports = await importPorts({ CODEMUX_PORT_OFFSET: "100" }); + + expect(ports.PORT_OFFSET).toBe(100); + expect(ports.WEB_PORT).toBe(8333); + expect(ports.WEB_STANDALONE_PORT).toBe(8334); + expect(ports.GATEWAY_PORT).toBe(4300); + expect(ports.OPENCODE_PORT).toBe(4196); + expect(ports.AUTH_API_PORT).toBe(4197); + expect(ports.WEBHOOK_PORT).toBe(4198); + }); + + it("lets individual port overrides win over the offset", async () => { + const ports = await importPorts({ + CODEMUX_PORT_OFFSET: "100", + CODEMUX_WEB_PORT: "9001", + CODEMUX_GATEWAY_PORT: "9002", + }); + + expect(ports.WEB_PORT).toBe(9001); + expect(ports.GATEWAY_PORT).toBe(9002); + expect(ports.OPENCODE_PORT).toBe(4196); + }); + + it("throws for invalid numeric values", async () => { + await expect(importPorts({ CODEMUX_PORT_OFFSET: "abc" })).rejects.toThrow("CODEMUX_PORT_OFFSET must be an integer"); + await expect(importPorts({ CODEMUX_WEB_PORT: "70000" })).rejects.toThrow("CODEMUX_WEB_PORT must be between 1 and 65535"); + await expect(importPorts({ CODEMUX_GATEWAY_PORT: "0" })).rejects.toThrow("CODEMUX_GATEWAY_PORT must be between 1 and 65535"); + }); + + it("throws when CODEMUX_PORT_OFFSET would push defaults beyond valid TCP ports", async () => { + await expect(importPorts({ CODEMUX_PORT_OFFSET: "60000" })).rejects.toThrow("CODEMUX_PORT_OFFSET must be between 0"); + }); + + it("throws when explicit overrides duplicate another service port", async () => { + await expect(importPorts({ + CODEMUX_WEB_PORT: "9001", + CODEMUX_GATEWAY_PORT: "9001", + })).rejects.toThrow("GATEWAY_PORT and WEB_PORT both resolve to port 9001"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 027b36b1..490dc362 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,13 +10,14 @@ import type { DeviceInfo } from "./shared/device-store-types"; import { sendJson, parseBody, extractBearerToken, getClientIp, isLocalhost, getLocalIp } from "./shared/http-utils"; import { handleAuthRoutes, handleSettingsRoutes } from "./shared/auth-route-handlers"; import { loadSettings as loadStandaloneSettings, saveSettings as saveStandaloneSettings } from "./scripts/settings-store"; +import { GATEWAY_PORT, OPENCODE_PORT, WEBHOOK_PORT, WEB_STANDALONE_PORT } from "./shared/ports"; // ============================================================================ // Helper Functions // ============================================================================ function parseUrl(req: IncomingMessage): URL { - return new URL(req.url || "", `http://localhost:8234`); + return new URL(req.url || "", `http://localhost:${WEB_STANDALONE_PORT}`); } const localAuthOptions = { @@ -112,7 +113,7 @@ export default defineConfig({ // GET/PATCH /api/settings/shared // ==================================================================== server.middlewares.use(async (req, res, next) => { - const reqUrl = new URL(req.url || "", `http://localhost:8234`); + const reqUrl = new URL(req.url || "", `http://localhost:${WEB_STANDALONE_PORT}`); const pathname = reqUrl.pathname; if (!pathname.startsWith("/api/settings/")) { next(); @@ -139,7 +140,7 @@ export default defineConfig({ sendJson(res, { localIp: getLocalIp(), - port: 8234, + port: WEB_STANDALONE_PORT, }); }); @@ -170,7 +171,7 @@ export default defineConfig({ try { if (req.url === "/api/tunnel/start" && req.method === "POST") { - const info = await tunnelManager.start(8234); + const info = await tunnelManager.start(WEB_STANDALONE_PORT); sendJson(res, info); return; } @@ -197,32 +198,32 @@ export default defineConfig({ }, ], server: { - port: 8234, + port: WEB_STANDALONE_PORT, host: true, allowedHosts: true, proxy: { "/ws": { - target: "http://localhost:4200", + target: `http://localhost:${GATEWAY_PORT}`, ws: true, }, "/opencode-api/global/event": { - target: "http://localhost:4096", + target: `http://localhost:${OPENCODE_PORT}`, changeOrigin: true, rewrite: (path) => path.replace(/^\/opencode-api/, ""), timeout: 0, }, "/opencode-api": { - target: "http://localhost:4096", + target: `http://localhost:${OPENCODE_PORT}`, changeOrigin: true, rewrite: (path) => path.replace(/^\/opencode-api/, ""), }, // Proxy webhook endpoints to the WebhookServer "/api/messages": { - target: "http://localhost:4098", + target: `http://localhost:${WEBHOOK_PORT}`, changeOrigin: true, }, "/webhook": { - target: "http://localhost:4098", + target: `http://localhost:${WEBHOOK_PORT}`, changeOrigin: true, }, }, diff --git a/vitest.config.ts b/vitest.config.ts index 464630a5..5190a2b4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,8 @@ export default defineConfig({ "shared/ports.ts", "shared/settings-keys.ts", "electron/main/index.ts", + // Main-process lifecycle wiring requires a real Electron app; new path logic is covered separately. + "electron/main/app-main.ts", "electron/main/engines/identity-prompt.ts", "electron/preload/**", "src/components/file-icons/**",