@btatum5/codex-bridge 0.1.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ // FILE: desktop-handler.js
2
+ // Purpose: Handles explicit desktop-handoff bridge actions for Codex.app.
3
+ // Layer: Bridge handler
4
+ // Exports: handleDesktopRequest
5
+ // Depends on: child_process, fs, os, path, ./rollout-watch
6
+
7
+ const { execFile } = require("child_process");
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const { promisify } = require("util");
11
+ const { findRolloutFileForThread, resolveSessionsRoot } = require("./rollout-watch");
12
+
13
+ const execFileAsync = promisify(execFile);
14
+ const DEFAULT_BUNDLE_ID = "com.openai.codex";
15
+ const DEFAULT_APP_PATH = "/Applications/Codex.app";
16
+ const DEFAULT_PLATFORM = process.platform;
17
+ const HANDOFF_TIMEOUT_MS = 20_000;
18
+ const DEFAULT_RELAUNCH_WAIT_MS = 300;
19
+ const DEFAULT_APP_BOOT_WAIT_MS = 1_200;
20
+ const DEFAULT_THREAD_MATERIALIZE_WAIT_MS = 4_000;
21
+ const DEFAULT_THREAD_MATERIALIZE_POLL_MS = 250;
22
+
23
+ function handleDesktopRequest(rawMessage, sendResponse, options = {}) {
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(rawMessage);
27
+ } catch {
28
+ return false;
29
+ }
30
+
31
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
32
+ if (!method.startsWith("desktop/")) {
33
+ return false;
34
+ }
35
+
36
+ const id = parsed.id;
37
+ const params = parsed.params || {};
38
+
39
+ handleDesktopMethod(method, params, options)
40
+ .then((result) => {
41
+ sendResponse(JSON.stringify({ id, result }));
42
+ })
43
+ .catch((err) => {
44
+ const errorCode = err.errorCode || "desktop_error";
45
+ const message = err.userMessage || err.message || "Unknown desktop handoff error";
46
+ sendResponse(JSON.stringify({
47
+ id,
48
+ error: {
49
+ code: -32000,
50
+ message,
51
+ data: { errorCode },
52
+ },
53
+ }));
54
+ });
55
+
56
+ return true;
57
+ }
58
+
59
+ async function handleDesktopMethod(method, params, options = {}) {
60
+ const platform = options.platform || DEFAULT_PLATFORM;
61
+ const bundleId = options.bundleId || DEFAULT_BUNDLE_ID;
62
+ const appPath = options.appPath || DEFAULT_APP_PATH;
63
+ const executor = options.executor || execFileAsync;
64
+ const env = options.env || process.env;
65
+ const fsModule = options.fsModule || fs;
66
+ const isAppRunning = options.isAppRunning || null;
67
+ const sleepFn = options.sleepFn || sleep;
68
+ const appBootWaitMs = options.appBootWaitMs ?? DEFAULT_APP_BOOT_WAIT_MS;
69
+ const relaunchWaitMs = options.relaunchWaitMs ?? DEFAULT_RELAUNCH_WAIT_MS;
70
+ const threadMaterializeWaitMs = options.threadMaterializeWaitMs ?? DEFAULT_THREAD_MATERIALIZE_WAIT_MS;
71
+ const threadMaterializePollMs = options.threadMaterializePollMs ?? DEFAULT_THREAD_MATERIALIZE_POLL_MS;
72
+
73
+ if (platform !== "darwin") {
74
+ throw desktopError(
75
+ "unsupported_platform",
76
+ "Desktop-app handoff is only available when the bridge is running on macOS."
77
+ );
78
+ }
79
+
80
+ switch (method) {
81
+ case "desktop/continueOnMac":
82
+ return continueOnMac(params, {
83
+ bundleId,
84
+ appPath,
85
+ executor,
86
+ env,
87
+ fsModule,
88
+ isAppRunning,
89
+ sleepFn,
90
+ appBootWaitMs,
91
+ relaunchWaitMs,
92
+ threadMaterializeWaitMs,
93
+ threadMaterializePollMs,
94
+ });
95
+ default:
96
+ throw desktopError("unknown_method", `Unknown desktop method: ${method}`);
97
+ }
98
+ }
99
+
100
+ // Waits for fresh phone-authored chats to materialize locally before deep-linking them on Mac.
101
+ async function continueOnMac(
102
+ params,
103
+ {
104
+ bundleId,
105
+ appPath,
106
+ executor,
107
+ env,
108
+ fsModule,
109
+ isAppRunning,
110
+ sleepFn,
111
+ appBootWaitMs,
112
+ relaunchWaitMs,
113
+ threadMaterializeWaitMs,
114
+ threadMaterializePollMs,
115
+ }
116
+ ) {
117
+ const threadId = resolveThreadId(params);
118
+ if (!threadId) {
119
+ throw desktopError("missing_thread_id", "A thread id is required to continue in the desktop app.");
120
+ }
121
+
122
+ const targetUrl = `codex://threads/${threadId}`;
123
+ const desktopKnown = isThreadLikelyKnownOnDesktop(threadId, { env, fsModule });
124
+ const appRunning = typeof isAppRunning === "function"
125
+ ? await isAppRunning(appPath)
126
+ : await detectRunningCodexApp(appPath, executor);
127
+
128
+ // If Codex.app is already open, explicit handoff should still feel like a
129
+ // real device switch: close, reopen, then focus the requested thread.
130
+ if (desktopKnown && !appRunning) {
131
+ try {
132
+ await openCodexTarget(targetUrl, { bundleId, appPath, executor });
133
+ } catch (error) {
134
+ throw desktopError(
135
+ "handoff_failed",
136
+ "Could not open Codex.app on this Mac.",
137
+ error
138
+ );
139
+ }
140
+
141
+ return {
142
+ success: true,
143
+ relaunched: false,
144
+ targetUrl,
145
+ threadId,
146
+ desktopKnown,
147
+ };
148
+ }
149
+
150
+ // Brand-new phone-authored threads still need a short boot/materialization
151
+ // window before the final deep link is likely to work.
152
+ if (!appRunning) {
153
+ try {
154
+ await openCodexApp({ bundleId, appPath, executor });
155
+ await sleepFn(appBootWaitMs);
156
+ await openWhenThreadReady(threadId, targetUrl, {
157
+ bundleId,
158
+ appPath,
159
+ executor,
160
+ env,
161
+ fsModule,
162
+ sleepFn,
163
+ waitMs: threadMaterializeWaitMs,
164
+ pollMs: threadMaterializePollMs,
165
+ });
166
+ } catch (error) {
167
+ throw desktopError(
168
+ "handoff_failed",
169
+ "Could not open Codex.app on this Mac.",
170
+ error
171
+ );
172
+ }
173
+
174
+ return {
175
+ success: true,
176
+ relaunched: false,
177
+ targetUrl,
178
+ threadId,
179
+ desktopKnown,
180
+ };
181
+ }
182
+
183
+ try {
184
+ await forceRelaunchCodexApp({
185
+ bundleId,
186
+ appPath,
187
+ executor,
188
+ isAppRunning,
189
+ sleepFn,
190
+ relaunchWaitMs,
191
+ appBootWaitMs,
192
+ });
193
+ await openWhenThreadReady(threadId, targetUrl, {
194
+ bundleId,
195
+ appPath,
196
+ executor,
197
+ env,
198
+ fsModule,
199
+ sleepFn,
200
+ waitMs: threadMaterializeWaitMs,
201
+ pollMs: threadMaterializePollMs,
202
+ });
203
+ } catch (error) {
204
+ throw desktopError(
205
+ "handoff_failed",
206
+ "Could not force close and reopen Codex.app on this Mac.",
207
+ error
208
+ );
209
+ }
210
+
211
+ return {
212
+ success: true,
213
+ relaunched: true,
214
+ targetUrl,
215
+ threadId,
216
+ desktopKnown,
217
+ };
218
+ }
219
+
220
+ function resolveThreadId(params) {
221
+ if (!params || typeof params !== "object") {
222
+ return "";
223
+ }
224
+
225
+ const candidates = [
226
+ params.threadId,
227
+ params.thread_id,
228
+ ];
229
+
230
+ for (const candidate of candidates) {
231
+ if (typeof candidate === "string" && candidate.trim()) {
232
+ return candidate.trim();
233
+ }
234
+ }
235
+
236
+ return "";
237
+ }
238
+
239
+ function desktopError(errorCode, userMessage, cause = null) {
240
+ const error = new Error(userMessage);
241
+ error.errorCode = errorCode;
242
+ error.userMessage = userMessage;
243
+ if (cause) {
244
+ error.cause = cause;
245
+ }
246
+ return error;
247
+ }
248
+
249
+ function isThreadLikelyKnownOnDesktop(threadId, { env, fsModule }) {
250
+ const sessionsRoot = resolveSessionsRootForEnv(env);
251
+ // Any rollout means the thread already materialized locally, even if it originated on iPhone.
252
+ return findRolloutFileForThread(sessionsRoot, threadId, { fsModule }) != null;
253
+ }
254
+
255
+ function resolveSessionsRootForEnv(env) {
256
+ if (env?.CODEX_HOME) {
257
+ return joinConfiguredPath(env.CODEX_HOME, "sessions");
258
+ }
259
+
260
+ return resolveSessionsRoot();
261
+ }
262
+
263
+ function joinConfiguredPath(basePath, ...segments) {
264
+ const normalizedBasePath = typeof basePath === "string" ? basePath.trim() : "";
265
+ if (!normalizedBasePath) {
266
+ return path.join(basePath, ...segments);
267
+ }
268
+
269
+ if (normalizedBasePath.startsWith("/")) {
270
+ return path.posix.join(normalizedBasePath, ...segments);
271
+ }
272
+
273
+ if (/^[A-Za-z]:[\\/]/.test(normalizedBasePath)) {
274
+ return path.win32.join(normalizedBasePath, ...segments);
275
+ }
276
+
277
+ return path.join(normalizedBasePath, ...segments);
278
+ }
279
+
280
+ async function detectRunningCodexApp(appPath, executor) {
281
+ const appName = path.basename(appPath, ".app");
282
+
283
+ try {
284
+ await executor("pgrep", ["-x", appName], {
285
+ timeout: HANDOFF_TIMEOUT_MS,
286
+ });
287
+ return true;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ async function openCodexTarget(targetUrl, { bundleId, appPath, executor }) {
294
+ try {
295
+ await executor("open", ["-b", bundleId, targetUrl], {
296
+ timeout: HANDOFF_TIMEOUT_MS,
297
+ });
298
+ } catch {
299
+ await executor("open", ["-a", appPath, targetUrl], {
300
+ timeout: HANDOFF_TIMEOUT_MS,
301
+ });
302
+ }
303
+ }
304
+
305
+ async function openCodexApp({ bundleId, appPath, executor }) {
306
+ try {
307
+ await executor("open", ["-b", bundleId], {
308
+ timeout: HANDOFF_TIMEOUT_MS,
309
+ });
310
+ } catch {
311
+ await executor("open", ["-a", appPath], {
312
+ timeout: HANDOFF_TIMEOUT_MS,
313
+ });
314
+ }
315
+ }
316
+
317
+ // Gives the desktop a short window to materialize the requested thread before the final deep link.
318
+ async function openWhenThreadReady(
319
+ threadId,
320
+ targetUrl,
321
+ { bundleId, appPath, executor, env, fsModule, sleepFn, waitMs, pollMs }
322
+ ) {
323
+ await waitForThreadMaterialization(threadId, {
324
+ env,
325
+ fsModule,
326
+ sleepFn,
327
+ timeoutMs: waitMs,
328
+ pollMs,
329
+ });
330
+ await openCodexTarget(targetUrl, { bundleId, appPath, executor });
331
+ }
332
+
333
+ async function forceRelaunchCodexApp({
334
+ bundleId,
335
+ appPath,
336
+ executor,
337
+ isAppRunning,
338
+ sleepFn,
339
+ relaunchWaitMs,
340
+ appBootWaitMs,
341
+ }) {
342
+ const appName = path.basename(appPath, ".app");
343
+
344
+ try {
345
+ await executor("pkill", ["-x", appName], {
346
+ timeout: HANDOFF_TIMEOUT_MS,
347
+ });
348
+ } catch (error) {
349
+ if (error?.code !== 1) {
350
+ throw error;
351
+ }
352
+ }
353
+
354
+ await waitForAppExit(appPath, executor, isAppRunning, sleepFn);
355
+ await sleepFn(relaunchWaitMs);
356
+ await openCodexApp({ bundleId, appPath, executor });
357
+ await sleepFn(appBootWaitMs);
358
+ }
359
+
360
+ async function waitForAppExit(appPath, executor, isAppRunning, sleepFn = sleep) {
361
+ const deadline = Date.now() + HANDOFF_TIMEOUT_MS;
362
+
363
+ while (Date.now() < deadline) {
364
+ const isRunning = typeof isAppRunning === "function"
365
+ ? await isAppRunning(appPath)
366
+ : await detectRunningCodexApp(appPath, executor);
367
+ if (!isRunning) {
368
+ return;
369
+ }
370
+
371
+ await sleepFn(100);
372
+ }
373
+
374
+ throw desktopError("handoff_timeout", "Timed out waiting for Codex.app to close.");
375
+ }
376
+
377
+ function hasDesktopRolloutForThread(threadId, { env, fsModule }) {
378
+ const sessionsRoot = resolveSessionsRootForEnv(env);
379
+ return findRolloutFileForThread(sessionsRoot, threadId, { fsModule }) != null;
380
+ }
381
+
382
+ async function waitForThreadMaterialization(
383
+ threadId,
384
+ { env, fsModule, sleepFn, timeoutMs, pollMs }
385
+ ) {
386
+ if (hasDesktopRolloutForThread(threadId, { env, fsModule })) {
387
+ return true;
388
+ }
389
+
390
+ const deadline = Date.now() + Math.max(0, timeoutMs);
391
+ while (Date.now() < deadline) {
392
+ await sleepFn(pollMs);
393
+ if (hasDesktopRolloutForThread(threadId, { env, fsModule })) {
394
+ return true;
395
+ }
396
+ }
397
+
398
+ return false;
399
+ }
400
+
401
+ function sleep(ms) {
402
+ return new Promise((resolve) => setTimeout(resolve, ms));
403
+ }
404
+
405
+ module.exports = {
406
+ handleDesktopRequest,
407
+ };