diff --git a/src/cli.ts b/src/cli.ts index abb8188..869793a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,11 @@ import { basename, dirname } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { AppServerBridge } from "./lib/app-server-bridge.js"; +import { + AppServerBridge, + buildLocalHostConfig, + resolveLocalHostKind, +} from "./lib/app-server-bridge.js"; import { normalizeCliArgv } from "./lib/cli-args.js"; import { loadCodexBundle, resolveDefaultCodexAppPath } from "./lib/codex-bundle.js"; import { renderBootstrapScript } from "./lib/bootstrap-script.js"; @@ -64,9 +68,11 @@ async function main(): Promise { buildFlavor: bundle.buildFlavor, buildNumber: bundle.buildNumber, }).catch(() => null); + const hostKind = await resolveLocalHostKind(process.cwd()); const relay = await AppServerBridge.connect({ appPath: options.appPath, cwd: process.cwd(), + hostKind, }); const sentryOptions: SentryInitOptions = { @@ -97,6 +103,7 @@ async function main(): Promise { return patchIndexHtml(indexHtml, { bootstrapScript: renderBootstrapScript({ devMode: options.devMode, + hostConfig: buildLocalHostConfig("local", hostKind), sentryOptions, stylesheetHref: POCODEX_STYLESHEET_HREF, importIconSvg: await readFile(importIconSvgPath, "utf8"), diff --git a/src/lib/app-server-bridge.ts b/src/lib/app-server-bridge.ts index 8f0d045..3f7b0a4 100644 --- a/src/lib/app-server-bridge.ts +++ b/src/lib/app-server-bridge.ts @@ -46,6 +46,7 @@ interface AppServerBridgeOptions { appPath: string; cwd: string; hostId?: string; + hostKind?: LocalHostKind; codexHomePath?: string; persistedAtomRegistryPath?: string; workspaceRootRegistryPath?: string; @@ -53,6 +54,14 @@ interface AppServerBridgeOptions { codexCliPath?: string; } +export type LocalHostKind = "git" | "local"; + +export interface LocalHostConfig { + id: string; + display_name: string; + kind: LocalHostKind; +} + interface WhamUsageCredits { has_credits: boolean; unlimited: boolean; @@ -272,6 +281,7 @@ const LOCAL_UNSUPPORTED_FETCH_BODY = { export class AppServerBridge extends EventEmitter implements HostBridge { private readonly child: ChildProcessWithoutNullStreams; private readonly hostId: string; + private readonly hostKind: LocalHostKind; private readonly cwd: string; private readonly terminalManager: TerminalSessionManager; private readonly localRequests = new Map< @@ -316,6 +326,7 @@ export class AppServerBridge extends EventEmitter implements HostBridge { private constructor(options: AppServerBridgeOptions) { super(); this.hostId = options.hostId ?? "local"; + this.hostKind = options.hostKind ?? "local"; this.cwd = options.cwd; this.codexHomePath = options.codexHomePath ?? deriveCodexHomePath(); this.persistedAtomRegistryPath = @@ -2990,12 +3001,8 @@ export class AppServerBridge extends EventEmitter implements HostBridge { }); } - private buildHostConfig(): Record { - return { - id: this.hostId, - display_name: "Local", - kind: "local", - }; + private buildHostConfig(): LocalHostConfig { + return buildLocalHostConfig(this.hostId, this.hostKind); } private emitFetchSuccess( @@ -3588,6 +3595,22 @@ async function resolveGitOrigins( }; } +export function buildLocalHostConfig( + hostId = "local", + hostKind: LocalHostKind = "local", +): LocalHostConfig { + return { + id: hostId, + display_name: "Local", + kind: hostKind, + }; +} + +export async function resolveLocalHostKind(cwd: string): Promise { + const repository = await resolveGitRepository(cwd, new Map()); + return repository ? "git" : "local"; +} + async function resolveGitOrigin( dir: string, repositoriesByRoot: Map, diff --git a/src/lib/bootstrap-script.ts b/src/lib/bootstrap-script.ts index b0508ab..71496b2 100644 --- a/src/lib/bootstrap-script.ts +++ b/src/lib/bootstrap-script.ts @@ -7,6 +7,11 @@ import { serializeInlineScript } from "./inline-script.js"; export interface BootstrapScriptConfig { devMode?: boolean; + hostConfig?: { + id: string; + display_name: string; + kind: "git" | "local"; + }; sentryOptions: SentryInitOptions; stylesheetHref: string; importIconSvg?: string; @@ -99,8 +104,12 @@ function bootstrapPocodexInBrowser(config: BootstrapScriptConfig): void { windowType: "electron"; sendMessageFromView(message: unknown): Promise; getPathForFile(): null; + getSharedObjectSnapshotValue?(key: string): unknown; + getSystemThemeVariant?(): "light" | "dark"; sendWorkerMessageFromView(workerName: string, message: unknown): Promise; + showApplicationMenu?(menuId: string, x: number, y: number): Promise; subscribeToWorkerMessages(workerName: string, callback: WorkerMessageListener): () => void; + subscribeToSystemThemeVariant?(callback: () => void): () => void; showContextMenu(): Promise; getFastModeRolloutMetrics(): Promise>; triggerSentryTestError(): Promise; @@ -146,6 +155,17 @@ function bootstrapPocodexInBrowser(config: BootstrapScriptConfig): void { const workerSubscribers = new Map>(); const restorableTerminalAttachments = new Map(); + const sharedObjectSnapshots = new Map([ + [ + "host_config", + config.hostConfig ?? { + id: LOCAL_HOST_ID, + display_name: "Local", + kind: "local", + }, + ], + ["remote_connections", []], + ]); const pendingMessages: string[] = []; const toastHost = document.createElement("div"); const statusHost = document.createElement("div"); @@ -2323,11 +2343,22 @@ function bootstrapPocodexInBrowser(config: BootstrapScriptConfig): void { const overriddenMessage = overrideEnterBehaviorInMessage(message); rememberDispatchedEnterBehavior(overriddenMessage); syncSidebarModeWithBridgeMessage(overriddenMessage); + if (isRecord(overriddenMessage)) { + rememberSharedObjectSnapshot(overriddenMessage); + } syncThreadQueryWithBridgeMessage(overriddenMessage); syncRestorableTerminalAttachments(overriddenMessage, "incoming"); return overriddenMessage; } + function rememberSharedObjectSnapshot(message: Record): void { + if (message.type !== "shared-object-updated" || typeof message.key !== "string") { + return; + } + + sharedObjectSnapshots.set(message.key, message.value ?? null); + } + function overrideEnterBehaviorInMessage(message: Record): unknown { if (!shouldForceNewlineEnterBehavior()) { return message; @@ -3478,6 +3509,48 @@ function bootstrapPocodexInBrowser(config: BootstrapScriptConfig): void { return typeof value === "object" && value !== null; } + function readSystemThemeVariant(): "light" | "dark" { + if (typeof window.matchMedia !== "function") { + return "light"; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + function observeSystemThemeVariant(callback: () => void): () => void { + if (typeof window.matchMedia !== "function") { + return () => {}; + } + + const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); + const listener = () => { + callback(); + }; + + if (typeof mediaQueryList.addEventListener === "function") { + mediaQueryList.addEventListener("change", listener); + return () => { + mediaQueryList.removeEventListener("change", listener); + }; + } + + const legacyMediaQueryList = mediaQueryList as MediaQueryList & { + addListener?: (listener: (event: unknown) => void) => void; + removeListener?: (listener: (event: unknown) => void) => void; + }; + if ( + typeof legacyMediaQueryList.addListener === "function" && + typeof legacyMediaQueryList.removeListener === "function" + ) { + legacyMediaQueryList.addListener(listener); + return () => { + legacyMediaQueryList.removeListener?.(listener); + }; + } + + return () => {}; + } + function addWorkerSubscriber(workerName: string, callback: WorkerMessageListener): () => void { let listeners = workerSubscribers.get(workerName); if (!listeners) { @@ -3518,10 +3591,16 @@ function bootstrapPocodexInBrowser(config: BootstrapScriptConfig): void { } }, getPathForFile: () => null, + getSharedObjectSnapshotValue: (key) => sharedObjectSnapshots.get(key) ?? null, + getSystemThemeVariant: () => readSystemThemeVariant(), sendWorkerMessageFromView: async (workerName, message) => { sendEnvelope({ type: "worker_message", workerName, message }); }, + showApplicationMenu: async () => { + showNotice("Application menus are not available in Pocodex."); + }, subscribeToWorkerMessages: (workerName, callback) => addWorkerSubscriber(workerName, callback), + subscribeToSystemThemeVariant: (callback) => observeSystemThemeVariant(callback), showContextMenu: async () => { showNotice("Context menus are not available in Pocodex."); }, @@ -3533,6 +3612,7 @@ function bootstrapPocodexInBrowser(config: BootstrapScriptConfig): void { }; const nativeFetch: typeof window.fetch = window.fetch.bind(window); + window.fetch = (input, init) => { const url = typeof input === "string" ? input : input instanceof Request ? input.url : String(input); diff --git a/src/lib/codex-desktop-git-worker.ts b/src/lib/codex-desktop-git-worker.ts index d9caba2..a622573 100644 --- a/src/lib/codex-desktop-git-worker.ts +++ b/src/lib/codex-desktop-git-worker.ts @@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { cp, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { platform, tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import process from "node:process"; import { Worker } from "node:worker_threads"; @@ -11,6 +11,7 @@ import { ensureCodexDesktopWorkerScript, type CodexDesktopWorkerScript } from ". import { debugLog, isDebugEnabled } from "./debug.js"; type GitWorkerMainRpcMethod = + | "platform-family" | "worktree-cleanup-inputs" | "fs-read-file" | "fs-write-file" @@ -352,12 +353,12 @@ export class DefaultCodexDesktopGitWorkerBridge }); worker.on("message", (message) => { + const responseId = extractWorkerResponseId(message); if (isWorkerMainRpcRequestEnvelope(message)) { void this.handleMainRpcRequest(worker, message); return; } - const responseId = extractWorkerResponseId(message); if (responseId) { this.pendingRequests.delete(responseId); } @@ -408,6 +409,10 @@ export class DefaultCodexDesktopGitWorkerBridge try { switch (message.method as GitWorkerMainRpcMethod) { + case "platform-family": { + postMainRpcSuccess(worker, message, platform() === "win32" ? "windows" : "unix"); + return; + } case "worktree-cleanup-inputs": { const params = parseWorktreeCleanupInputs(message.params); postMainRpcSuccess(worker, message, { diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts index 71cb871..4d13d17 100644 --- a/src/lib/runtime.ts +++ b/src/lib/runtime.ts @@ -5,7 +5,11 @@ import { basename, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { EventEmitter } from "node:events"; -import { AppServerBridge } from "./app-server-bridge.js"; +import { + AppServerBridge, + buildLocalHostConfig, + resolveLocalHostKind, +} from "./app-server-bridge.js"; import { renderBootstrapScript } from "./bootstrap-script.js"; import { loadCodexBundle } from "./codex-bundle.js"; import { patchIndexHtml } from "./html-patcher.js"; @@ -152,10 +156,12 @@ class ManagedPocodexRuntime extends EventEmitter implements PocodexRuntime { const bundle = await loadCodexBundle(this.options.appPath); const pocodexCssPath = fileURLToPath(new URL("../pocodex.css", import.meta.url)); const importIconSvgPath = fileURLToPath(new URL("../images/import.svg", import.meta.url)); + const hostKind = await resolveLocalHostKind(this.options.cwd); relay = await AppServerBridge.connect({ appPath: this.options.appPath, cwd: this.options.cwd, + hostKind, }); relayErrorListener = (error) => { @@ -191,6 +197,7 @@ class ManagedPocodexRuntime extends EventEmitter implements PocodexRuntime { return patchIndexHtml(indexHtml, { bootstrapScript: renderBootstrapScript({ devMode: this.options.devMode, + hostConfig: buildLocalHostConfig("local", hostKind), sentryOptions, stylesheetHref: POCODEX_STYLESHEET_HREF, importIconSvg: await readFile(importIconSvgPath, "utf8"), diff --git a/test/app-server-bridge-host-config.test.ts b/test/app-server-bridge-host-config.test.ts new file mode 100644 index 0000000..e4ab2c4 --- /dev/null +++ b/test/app-server-bridge-host-config.test.ts @@ -0,0 +1,37 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { buildLocalHostConfig, resolveLocalHostKind } from "../src/lib/app-server-bridge.js"; + +const tempDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("local host config helpers", () => { + it("builds host config for git-backed workspaces", () => { + expect(buildLocalHostConfig("workspace", "git")).toEqual({ + id: "workspace", + display_name: "Local", + kind: "git", + }); + }); + + it("detects whether a workspace is git-backed", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocodex-host-kind-")); + tempDirectories.push(directory); + + await expect(resolveLocalHostKind(directory)).resolves.toBe("local"); + + execFileSync("git", ["init", "-q"], { cwd: directory }); + + await expect(resolveLocalHostKind(directory)).resolves.toBe("git"); + }); +}); diff --git a/test/bootstrap-script.test.ts b/test/bootstrap-script.test.ts index c87002a..41cda57 100644 --- a/test/bootstrap-script.test.ts +++ b/test/bootstrap-script.test.ts @@ -489,16 +489,48 @@ function readTestAttribute(element: TestElement, name: string): string | null { return element.getAttribute(name); } -function drainTestTimers(pendingTimers: Map void>, maxRuns = 20): void { +interface TestTimer { + callback: () => void; + delay: number; +} + +function drainTestTimers(pendingTimers: Map, maxRuns = 20): void { for (let index = 0; index < maxRuns; index += 1) { - const next = pendingTimers.entries().next().value as [number, () => void] | undefined; + const next = pendingTimers.entries().next().value as [number, TestTimer] | undefined; if (!next) { return; } - const [timerId, callback] = next; + const [timerId, timer] = next; pendingTimers.delete(timerId); - callback(); + timer.callback(); + } +} + +function drainTestTimersByDelay(pendingTimers: Map, maxRuns = 20): void { + for (let index = 0; index < maxRuns; index += 1) { + const scheduledTimers = Array.from(pendingTimers.entries()); + if (scheduledTimers.length === 0) { + return; + } + + const nextDelay = Math.min(...scheduledTimers.map(([, timer]) => timer.delay)); + if (nextDelay > 0) { + for (const timer of pendingTimers.values()) { + timer.delay = Math.max(0, timer.delay - nextDelay); + } + } + + const next = Array.from(pendingTimers.entries()) + .filter(([, timer]) => timer.delay === 0) + .sort((left, right) => left[0] - right[0])[0]; + if (!next) { + return; + } + + const [timerId, timer] = next; + pendingTimers.delete(timerId); + timer.callback(); } } @@ -560,7 +592,7 @@ function createBootstrapHarness( scriptUrl: string; options?: { scope?: string; updateViaCache?: "all" | "imports" | "none" }; }> = []; - const timers = new Map void>(); + const timers = new Map(); const fetchCalls: Array<{ input: unknown; init: unknown }> = []; const dispatchedMessages: unknown[] = []; const replaceStateCalls: Array = []; @@ -594,7 +626,7 @@ function createBootstrapHarness( replaceState: (data: unknown, unused: string, url?: string | URL | null) => void; }; fetch: (input: unknown, init?: unknown) => Promise; - setTimeout: (callback: () => void, delay: number) => number; + setTimeout: (callback: () => void, delay?: number) => number; clearTimeout: (id: number) => void; matchMedia: (query: string) => { matches: boolean; media: string }; navigator: { @@ -654,9 +686,9 @@ function createBootstrapHarness( fetchCalls.push({ input, init }); return fetchImplementation(input, init); }; - windowObject.setTimeout = (callback: () => void) => { + windowObject.setTimeout = (callback: () => void, delay = 0) => { const id = nextTimerId++; - timers.set(id, callback); + timers.set(id, { callback, delay }); return id; }; windowObject.clearTimeout = (id: number) => { @@ -754,9 +786,17 @@ function createBootstrapHarness( }, getElectronBridge(): { sendMessageFromView: (message: unknown) => Promise; + getSharedObjectSnapshotValue?: (key: string) => unknown; + getSystemThemeVariant?: () => "light" | "dark"; + showApplicationMenu?: (menuId: string, x: number, y: number) => Promise; + subscribeToSystemThemeVariant?: (callback: () => void) => () => void; } { return Reflect.get(windowObject, "electronBridge") as { sendMessageFromView: (message: unknown) => Promise; + getSharedObjectSnapshotValue?: (key: string) => unknown; + getSystemThemeVariant?: () => "light" | "dark"; + showApplicationMenu?: (menuId: string, x: number, y: number) => Promise; + subscribeToSystemThemeVariant?: (callback: () => void) => () => void; }; }, emitServerEnvelope(envelope: unknown): void { @@ -842,6 +882,40 @@ describe("renderBootstrapScript", () => { ]); }); + it("exposes the current electron bridge compatibility methods and seeds host config", async () => { + const harness = createBootstrapHarness(); + const hostConfig = { + id: "workspace", + display_name: "Workspace", + kind: "git" as const, + }; + const script = renderBootstrapScript({ + hostConfig, + sentryOptions: { + buildFlavor: "stable", + appVersion: "1", + buildNumber: "123", + codexAppSessionId: "session-id", + }, + stylesheetHref: "/pocodex.css", + }); + + harness.run(script); + + const bridge = harness.getElectronBridge(); + + expect(typeof bridge.getSharedObjectSnapshotValue).toBe("function"); + expect(bridge.getSharedObjectSnapshotValue?.("host_config")).toEqual(hostConfig); + expect(bridge.getSharedObjectSnapshotValue?.("remote_connections")).toEqual([]); + expect(typeof bridge.getSystemThemeVariant).toBe("function"); + expect(bridge.getSystemThemeVariant?.()).toBe("light"); + expect(typeof bridge.subscribeToSystemThemeVariant).toBe("function"); + expect(typeof bridge.subscribeToSystemThemeVariant?.(() => {})).toBe("function"); + + await expect(bridge.showApplicationMenu?.("main", 12, 24)).resolves.toBeUndefined(); + expect(harness.document.querySelector("[data-pocodex-toast='true']")).toBeTruthy(); + }); + it("offers reconnect and reload actions from the connection status overlay", async () => { const harness = createBootstrapHarness({ mobile: true, @@ -2662,18 +2736,18 @@ describe("renderBootstrapScript", () => { await flushBootstrapMicrotasks(); harness.openSocket(); - drainTestTimers(harness.timers, 1); + drainTestTimersByDelay(harness.timers, 1); expect(harness.dispatchedMessages).toContainEqual({ type: "toggle-sidebar" }); contentPane.setBoundingClientRect({ left: 0, width: 1280 }); - drainTestTimers(harness.timers); + drainTestTimersByDelay(harness.timers); expect(harness.getLocalStorageValue("pocodex-sidebar-mode")).toBe("collapsed"); contentPane.setBoundingClientRect({ left: 300, width: 980 }); harness.document.dispatchEvent(new TestMouseEvent("click", { target: toggleButton })); - drainTestTimers(harness.timers); + drainTestTimersByDelay(harness.timers); expect(harness.getLocalStorageValue("pocodex-sidebar-mode")).toBe("expanded"); }); @@ -2737,13 +2811,13 @@ describe("renderBootstrapScript", () => { await flushBootstrapMicrotasks(); harness.openSocket(); - drainTestTimers(harness.timers, 1); + drainTestTimersByDelay(harness.timers, 1); expect(harness.dispatchedMessages).toContainEqual({ type: "toggle-sidebar" }); contentPane.setAttribute("class", "main-surface left-0"); contentPane.setBoundingClientRect({ left: 0, width: 1280 }); harness.windowObject.dispatchEvent({ type: "resize" }); - drainTestTimers(harness.timers); + drainTestTimersByDelay(harness.timers); contentPane.setAttribute("class", "main-surface left-token-sidebar"); contentPane.setBoundingClientRect({ left: 300, width: 980 }); @@ -2842,7 +2916,7 @@ describe("renderBootstrapScript", () => { contentPane.style.transform = "translateX(0)"; contentPane.setBoundingClientRect({ left: 0, width: 390 }); navigation.setBoundingClientRect({ left: 0, width: 240 }); - drainTestTimers(harness.timers); + drainTestTimersByDelay(harness.timers); expect(harness.getLocalStorageValue("pocodex-sidebar-mode")).toBe("collapsed"); diff --git a/test/codex-desktop-git-worker.test.ts b/test/codex-desktop-git-worker.test.ts index 608d98b..153db8d 100644 --- a/test/codex-desktop-git-worker.test.ts +++ b/test/codex-desktop-git-worker.test.ts @@ -160,6 +160,45 @@ describe("DefaultCodexDesktopGitWorkerBridge", () => { await bridge.close(); }); + it("supports the platform-family main RPC method used by current desktop workers", async () => { + const bridge = new DefaultCodexDesktopGitWorkerBridge({ + appPath: "/Applications/Codex.app", + resolveWorkerScript: async () => ({ + workerPath: "/tmp/pocodex-worker.js", + metadata: { + appPath: "/Applications/Codex.app", + buildFlavor: "stable", + buildNumber: "123", + version: "1.2.3", + }, + }), + WorkerClass: FakeWorker as never, + }); + + await bridge.subscribe(); + + const worker = FakeWorker.instances[0]; + worker?.emit("message", { + type: "worker-main-rpc-request", + workerId: "git", + requestId: "rpc-platform-family", + method: "platform-family", + }); + + expect(worker?.postedMessages.at(-1)).toEqual({ + type: "worker-main-rpc-response", + workerId: "git", + requestId: "rpc-platform-family", + method: "platform-family", + result: { + type: "ok", + value: process.platform === "win32" ? "windows" : "unix", + }, + }); + + await bridge.close(); + }); + it("returns an explicit error for unknown main RPC methods", async () => { const bridge = new DefaultCodexDesktopGitWorkerBridge({ appPath: "/Applications/Codex.app",