- commit
- 1d74e50
- parent
- f2ba058
- author
- codex@macbookpro
- date
- 2026-04-03 15:25:49 +0800 CST
Merge remote-tracking branch 'origin/feat/safari-chatgpt-final-message'
45 files changed,
+6737,
-94
Raw patch view.
1diff --git a/apps/conductor-daemon/src/firefox-ws.ts b/apps/conductor-daemon/src/firefox-ws.ts
2index 0190a19e5415f42475b68c58f243f59e70299f1b..f764b8a1574ef70b286124ce1775a68fafdc0acb 100644
3--- a/apps/conductor-daemon/src/firefox-ws.ts
4+++ b/apps/conductor-daemon/src/firefox-ws.ts
5@@ -38,8 +38,9 @@ import {
6 } from "./renewal/conversations.js";
7
8 const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
9+const BROWSER_WS_PROTOCOL = "baa.browser.local";
10 const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
11-const FIREFOX_WS_PROTOCOL_VERSION = 1;
12+const BROWSER_WS_PROTOCOL_VERSION = 1;
13 const FIREFOX_WS_STATE_POLL_INTERVAL_MS = 2_000;
14 const FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS = 45_000;
15 const FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS = 120_000;
16@@ -50,6 +51,7 @@ const NORMAL_CLOSE_CODE = 1000;
17 const UNSUPPORTED_DATA_CLOSE_CODE = 1003;
18 const MESSAGE_TOO_LARGE_CLOSE_CODE = 1009;
19
20+export const BROWSER_WS_PATH = "/ws/browser";
21 export const FIREFOX_WS_PATH = "/ws/firefox";
22
23 type IntervalHandle = ReturnType<typeof globalThis.setInterval>;
24@@ -163,6 +165,33 @@ function normalizeNonEmptyString(value: unknown): string | null {
25 return normalized === "" ? null : normalized;
26 }
27
28+function inferNodePlatformFromClientId(clientId: string): string | null {
29+ const normalized = clientId.trim().toLowerCase();
30+
31+ if (normalized.startsWith("firefox-")) {
32+ return "firefox";
33+ }
34+
35+ if (normalized.startsWith("safari-")) {
36+ return "safari";
37+ }
38+
39+ return null;
40+}
41+
42+function resolveNodePlatform(input: {
43+ clientId: string;
44+ nodePlatform: string | null;
45+}): string | null {
46+ const explicit = normalizeNonEmptyString(input.nodePlatform);
47+
48+ if (explicit != null) {
49+ return explicit;
50+ }
51+
52+ return inferNodePlatformFromClientId(input.clientId);
53+}
54+
55 function readFirstString(
56 input: Record<string, unknown>,
57 keys: readonly string[]
58@@ -672,6 +701,21 @@ function buildFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer {
59 return Buffer.concat([header, payload]);
60 }
61
62+export function buildBrowserWebSocketUrl(localApiBase: string | null | undefined): string | null {
63+ const normalized = normalizeNonEmptyString(localApiBase);
64+
65+ if (normalized == null) {
66+ return null;
67+ }
68+
69+ const url = new URL(normalized);
70+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
71+ url.pathname = BROWSER_WS_PATH;
72+ url.search = "";
73+ url.hash = "";
74+ return url.toString();
75+}
76+
77 export function buildFirefoxWebSocketUrl(localApiBase: string | null | undefined): string | null {
78 const normalized = normalizeNonEmptyString(localApiBase);
79
80@@ -1091,6 +1135,10 @@ export class ConductorFirefoxWebSocketServer {
81 }
82
83 getUrl(): string | null {
84+ return buildBrowserWebSocketUrl(this.baseUrlLoader());
85+ }
86+
87+ getFirefoxCompatUrl(): string | null {
88 return buildFirefoxWebSocketUrl(this.baseUrlLoader());
89 }
90
91@@ -1149,7 +1197,7 @@ export class ConductorFirefoxWebSocketServer {
92 handleUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): boolean {
93 const pathname = normalizePathname(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
94
95- if (pathname !== FIREFOX_WS_PATH) {
96+ if (pathname !== BROWSER_WS_PATH && pathname !== FIREFOX_WS_PATH) {
97 socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
98 socket.destroy();
99 return false;
100@@ -1276,7 +1324,7 @@ export class ConductorFirefoxWebSocketServer {
101 this.sendError(
102 connection,
103 "unsupported_message_type",
104- "api_request is a server-initiated Firefox bridge command and cannot be sent by the client."
105+ "api_request is a server-initiated browser bridge command and cannot be sent by the client."
106 );
107 return;
108 case "api_response":
109@@ -1314,7 +1362,10 @@ export class ConductorFirefoxWebSocketServer {
110 connection.setClientMetadata({
111 clientId,
112 nodeCategory: readFirstString(message, ["nodeCategory", "node_category"]),
113- nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"]),
114+ nodePlatform: resolveNodePlatform({
115+ clientId,
116+ nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"])
117+ }),
118 nodeType: readFirstString(message, ["nodeType", "node_type"])
119 });
120 this.connectionsByClientId.set(clientId, connection);
121@@ -1323,9 +1374,11 @@ export class ConductorFirefoxWebSocketServer {
122 type: "hello_ack",
123 clientId,
124 localApiBase: this.snapshotLoader().controlApi.localApiBase ?? null,
125- protocol: FIREFOX_WS_PROTOCOL,
126- version: FIREFOX_WS_PROTOCOL_VERSION,
127+ protocol: BROWSER_WS_PROTOCOL,
128+ protocol_compat: [FIREFOX_WS_PROTOCOL],
129+ version: BROWSER_WS_PROTOCOL_VERSION,
130 wsUrl: this.getUrl(),
131+ wsCompatUrls: [this.getFirefoxCompatUrl()].filter((entry): entry is string => entry != null),
132 supports: {
133 inbound: [
134 "hello",
135@@ -1570,7 +1623,10 @@ export class ConductorFirefoxWebSocketServer {
136 const runtime = this.snapshotLoader();
137 await this.repository.upsertBrowserLoginState({
138 account: summary.account,
139- browser: connection.session.nodePlatform ?? "firefox",
140+ browser:
141+ connection.session.nodePlatform
142+ ?? inferNodePlatformFromClientId(clientId)
143+ ?? "firefox",
144 capturedAt,
145 clientId,
146 credentialFingerprint: fingerprint,
147@@ -2108,11 +2164,11 @@ export class ConductorFirefoxWebSocketServer {
148 scheduler_enabled: runtime.daemon.schedulerEnabled,
149 started: runtime.runtime.started,
150 started_at: toUnixMilliseconds(runtime.runtime.startedAt),
151- ws_path: FIREFOX_WS_PATH,
152+ ws_path: BROWSER_WS_PATH,
153 ws_url: this.getUrl()
154 },
155 system: await buildSystemStateData(this.repository),
156- version: FIREFOX_WS_PROTOCOL_VERSION
157+ version: BROWSER_WS_PROTOCOL_VERSION
158 };
159 }
160
161@@ -2145,7 +2201,7 @@ export class ConductorFirefoxWebSocketServer {
162 recent_executes: [],
163 recent_ingests: []
164 },
165- ws_path: FIREFOX_WS_PATH,
166+ ws_path: BROWSER_WS_PATH,
167 ws_url: this.getUrl()
168 };
169 }
170diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
171index a84d995c6e5c88ca7b9516659145216c5ea669de..94f1762dad24e1b166da3a7a02e59d6bbe497569 100644
172--- a/apps/conductor-daemon/src/index.test.js
173+++ b/apps/conductor-daemon/src/index.test.js
174@@ -228,6 +228,7 @@ async function createLocalApiFixture(options = {}) {
175 },
176 controlApi: {
177 baseUrl: "https://control.example.test",
178+ browserWsUrl: "ws://127.0.0.1:4317/ws/browser",
179 firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
180 hasSharedToken: true,
181 localApiBase: "http://127.0.0.1:4317",
182@@ -2288,8 +2289,8 @@ function createBrowserBridgeStub() {
183 shell_runtime: [buildShellRuntime("claude")]
184 }
185 ],
186- ws_path: "/ws/firefox",
187- ws_url: "ws://127.0.0.1:4317/ws/firefox"
188+ ws_path: "/ws/browser",
189+ ws_url: "ws://127.0.0.1:4317/ws/browser"
190 };
191
192 const buildApiResponse = ({ body, clientId = "firefox-claude", error = null, id = null, status = 200 }) => ({
193@@ -2857,7 +2858,7 @@ async function fetchJson(url, init) {
194 };
195 }
196
197-async function connectFirefoxBridgeClient(wsUrl, clientId) {
198+async function connectBrowserBridgeClient(wsUrl, clientId, nodePlatform = "firefox") {
199 const socket = new WebSocket(wsUrl);
200 const queue = createWebSocketMessageQueue(socket);
201
202@@ -2868,7 +2869,7 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
203 clientId,
204 nodeType: "browser",
205 nodeCategory: "proxy",
206- nodePlatform: "firefox"
207+ nodePlatform
208 })
209 );
210
211@@ -2891,6 +2892,10 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
212 };
213 }
214
215+async function connectFirefoxBridgeClient(wsUrl, clientId) {
216+ return await connectBrowserBridgeClient(wsUrl, clientId, "firefox");
217+}
218+
219 test("writeHttpResponse stops waiting for body backpressure when the client closes", async () => {
220 const response = new MockWritableResponse((_chunk, writeCount, writableResponse) => {
221 assert.equal(writeCount, 1);
222@@ -6977,7 +6982,7 @@ test("handleConductorHttpRequest returns browser_request_timeout with timeout de
223 assert.equal(payload.details.bridge_request_id, "browser-timeout-1");
224 });
225
226-test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active Firefox client", async () => {
227+test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active browser client", async () => {
228 const { repository, snapshot } = await createLocalApiFixture();
229
230 const response = await handleConductorHttpRequest(
231@@ -6991,8 +6996,8 @@ test("handleConductorHttpRequest returns a clear 503 for Claude browser actions
232 active_connection_id: null,
233 client_count: 0,
234 clients: [],
235- ws_path: "/ws/firefox",
236- ws_url: snapshot.controlApi.firefoxWsUrl
237+ ws_path: "/ws/browser",
238+ ws_url: snapshot.controlApi.browserWsUrl
239 }),
240 repository,
241 snapshotLoader: () => snapshot
242@@ -7673,6 +7678,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
243 assert.equal(snapshot.daemon.schedulerEnabled, true);
244 assert.equal(snapshot.codexd.localApiBase, codexd.baseUrl);
245 assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
246+ assert.match(snapshot.controlApi.browserWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/browser$/u);
247 assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
248
249 const baseUrl = snapshot.controlApi.localApiBase;
250@@ -7704,6 +7710,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
251 const payload = await runtimeResponse.json();
252 assert.equal(payload.ok, true);
253 assert.equal(payload.data.identity, "mini-main@mini(primary)");
254+ assert.equal(payload.data.controlApi.browserWsUrl, snapshot.controlApi.browserWsUrl);
255 assert.equal(payload.data.controlApi.firefoxWsUrl, snapshot.controlApi.firefoxWsUrl);
256 assert.equal(payload.data.controlApi.localApiBase, baseUrl);
257 assert.equal(payload.data.runtime.started, true);
258@@ -8383,6 +8390,7 @@ test("ConductorRuntime awaits daemon shutdown when local API startup fails", asy
259 },
260 stop: async () => {},
261 getBaseUrl: () => "http://127.0.0.1:0",
262+ getBrowserWebSocketUrl: () => null,
263 getFirefoxWebSocketUrl: () => null
264 };
265
266@@ -8441,6 +8449,7 @@ test("ConductorRuntime.stop waits for daemon shutdown before closing the local A
267 localApiStopSawDaemonResolved = daemonStopResolved;
268 },
269 getBaseUrl: () => "http://127.0.0.1:0",
270+ getBrowserWebSocketUrl: () => null,
271 getFirefoxWebSocketUrl: () => null
272 };
273
274@@ -8608,7 +8617,9 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
275
276 const helloAck = await queue.next((message) => message.type === "hello_ack");
277 assert.equal(helloAck.clientId, "firefox-test");
278- assert.equal(helloAck.wsUrl, wsUrl);
279+ assert.equal(helloAck.protocol, "baa.browser.local");
280+ assert.equal(helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
281+ assert.deepEqual(helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
282
283 const initialSnapshot = await queue.next(
284 (message) => message.type === "state_snapshot" && message.reason === "hello"
285@@ -8719,6 +8730,43 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
286 });
287 });
288
289+test("ConductorRuntime accepts Safari bridge clients on the canonical browser websocket path", async () => {
290+ await withRuntimeFixture(async ({ runtime }) => {
291+ let client = null;
292+
293+ try {
294+ const snapshot = await runtime.start();
295+ const wsUrl = snapshot.controlApi.browserWsUrl;
296+ const baseUrl = snapshot.controlApi.localApiBase;
297+
298+ client = await connectBrowserBridgeClient(wsUrl, "safari-test", "safari");
299+
300+ assert.equal(client.helloAck.clientId, "safari-test");
301+ assert.equal(client.helloAck.protocol, "baa.browser.local");
302+ assert.equal(client.helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
303+ assert.deepEqual(client.helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
304+ assert.equal(client.initialSnapshot.snapshot.browser.clients[0]?.node_platform, "safari");
305+ assert.equal(client.initialSnapshot.snapshot.browser.ws_path, "/ws/browser");
306+
307+ const browserStatus = await fetch(`${baseUrl}/v1/browser`);
308+ assert.equal(browserStatus.status, 200);
309+ const browserPayload = await browserStatus.json();
310+ assert.equal(browserPayload.data.bridge.transport, "local_browser_ws");
311+ assert.equal(browserPayload.data.bridge.ws_path, "/ws/browser");
312+ assert.equal(browserPayload.data.bridge.ws_url, snapshot.controlApi.browserWsUrl);
313+ assert.equal(browserPayload.data.current_client.client_id, "safari-test");
314+ assert.equal(browserPayload.data.current_client.node_platform, "safari");
315+ } finally {
316+ client?.queue.stop();
317+ client?.socket.close();
318+
319+ if (client?.socket) {
320+ await waitForWebSocketClose(client.socket);
321+ }
322+ }
323+ });
324+});
325+
326 test("ConductorRuntime exposes Firefox outbound bridge commands and api request responses", async () => {
327 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-bridge-"));
328 const runtime = new ConductorRuntime(
329@@ -10146,6 +10194,7 @@ test("persistent live ingest survives restart and /v1/browser restores recent hi
330 },
331 controlApi: {
332 baseUrl: "https://control.example.test",
333+ browserWsUrl: "ws://127.0.0.1:4317/ws/browser",
334 firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
335 hasSharedToken: true,
336 localApiBase: "http://127.0.0.1:4317",
337diff --git a/apps/conductor-daemon/src/index.ts b/apps/conductor-daemon/src/index.ts
338index 8bbf934e0f7899725ae43847d55546a8734c8bbf..bab2759a5ccc256b450d272bc6c3e52a4ad1c733 100644
339--- a/apps/conductor-daemon/src/index.ts
340+++ b/apps/conductor-daemon/src/index.ts
341@@ -28,6 +28,7 @@ import {
342 type ConductorHttpResponse
343 } from "./http-types.js";
344 import {
345+ buildBrowserWebSocketUrl,
346 ConductorFirefoxWebSocketServer,
347 buildFirefoxWebSocketUrl
348 } from "./firefox-ws.js";
349@@ -252,6 +253,7 @@ export interface ConductorRuntimeSnapshot {
350 paths: ConductorRuntimePaths;
351 controlApi: {
352 baseUrl: string;
353+ browserWsUrl: string | null;
354 firefoxWsUrl: string | null;
355 localApiBase: string | null;
356 hasSharedToken: boolean;
357@@ -848,6 +850,10 @@ class ConductorLocalHttpServer {
358 }
359
360 getFirefoxWebSocketUrl(): string | null {
361+ return this.firefoxWebSocketServer.getFirefoxCompatUrl();
362+ }
363+
364+ getBrowserWebSocketUrl(): string | null {
365 return this.firefoxWebSocketServer.getUrl();
366 }
367
368@@ -2165,6 +2171,7 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
369 `codexd_local_api_base: ${config.codexdLocalApiBase ?? "not-configured"}`,
370 `code_root_dir: ${config.codeRootDir}`,
371 `local_api_base: ${config.localApiBase ?? "not-configured"}`,
372+ `browser_ws_url: ${buildBrowserWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
373 `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
374 `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
375 `priority: ${config.priority}`,
376@@ -2436,6 +2443,8 @@ export class ConductorRuntime {
377
378 getRuntimeSnapshot(now: number = this.now()): ConductorRuntimeSnapshot {
379 const localApiBase = this.localApiServer?.getBaseUrl() ?? this.config.localApiBase;
380+ const browserWsUrl =
381+ this.localApiServer?.getBrowserWebSocketUrl() ?? buildBrowserWebSocketUrl(localApiBase);
382 const firefoxWsUrl =
383 this.localApiServer?.getFirefoxWebSocketUrl() ?? buildFirefoxWebSocketUrl(localApiBase);
384
385@@ -2446,6 +2455,7 @@ export class ConductorRuntime {
386 paths: { ...this.config.paths },
387 controlApi: {
388 baseUrl: this.config.publicApiBase,
389+ browserWsUrl,
390 firefoxWsUrl,
391 localApiBase,
392 hasSharedToken: this.config.sharedToken != null,
393diff --git a/apps/conductor-daemon/src/local-api.ts b/apps/conductor-daemon/src/local-api.ts
394index 1a2370d743d09a829aa0da6ab91f4122d0378c64..447f523769bb11533b1ad26b31c2afad137402f8 100644
395--- a/apps/conductor-daemon/src/local-api.ts
396+++ b/apps/conductor-daemon/src/local-api.ts
397@@ -345,6 +345,7 @@ export interface ConductorRuntimeApiSnapshot {
398 };
399 controlApi: {
400 baseUrl: string;
401+ browserWsUrl?: string | null;
402 firefoxWsUrl?: string | null;
403 hasSharedToken: boolean;
404 localApiBase: string | null;
405@@ -616,7 +617,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
406 lifecycle: "legacy",
407 method: "POST",
408 pathPattern: "/v1/browser/claude/send",
409- summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Claude 对话"
410+ summary: "legacy 包装:通过本地 browser bridge 发起一轮 Claude 对话"
411 },
412 {
413 id: "browser.claude.current",
414@@ -633,7 +634,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
415 lifecycle: "legacy",
416 method: "POST",
417 pathPattern: "/v1/browser/chatgpt/send",
418- summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 ChatGPT 对话"
419+ summary: "legacy 包装:通过本地 browser bridge 发起一轮 ChatGPT 对话"
420 },
421 {
422 id: "browser.chatgpt.current",
423@@ -650,7 +651,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
424 lifecycle: "legacy",
425 method: "POST",
426 pathPattern: "/v1/browser/gemini/send",
427- summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Gemini 对话"
428+ summary: "legacy 包装:通过本地 browser bridge 发起一轮 Gemini 对话"
429 },
430 {
431 id: "browser.gemini.current",
432@@ -2179,8 +2180,8 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
433 lastSession: null
434 },
435 instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
436- ws_path: "/ws/firefox",
437- ws_url: snapshot.controlApi.firefoxWsUrl ?? null
438+ ws_path: "/ws/browser",
439+ ws_url: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
440 };
441 }
442
443@@ -2578,14 +2579,14 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
444 return new LocalApiHttpError(
445 503,
446 "browser_bridge_unavailable",
447- "No active Firefox bridge client is connected.",
448+ "No active browser bridge client is connected.",
449 details
450 );
451 case "client_not_found":
452 return new LocalApiHttpError(
453 409,
454 "browser_client_not_found",
455- "The requested Firefox bridge client is not connected.",
456+ "The requested browser bridge client is not connected.",
457 details
458 );
459 case "duplicate_request_id":
460@@ -2625,14 +2626,14 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
461 return new LocalApiHttpError(
462 503,
463 "browser_bridge_unavailable",
464- `The Firefox bridge became unavailable while processing ${action}.`,
465+ `The browser bridge became unavailable while processing ${action}.`,
466 details
467 );
468 default:
469 return new LocalApiHttpError(
470 502,
471 "browser_bridge_error",
472- `Failed to execute ${action} through the Firefox bridge.`,
473+ `Failed to execute ${action} through the browser bridge.`,
474 details
475 );
476 }
477@@ -2715,14 +2716,14 @@ function ensureBrowserClientReady(
478 throw new LocalApiHttpError(
479 503,
480 "browser_bridge_unavailable",
481- `No active Firefox bridge client is connected for ${platform} requests.`
482+ `No active browser bridge client is connected for ${platform} requests.`
483 );
484 }
485
486 throw new LocalApiHttpError(
487 409,
488 "browser_client_not_found",
489- `Firefox bridge client "${normalizedRequestedClientId}" is not connected.`,
490+ `Browser bridge client "${normalizedRequestedClientId}" is not connected.`,
491 compactJsonObject({
492 client_id: normalizedRequestedClientId,
493 platform
494@@ -2738,7 +2739,7 @@ function ensureClaudeBridgeReady(
495 throw new LocalApiHttpError(
496 503,
497 "browser_bridge_unavailable",
498- "No active Firefox bridge client is connected for Claude actions."
499+ "No active browser bridge client is connected for Claude actions."
500 );
501 }
502
503@@ -2746,7 +2747,7 @@ function ensureClaudeBridgeReady(
504 throw new LocalApiHttpError(
505 409,
506 "claude_credentials_unavailable",
507- "Claude credentials are not available yet on the selected Firefox bridge client.",
508+ "Claude credentials are not available yet on the selected browser bridge client.",
509 compactJsonObject({
510 client_id: selection.client.client_id,
511 requested_client_id: normalizeOptionalString(requestedClientId),
512@@ -3811,7 +3812,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
513 client_count: browserState.client_count,
514 clients: browserState.clients.map(serializeBrowserClientSnapshot),
515 status: browserState.client_count > 0 ? "connected" : "disconnected",
516- transport: "local_firefox_ws",
517+ transport: "local_browser_ws",
518 ws_path: browserState.ws_path,
519 ws_url: browserState.ws_url
520 },
521@@ -4106,9 +4107,11 @@ async function requestCodexd(
522 }
523
524 function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
525+ const canonicalUrl = snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null;
526+
527 return {
528 auth_mode: "local_network_only",
529- enabled: snapshot.controlApi.firefoxWsUrl != null,
530+ enabled: canonicalUrl != null,
531 inbound_messages: [
532 "hello",
533 "state_request",
534@@ -4140,10 +4143,12 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
535 "reload",
536 "error"
537 ],
538- path: "/ws/firefox",
539- purpose: "local Firefox extension bridge",
540+ compat_paths: ["/ws/firefox"],
541+ compat_url: snapshot.controlApi.firefoxWsUrl ?? null,
542+ path: "/ws/browser",
543+ purpose: "local browser extension bridge",
544 reconnect: "client auto-reconnect is expected and supported",
545- url: snapshot.controlApi.firefoxWsUrl ?? null
546+ url: canonicalUrl
547 };
548 }
549
550@@ -4155,7 +4160,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
551 "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
552 platform:
553 "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式 shell / credential 管理平台已覆盖 claude 和 chatgpt,Gemini 仍留在下一波。",
554- clientId: "可选字符串;指定目标 Firefox bridge client。",
555+ clientId: "可选字符串;指定目标 browser bridge client。",
556 reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。",
557 disconnectMs:
558 `仅 ws_reconnect 可选;断开后保持离线的毫秒数。支持 disconnect_ms / delayMs / delay_ms 别名;范围 0-${MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS}。`,
559@@ -4195,7 +4200,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
560 }
561 ],
562 error_semantics: [
563- "503 browser_bridge_unavailable: 当前没有可用 Firefox bridge client。",
564+ "503 browser_bridge_unavailable: 当前没有可用 browser bridge client。",
565 "409 browser_client_not_found: 指定的 clientId 当前未连接。",
566 "504 browser_action_timeout: 插件未在约定时间内回传结构化 action_result。"
567 ]
568@@ -4208,7 +4213,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
569 request_body: {
570 platform:
571 "必填字符串;当前正式支持 claude、chatgpt 和 gemini。claude 额外支持省略 path + prompt 的兼容模式;chatgpt / gemini 当前只支持显式 path 的 raw proxy 请求。",
572- clientId: "可选字符串;指定目标 Firefox bridge client。",
573+ clientId: "可选字符串;指定目标 browser bridge client。",
574 requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
575 method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
576 path: "raw proxy 模式下必填;直接转发给浏览器本地 HTTP 代理路径。",
577@@ -4262,7 +4267,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
578 error_semantics: [
579 "400 invalid_request: 缺字段或组合不合法;例如既没有 path,也不是 platform=claude + prompt 的兼容模式。",
580 "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
581- "503 browser_bridge_unavailable: 当前没有活跃 Firefox bridge client。",
582+ "503 browser_bridge_unavailable: 当前没有活跃 browser bridge client。",
583 "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误;responseMode=sse 时会在事件流里交付 stream_error。"
584 ]
585 };
586@@ -4278,12 +4283,12 @@ function buildBrowserRequestCancelContract(): JsonObject {
587 reason: "可选字符串;取消原因。"
588 },
589 current_state: "active",
590- implementation_status: "会把 cancel 请求转发给当前执行中的 Firefox bridge client,并让原始 request 尽快以取消错误结束。",
591+ implementation_status: "会把 cancel 请求转发给当前执行中的 browser bridge client,并让原始 request 尽快以取消错误结束。",
592 error_semantics: [
593 "400 invalid_request: requestId 或 platform 缺失。",
594 "404 browser_request_not_found: 对应 requestId 当前不在执行中。",
595 "409 browser_request_client_mismatch: 指定了 clientId,但与实际执行中的 client 不一致。",
596- "503 browser_bridge_unavailable: 当前执行中的 Firefox bridge client 已断开。"
597+ "503 browser_bridge_unavailable: 当前执行中的 browser bridge client 已断开。"
598 ]
599 };
600 }
601@@ -4303,7 +4308,7 @@ function buildBrowserLegacyRouteData(): JsonObject[] {
602
603 function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: string): JsonObject {
604 return {
605- enabled: snapshot.controlApi.firefoxWsUrl != null,
606+ enabled: (snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl) != null,
607 legacy_helper_platform: BROWSER_CLAUDE_PLATFORM,
608 legacy_helper_platforms: [
609 BROWSER_CLAUDE_PLATFORM,
610@@ -4326,12 +4331,12 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
611 legacy_routes: buildBrowserLegacyRouteData(),
612 transport: {
613 http: snapshot.controlApi.localApiBase ?? null,
614- websocket: snapshot.controlApi.firefoxWsUrl ?? null
615+ websocket: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
616 },
617 notes: [
618 "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
619 "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
620- "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local Firefox bridge client.",
621+ "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local browser bridge client.",
622 "Claude keeps the prompt shortcut when path is omitted; ChatGPT and Gemini require an explicit path and a real browser login context captured on the selected client.",
623 "Gemini raw relay can additionally use conversationId to prefer a conversation-matched persisted send template when the plugin has one.",
624 "The legacy helper surface now also exposes /v1/browser/chatgpt/* and /v1/browser/gemini/* wrappers for BAA target compatibility.",
625@@ -4849,7 +4854,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
626 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
627 "These routes read and mutate the mini node's local truth source directly.",
628 "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
629- "The optional Firefox bridge WS reuses the same local listener and upgrades on /ws/firefox."
630+ "The optional browser bridge WS reuses the same local listener and upgrades on /ws/browser, with /ws/firefox kept as a compatibility path."
631 ]
632 });
633 }
634@@ -6023,7 +6028,7 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
635 throw new LocalApiHttpError(
636 409,
637 "browser_request_client_mismatch",
638- `Browser request "${requestId}" is not running on the requested Firefox bridge client.`,
639+ `Browser request "${requestId}" is not running on the requested browser bridge client.`,
640 compactJsonObject({
641 client_id: clientId,
642 platform,
643diff --git a/docs/api/README.md b/docs/api/README.md
644index c36e7831b6a005551bac9024bc63a3df572c9b76..d372ce7594643fb09b127dafeaf91119ce66aba6 100644
645--- a/docs/api/README.md
646+++ b/docs/api/README.md
647@@ -31,7 +31,7 @@
648 | conductor public host | `https://conductor.makefile.so` | 唯一公网入口;VPS Nginx 回源到同一个 `conductor-daemon` local-api |
649 | conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/status/browser/system/controllers/tasks/codex/host-ops |
650 | codexd local-api | `BAA_CODEXD_LOCAL_API_BASE`,默认可用值如 `http://127.0.0.1:4319` | 独立 `codexd` 本地服务;支持 `GET /describe` 自描述;`conductor-daemon` 的 `/v1/codex/*` 只代理到这里 |
651-| conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
652+| conductor-daemon local-browser-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/browser` | 本地 browser 插件双向 bridge;复用同一个 listener,不单独开公网端口,`/ws/firefox` 保留兼容路径 |
653 | status-api local view | `http://127.0.0.1:4318` | 本地只读状态兼容包装层;继续提供 `/describe`、`/v1/status`、`/v1/status/ui` 和 `/ui`,默认从 `BAA_CONDUCTOR_LOCAL_API` 的 `/v1/system/state` 取数,不承担公网入口角色 |
654
655 ## Describe First
656@@ -45,7 +45,7 @@
657 5. 按需查看 `browser`、`controllers`、`tasks`、`codex`
658 6. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`,并准备 `Authorization: Bearer <BAA_SHARED_TOKEN>`
659 7. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
660-8. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
661+8. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/browser`
662
663 如果是直接调用 `codexd`:
664
665@@ -134,7 +134,7 @@
666
667 | 方法 | 路径 | 说明 |
668 | --- | --- | --- |
669-| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
670+| `GET` | `/v1/browser` | 读取活跃 browser bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
671 | `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
672 | `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
673 | `POST` | `/v1/browser/request/cancel` | 取消请求或流;会向对应 Firefox client 派发 `request_cancel` |
674@@ -159,10 +159,10 @@ Browser 面约定:
675 - SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
676 - `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`,并返回结构化 `action_result`
677 - `GET /v1/browser` 会回显当前风控默认值、最新 `shell_runtime` / `last_action_result` 和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
678-- `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
679-- `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
680+- `/ws/browser` 只在本地 listener 上可用,不是公网产品接口;`/ws/firefox` 仅作兼容路径
681+- `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 browser bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
682 - ChatGPT / Gemini raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh` 确认再发请求
683-- 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
684+- 如果当前没有活跃 browser client,会返回清晰的 `503` JSON 错误
685 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
686
687 ### 最小 Claude Curl
688@@ -316,18 +316,19 @@ host-ops 约定:
689 | `GET` | `/rolez` | 当前 `leader` / `standby` 视图 |
690 | `GET` | `/v1/runtime` | daemon runtime 快照 |
691
692-## Firefox Local WS
693+## Browser Local WS
694
695-本地 Firefox bridge 复用 `conductor-daemon` 的同一个监听地址,不额外引入第二个端口:
696+本地 browser bridge 复用 `conductor-daemon` 的同一个监听地址,不额外引入第二个端口:
697
698 - HTTP base: `http://127.0.0.1:4317`
699-- Firefox WS: `ws://127.0.0.1:4317/ws/firefox`
700+- Browser WS: `ws://127.0.0.1:4317/ws/browser`
701+- Firefox compat WS: `ws://127.0.0.1:4317/ws/firefox`
702
703 当前约定:
704
705 - 只服务本地 / loopback / 明确允许的 Tailscale `100.x` 地址
706 - 不是公网通道,不单独暴露到 `conductor.makefile.so`
707-- Firefox 插件默认同时使用本地 WS 和同一个本地 HTTP listener
708+- Firefox / Safari 插件默认同时使用本地 WS 和同一个本地 HTTP listener
709 - `state_snapshot.system` 直接复用 `/v1/system/state` 的字段结构
710 - `action_request` 支持 `pause` / `resume` / `drain`
711 - 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
712diff --git a/docs/api/business-interfaces.md b/docs/api/business-interfaces.md
713index a141e4687f970ba1237fc51301e86704effb0cd6..068ea2ed8a118a31773a94cade892e9135d83eec 100644
714--- a/docs/api/business-interfaces.md
715+++ b/docs/api/business-interfaces.md
716@@ -69,15 +69,15 @@
717
718 | 方法 | 路径 | 作用 |
719 | --- | --- | --- |
720-| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
721-| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 Firefox client 下发正式 `request_cancel` |
722+| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/browser -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
723+| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 browser client 下发正式 `request_cancel` |
724 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
725
726 说明:
727
728 - `GET /v1/browser` 是当前正式浏览器桥接读面;支持按 `platform`、`account`、`client_id`、`host`、`status` 过滤
729 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
730-- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox 浮层和调试读面同步统一自动化状态
731+- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox / Safari 浮层和调试读面同步统一自动化状态
732 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
733 - 当前浏览器代发面正式支持 `claude`、`chatgpt` 和 `gemini`
734 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` / `platform=gemini` 当前都必须显式带 `path`
735@@ -100,7 +100,7 @@
736 - `repeated_renewal_count`
737 - `conductor` 启动时会自动释放异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 安全回排为 `pending`
738 - ChatGPT / Gemini raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh`
739-- 如果没有活跃 Firefox bridge client,会返回 `503`
740+- 如果没有活跃 browser bridge client,会返回 `503`
741 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
742 - 打开、聚焦、重载标签页等 browser/plugin 管理动作已经移到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
743
744diff --git a/docs/api/control-interfaces.md b/docs/api/control-interfaces.md
745index eb72fef2b821396567bc49718f21c5f8fa2d1a6e..0decd63642d27de3e3bcf57438d3e087637fea7c 100644
746--- a/docs/api/control-interfaces.md
747+++ b/docs/api/control-interfaces.md
748@@ -69,7 +69,7 @@
749
750 | 方法 | 路径 | 作用 |
751 | --- | --- | --- |
752-| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
753+| `GET` | `/v1/browser` | 返回 browser bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
754 | `GET` | `/v1/system/state` | 返回当前 automation mode、leader、queue、active runs |
755
756 ### Browser / Plugin 管理
757@@ -83,7 +83,7 @@
758 browser/plugin 管理约定:
759
760 - `GET /v1/browser` 是 plugin 状态和登录态元数据的共享读面;不是单独的第三层 describe
761-- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox 浮层同步
762+- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox / Safari 浮层同步
763 - `POST /v1/browser/actions` 当前正式支持:
764 - `request_credentials`
765 - `tab_open`
766@@ -98,7 +98,7 @@ browser/plugin 管理约定:
767 - `repeatCount` / `repeat_count`
768 - `repeatIntervalMs` / `repeat_interval_ms` / `intervalMs` / `interval_ms`
769 - 当前正式 shell / credential 管理平台仍覆盖 `claude` 和 `chatgpt`;Gemini raw relay 已转入 business 面,但 control 面的 shell 管理合同没有在这轮一起扩面
770-- 如果没有活跃 Firefox bridge client,会返回 `503`
771+- 如果没有活跃 browser bridge client,会返回 `503`
772 - 如果指定了不存在的 `clientId`,会返回 `409`
773 - `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
774 - 对 `browser.proxy_delivery` 一类动作,`action_result.results[*]` 现在可额外带 `delivery_ack`,用于表达下游确认层级;首版已稳定提供 Level 1 `status_code`
775@@ -137,7 +137,7 @@ system 控制约定:
776 - `GET /v1/renewal/jobs?local_conversation_id=...`
777 - `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
778 - `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
779-- renewal REST 写接口修改状态后,Firefox bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
780+- renewal REST 写接口修改状态后,browser bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
781 - conductor 启动时会自动清理上次异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 重新排回 `pending`
782 - `GET /v1/renewal/conversations/:local_conversation_id` 现在会额外暴露:
783 - `pause_reason`
784diff --git a/docs/api/firefox-local-ws.md b/docs/api/firefox-local-ws.md
785index 53b4895930b48e84c1a378ab2525cb287eb20e3e..da4f6996a4f4aa82adb8482160827fc08fdcbc27 100644
786--- a/docs/api/firefox-local-ws.md
787+++ b/docs/api/firefox-local-ws.md
788@@ -1,10 +1,10 @@
789-# Firefox Local WS
790+# Browser Local WS
791
792-`conductor-daemon` 现在在本地 HTTP listener 上正式支持 Firefox bridge WebSocket。
793+`conductor-daemon` 现在在本地 HTTP listener 上正式支持 browser bridge WebSocket。
794
795 目标:
796
797-- 给本地 Firefox 插件一个正式、稳定、可重连的双向入口
798+- 给本地 Firefox / Safari 插件一个正式、稳定、可重连的双向入口
799 - 复用 `mini` 本地 control plane 的 system state / action write 能力
800 - 不再把这条链路当成公网或 Cloudflare Worker 通道
801
802@@ -14,19 +14,20 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
803
804 例子:
805
806-- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/firefox`
807-- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/firefox`
808+- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/browser`
809+- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/browser`
810
811 约束:
812
813-- path 固定是 `/ws/firefox`
814+- canonical path 是 `/ws/browser`
815+- `/ws/firefox` 仍保留为兼容路径
816 - 只应监听 loopback 或显式允许的 Tailscale `100.x` 地址
817 - 不是公网入口
818 - 当前正式浏览器代发 HTTP 面只支持 `claude`;但 `GET /v1/browser` 的元数据读面可以返回所有已上报 `platform`
819
820 ## 自动重连语义
821
822-- Firefox 客户端断开后可以直接重连到同一个 URL
823+- browser 客户端断开后可以直接重连到同一个 URL
824 - server 会接受新的连接并继续推送最新 snapshot
825 - 如果同一个 `clientId` 建立了新连接,旧连接会被替换
826 - `state_snapshot` 会在 `hello`、browser metadata 变化、system state 变化时重新推送
827@@ -37,7 +38,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
828
829 | `type` | 说明 |
830 | --- | --- |
831-| `hello` | 注册当前 Firefox client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
832+| `hello` | 注册当前 browser client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
833 | `state_request` | 主动请求最新 snapshot |
834 | `action_request` | 请求执行 `pause` / `resume` / `drain` |
835 | `action_result` | 回传 browser/plugin 管理动作的结构化执行结果;带 `accepted` / `completed` / `failed` / `reason` / `result` / `shell_runtime` |
836@@ -73,10 +74,10 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
837 ```json
838 {
839 "type": "hello",
840- "clientId": "firefox-ab12cd",
841+ "clientId": "safari-ab12cd",
842 "nodeType": "browser",
843 "nodeCategory": "proxy",
844- "nodePlatform": "firefox"
845+ "nodePlatform": "safari"
846 }
847 ```
848
849@@ -85,10 +86,12 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
850 ```json
851 {
852 "type": "hello_ack",
853- "clientId": "firefox-ab12cd",
854- "protocol": "baa.firefox.local",
855+ "clientId": "safari-ab12cd",
856+ "protocol": "baa.browser.local",
857+ "protocol_compat": ["baa.firefox.local"],
858 "version": 1,
859- "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
860+ "wsUrl": "ws://100.71.210.78:4317/ws/browser",
861+ "wsCompatUrls": ["ws://100.71.210.78:4317/ws/firefox"],
862 "localApiBase": "http://100.71.210.78:4317",
863 "supports": {
864 "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "browser.final_message", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
865@@ -108,8 +111,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
866 "server": {
867 "identity": "mini-main@mini(primary)",
868 "local_api_base": "http://100.71.210.78:4317",
869- "ws_path": "/ws/firefox",
870- "ws_url": "ws://100.71.210.78:4317/ws/firefox"
871+ "ws_path": "/ws/browser",
872+ "ws_url": "ws://100.71.210.78:4317/ws/browser"
873 },
874 "system": {
875 "mode": "running",
876@@ -142,8 +145,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
877 "client_count": 1,
878 "clients": [
879 {
880- "client_id": "firefox-ab12cd",
881- "node_platform": "firefox",
882+ "client_id": "safari-ab12cd",
883+ "node_platform": "safari",
884 "credentials": [],
885 "final_messages": [],
886 "request_hooks": []
887@@ -159,7 +162,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
888 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
889 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
890 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
891-- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox 浮层同步统一自动化状态
892+- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox / Safari 浮层同步统一自动化状态
893 - `snapshot.browser.instruction_ingest` 暴露 live ingest / execute 的持久化读面:
894 - `last_ingest`
895 - `last_execute`
896@@ -494,7 +497,7 @@ SSE 请求示例:
897
898 说明:
899
900-- 可显式指定 `clientId` 目标;未指定时 server 会选最近活跃的 Firefox client
901+- 可显式指定 `clientId` 目标;未指定时 server 会选最近活跃的 browser client
902 - 当前插件按 `platform` 激活或拉起对应页面
903
904 ### `request_credentials`
905@@ -569,7 +572,7 @@ SSE 请求示例:
906
907 当前非目标:
908
909-- 不把 `/ws/firefox` 直接暴露成公网产品接口
910+- 不把 `/ws/browser` 或兼容路径 `/ws/firefox` 直接暴露成公网产品接口
911 - 不把页面对话 UI、聊天 DOM 自动化或多标签会话编排写成正式 bridge 能力
912 - 正式 HTTP 面已经收口到 `GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`;`/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
913 - 本文仍只讨论 WS transport、client registry 和 request-response 基础能力
914@@ -580,7 +583,7 @@ SSE 请求示例:
915
916 ```bash
917 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://100.71.210.78:4317}"
918-WS_URL="$(node --input-type=module -e 'const u = new URL(process.argv[1]); u.protocol = u.protocol === \"https:\" ? \"wss:\" : \"ws:\"; u.pathname = \"/ws/firefox\"; u.search = \"\"; u.hash = \"\"; console.log(u.toString());' "$LOCAL_API_BASE")"
919+WS_URL="$(node --input-type=module -e 'const u = new URL(process.argv[1]); u.protocol = u.protocol === \"https:\" ? \"wss:\" : \"ws:\"; u.pathname = \"/ws/browser\"; u.search = \"\"; u.hash = \"\"; console.log(u.toString());' "$LOCAL_API_BASE")"
920
921 WS_URL="$WS_URL" node --input-type=module <<'EOF'
922 const socket = new WebSocket(process.env.WS_URL);
923@@ -588,10 +591,10 @@ const socket = new WebSocket(process.env.WS_URL);
924 socket.addEventListener("open", () => {
925 socket.send(JSON.stringify({
926 type: "hello",
927- clientId: "smoke-client",
928+ clientId: "safari-smoke-client",
929 nodeType: "browser",
930 nodeCategory: "proxy",
931- nodePlatform: "firefox"
932+ nodePlatform: "safari"
933 }));
934
935 socket.send(JSON.stringify({
936@@ -607,7 +610,7 @@ EOF
937
938 ### 端到端 Claude HTTP smoke
939
940-如果要验证 `conductor HTTP -> /ws/firefox -> Claude 页面内 HTTP 代理` 这条最小闭环,直接运行:
941+如果要验证 `conductor HTTP -> /ws/browser -> Claude 页面内 HTTP 代理` 这条最小闭环,直接运行:
942
943 ```bash
944 ./scripts/runtime/browser-control-e2e-smoke.sh
945@@ -623,4 +626,4 @@ EOF
946 - `POST /v1/browser/request`
947 - `POST /v1/browser/request/cancel`
948 - 正式 SSE 与 Claude legacy wrapper
949-- 以及 `/ws/firefox` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
950+- 以及 `/ws/browser` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
951diff --git a/plugins/baa-safari/README.md b/plugins/baa-safari/README.md
952new file mode 100644
953index 0000000000000000000000000000000000000000..4eba862f8ddf515b33e275e349aa1537c14c59f2
954--- /dev/null
955+++ b/plugins/baa-safari/README.md
956@@ -0,0 +1,120 @@
957+# BAA Safari
958+
959+`plugins/baa-safari/` 当前收口到 Safari Phase 1 的页面桥接与 ChatGPT final-message relay。
960+
961+这一版完成的是:
962+
963+- Safari Web Extension 基础壳和 Xcode 宿主 App
964+- content script 注入 `page-interceptor.js`
965+- 复用 Firefox shared helper 的 `final-message.js`
966+- 与 Firefox 对齐的页面事件协议:
967+ - `__baa_ready__`
968+ - `__baa_net__`
969+ - `__baa_sse__`
970+ - `__baa_proxy_request__`
971+ - `__baa_proxy_response__`
972+- ChatGPT 回复完成后上送 `browser.final_message`
973+- 本地 browser WS bridge:默认连接 `ws://127.0.0.1:4317/ws/browser`
974+- ChatGPT 的脱敏登录态元数据基础:
975+ - `credential_fingerprint`
976+ - `endpoint_metadata`
977+ - `account_hint`
978+- controller 页面的权限和注入诊断
979+
980+这一版刻意不做:
981+
982+- Safari `proxy_delivery` 正式闭环
983+- Claude / Gemini 的 Safari 业务能力
984+
985+## 当前支持边界
986+
987+- `chatgpt`
988+ 已实现页面桥接、SSE/network 事件转发、`browser.final_message` relay、endpoint metadata 和脱敏登录态元数据采集。
989+- `claude`
990+ 只保留页面桥接基础和平台抽象占位,不宣称已支持 final-message 或 proxy_delivery。
991+- `gemini`
992+ 只保留页面桥接基础和平台抽象占位,不宣称已支持 final-message 或 proxy_delivery。
993+
994+## Runtime 合同
995+
996+- `controller.html`
997+ 控制页和长期 runtime owner 视图;负责展示 bridge 健康度、权限提示和 metadata 快照。
998+- `background.js`
999+ 聚合 content-script / page-interceptor 事件、`webRequest` 脱敏元数据、ChatGPT final-message observer 和本地 browser WS。
1000+- `final-message.js`
1001+ 与 Firefox 保持合同一致的 pure-JS helper;Safari 直接复用其 SSE/network 归并和 dedupe 语义。
1002+- `content-script.js`
1003+ 页面入口;注入 `page-interceptor.js`,并把页面事件转成 runtime message。
1004+- `page-interceptor.js`
1005+ 运行在页面主世界;复用 Firefox 风格的 fetch/XHR 拦截和 `__baa_proxy_request__` 事件协议。
1006+
1007+## 关键观测点
1008+
1009+打开 ChatGPT 页面后,控制页应该至少能看到:
1010+
1011+- `Content Script` 卡片有最近页面回报
1012+- `Page Bridge` 卡片进入 ready
1013+- `Local WS` 卡片进入已连接
1014+- `ChatGPT Metadata` 卡片出现 `credential_fingerprint` 或 `endpoint_count > 0`
1015+- `Final Message` 卡片在回复完成后出现最近一次 relay
1016+- `最近诊断日志` 里能看到 `page_bridge_ready`、`endpoint_metadata_seen` 或 `credential_snapshot_seen`
1017+
1018+## Website Access 排查
1019+
1020+Safari 最容易踩坑的是网站授权。
1021+
1022+如果控制页一直显示:
1023+
1024+- 没有 `Content Script` 回报
1025+- 没有 `Page Bridge` ready
1026+- `permissionHints` 提示 `website_access_or_content_script_missing`
1027+
1028+优先检查:
1029+
1030+1. `Safari -> Settings -> Extensions`
1031+2. 启用 `BAA Safari Extension`
1032+3. 在 `Website Access` 给 `chatgpt.com` / `chat.openai.com` 授权
1033+4. 刷新目标页面后再看控制页
1034+
1035+如果已经有 `Content Script` 回报,但一直没有 `Page Bridge` ready,通常是:
1036+
1037+- `page-interceptor.js` 注入失败
1038+- 页面 CSP 或主世界注入时机有问题
1039+
1040+这时看控制页里的 `最近诊断日志`,重点找:
1041+
1042+- `page_interceptor_script_error`
1043+- `page_interceptor_timeout`
1044+- `page_bridge_ready`
1045+
1046+## 目录
1047+
1048+- [manifest.json](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/manifest.json)
1049+- [background.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/background.js)
1050+- [final-message.js](/Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message/plugins/baa-safari/final-message.js)
1051+- [content-script.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/content-script.js)
1052+- [page-interceptor.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/page-interceptor.js)
1053+- [controller.html](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/controller.html)
1054+- [controller.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/controller.js)
1055+- [baa-safari-host](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/baa-safari-host)
1056+
1057+## 本地构建
1058+
1059+```bash
1060+cd /Users/george/code/baa-conductor-safari-plugin-safari-page-bridge
1061+env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
1062+ xcodebuild \
1063+ -project plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj \
1064+ -scheme BAASafariHost \
1065+ -configuration Debug build
1066+```
1067+
1068+## 首次启用
1069+
1070+1. 先构建并启动 `BAASafariHost.app`。
1071+2. 打开 Safari。
1072+3. 进入 `Safari -> Settings -> Extensions`。
1073+4. 启用 `BAA Safari Extension`。
1074+5. 在 `Website Access` 给 ChatGPT 授权。
1075+6. 点击工具栏扩展按钮,让 `background.js` 打开 [controller.html](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/controller.html)。
1076+7. 打开 ChatGPT 页面,观察 controller 里的 bridge 和 metadata 状态。
1077diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/BAASafariHost_Extension.entitlements b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/BAASafariHost_Extension.entitlements
1078new file mode 100644
1079index 0000000000000000000000000000000000000000..f2ef3ae0265b40c475e8ef90e3a311c31786c594
1080--- /dev/null
1081+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/BAASafariHost_Extension.entitlements
1082@@ -0,0 +1,10 @@
1083+<?xml version="1.0" encoding="UTF-8"?>
1084+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1085+<plist version="1.0">
1086+<dict>
1087+ <key>com.apple.security.app-sandbox</key>
1088+ <true/>
1089+ <key>com.apple.security.files.user-selected.read-only</key>
1090+ <true/>
1091+</dict>
1092+</plist>
1093diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/Info.plist b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/Info.plist
1094new file mode 100644
1095index 0000000000000000000000000000000000000000..9ee504dc51f3d3c54be0fb0038a89b13b2250107
1096--- /dev/null
1097+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/Info.plist
1098@@ -0,0 +1,13 @@
1099+<?xml version="1.0" encoding="UTF-8"?>
1100+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1101+<plist version="1.0">
1102+<dict>
1103+ <key>NSExtension</key>
1104+ <dict>
1105+ <key>NSExtensionPointIdentifier</key>
1106+ <string>com.apple.Safari.web-extension</string>
1107+ <key>NSExtensionPrincipalClass</key>
1108+ <string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
1109+ </dict>
1110+</dict>
1111+</plist>
1112diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/SafariWebExtensionHandler.swift b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/SafariWebExtensionHandler.swift
1113new file mode 100644
1114index 0000000000000000000000000000000000000000..12ffd9f1016a8d8b23f75a9851d70af46ece0569
1115--- /dev/null
1116+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/SafariWebExtensionHandler.swift
1117@@ -0,0 +1,42 @@
1118+//
1119+// SafariWebExtensionHandler.swift
1120+// BAASafariHost Extension
1121+//
1122+// Created by george on 2026/4/2.
1123+//
1124+
1125+import SafariServices
1126+import os.log
1127+
1128+class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
1129+
1130+ func beginRequest(with context: NSExtensionContext) {
1131+ let request = context.inputItems.first as? NSExtensionItem
1132+
1133+ let profile: UUID?
1134+ if #available(iOS 17.0, macOS 14.0, *) {
1135+ profile = request?.userInfo?[SFExtensionProfileKey] as? UUID
1136+ } else {
1137+ profile = request?.userInfo?["profile"] as? UUID
1138+ }
1139+
1140+ let message: Any?
1141+ if #available(iOS 15.0, macOS 11.0, *) {
1142+ message = request?.userInfo?[SFExtensionMessageKey]
1143+ } else {
1144+ message = request?.userInfo?["message"]
1145+ }
1146+
1147+ os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none")
1148+
1149+ let response = NSExtensionItem()
1150+ if #available(iOS 15.0, macOS 11.0, *) {
1151+ response.userInfo = [ SFExtensionMessageKey: [ "echo": message ] ]
1152+ } else {
1153+ response.userInfo = [ "message": [ "echo": message ] ]
1154+ }
1155+
1156+ context.completeRequest(returningItems: [ response ], completionHandler: nil)
1157+ }
1158+
1159+}
1160diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj
1161new file mode 100644
1162index 0000000000000000000000000000000000000000..8540cae8d467d55ed8433a2f4e2f738116838c6d
1163--- /dev/null
1164+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj
1165@@ -0,0 +1,857 @@
1166+// !$*UTF8*$!
1167+{
1168+ archiveVersion = 1;
1169+ classes = {
1170+ };
1171+ objectVersion = 77;
1172+ objects = {
1173+
1174+/* Begin PBXBuildFile section */
1175+ 5EF028BD2F7E7177006A58E2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028BC2F7E7177006A58E2 /* AppDelegate.swift */; };
1176+ 5EF028C12F7E7177006A58E2 /* Main.html in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028BF2F7E7177006A58E2 /* Main.html */; };
1177+ 5EF028C32F7E7177006A58E2 /* Icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028C22F7E7177006A58E2 /* Icon.png */; };
1178+ 5EF028C52F7E7177006A58E2 /* Style.css in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028C42F7E7177006A58E2 /* Style.css */; };
1179+ 5EF028C72F7E7177006A58E2 /* Script.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028C62F7E7177006A58E2 /* Script.js */; };
1180+ 5EF028C92F7E7177006A58E2 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028C82F7E7177006A58E2 /* ViewController.swift */; };
1181+ 5EF028CC2F7E7177006A58E2 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028CA2F7E7177006A58E2 /* Main.storyboard */; };
1182+ 5EF028CE2F7E7177006A58E2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028CD2F7E7177006A58E2 /* Assets.xcassets */; };
1183+ 5EF028DB2F7E7177006A58E2 /* BAASafariHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028DA2F7E7177006A58E2 /* BAASafariHostTests.swift */; };
1184+ 5EF028E52F7E7177006A58E2 /* BAASafariHostUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028E42F7E7177006A58E2 /* BAASafariHostUITests.swift */; };
1185+ 5EF028E72F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028E62F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift */; };
1186+ 5EF028ED2F7E7177006A58E2 /* BAASafariHost Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1187+ 5EF028F22F7E7177006A58E2 /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028F12F7E7177006A58E2 /* SafariWebExtensionHandler.swift */; };
1188+ 5EF0290E2F7E7178006A58E2 /* background.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029052F7E7178006A58E2 /* background.js */; };
1189+ 5EF0290F2F7E7178006A58E2 /* controller.css in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029062F7E7178006A58E2 /* controller.css */; };
1190+ 5EF029102F7E7178006A58E2 /* controller.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029072F7E7178006A58E2 /* controller.js */; };
1191+ 5EF029112F7E7178006A58E2 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029082F7E7178006A58E2 /* README.md */; };
1192+ 5EF029202F7E7178006A58E2 /* final-message.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029212F7E7178006A58E2 /* final-message.js */; };
1193+ 5EF029122F7E7178006A58E2 /* controller.html in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029092F7E7178006A58E2 /* controller.html */; };
1194+ 5EF029132F7E7178006A58E2 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0290A2F7E7178006A58E2 /* manifest.json */; };
1195+ 5EF029142F7E7178006A58E2 /* content-script.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0290B2F7E7178006A58E2 /* content-script.js */; };
1196+ 5EF029162F7E7178006A58E2 /* page-interceptor.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0290D2F7E7178006A58E2 /* page-interceptor.js */; };
1197+ 5EF029172F7E7178006A58E2 /* 48.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029182F7E7178006A58E2 /* 48.png */; };
1198+ 5EF029192F7E7178006A58E2 /* 96.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0291A2F7E7178006A58E2 /* 96.png */; };
1199+ 5EF0291B2F7E7178006A58E2 /* 128.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0291C2F7E7178006A58E2 /* 128.png */; };
1200+/* End PBXBuildFile section */
1201+
1202+/* Begin PBXContainerItemProxy section */
1203+ 5EF028D72F7E7177006A58E2 /* PBXContainerItemProxy */ = {
1204+ isa = PBXContainerItemProxy;
1205+ containerPortal = 5EF028B12F7E7177006A58E2 /* Project object */;
1206+ proxyType = 1;
1207+ remoteGlobalIDString = 5EF028B82F7E7177006A58E2;
1208+ remoteInfo = BAASafariHost;
1209+ };
1210+ 5EF028E12F7E7177006A58E2 /* PBXContainerItemProxy */ = {
1211+ isa = PBXContainerItemProxy;
1212+ containerPortal = 5EF028B12F7E7177006A58E2 /* Project object */;
1213+ proxyType = 1;
1214+ remoteGlobalIDString = 5EF028B82F7E7177006A58E2;
1215+ remoteInfo = BAASafariHost;
1216+ };
1217+ 5EF028EE2F7E7177006A58E2 /* PBXContainerItemProxy */ = {
1218+ isa = PBXContainerItemProxy;
1219+ containerPortal = 5EF028B12F7E7177006A58E2 /* Project object */;
1220+ proxyType = 1;
1221+ remoteGlobalIDString = 5EF028EB2F7E7177006A58E2;
1222+ remoteInfo = "BAASafariHost Extension";
1223+ };
1224+/* End PBXContainerItemProxy section */
1225+
1226+/* Begin PBXCopyFilesBuildPhase section */
1227+ 5EF028FA2F7E7177006A58E2 /* Embed Foundation Extensions */ = {
1228+ isa = PBXCopyFilesBuildPhase;
1229+ buildActionMask = 2147483647;
1230+ dstPath = "";
1231+ dstSubfolderSpec = 13;
1232+ files = (
1233+ 5EF028ED2F7E7177006A58E2 /* BAASafariHost Extension.appex in Embed Foundation Extensions */,
1234+ );
1235+ name = "Embed Foundation Extensions";
1236+ runOnlyForDeploymentPostprocessing = 0;
1237+ };
1238+/* End PBXCopyFilesBuildPhase section */
1239+
1240+/* Begin PBXFileReference section */
1241+ 5EF028B92F7E7177006A58E2 /* BAASafariHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BAASafariHost.app; sourceTree = BUILT_PRODUCTS_DIR; };
1242+ 5EF028BC2F7E7177006A58E2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
1243+ 5EF028C02F7E7177006A58E2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/Main.html; sourceTree = "<group>"; };
1244+ 5EF028C22F7E7177006A58E2 /* Icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = Icon.png; sourceTree = "<group>"; };
1245+ 5EF028C42F7E7177006A58E2 /* Style.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = Style.css; sourceTree = "<group>"; };
1246+ 5EF028C62F7E7177006A58E2 /* Script.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Script.js; sourceTree = "<group>"; };
1247+ 5EF028C82F7E7177006A58E2 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
1248+ 5EF028CB2F7E7177006A58E2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
1249+ 5EF028CD2F7E7177006A58E2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
1250+ 5EF028CF2F7E7177006A58E2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1251+ 5EF028D02F7E7177006A58E2 /* BAASafariHost.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BAASafariHost.entitlements; sourceTree = "<group>"; };
1252+ 5EF028D12F7E7177006A58E2 /* BAASafariHost.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BAASafariHost.entitlements; sourceTree = "<group>"; };
1253+ 5EF028D62F7E7177006A58E2 /* BAASafariHostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BAASafariHostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1254+ 5EF028DA2F7E7177006A58E2 /* BAASafariHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BAASafariHostTests.swift; sourceTree = "<group>"; };
1255+ 5EF028E02F7E7177006A58E2 /* BAASafariHostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BAASafariHostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1256+ 5EF028E42F7E7177006A58E2 /* BAASafariHostUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BAASafariHostUITests.swift; sourceTree = "<group>"; };
1257+ 5EF028E62F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BAASafariHostUITestsLaunchTests.swift; sourceTree = "<group>"; };
1258+ 5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "BAASafariHost Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
1259+ 5EF028F12F7E7177006A58E2 /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = "<group>"; };
1260+ 5EF028F32F7E7177006A58E2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1261+ 5EF028F42F7E7177006A58E2 /* BAASafariHost_Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BAASafariHost_Extension.entitlements; sourceTree = "<group>"; };
1262+ 5EF029052F7E7178006A58E2 /* background.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = background.js; path = ../../background.js; sourceTree = "<group>"; };
1263+ 5EF029062F7E7178006A58E2 /* controller.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; name = controller.css; path = ../../controller.css; sourceTree = "<group>"; };
1264+ 5EF029072F7E7178006A58E2 /* controller.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = controller.js; path = ../../controller.js; sourceTree = "<group>"; };
1265+ 5EF029082F7E7178006A58E2 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../../README.md; sourceTree = "<group>"; };
1266+ 5EF029212F7E7178006A58E2 /* final-message.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = "final-message.js"; path = "../../final-message.js"; sourceTree = "<group>"; };
1267+ 5EF029092F7E7178006A58E2 /* controller.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = controller.html; path = ../../controller.html; sourceTree = "<group>"; };
1268+ 5EF0290A2F7E7178006A58E2 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = ../../manifest.json; sourceTree = "<group>"; };
1269+ 5EF0290B2F7E7178006A58E2 /* content-script.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = "content-script.js"; path = "../../content-script.js"; sourceTree = "<group>"; };
1270+ 5EF0290D2F7E7178006A58E2 /* page-interceptor.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = "page-interceptor.js"; path = "../../page-interceptor.js"; sourceTree = "<group>"; };
1271+ 5EF029182F7E7178006A58E2 /* 48.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = 48.png; path = ../../icons/48.png; sourceTree = "<group>"; };
1272+ 5EF0291A2F7E7178006A58E2 /* 96.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = 96.png; path = ../../icons/96.png; sourceTree = "<group>"; };
1273+ 5EF0291C2F7E7178006A58E2 /* 128.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = 128.png; path = ../../icons/128.png; sourceTree = "<group>"; };
1274+/* End PBXFileReference section */
1275+
1276+/* Begin PBXFrameworksBuildPhase section */
1277+ 5EF028B62F7E7177006A58E2 /* Frameworks */ = {
1278+ isa = PBXFrameworksBuildPhase;
1279+ buildActionMask = 2147483647;
1280+ files = (
1281+ );
1282+ runOnlyForDeploymentPostprocessing = 0;
1283+ };
1284+ 5EF028D32F7E7177006A58E2 /* Frameworks */ = {
1285+ isa = PBXFrameworksBuildPhase;
1286+ buildActionMask = 2147483647;
1287+ files = (
1288+ );
1289+ runOnlyForDeploymentPostprocessing = 0;
1290+ };
1291+ 5EF028DD2F7E7177006A58E2 /* Frameworks */ = {
1292+ isa = PBXFrameworksBuildPhase;
1293+ buildActionMask = 2147483647;
1294+ files = (
1295+ );
1296+ runOnlyForDeploymentPostprocessing = 0;
1297+ };
1298+ 5EF028E92F7E7177006A58E2 /* Frameworks */ = {
1299+ isa = PBXFrameworksBuildPhase;
1300+ buildActionMask = 2147483647;
1301+ files = (
1302+ );
1303+ runOnlyForDeploymentPostprocessing = 0;
1304+ };
1305+/* End PBXFrameworksBuildPhase section */
1306+
1307+/* Begin PBXGroup section */
1308+ 5EF028B02F7E7177006A58E2 = {
1309+ isa = PBXGroup;
1310+ children = (
1311+ 5EF028BB2F7E7177006A58E2 /* BAASafariHost */,
1312+ 5EF028D92F7E7177006A58E2 /* BAASafariHostTests */,
1313+ 5EF028E32F7E7177006A58E2 /* BAASafariHostUITests */,
1314+ 5EF028F02F7E7177006A58E2 /* BAASafariHost Extension */,
1315+ 5EF028BA2F7E7177006A58E2 /* Products */,
1316+ );
1317+ sourceTree = "<group>";
1318+ };
1319+ 5EF028BA2F7E7177006A58E2 /* Products */ = {
1320+ isa = PBXGroup;
1321+ children = (
1322+ 5EF028B92F7E7177006A58E2 /* BAASafariHost.app */,
1323+ 5EF028D62F7E7177006A58E2 /* BAASafariHostTests.xctest */,
1324+ 5EF028E02F7E7177006A58E2 /* BAASafariHostUITests.xctest */,
1325+ 5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */,
1326+ );
1327+ name = Products;
1328+ sourceTree = "<group>";
1329+ };
1330+ 5EF028BB2F7E7177006A58E2 /* BAASafariHost */ = {
1331+ isa = PBXGroup;
1332+ children = (
1333+ 5EF028BC2F7E7177006A58E2 /* AppDelegate.swift */,
1334+ 5EF028C82F7E7177006A58E2 /* ViewController.swift */,
1335+ 5EF028CA2F7E7177006A58E2 /* Main.storyboard */,
1336+ 5EF028CD2F7E7177006A58E2 /* Assets.xcassets */,
1337+ 5EF028CF2F7E7177006A58E2 /* Info.plist */,
1338+ 5EF028D02F7E7177006A58E2 /* BAASafariHost.entitlements */,
1339+ 5EF028D12F7E7177006A58E2 /* BAASafariHost.entitlements */,
1340+ 5EF028BE2F7E7177006A58E2 /* Resources */,
1341+ );
1342+ path = BAASafariHost;
1343+ sourceTree = "<group>";
1344+ };
1345+ 5EF028BE2F7E7177006A58E2 /* Resources */ = {
1346+ isa = PBXGroup;
1347+ children = (
1348+ 5EF028BF2F7E7177006A58E2 /* Main.html */,
1349+ 5EF028C22F7E7177006A58E2 /* Icon.png */,
1350+ 5EF028C42F7E7177006A58E2 /* Style.css */,
1351+ 5EF028C62F7E7177006A58E2 /* Script.js */,
1352+ );
1353+ path = Resources;
1354+ sourceTree = "<group>";
1355+ };
1356+ 5EF028D92F7E7177006A58E2 /* BAASafariHostTests */ = {
1357+ isa = PBXGroup;
1358+ children = (
1359+ 5EF028DA2F7E7177006A58E2 /* BAASafariHostTests.swift */,
1360+ );
1361+ path = BAASafariHostTests;
1362+ sourceTree = "<group>";
1363+ };
1364+ 5EF028E32F7E7177006A58E2 /* BAASafariHostUITests */ = {
1365+ isa = PBXGroup;
1366+ children = (
1367+ 5EF028E42F7E7177006A58E2 /* BAASafariHostUITests.swift */,
1368+ 5EF028E62F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift */,
1369+ );
1370+ path = BAASafariHostUITests;
1371+ sourceTree = "<group>";
1372+ };
1373+ 5EF028F02F7E7177006A58E2 /* BAASafariHost Extension */ = {
1374+ isa = PBXGroup;
1375+ children = (
1376+ 5EF029042F7E7178006A58E2 /* Resources */,
1377+ 5EF028F12F7E7177006A58E2 /* SafariWebExtensionHandler.swift */,
1378+ 5EF028F32F7E7177006A58E2 /* Info.plist */,
1379+ 5EF028F42F7E7177006A58E2 /* BAASafariHost_Extension.entitlements */,
1380+ );
1381+ path = "BAASafariHost Extension";
1382+ sourceTree = "<group>";
1383+ };
1384+ 5EF029042F7E7178006A58E2 /* Resources */ = {
1385+ isa = PBXGroup;
1386+ children = (
1387+ 5EF029052F7E7178006A58E2 /* background.js */,
1388+ 5EF029062F7E7178006A58E2 /* controller.css */,
1389+ 5EF029072F7E7178006A58E2 /* controller.js */,
1390+ 5EF029082F7E7178006A58E2 /* README.md */,
1391+ 5EF029212F7E7178006A58E2 /* final-message.js */,
1392+ 5EF029092F7E7178006A58E2 /* controller.html */,
1393+ 5EF0290A2F7E7178006A58E2 /* manifest.json */,
1394+ 5EF0290B2F7E7178006A58E2 /* content-script.js */,
1395+ 5EF0290D2F7E7178006A58E2 /* page-interceptor.js */,
1396+ 5EF029182F7E7178006A58E2 /* 48.png */,
1397+ 5EF0291A2F7E7178006A58E2 /* 96.png */,
1398+ 5EF0291C2F7E7178006A58E2 /* 128.png */,
1399+ );
1400+ name = Resources;
1401+ path = "BAASafariHost Extension";
1402+ sourceTree = SOURCE_ROOT;
1403+ };
1404+/* End PBXGroup section */
1405+
1406+/* Begin PBXNativeTarget section */
1407+ 5EF028B82F7E7177006A58E2 /* BAASafariHost */ = {
1408+ isa = PBXNativeTarget;
1409+ buildConfigurationList = 5EF028FB2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost" */;
1410+ buildPhases = (
1411+ 5EF028B52F7E7177006A58E2 /* Sources */,
1412+ 5EF028B62F7E7177006A58E2 /* Frameworks */,
1413+ 5EF028B72F7E7177006A58E2 /* Resources */,
1414+ 5EF028FA2F7E7177006A58E2 /* Embed Foundation Extensions */,
1415+ );
1416+ buildRules = (
1417+ );
1418+ dependencies = (
1419+ 5EF028EF2F7E7177006A58E2 /* PBXTargetDependency */,
1420+ );
1421+ name = BAASafariHost;
1422+ packageProductDependencies = (
1423+ );
1424+ productName = BAASafariHost;
1425+ productReference = 5EF028B92F7E7177006A58E2 /* BAASafariHost.app */;
1426+ productType = "com.apple.product-type.application";
1427+ };
1428+ 5EF028D52F7E7177006A58E2 /* BAASafariHostTests */ = {
1429+ isa = PBXNativeTarget;
1430+ buildConfigurationList = 5EF028FE2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostTests" */;
1431+ buildPhases = (
1432+ 5EF028D22F7E7177006A58E2 /* Sources */,
1433+ 5EF028D32F7E7177006A58E2 /* Frameworks */,
1434+ 5EF028D42F7E7177006A58E2 /* Resources */,
1435+ );
1436+ buildRules = (
1437+ );
1438+ dependencies = (
1439+ 5EF028D82F7E7177006A58E2 /* PBXTargetDependency */,
1440+ );
1441+ name = BAASafariHostTests;
1442+ packageProductDependencies = (
1443+ );
1444+ productName = BAASafariHostTests;
1445+ productReference = 5EF028D62F7E7177006A58E2 /* BAASafariHostTests.xctest */;
1446+ productType = "com.apple.product-type.bundle.unit-test";
1447+ };
1448+ 5EF028DF2F7E7177006A58E2 /* BAASafariHostUITests */ = {
1449+ isa = PBXNativeTarget;
1450+ buildConfigurationList = 5EF029012F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostUITests" */;
1451+ buildPhases = (
1452+ 5EF028DC2F7E7177006A58E2 /* Sources */,
1453+ 5EF028DD2F7E7177006A58E2 /* Frameworks */,
1454+ 5EF028DE2F7E7177006A58E2 /* Resources */,
1455+ );
1456+ buildRules = (
1457+ );
1458+ dependencies = (
1459+ 5EF028E22F7E7177006A58E2 /* PBXTargetDependency */,
1460+ );
1461+ name = BAASafariHostUITests;
1462+ packageProductDependencies = (
1463+ );
1464+ productName = BAASafariHostUITests;
1465+ productReference = 5EF028E02F7E7177006A58E2 /* BAASafariHostUITests.xctest */;
1466+ productType = "com.apple.product-type.bundle.ui-testing";
1467+ };
1468+ 5EF028EB2F7E7177006A58E2 /* BAASafariHost Extension */ = {
1469+ isa = PBXNativeTarget;
1470+ buildConfigurationList = 5EF028F72F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost Extension" */;
1471+ buildPhases = (
1472+ 5EF028E82F7E7177006A58E2 /* Sources */,
1473+ 5EF028E92F7E7177006A58E2 /* Frameworks */,
1474+ 5EF028EA2F7E7177006A58E2 /* Resources */,
1475+ );
1476+ buildRules = (
1477+ );
1478+ dependencies = (
1479+ );
1480+ name = "BAASafariHost Extension";
1481+ packageProductDependencies = (
1482+ );
1483+ productName = "BAASafariHost Extension";
1484+ productReference = 5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */;
1485+ productType = "com.apple.product-type.app-extension";
1486+ };
1487+/* End PBXNativeTarget section */
1488+
1489+/* Begin PBXProject section */
1490+ 5EF028B12F7E7177006A58E2 /* Project object */ = {
1491+ isa = PBXProject;
1492+ attributes = {
1493+ BuildIndependentTargetsInParallel = 1;
1494+ LastSwiftUpdateCheck = 1620;
1495+ LastUpgradeCheck = 1620;
1496+ TargetAttributes = {
1497+ 5EF028B82F7E7177006A58E2 = {
1498+ CreatedOnToolsVersion = 16.2;
1499+ };
1500+ 5EF028D52F7E7177006A58E2 = {
1501+ CreatedOnToolsVersion = 16.2;
1502+ TestTargetID = 5EF028B82F7E7177006A58E2;
1503+ };
1504+ 5EF028DF2F7E7177006A58E2 = {
1505+ CreatedOnToolsVersion = 16.2;
1506+ TestTargetID = 5EF028B82F7E7177006A58E2;
1507+ };
1508+ 5EF028EB2F7E7177006A58E2 = {
1509+ CreatedOnToolsVersion = 16.2;
1510+ };
1511+ };
1512+ };
1513+ buildConfigurationList = 5EF028B42F7E7177006A58E2 /* Build configuration list for PBXProject "BAASafariHost" */;
1514+ developmentRegion = en;
1515+ hasScannedForEncodings = 0;
1516+ knownRegions = (
1517+ en,
1518+ Base,
1519+ );
1520+ mainGroup = 5EF028B02F7E7177006A58E2;
1521+ minimizedProjectReferenceProxies = 1;
1522+ preferredProjectObjectVersion = 77;
1523+ productRefGroup = 5EF028BA2F7E7177006A58E2 /* Products */;
1524+ projectDirPath = "";
1525+ projectRoot = "";
1526+ targets = (
1527+ 5EF028B82F7E7177006A58E2 /* BAASafariHost */,
1528+ 5EF028D52F7E7177006A58E2 /* BAASafariHostTests */,
1529+ 5EF028DF2F7E7177006A58E2 /* BAASafariHostUITests */,
1530+ 5EF028EB2F7E7177006A58E2 /* BAASafariHost Extension */,
1531+ );
1532+ };
1533+/* End PBXProject section */
1534+
1535+/* Begin PBXResourcesBuildPhase section */
1536+ 5EF028B72F7E7177006A58E2 /* Resources */ = {
1537+ isa = PBXResourcesBuildPhase;
1538+ buildActionMask = 2147483647;
1539+ files = (
1540+ 5EF028C32F7E7177006A58E2 /* Icon.png in Resources */,
1541+ 5EF028CC2F7E7177006A58E2 /* Main.storyboard in Resources */,
1542+ 5EF028C72F7E7177006A58E2 /* Script.js in Resources */,
1543+ 5EF028C12F7E7177006A58E2 /* Main.html in Resources */,
1544+ 5EF028CE2F7E7177006A58E2 /* Assets.xcassets in Resources */,
1545+ 5EF028C52F7E7177006A58E2 /* Style.css in Resources */,
1546+ );
1547+ runOnlyForDeploymentPostprocessing = 0;
1548+ };
1549+ 5EF028D42F7E7177006A58E2 /* Resources */ = {
1550+ isa = PBXResourcesBuildPhase;
1551+ buildActionMask = 2147483647;
1552+ files = (
1553+ );
1554+ runOnlyForDeploymentPostprocessing = 0;
1555+ };
1556+ 5EF028DE2F7E7177006A58E2 /* Resources */ = {
1557+ isa = PBXResourcesBuildPhase;
1558+ buildActionMask = 2147483647;
1559+ files = (
1560+ );
1561+ runOnlyForDeploymentPostprocessing = 0;
1562+ };
1563+ 5EF028EA2F7E7177006A58E2 /* Resources */ = {
1564+ isa = PBXResourcesBuildPhase;
1565+ buildActionMask = 2147483647;
1566+ files = (
1567+ 5EF029142F7E7178006A58E2 /* content-script.js in Resources */,
1568+ 5EF0290E2F7E7178006A58E2 /* background.js in Resources */,
1569+ 5EF029102F7E7178006A58E2 /* controller.js in Resources */,
1570+ 5EF029202F7E7178006A58E2 /* final-message.js in Resources */,
1571+ 5EF029122F7E7178006A58E2 /* controller.html in Resources */,
1572+ 5EF029162F7E7178006A58E2 /* page-interceptor.js in Resources */,
1573+ 5EF029172F7E7178006A58E2 /* 48.png in Resources */,
1574+ 5EF029192F7E7178006A58E2 /* 96.png in Resources */,
1575+ 5EF0291B2F7E7178006A58E2 /* 128.png in Resources */,
1576+ 5EF029112F7E7178006A58E2 /* README.md in Resources */,
1577+ 5EF029132F7E7178006A58E2 /* manifest.json in Resources */,
1578+ 5EF0290F2F7E7178006A58E2 /* controller.css in Resources */,
1579+ );
1580+ runOnlyForDeploymentPostprocessing = 0;
1581+ };
1582+/* End PBXResourcesBuildPhase section */
1583+
1584+/* Begin PBXSourcesBuildPhase section */
1585+ 5EF028B52F7E7177006A58E2 /* Sources */ = {
1586+ isa = PBXSourcesBuildPhase;
1587+ buildActionMask = 2147483647;
1588+ files = (
1589+ 5EF028C92F7E7177006A58E2 /* ViewController.swift in Sources */,
1590+ 5EF028BD2F7E7177006A58E2 /* AppDelegate.swift in Sources */,
1591+ );
1592+ runOnlyForDeploymentPostprocessing = 0;
1593+ };
1594+ 5EF028D22F7E7177006A58E2 /* Sources */ = {
1595+ isa = PBXSourcesBuildPhase;
1596+ buildActionMask = 2147483647;
1597+ files = (
1598+ 5EF028DB2F7E7177006A58E2 /* BAASafariHostTests.swift in Sources */,
1599+ );
1600+ runOnlyForDeploymentPostprocessing = 0;
1601+ };
1602+ 5EF028DC2F7E7177006A58E2 /* Sources */ = {
1603+ isa = PBXSourcesBuildPhase;
1604+ buildActionMask = 2147483647;
1605+ files = (
1606+ 5EF028E52F7E7177006A58E2 /* BAASafariHostUITests.swift in Sources */,
1607+ 5EF028E72F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift in Sources */,
1608+ );
1609+ runOnlyForDeploymentPostprocessing = 0;
1610+ };
1611+ 5EF028E82F7E7177006A58E2 /* Sources */ = {
1612+ isa = PBXSourcesBuildPhase;
1613+ buildActionMask = 2147483647;
1614+ files = (
1615+ 5EF028F22F7E7177006A58E2 /* SafariWebExtensionHandler.swift in Sources */,
1616+ );
1617+ runOnlyForDeploymentPostprocessing = 0;
1618+ };
1619+/* End PBXSourcesBuildPhase section */
1620+
1621+/* Begin PBXTargetDependency section */
1622+ 5EF028D82F7E7177006A58E2 /* PBXTargetDependency */ = {
1623+ isa = PBXTargetDependency;
1624+ target = 5EF028B82F7E7177006A58E2 /* BAASafariHost */;
1625+ targetProxy = 5EF028D72F7E7177006A58E2 /* PBXContainerItemProxy */;
1626+ };
1627+ 5EF028E22F7E7177006A58E2 /* PBXTargetDependency */ = {
1628+ isa = PBXTargetDependency;
1629+ target = 5EF028B82F7E7177006A58E2 /* BAASafariHost */;
1630+ targetProxy = 5EF028E12F7E7177006A58E2 /* PBXContainerItemProxy */;
1631+ };
1632+ 5EF028EF2F7E7177006A58E2 /* PBXTargetDependency */ = {
1633+ isa = PBXTargetDependency;
1634+ target = 5EF028EB2F7E7177006A58E2 /* BAASafariHost Extension */;
1635+ targetProxy = 5EF028EE2F7E7177006A58E2 /* PBXContainerItemProxy */;
1636+ };
1637+/* End PBXTargetDependency section */
1638+
1639+/* Begin PBXVariantGroup section */
1640+ 5EF028BF2F7E7177006A58E2 /* Main.html */ = {
1641+ isa = PBXVariantGroup;
1642+ children = (
1643+ 5EF028C02F7E7177006A58E2 /* Base */,
1644+ );
1645+ name = Main.html;
1646+ sourceTree = "<group>";
1647+ };
1648+ 5EF028CA2F7E7177006A58E2 /* Main.storyboard */ = {
1649+ isa = PBXVariantGroup;
1650+ children = (
1651+ 5EF028CB2F7E7177006A58E2 /* Base */,
1652+ );
1653+ name = Main.storyboard;
1654+ sourceTree = "<group>";
1655+ };
1656+/* End PBXVariantGroup section */
1657+
1658+/* Begin XCBuildConfiguration section */
1659+ 5EF028F52F7E7177006A58E2 /* Debug */ = {
1660+ isa = XCBuildConfiguration;
1661+ buildSettings = {
1662+ ALWAYS_SEARCH_USER_PATHS = NO;
1663+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
1664+ CLANG_ANALYZER_NONNULL = YES;
1665+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
1666+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
1667+ CLANG_ENABLE_MODULES = YES;
1668+ CLANG_ENABLE_OBJC_ARC = YES;
1669+ CLANG_ENABLE_OBJC_WEAK = YES;
1670+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
1671+ CLANG_WARN_BOOL_CONVERSION = YES;
1672+ CLANG_WARN_COMMA = YES;
1673+ CLANG_WARN_CONSTANT_CONVERSION = YES;
1674+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
1675+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
1676+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
1677+ CLANG_WARN_EMPTY_BODY = YES;
1678+ CLANG_WARN_ENUM_CONVERSION = YES;
1679+ CLANG_WARN_INFINITE_RECURSION = YES;
1680+ CLANG_WARN_INT_CONVERSION = YES;
1681+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
1682+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
1683+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
1684+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
1685+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
1686+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
1687+ CLANG_WARN_STRICT_PROTOTYPES = YES;
1688+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
1689+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
1690+ CLANG_WARN_UNREACHABLE_CODE = YES;
1691+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
1692+ COPY_PHASE_STRIP = NO;
1693+ DEBUG_INFORMATION_FORMAT = dwarf;
1694+ ENABLE_STRICT_OBJC_MSGSEND = YES;
1695+ ENABLE_TESTABILITY = YES;
1696+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
1697+ GCC_C_LANGUAGE_STANDARD = gnu17;
1698+ GCC_DYNAMIC_NO_PIC = NO;
1699+ GCC_NO_COMMON_BLOCKS = YES;
1700+ GCC_OPTIMIZATION_LEVEL = 0;
1701+ GCC_PREPROCESSOR_DEFINITIONS = (
1702+ "DEBUG=1",
1703+ "$(inherited)",
1704+ );
1705+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
1706+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
1707+ GCC_WARN_UNDECLARED_SELECTOR = YES;
1708+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
1709+ GCC_WARN_UNUSED_FUNCTION = YES;
1710+ GCC_WARN_UNUSED_VARIABLE = YES;
1711+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
1712+ MACOSX_DEPLOYMENT_TARGET = 15.1;
1713+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
1714+ MTL_FAST_MATH = YES;
1715+ ONLY_ACTIVE_ARCH = YES;
1716+ SDKROOT = macosx;
1717+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
1718+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1719+ };
1720+ name = Debug;
1721+ };
1722+ 5EF028F62F7E7177006A58E2 /* Release */ = {
1723+ isa = XCBuildConfiguration;
1724+ buildSettings = {
1725+ ALWAYS_SEARCH_USER_PATHS = NO;
1726+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
1727+ CLANG_ANALYZER_NONNULL = YES;
1728+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
1729+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
1730+ CLANG_ENABLE_MODULES = YES;
1731+ CLANG_ENABLE_OBJC_ARC = YES;
1732+ CLANG_ENABLE_OBJC_WEAK = YES;
1733+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
1734+ CLANG_WARN_BOOL_CONVERSION = YES;
1735+ CLANG_WARN_COMMA = YES;
1736+ CLANG_WARN_CONSTANT_CONVERSION = YES;
1737+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
1738+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
1739+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
1740+ CLANG_WARN_EMPTY_BODY = YES;
1741+ CLANG_WARN_ENUM_CONVERSION = YES;
1742+ CLANG_WARN_INFINITE_RECURSION = YES;
1743+ CLANG_WARN_INT_CONVERSION = YES;
1744+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
1745+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
1746+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
1747+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
1748+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
1749+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
1750+ CLANG_WARN_STRICT_PROTOTYPES = YES;
1751+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
1752+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
1753+ CLANG_WARN_UNREACHABLE_CODE = YES;
1754+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
1755+ COPY_PHASE_STRIP = NO;
1756+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
1757+ ENABLE_NS_ASSERTIONS = NO;
1758+ ENABLE_STRICT_OBJC_MSGSEND = YES;
1759+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
1760+ GCC_C_LANGUAGE_STANDARD = gnu17;
1761+ GCC_NO_COMMON_BLOCKS = YES;
1762+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
1763+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
1764+ GCC_WARN_UNDECLARED_SELECTOR = YES;
1765+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
1766+ GCC_WARN_UNUSED_FUNCTION = YES;
1767+ GCC_WARN_UNUSED_VARIABLE = YES;
1768+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
1769+ MACOSX_DEPLOYMENT_TARGET = 15.1;
1770+ MTL_ENABLE_DEBUG_INFO = NO;
1771+ MTL_FAST_MATH = YES;
1772+ SDKROOT = macosx;
1773+ SWIFT_COMPILATION_MODE = wholemodule;
1774+ };
1775+ name = Release;
1776+ };
1777+ 5EF028F82F7E7177006A58E2 /* Debug */ = {
1778+ isa = XCBuildConfiguration;
1779+ buildSettings = {
1780+ CODE_SIGN_ENTITLEMENTS = "BAASafariHost Extension/BAASafariHost_Extension.entitlements";
1781+ CODE_SIGN_STYLE = Automatic;
1782+ CURRENT_PROJECT_VERSION = 1;
1783+ ENABLE_HARDENED_RUNTIME = YES;
1784+ GENERATE_INFOPLIST_FILE = YES;
1785+ INFOPLIST_FILE = "BAASafariHost Extension/Info.plist";
1786+ INFOPLIST_KEY_CFBundleDisplayName = "BAASafariHost Extension";
1787+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
1788+ LD_RUNPATH_SEARCH_PATHS = (
1789+ "$(inherited)",
1790+ "@executable_path/../Frameworks",
1791+ "@executable_path/../../../../Frameworks",
1792+ );
1793+ MACOSX_DEPLOYMENT_TARGET = 10.14;
1794+ MARKETING_VERSION = 1.0;
1795+ OTHER_LDFLAGS = (
1796+ "-framework",
1797+ SafariServices,
1798+ );
1799+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost.Extension;
1800+ PRODUCT_NAME = "$(TARGET_NAME)";
1801+ SKIP_INSTALL = YES;
1802+ SWIFT_EMIT_LOC_STRINGS = YES;
1803+ SWIFT_VERSION = 5.0;
1804+ };
1805+ name = Debug;
1806+ };
1807+ 5EF028F92F7E7177006A58E2 /* Release */ = {
1808+ isa = XCBuildConfiguration;
1809+ buildSettings = {
1810+ CODE_SIGN_ENTITLEMENTS = "BAASafariHost Extension/BAASafariHost_Extension.entitlements";
1811+ CODE_SIGN_STYLE = Automatic;
1812+ CURRENT_PROJECT_VERSION = 1;
1813+ ENABLE_HARDENED_RUNTIME = YES;
1814+ GENERATE_INFOPLIST_FILE = YES;
1815+ INFOPLIST_FILE = "BAASafariHost Extension/Info.plist";
1816+ INFOPLIST_KEY_CFBundleDisplayName = "BAASafariHost Extension";
1817+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
1818+ LD_RUNPATH_SEARCH_PATHS = (
1819+ "$(inherited)",
1820+ "@executable_path/../Frameworks",
1821+ "@executable_path/../../../../Frameworks",
1822+ );
1823+ MACOSX_DEPLOYMENT_TARGET = 10.14;
1824+ MARKETING_VERSION = 1.0;
1825+ OTHER_LDFLAGS = (
1826+ "-framework",
1827+ SafariServices,
1828+ );
1829+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost.Extension;
1830+ PRODUCT_NAME = "$(TARGET_NAME)";
1831+ SKIP_INSTALL = YES;
1832+ SWIFT_EMIT_LOC_STRINGS = YES;
1833+ SWIFT_VERSION = 5.0;
1834+ };
1835+ name = Release;
1836+ };
1837+ 5EF028FC2F7E7177006A58E2 /* Debug */ = {
1838+ isa = XCBuildConfiguration;
1839+ buildSettings = {
1840+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1841+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1842+ CODE_SIGN_ENTITLEMENTS = BAASafariHost/BAASafariHost.entitlements;
1843+ CODE_SIGN_STYLE = Automatic;
1844+ COMBINE_HIDPI_IMAGES = YES;
1845+ CURRENT_PROJECT_VERSION = 1;
1846+ ENABLE_HARDENED_RUNTIME = YES;
1847+ GENERATE_INFOPLIST_FILE = YES;
1848+ INFOPLIST_FILE = BAASafariHost/Info.plist;
1849+ INFOPLIST_KEY_CFBundleDisplayName = BAASafariHost;
1850+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
1851+ INFOPLIST_KEY_NSMainStoryboardFile = Main;
1852+ INFOPLIST_KEY_NSPrincipalClass = NSApplication;
1853+ LD_RUNPATH_SEARCH_PATHS = (
1854+ "$(inherited)",
1855+ "@executable_path/../Frameworks",
1856+ );
1857+ MACOSX_DEPLOYMENT_TARGET = 10.14;
1858+ MARKETING_VERSION = 1.0;
1859+ OTHER_LDFLAGS = (
1860+ "-framework",
1861+ SafariServices,
1862+ "-framework",
1863+ WebKit,
1864+ );
1865+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost;
1866+ PRODUCT_NAME = "$(TARGET_NAME)";
1867+ SWIFT_EMIT_LOC_STRINGS = YES;
1868+ SWIFT_VERSION = 5.0;
1869+ };
1870+ name = Debug;
1871+ };
1872+ 5EF028FD2F7E7177006A58E2 /* Release */ = {
1873+ isa = XCBuildConfiguration;
1874+ buildSettings = {
1875+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1876+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1877+ CODE_SIGN_ENTITLEMENTS = BAASafariHost/BAASafariHost.entitlements;
1878+ CODE_SIGN_STYLE = Automatic;
1879+ COMBINE_HIDPI_IMAGES = YES;
1880+ CURRENT_PROJECT_VERSION = 1;
1881+ ENABLE_HARDENED_RUNTIME = YES;
1882+ GENERATE_INFOPLIST_FILE = YES;
1883+ INFOPLIST_FILE = BAASafariHost/Info.plist;
1884+ INFOPLIST_KEY_CFBundleDisplayName = BAASafariHost;
1885+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
1886+ INFOPLIST_KEY_NSMainStoryboardFile = Main;
1887+ INFOPLIST_KEY_NSPrincipalClass = NSApplication;
1888+ LD_RUNPATH_SEARCH_PATHS = (
1889+ "$(inherited)",
1890+ "@executable_path/../Frameworks",
1891+ );
1892+ MACOSX_DEPLOYMENT_TARGET = 10.14;
1893+ MARKETING_VERSION = 1.0;
1894+ OTHER_LDFLAGS = (
1895+ "-framework",
1896+ SafariServices,
1897+ "-framework",
1898+ WebKit,
1899+ );
1900+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost;
1901+ PRODUCT_NAME = "$(TARGET_NAME)";
1902+ SWIFT_EMIT_LOC_STRINGS = YES;
1903+ SWIFT_VERSION = 5.0;
1904+ };
1905+ name = Release;
1906+ };
1907+ 5EF028FF2F7E7177006A58E2 /* Debug */ = {
1908+ isa = XCBuildConfiguration;
1909+ buildSettings = {
1910+ BUNDLE_LOADER = "$(TEST_HOST)";
1911+ CODE_SIGN_STYLE = Automatic;
1912+ CURRENT_PROJECT_VERSION = 1;
1913+ GENERATE_INFOPLIST_FILE = YES;
1914+ MACOSX_DEPLOYMENT_TARGET = 10.14;
1915+ MARKETING_VERSION = 1.0;
1916+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostTests;
1917+ PRODUCT_NAME = "$(TARGET_NAME)";
1918+ SWIFT_EMIT_LOC_STRINGS = NO;
1919+ SWIFT_VERSION = 5.0;
1920+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BAASafariHost.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BAASafariHost";
1921+ };
1922+ name = Debug;
1923+ };
1924+ 5EF029002F7E7177006A58E2 /* Release */ = {
1925+ isa = XCBuildConfiguration;
1926+ buildSettings = {
1927+ BUNDLE_LOADER = "$(TEST_HOST)";
1928+ CODE_SIGN_STYLE = Automatic;
1929+ CURRENT_PROJECT_VERSION = 1;
1930+ GENERATE_INFOPLIST_FILE = YES;
1931+ MACOSX_DEPLOYMENT_TARGET = 10.14;
1932+ MARKETING_VERSION = 1.0;
1933+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostTests;
1934+ PRODUCT_NAME = "$(TARGET_NAME)";
1935+ SWIFT_EMIT_LOC_STRINGS = NO;
1936+ SWIFT_VERSION = 5.0;
1937+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BAASafariHost.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BAASafariHost";
1938+ };
1939+ name = Release;
1940+ };
1941+ 5EF029022F7E7177006A58E2 /* Debug */ = {
1942+ isa = XCBuildConfiguration;
1943+ buildSettings = {
1944+ CODE_SIGN_STYLE = Automatic;
1945+ CURRENT_PROJECT_VERSION = 1;
1946+ GENERATE_INFOPLIST_FILE = YES;
1947+ MARKETING_VERSION = 1.0;
1948+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostUITests;
1949+ PRODUCT_NAME = "$(TARGET_NAME)";
1950+ SWIFT_EMIT_LOC_STRINGS = NO;
1951+ SWIFT_VERSION = 5.0;
1952+ TEST_TARGET_NAME = BAASafariHost;
1953+ };
1954+ name = Debug;
1955+ };
1956+ 5EF029032F7E7177006A58E2 /* Release */ = {
1957+ isa = XCBuildConfiguration;
1958+ buildSettings = {
1959+ CODE_SIGN_STYLE = Automatic;
1960+ CURRENT_PROJECT_VERSION = 1;
1961+ GENERATE_INFOPLIST_FILE = YES;
1962+ MARKETING_VERSION = 1.0;
1963+ PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostUITests;
1964+ PRODUCT_NAME = "$(TARGET_NAME)";
1965+ SWIFT_EMIT_LOC_STRINGS = NO;
1966+ SWIFT_VERSION = 5.0;
1967+ TEST_TARGET_NAME = BAASafariHost;
1968+ };
1969+ name = Release;
1970+ };
1971+/* End XCBuildConfiguration section */
1972+
1973+/* Begin XCConfigurationList section */
1974+ 5EF028B42F7E7177006A58E2 /* Build configuration list for PBXProject "BAASafariHost" */ = {
1975+ isa = XCConfigurationList;
1976+ buildConfigurations = (
1977+ 5EF028F52F7E7177006A58E2 /* Debug */,
1978+ 5EF028F62F7E7177006A58E2 /* Release */,
1979+ );
1980+ defaultConfigurationIsVisible = 0;
1981+ defaultConfigurationName = Release;
1982+ };
1983+ 5EF028F72F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost Extension" */ = {
1984+ isa = XCConfigurationList;
1985+ buildConfigurations = (
1986+ 5EF028F82F7E7177006A58E2 /* Debug */,
1987+ 5EF028F92F7E7177006A58E2 /* Release */,
1988+ );
1989+ defaultConfigurationIsVisible = 0;
1990+ defaultConfigurationName = Release;
1991+ };
1992+ 5EF028FB2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost" */ = {
1993+ isa = XCConfigurationList;
1994+ buildConfigurations = (
1995+ 5EF028FC2F7E7177006A58E2 /* Debug */,
1996+ 5EF028FD2F7E7177006A58E2 /* Release */,
1997+ );
1998+ defaultConfigurationIsVisible = 0;
1999+ defaultConfigurationName = Release;
2000+ };
2001+ 5EF028FE2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostTests" */ = {
2002+ isa = XCConfigurationList;
2003+ buildConfigurations = (
2004+ 5EF028FF2F7E7177006A58E2 /* Debug */,
2005+ 5EF029002F7E7177006A58E2 /* Release */,
2006+ );
2007+ defaultConfigurationIsVisible = 0;
2008+ defaultConfigurationName = Release;
2009+ };
2010+ 5EF029012F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostUITests" */ = {
2011+ isa = XCConfigurationList;
2012+ buildConfigurations = (
2013+ 5EF029022F7E7177006A58E2 /* Debug */,
2014+ 5EF029032F7E7177006A58E2 /* Release */,
2015+ );
2016+ defaultConfigurationIsVisible = 0;
2017+ defaultConfigurationName = Release;
2018+ };
2019+/* End XCConfigurationList section */
2020+ };
2021+ rootObject = 5EF028B12F7E7177006A58E2 /* Project object */;
2022+}
2023diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata
2024new file mode 100644
2025index 0000000000000000000000000000000000000000..919434a6254f0e9651f402737811be6634a03e9c
2026--- /dev/null
2027+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata
2028@@ -0,0 +1,7 @@
2029+<?xml version="1.0" encoding="UTF-8"?>
2030+<Workspace
2031+ version = "1.0">
2032+ <FileRef
2033+ location = "self:">
2034+ </FileRef>
2035+</Workspace>
2036diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/AppDelegate.swift b/plugins/baa-safari/baa-safari-host/BAASafariHost/AppDelegate.swift
2037new file mode 100644
2038index 0000000000000000000000000000000000000000..b75d6d6405b5fd844d648740581701f2e06f997a
2039--- /dev/null
2040+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/AppDelegate.swift
2041@@ -0,0 +1,21 @@
2042+//
2043+// AppDelegate.swift
2044+// BAASafariHost
2045+//
2046+// Created by george on 2026/4/2.
2047+//
2048+
2049+import Cocoa
2050+
2051+@main
2052+class AppDelegate: NSObject, NSApplicationDelegate {
2053+
2054+ func applicationDidFinishLaunching(_ notification: Notification) {
2055+ // Override point for customization after application launch.
2056+ }
2057+
2058+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
2059+ return true
2060+ }
2061+
2062+}
2063diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AccentColor.colorset/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AccentColor.colorset/Contents.json
2064new file mode 100644
2065index 0000000000000000000000000000000000000000..eb8789700816459c1e1480e0b34781d9fb78a1ca
2066--- /dev/null
2067+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AccentColor.colorset/Contents.json
2068@@ -0,0 +1,11 @@
2069+{
2070+ "colors" : [
2071+ {
2072+ "idiom" : "universal"
2073+ }
2074+ ],
2075+ "info" : {
2076+ "author" : "xcode",
2077+ "version" : 1
2078+ }
2079+}
2080diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AppIcon.appiconset/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AppIcon.appiconset/Contents.json
2081new file mode 100644
2082index 0000000000000000000000000000000000000000..3f00db43ec3c8b462759505d635dc5545d4e8e50
2083--- /dev/null
2084+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AppIcon.appiconset/Contents.json
2085@@ -0,0 +1,58 @@
2086+{
2087+ "images" : [
2088+ {
2089+ "idiom" : "mac",
2090+ "scale" : "1x",
2091+ "size" : "16x16"
2092+ },
2093+ {
2094+ "idiom" : "mac",
2095+ "scale" : "2x",
2096+ "size" : "16x16"
2097+ },
2098+ {
2099+ "idiom" : "mac",
2100+ "scale" : "1x",
2101+ "size" : "32x32"
2102+ },
2103+ {
2104+ "idiom" : "mac",
2105+ "scale" : "2x",
2106+ "size" : "32x32"
2107+ },
2108+ {
2109+ "idiom" : "mac",
2110+ "scale" : "1x",
2111+ "size" : "128x128"
2112+ },
2113+ {
2114+ "idiom" : "mac",
2115+ "scale" : "2x",
2116+ "size" : "128x128"
2117+ },
2118+ {
2119+ "idiom" : "mac",
2120+ "scale" : "1x",
2121+ "size" : "256x256"
2122+ },
2123+ {
2124+ "idiom" : "mac",
2125+ "scale" : "2x",
2126+ "size" : "256x256"
2127+ },
2128+ {
2129+ "idiom" : "mac",
2130+ "scale" : "1x",
2131+ "size" : "512x512"
2132+ },
2133+ {
2134+ "idiom" : "mac",
2135+ "scale" : "2x",
2136+ "size" : "512x512"
2137+ }
2138+ ],
2139+ "info" : {
2140+ "author" : "xcode",
2141+ "version" : 1
2142+ }
2143+}
2144diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/Contents.json
2145new file mode 100644
2146index 0000000000000000000000000000000000000000..73c00596a7fca3f3d4bdd64053b69d86745f9e10
2147--- /dev/null
2148+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/Contents.json
2149@@ -0,0 +1,6 @@
2150+{
2151+ "info" : {
2152+ "author" : "xcode",
2153+ "version" : 1
2154+ }
2155+}
2156diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/LargeIcon.imageset/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/LargeIcon.imageset/Contents.json
2157new file mode 100644
2158index 0000000000000000000000000000000000000000..a19a5492203a8d30fc85ccc30497536a9500155d
2159--- /dev/null
2160+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/LargeIcon.imageset/Contents.json
2161@@ -0,0 +1,20 @@
2162+{
2163+ "images" : [
2164+ {
2165+ "idiom" : "universal",
2166+ "scale" : "1x"
2167+ },
2168+ {
2169+ "idiom" : "universal",
2170+ "scale" : "2x"
2171+ },
2172+ {
2173+ "idiom" : "universal",
2174+ "scale" : "3x"
2175+ }
2176+ ],
2177+ "info" : {
2178+ "author" : "xcode",
2179+ "version" : 1
2180+ }
2181+}
2182diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/BAASafariHost.entitlements b/plugins/baa-safari/baa-safari-host/BAASafariHost/BAASafariHost.entitlements
2183new file mode 100644
2184index 0000000000000000000000000000000000000000..625af03d99b2ea991b4a6dc8eed9db088e5afba1
2185--- /dev/null
2186+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/BAASafariHost.entitlements
2187@@ -0,0 +1,12 @@
2188+<?xml version="1.0" encoding="UTF-8"?>
2189+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2190+<plist version="1.0">
2191+<dict>
2192+ <key>com.apple.security.app-sandbox</key>
2193+ <true/>
2194+ <key>com.apple.security.files.user-selected.read-only</key>
2195+ <true/>
2196+ <key>com.apple.security.network.client</key>
2197+ <true/>
2198+</dict>
2199+</plist>
2200diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Base.lproj/Main.storyboard b/plugins/baa-safari/baa-safari-host/BAASafariHost/Base.lproj/Main.storyboard
2201new file mode 100644
2202index 0000000000000000000000000000000000000000..f31b8241388f186e0882183ea04dbe6af071083a
2203--- /dev/null
2204+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Base.lproj/Main.storyboard
2205@@ -0,0 +1,124 @@
2206+<?xml version="1.0" encoding="UTF-8"?>
2207+<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="19085" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
2208+ <dependencies>
2209+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="19085"/>
2210+ <plugIn identifier="com.apple.WebKit2IBPlugin" version="19085"/>
2211+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
2212+ </dependencies>
2213+ <scenes>
2214+ <!--Application-->
2215+ <scene sceneID="JPo-4y-FX3">
2216+ <objects>
2217+ <application id="hnw-xV-0zn" sceneMemberID="viewController">
2218+ <menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
2219+ <items>
2220+ <menuItem title="BAASafariHost" id="1Xt-HY-uBw">
2221+ <modifierMask key="keyEquivalentModifierMask"/>
2222+ <menu key="submenu" title="BAASafariHost" systemMenu="apple" id="uQy-DD-JDr">
2223+ <items>
2224+ <menuItem title="About BAASafariHost" id="5kV-Vb-QxS">
2225+ <modifierMask key="keyEquivalentModifierMask"/>
2226+ <connections>
2227+ <action selector="orderFrontStandardAboutPanel:" target="Ady-hI-5gd" id="Exp-CZ-Vem"/>
2228+ </connections>
2229+ </menuItem>
2230+ <menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
2231+ <menuItem title="Hide BAASafariHost" keyEquivalent="h" id="Olw-nP-bQN">
2232+ <connections>
2233+ <action selector="hide:" target="Ady-hI-5gd" id="PnN-Uc-m68"/>
2234+ </connections>
2235+ </menuItem>
2236+ <menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
2237+ <modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
2238+ <connections>
2239+ <action selector="hideOtherApplications:" target="Ady-hI-5gd" id="VT4-aY-XCT"/>
2240+ </connections>
2241+ </menuItem>
2242+ <menuItem title="Show All" id="Kd2-mp-pUS">
2243+ <modifierMask key="keyEquivalentModifierMask"/>
2244+ <connections>
2245+ <action selector="unhideAllApplications:" target="Ady-hI-5gd" id="Dhg-Le-xox"/>
2246+ </connections>
2247+ </menuItem>
2248+ <menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
2249+ <menuItem title="Quit BAASafariHost" keyEquivalent="q" id="4sb-4s-VLi">
2250+ <connections>
2251+ <action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>
2252+ </connections>
2253+ </menuItem>
2254+ </items>
2255+ </menu>
2256+ </menuItem>
2257+ <menuItem title="Help" id="wpr-3q-Mcd">
2258+ <modifierMask key="keyEquivalentModifierMask"/>
2259+ <menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
2260+ <items>
2261+ <menuItem title="BAASafariHost Help" keyEquivalent="?" id="FKE-Sm-Kum">
2262+ <connections>
2263+ <action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
2264+ </connections>
2265+ </menuItem>
2266+ </items>
2267+ </menu>
2268+ </menuItem>
2269+ </items>
2270+ </menu>
2271+ <connections>
2272+ <outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
2273+ </connections>
2274+ </application>
2275+ <customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModuleProvider="target"/>
2276+ <customObject id="YLy-65-1bz" customClass="NSFontManager"/>
2277+ <customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
2278+ </objects>
2279+ <point key="canvasLocation" x="76" y="-134"/>
2280+ </scene>
2281+ <!--Window Controller-->
2282+ <scene sceneID="R2V-B0-nI4">
2283+ <objects>
2284+ <windowController showSeguePresentationStyle="single" id="B8D-0N-5wS" sceneMemberID="viewController">
2285+ <window key="window" title="BAASafariHost" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" releasedWhenClosed="NO" animationBehavior="default" id="IQv-IB-iLA">
2286+ <windowStyleMask key="styleMask" titled="YES" closable="YES"/>
2287+ <windowCollectionBehavior key="collectionBehavior" fullScreenNone="YES"/>
2288+ <rect key="contentRect" x="196" y="240" width="425" height="325"/>
2289+ <rect key="screenRect" x="0.0" y="0.0" width="1680" height="1027"/>
2290+ <connections>
2291+ <outlet property="delegate" destination="B8D-0N-5wS" id="98r-iN-zZc"/>
2292+ </connections>
2293+ </window>
2294+ <connections>
2295+ <segue destination="XfG-lQ-9wD" kind="relationship" relationship="window.shadowedContentViewController" id="cq2-FE-JQM"/>
2296+ </connections>
2297+ </windowController>
2298+ <customObject id="Oky-zY-oP4" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
2299+ </objects>
2300+ <point key="canvasLocation" x="75" y="250"/>
2301+ </scene>
2302+ <!--View Controller-->
2303+ <scene sceneID="hIz-AP-VOD">
2304+ <objects>
2305+ <viewController id="XfG-lQ-9wD" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
2306+ <view key="view" id="m2S-Jp-Qdl">
2307+ <rect key="frame" x="0.0" y="0.0" width="425" height="325"/>
2308+ <autoresizingMask key="autoresizingMask"/>
2309+ <subviews>
2310+ <wkWebView wantsLayer="YES" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eOr-cG-IQY">
2311+ <rect key="frame" x="0.0" y="0.0" width="425" height="325"/>
2312+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
2313+ <wkWebViewConfiguration key="configuration">
2314+ <audiovisualMediaTypes key="mediaTypesRequiringUserActionForPlayback" none="YES"/>
2315+ <wkPreferences key="preferences"/>
2316+ </wkWebViewConfiguration>
2317+ </wkWebView>
2318+ </subviews>
2319+ </view>
2320+ <connections>
2321+ <outlet property="webView" destination="eOr-cG-IQY" id="GFe-mU-dBY"/>
2322+ </connections>
2323+ </viewController>
2324+ <customObject id="rPt-NT-nkU" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
2325+ </objects>
2326+ <point key="canvasLocation" x="75" y="655"/>
2327+ </scene>
2328+ </scenes>
2329+</document>
2330diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Info.plist b/plugins/baa-safari/baa-safari-host/BAASafariHost/Info.plist
2331new file mode 100644
2332index 0000000000000000000000000000000000000000..716d66f1ba524566386509a943210e39fc8e4dd0
2333--- /dev/null
2334+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Info.plist
2335@@ -0,0 +1,8 @@
2336+<?xml version="1.0" encoding="UTF-8"?>
2337+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2338+<plist version="1.0">
2339+<dict>
2340+ <key>SFSafariWebExtensionConverterVersion</key>
2341+ <string>16.2</string>
2342+</dict>
2343+</plist>
2344diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Base.lproj/Main.html b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Base.lproj/Main.html
2345new file mode 100644
2346index 0000000000000000000000000000000000000000..e591d7054e219cc6e52a6744eac938dd88e4bfa6
2347--- /dev/null
2348+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Base.lproj/Main.html
2349@@ -0,0 +1,31 @@
2350+<!DOCTYPE html>
2351+<html>
2352+<head>
2353+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
2354+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
2355+
2356+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
2357+
2358+ <link rel="stylesheet" href="../Style.css">
2359+ <script src="../Script.js" defer></script>
2360+</head>
2361+<body>
2362+ <img src="../Icon.png" width="128" height="128" alt="BAASafariHost Icon">
2363+ <p class="eyebrow">BAA Safari Host</p>
2364+ <h1>请在 Safari 设置中启用 BAA 扩展</h1>
2365+ <p class="lead">
2366+ 这个宿主 App 只负责承载 Safari Web Extension。首版长期 runtime owner 仍然预留给
2367+ <code>controller.html</code>,不是这个宿主窗口。
2368+ </p>
2369+ <p class="state-unknown">你可以在 Safari 设置的“扩展”里启用 BAA Safari Extension。</p>
2370+ <p class="state-on">BAA Safari Extension 当前已启用。接下来请点击 Safari 工具栏里的扩展按钮打开控制页。</p>
2371+ <p class="state-off">BAA Safari Extension 当前未启用。请先在 Safari 设置的“扩展”里打开它。</p>
2372+ <ol class="steps">
2373+ <li>打开 Safari 设置。</li>
2374+ <li>进入“扩展”。</li>
2375+ <li>启用 BAA Safari Extension。</li>
2376+ <li>给 Claude / ChatGPT / Gemini 网站授权。</li>
2377+ </ol>
2378+ <button class="open-preferences">退出并打开 Safari 设置…</button>
2379+</body>
2380+</html>
2381diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Icon.png b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Icon.png
2382new file mode 100644
2383index 0000000000000000000000000000000000000000..423b491de9f0c035d4c9d7736c535bebfe078c8e
2384Binary files /dev/null and b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Icon.png differ
2385diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Script.js b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Script.js
2386new file mode 100644
2387index 0000000000000000000000000000000000000000..48a5ffa86d180d371570d5f63658d71a88ee25c3
2388--- /dev/null
2389+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Script.js
2390@@ -0,0 +1,22 @@
2391+function show(enabled, useSettingsInsteadOfPreferences) {
2392+ if (useSettingsInsteadOfPreferences) {
2393+ document.getElementsByClassName('state-on')[0].innerText = "BAA Safari Extension 当前已启用。接下来请点击 Safari 工具栏里的扩展按钮打开控制页。";
2394+ document.getElementsByClassName('state-off')[0].innerText = "BAA Safari Extension 当前未启用。请先在 Safari 设置的“扩展”里打开它。";
2395+ document.getElementsByClassName('state-unknown')[0].innerText = "你可以在 Safari 设置的“扩展”里启用 BAA Safari Extension。";
2396+ document.getElementsByClassName('open-preferences')[0].innerText = "退出并打开 Safari 设置…";
2397+ }
2398+
2399+ if (typeof enabled === "boolean") {
2400+ document.body.classList.toggle(`state-on`, enabled);
2401+ document.body.classList.toggle(`state-off`, !enabled);
2402+ } else {
2403+ document.body.classList.remove(`state-on`);
2404+ document.body.classList.remove(`state-off`);
2405+ }
2406+}
2407+
2408+function openPreferences() {
2409+ webkit.messageHandlers.controller.postMessage("open-preferences");
2410+}
2411+
2412+document.querySelector("button.open-preferences").addEventListener("click", openPreferences);
2413diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Style.css b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Style.css
2414new file mode 100644
2415index 0000000000000000000000000000000000000000..f7aadd600b769dd3a27f5314f87c890ac57b7ea9
2416--- /dev/null
2417+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Style.css
2418@@ -0,0 +1,82 @@
2419+* {
2420+ -webkit-user-select: none;
2421+ -webkit-user-drag: none;
2422+ cursor: default;
2423+}
2424+
2425+:root {
2426+ color-scheme: light dark;
2427+
2428+ --spacing: 20px;
2429+}
2430+
2431+html {
2432+ height: 100%;
2433+}
2434+
2435+body {
2436+ display: flex;
2437+ align-items: center;
2438+ justify-content: center;
2439+ flex-direction: column;
2440+
2441+ gap: var(--spacing);
2442+ margin: 0 calc(var(--spacing) * 2);
2443+ height: 100%;
2444+
2445+ font: -apple-system-short-body;
2446+ text-align: center;
2447+}
2448+
2449+img {
2450+ border-radius: 24px;
2451+}
2452+
2453+.eyebrow {
2454+ margin: 0;
2455+ font-size: 12px;
2456+ font-weight: 700;
2457+ letter-spacing: 0.08em;
2458+ text-transform: uppercase;
2459+ color: #2f688f;
2460+}
2461+
2462+h1 {
2463+ margin: 0;
2464+ max-width: 16em;
2465+ font-size: 28px;
2466+ line-height: 1.25;
2467+}
2468+
2469+.lead,
2470+.steps,
2471+p {
2472+ margin: 0;
2473+ max-width: 34rem;
2474+ line-height: 1.6;
2475+}
2476+
2477+.steps {
2478+ text-align: left;
2479+}
2480+
2481+body:not(.state-on, .state-off) :is(.state-on, .state-off) {
2482+ display: none;
2483+}
2484+
2485+body.state-on :is(.state-off, .state-unknown) {
2486+ display: none;
2487+}
2488+
2489+body.state-off :is(.state-on, .state-unknown) {
2490+ display: none;
2491+}
2492+
2493+button {
2494+ font-size: 1em;
2495+ border-radius: 999px;
2496+ border: 0;
2497+ padding: 11px 16px;
2498+ background: #1f6f5f;
2499+ color: #fff;
2500+}
2501diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/ViewController.swift b/plugins/baa-safari/baa-safari-host/BAASafariHost/ViewController.swift
2502new file mode 100644
2503index 0000000000000000000000000000000000000000..42a6de11a74f77d85e090d5320a7f5dc05bfcb70
2504--- /dev/null
2505+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/ViewController.swift
2506@@ -0,0 +1,57 @@
2507+//
2508+// ViewController.swift
2509+// BAASafariHost
2510+//
2511+// Created by george on 2026/4/2.
2512+//
2513+
2514+import Cocoa
2515+import SafariServices
2516+import WebKit
2517+
2518+let extensionBundleIdentifier = "com.baa.safari.BAASafariHost.Extension"
2519+
2520+class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler {
2521+
2522+ @IBOutlet var webView: WKWebView!
2523+
2524+ override func viewDidLoad() {
2525+ super.viewDidLoad()
2526+
2527+ self.webView.navigationDelegate = self
2528+
2529+ self.webView.configuration.userContentController.add(self, name: "controller")
2530+
2531+ self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!)
2532+ }
2533+
2534+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
2535+ SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in
2536+ guard let state = state, error == nil else {
2537+ // Insert code to inform the user that something went wrong.
2538+ return
2539+ }
2540+
2541+ DispatchQueue.main.async {
2542+ if #available(macOS 13, *) {
2543+ webView.evaluateJavaScript("show(\(state.isEnabled), true)")
2544+ } else {
2545+ webView.evaluateJavaScript("show(\(state.isEnabled), false)")
2546+ }
2547+ }
2548+ }
2549+ }
2550+
2551+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
2552+ if (message.body as! String != "open-preferences") {
2553+ return;
2554+ }
2555+
2556+ SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in
2557+ DispatchQueue.main.async {
2558+ NSApplication.shared.terminate(nil)
2559+ }
2560+ }
2561+ }
2562+
2563+}
2564diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHostTests/BAASafariHostTests.swift b/plugins/baa-safari/baa-safari-host/BAASafariHostTests/BAASafariHostTests.swift
2565new file mode 100644
2566index 0000000000000000000000000000000000000000..6649c2b53dbb3bf4edb2cda2ffd30d4a259909ce
2567--- /dev/null
2568+++ b/plugins/baa-safari/baa-safari-host/BAASafariHostTests/BAASafariHostTests.swift
2569@@ -0,0 +1,17 @@
2570+//
2571+// BAASafariHostTests.swift
2572+// BAASafariHostTests
2573+//
2574+// Created by george on 2026/4/2.
2575+//
2576+
2577+import Testing
2578+@testable import BAASafariHost
2579+
2580+struct BAASafariHostTests {
2581+
2582+ @Test func example() async throws {
2583+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
2584+ }
2585+
2586+}
2587diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITests.swift b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITests.swift
2588new file mode 100644
2589index 0000000000000000000000000000000000000000..8b2a1710cbe0c292e8b5293c2facfa13288ee051
2590--- /dev/null
2591+++ b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITests.swift
2592@@ -0,0 +1,43 @@
2593+//
2594+// BAASafariHostUITests.swift
2595+// BAASafariHostUITests
2596+//
2597+// Created by george on 2026/4/2.
2598+//
2599+
2600+import XCTest
2601+
2602+final class BAASafariHostUITests: XCTestCase {
2603+
2604+ override func setUpWithError() throws {
2605+ // Put setup code here. This method is called before the invocation of each test method in the class.
2606+
2607+ // In UI tests it is usually best to stop immediately when a failure occurs.
2608+ continueAfterFailure = false
2609+
2610+ // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
2611+ }
2612+
2613+ override func tearDownWithError() throws {
2614+ // Put teardown code here. This method is called after the invocation of each test method in the class.
2615+ }
2616+
2617+ @MainActor
2618+ func testExample() throws {
2619+ // UI tests must launch the application that they test.
2620+ let app = XCUIApplication()
2621+ app.launch()
2622+
2623+ // Use XCTAssert and related functions to verify your tests produce the correct results.
2624+ }
2625+
2626+ @MainActor
2627+ func testLaunchPerformance() throws {
2628+ if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
2629+ // This measures how long it takes to launch your application.
2630+ measure(metrics: [XCTApplicationLaunchMetric()]) {
2631+ XCUIApplication().launch()
2632+ }
2633+ }
2634+ }
2635+}
2636diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITestsLaunchTests.swift b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITestsLaunchTests.swift
2637new file mode 100644
2638index 0000000000000000000000000000000000000000..a7a8c9bd5e63f7e4c7fd458161e8f764fe046a80
2639--- /dev/null
2640+++ b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITestsLaunchTests.swift
2641@@ -0,0 +1,33 @@
2642+//
2643+// BAASafariHostUITestsLaunchTests.swift
2644+// BAASafariHostUITests
2645+//
2646+// Created by george on 2026/4/2.
2647+//
2648+
2649+import XCTest
2650+
2651+final class BAASafariHostUITestsLaunchTests: XCTestCase {
2652+
2653+ override class var runsForEachTargetApplicationUIConfiguration: Bool {
2654+ true
2655+ }
2656+
2657+ override func setUpWithError() throws {
2658+ continueAfterFailure = false
2659+ }
2660+
2661+ @MainActor
2662+ func testLaunch() throws {
2663+ let app = XCUIApplication()
2664+ app.launch()
2665+
2666+ // Insert steps here to perform after app launch but before taking a screenshot,
2667+ // such as logging into a test account or navigating somewhere in the app
2668+
2669+ let attachment = XCTAttachment(screenshot: app.screenshot())
2670+ attachment.name = "Launch Screen"
2671+ attachment.lifetime = .keepAlways
2672+ add(attachment)
2673+ }
2674+}
2675diff --git a/plugins/baa-safari/background.js b/plugins/baa-safari/background.js
2676new file mode 100644
2677index 0000000000000000000000000000000000000000..9161e77600226278677d93ee893e919b325526a8
2678--- /dev/null
2679+++ b/plugins/baa-safari/background.js
2680@@ -0,0 +1,1568 @@
2681+const CONTROLLER_URL = browser.runtime.getURL("controller.html");
2682+const CONTROLLER_TAB_STORAGE_KEY = "baaSafari.controllerTabId";
2683+const FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY = "baaSafari.finalMessageRelayCache";
2684+const SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY = "baaSafari.browserWsClientId";
2685+const SAFARI_BROWSER_WS_URL_STORAGE_KEY = "baaSafari.browserWsUrl";
2686+const LOG_LIMIT = 80;
2687+const ENDPOINT_LIMIT = 80;
2688+const CONTENT_SCRIPT_STALE_MS = 30_000;
2689+const PAGE_BRIDGE_STALE_MS = 12_000;
2690+const CREDENTIAL_TTL_MS = 15 * 60_000;
2691+const DEFAULT_BROWSER_WS_URL = "ws://127.0.0.1:4317/ws/browser";
2692+const BROWSER_WS_PROTOCOLS = ["baa.browser.local", "baa.firefox.local"];
2693+const WS_RECONNECT_DELAY_MS = 2_000;
2694+const FINAL_MESSAGE_RELAY_CACHE_LIMIT = 20;
2695+const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
2696+const FINAL_MESSAGE_HELPERS = globalThis.BAAFinalMessage || null;
2697+
2698+const PLATFORMS = {
2699+ claude: {
2700+ implemented: false,
2701+ label: "Claude",
2702+ pageHosts: ["claude.ai"],
2703+ requestHosts: ["claude.ai"],
2704+ requestUrlPatterns: ["*://claude.ai/*"]
2705+ },
2706+ chatgpt: {
2707+ implemented: true,
2708+ label: "ChatGPT",
2709+ pageHosts: ["chatgpt.com", "chat.openai.com"],
2710+ requestHosts: ["chatgpt.com", "chat.openai.com", "openai.com", "oaiusercontent.com"],
2711+ requestUrlPatterns: [
2712+ "*://chatgpt.com/*",
2713+ "*://*.chatgpt.com/*",
2714+ "*://chat.openai.com/*",
2715+ "*://*.chat.openai.com/*",
2716+ "*://openai.com/*",
2717+ "*://*.openai.com/*",
2718+ "*://oaiusercontent.com/*",
2719+ "*://*.oaiusercontent.com/*"
2720+ ]
2721+ },
2722+ gemini: {
2723+ implemented: false,
2724+ label: "Gemini",
2725+ pageHosts: ["gemini.google.com"],
2726+ requestHosts: ["gemini.google.com"],
2727+ requestUrlPatterns: ["*://gemini.google.com/*"]
2728+ }
2729+};
2730+
2731+const PLATFORM_ORDER = Object.keys(PLATFORMS);
2732+const REQUEST_URL_PATTERNS = PLATFORM_ORDER.flatMap((platform) => PLATFORMS[platform].requestUrlPatterns);
2733+const SENSITIVE_HEADERS = {
2734+ claude: ["cookie", "x-org-id"],
2735+ chatgpt: [
2736+ "authorization",
2737+ "cookie",
2738+ "oai-device-id",
2739+ "openai-sentinel-chat-requirements-token",
2740+ "openai-sentinel-proof-token",
2741+ "openai-sentinel-turnstile-token"
2742+ ],
2743+ gemini: ["authorization", "cookie", "x-goog-authuser"]
2744+};
2745+
2746+function createPlatformMap(factory) {
2747+ return Object.fromEntries(PLATFORM_ORDER.map((platform) => [platform, factory(platform)]));
2748+}
2749+
2750+function createFinalMessageRelayObserver(platform) {
2751+ if (platform !== "chatgpt" || !FINAL_MESSAGE_HELPERS?.createRelayState) {
2752+ return null;
2753+ }
2754+
2755+ return FINAL_MESSAGE_HELPERS.createRelayState(platform);
2756+}
2757+
2758+function createDefaultCredentialState() {
2759+ return {
2760+ account_captured_at: null,
2761+ account_hint: null,
2762+ account_kind: null,
2763+ account_last_seen_at: null,
2764+ account_source: null,
2765+ captured_at: null,
2766+ credential_fingerprint: null,
2767+ endpoint_count: 0,
2768+ freshness: "missing",
2769+ header_count: 0,
2770+ header_names: [],
2771+ last_seen_at: null,
2772+ tab_id: null,
2773+ url: null
2774+ };
2775+}
2776+
2777+function createDefaultPlatformState(platform) {
2778+ return {
2779+ endpoint_metadata: [],
2780+ implemented: PLATFORMS[platform]?.implemented === true,
2781+ label: PLATFORMS[platform]?.label || platform,
2782+ lastContentScript: null,
2783+ lastDiagnostic: null,
2784+ lastFinalMessage: null,
2785+ lastNetwork: null,
2786+ lastPageBridgeReady: null,
2787+ lastProxyResponse: null,
2788+ lastSse: null,
2789+ platform,
2790+ scope: PLATFORMS[platform]?.implemented === true ? "foundation" : "placeholder",
2791+ credential: createDefaultCredentialState()
2792+ };
2793+}
2794+
2795+function createDefaultWsState() {
2796+ return {
2797+ clientId: null,
2798+ connected: false,
2799+ lastConnectedAt: null,
2800+ lastError: null,
2801+ lastHelloAckAt: null,
2802+ pendingRelayCount: 0,
2803+ status: "idle",
2804+ wsUrl: DEFAULT_BROWSER_WS_URL
2805+ };
2806+}
2807+
2808+const runtimeState = {
2809+ background: {
2810+ startedAt: Date.now()
2811+ },
2812+ controller: {
2813+ lastReadyAt: null,
2814+ tabId: null
2815+ },
2816+ lastUpdatedAt: Date.now(),
2817+ permissionHints: [],
2818+ platforms: createPlatformMap((platform) => createDefaultPlatformState(platform)),
2819+ recentLogs: [],
2820+ runtimeOwner: "controller.html",
2821+ ws: createDefaultWsState()
2822+};
2823+
2824+const finalMessageRelayObservers = createPlatformMap((platform) => createFinalMessageRelayObserver(platform));
2825+const wsRuntime = {
2826+ reconnectTimer: null,
2827+ socket: null
2828+};
2829+const pendingRelayQueue = [];
2830+
2831+function trimToNull(value) {
2832+ if (typeof value !== "string") {
2833+ return null;
2834+ }
2835+
2836+ const normalized = value.trim();
2837+ return normalized === "" ? null : normalized;
2838+}
2839+
2840+function isRecord(value) {
2841+ return value !== null && typeof value === "object" && !Array.isArray(value);
2842+}
2843+
2844+const TEST_FLAGS = isRecord(globalThis.__BAA_SAFARI_TEST_FLAGS__)
2845+ ? globalThis.__BAA_SAFARI_TEST_FLAGS__
2846+ : {};
2847+
2848+function simpleHash(input) {
2849+ const text = String(input || "");
2850+ let hash = 2166136261;
2851+
2852+ for (let index = 0; index < text.length; index += 1) {
2853+ hash ^= text.charCodeAt(index);
2854+ hash = Math.imul(hash, 16777619);
2855+ }
2856+
2857+ return (hash >>> 0).toString(16).padStart(8, "0");
2858+}
2859+
2860+function normalizeRecentRelayKeys(value) {
2861+ const source = Array.isArray(value) ? value : [];
2862+ const seen = new Set();
2863+ const normalized = [];
2864+
2865+ for (const entry of source) {
2866+ const key = trimToNull(entry);
2867+ if (!key || seen.has(key)) {
2868+ continue;
2869+ }
2870+
2871+ seen.add(key);
2872+ normalized.push(key);
2873+ }
2874+
2875+ return normalized.slice(-FINAL_MESSAGE_RELAY_CACHE_LIMIT);
2876+}
2877+
2878+function serializeFinalMessageRelayCache() {
2879+ return createPlatformMap((platform) => {
2880+ const observer = finalMessageRelayObservers[platform];
2881+ return observer ? normalizeRecentRelayKeys(observer.recentRelayKeys) : [];
2882+ });
2883+}
2884+
2885+function restoreFinalMessageRelayCache(raw) {
2886+ const source = isRecord(raw) ? raw : {};
2887+
2888+ for (const platform of PLATFORM_ORDER) {
2889+ const observer = finalMessageRelayObservers[platform];
2890+ if (!observer) {
2891+ continue;
2892+ }
2893+
2894+ observer.activeStream = null;
2895+ observer.recentRelayKeys = normalizeRecentRelayKeys(source[platform]);
2896+ }
2897+}
2898+
2899+async function persistFinalMessageRelayCache() {
2900+ await browser.storage.local.set({
2901+ [FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY]: serializeFinalMessageRelayCache()
2902+ });
2903+}
2904+
2905+function normalizeBrowserWsUrl(value) {
2906+ const raw = trimToNull(value);
2907+ if (!raw) {
2908+ return DEFAULT_BROWSER_WS_URL;
2909+ }
2910+
2911+ try {
2912+ const parsed = new URL(raw);
2913+ if (!/^wss?:$/u.test(parsed.protocol)) {
2914+ return DEFAULT_BROWSER_WS_URL;
2915+ }
2916+
2917+ if (!parsed.pathname || parsed.pathname === "/") {
2918+ parsed.pathname = "/ws/browser";
2919+ }
2920+
2921+ parsed.search = "";
2922+ parsed.hash = "";
2923+ return parsed.toString();
2924+ } catch (_) {
2925+ return DEFAULT_BROWSER_WS_URL;
2926+ }
2927+}
2928+
2929+function buildBrowserWsClientId() {
2930+ const entropy = typeof crypto?.randomUUID === "function"
2931+ ? crypto.randomUUID()
2932+ : `${Date.now()}|${Math.random()}`;
2933+ return `safari-${simpleHash(entropy)}`;
2934+}
2935+
2936+function touchState() {
2937+ runtimeState.lastUpdatedAt = Date.now();
2938+}
2939+
2940+function cloneState() {
2941+ return JSON.parse(JSON.stringify(runtimeState));
2942+}
2943+
2944+function appendLog(level, event, detail = {}) {
2945+ runtimeState.recentLogs.unshift({
2946+ detail,
2947+ event,
2948+ level,
2949+ timestamp: Date.now()
2950+ });
2951+ runtimeState.recentLogs = runtimeState.recentLogs.slice(0, LOG_LIMIT);
2952+ touchState();
2953+}
2954+
2955+function hostnameMatches(hostname, hosts) {
2956+ return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
2957+}
2958+
2959+function detectPlatformFromUrl(url, type = "page") {
2960+ const raw = trimToNull(url);
2961+ if (!raw) {
2962+ return null;
2963+ }
2964+
2965+ try {
2966+ const parsed = new URL(raw);
2967+ for (const platform of PLATFORM_ORDER) {
2968+ const hosts = type === "request" ? PLATFORMS[platform].requestHosts : PLATFORMS[platform].pageHosts;
2969+
2970+ if (hostnameMatches(parsed.hostname, hosts)) {
2971+ return platform;
2972+ }
2973+ }
2974+ } catch (_) {}
2975+
2976+ return null;
2977+}
2978+
2979+function getRequestPath(url) {
2980+ const raw = trimToNull(url);
2981+
2982+ if (!raw) {
2983+ return "";
2984+ }
2985+
2986+ try {
2987+ return new URL(raw).pathname || "/";
2988+ } catch (_) {
2989+ return raw.split(/[?#]/u)[0] || "";
2990+ }
2991+}
2992+
2993+function shouldTrackRequest(platform, path) {
2994+ const lower = String(path || "").toLowerCase();
2995+
2996+ switch (platform) {
2997+ case "claude":
2998+ return lower.includes("/api/");
2999+ case "chatgpt":
3000+ return lower.includes("/backend-api/")
3001+ || lower.includes("/backend-anon/")
3002+ || lower.includes("/public-api/")
3003+ || lower.includes("/conversation")
3004+ || lower.includes("/models")
3005+ || lower.includes("/accounts")
3006+ || lower.includes("/auth")
3007+ || lower.includes("/session")
3008+ || lower.includes("/user")
3009+ || lower.includes("/settings");
3010+ case "gemini":
3011+ return lower.startsWith("/_/")
3012+ || lower.includes("/api/")
3013+ || lower.includes("bardchatui")
3014+ || lower.includes("streamgenerate")
3015+ || lower.includes("generatecontent");
3016+ default:
3017+ return false;
3018+ }
3019+}
3020+
3021+function normalizeEndpointPath(url) {
3022+ const raw = trimToNull(url);
3023+ if (!raw) {
3024+ return null;
3025+ }
3026+
3027+ try {
3028+ const parsed = new URL(raw);
3029+ const pathname = parsed.pathname || "/";
3030+ return `${pathname}${parsed.search || ""}`;
3031+ } catch (_) {
3032+ return raw;
3033+ }
3034+}
3035+
3036+function normalizeHeaderMap(source) {
3037+ const out = {};
3038+ const entries = Array.isArray(source)
3039+ ? source
3040+ : (isRecord(source) ? Object.entries(source).map(([name, value]) => ({ name, value })) : []);
3041+
3042+ for (const entry of entries) {
3043+ const name = trimToNull(entry?.name);
3044+ if (!name) {
3045+ continue;
3046+ }
3047+
3048+ const value = entry?.value;
3049+ if (typeof value !== "string" || value === "") {
3050+ continue;
3051+ }
3052+
3053+ out[name.toLowerCase()] = value;
3054+ }
3055+
3056+ return out;
3057+}
3058+
3059+function buildCredentialFingerprint(platform, headerMap) {
3060+ const names = SENSITIVE_HEADERS[platform] || [];
3061+ const parts = [];
3062+
3063+ for (const name of names) {
3064+ const value = trimToNull(headerMap[name]);
3065+ if (value) {
3066+ parts.push(`${name}:${value}`);
3067+ }
3068+ }
3069+
3070+ return parts.length > 0 ? simpleHash(parts.join("\n")) : null;
3071+}
3072+
3073+function computeCredentialFreshness(lastSeenAt) {
3074+ if (!Number.isFinite(lastSeenAt) || lastSeenAt <= 0) {
3075+ return "missing";
3076+ }
3077+
3078+ return Date.now() - lastSeenAt > CREDENTIAL_TTL_MS ? "stale" : "fresh";
3079+}
3080+
3081+function updateCredentialSnapshot(platform, input) {
3082+ const current = runtimeState.platforms[platform].credential;
3083+ const headerMap = normalizeHeaderMap(input?.requestHeaders);
3084+ const headerNames = Object.keys(headerMap).sort();
3085+ const fingerprint = buildCredentialFingerprint(platform, headerMap);
3086+
3087+ if (headerNames.length === 0 && !fingerprint) {
3088+ return false;
3089+ }
3090+
3091+ const now = Date.now();
3092+ const nextCapturedAt =
3093+ current.credential_fingerprint === fingerprint && current.captured_at != null
3094+ ? current.captured_at
3095+ : now;
3096+
3097+ const next = {
3098+ ...current,
3099+ captured_at: nextCapturedAt,
3100+ credential_fingerprint: fingerprint,
3101+ endpoint_count: runtimeState.platforms[platform].endpoint_metadata.length,
3102+ freshness: computeCredentialFreshness(now),
3103+ header_count: headerNames.length,
3104+ header_names: headerNames,
3105+ last_seen_at: now,
3106+ tab_id: Number.isInteger(input?.tabId) ? input.tabId : current.tab_id,
3107+ url: trimToNull(input?.url) || current.url
3108+ };
3109+ const changed = JSON.stringify(current) !== JSON.stringify(next);
3110+
3111+ runtimeState.platforms[platform].credential = next;
3112+ touchState();
3113+ return changed;
3114+}
3115+
3116+function createAccountCandidate(value, meta = {}) {
3117+ const normalized = trimToNull(value);
3118+ if (!normalized) {
3119+ return null;
3120+ }
3121+
3122+ return {
3123+ kind: trimToNull(meta.kind),
3124+ observedAt: Number(meta.observedAt) || Date.now(),
3125+ priority: Number(meta.priority) || 0,
3126+ source: trimToNull(meta.source),
3127+ value: ACCOUNT_EMAIL_RE.test(normalized) ? normalized.toLowerCase() : normalized
3128+ };
3129+}
3130+
3131+function selectAccountCandidate(current, next) {
3132+ if (!next) {
3133+ return current;
3134+ }
3135+
3136+ if (!current || !current.value) {
3137+ return next;
3138+ }
3139+
3140+ if (current.value === next.value) {
3141+ return {
3142+ ...current,
3143+ kind: next.kind || current.kind,
3144+ observedAt: Math.max(current.observedAt || 0, next.observedAt || 0),
3145+ priority: Math.max(current.priority || 0, next.priority || 0),
3146+ source: next.source || current.source
3147+ };
3148+ }
3149+
3150+ return (next.priority || 0) > (current.priority || 0) ? next : current;
3151+}
3152+
3153+function pathLooksLikeAccountMetadata(url) {
3154+ const lower = getRequestPath(url).toLowerCase();
3155+ if (!lower) {
3156+ return false;
3157+ }
3158+
3159+ if (lower.includes("/conversation") || lower.includes("/backend-api/conversation")) {
3160+ return false;
3161+ }
3162+
3163+ return lower.includes("/account")
3164+ || lower.includes("/accounts")
3165+ || lower.includes("/profile")
3166+ || lower.includes("/session")
3167+ || lower.includes("/settings")
3168+ || lower.includes("/user")
3169+ || lower.includes("/users")
3170+ || lower.includes("/me")
3171+ || lower.includes("/auth")
3172+ || lower.includes("/organizations");
3173+}
3174+
3175+function collectAccountCandidates(source, candidates, meta = {}, path = "", depth = 0, scanned = { count: 0 }) {
3176+ if (depth > 4 || scanned.count > 120) {
3177+ return;
3178+ }
3179+
3180+ scanned.count += 1;
3181+
3182+ if (typeof source === "string") {
3183+ const normalized = source.trim();
3184+ if (!normalized) {
3185+ return;
3186+ }
3187+
3188+ const lowerPath = path.toLowerCase();
3189+ const emailMatch = normalized.match(ACCOUNT_EMAIL_RE);
3190+
3191+ if (emailMatch?.[0]) {
3192+ candidates.push(createAccountCandidate(emailMatch[0], {
3193+ kind: "email",
3194+ observedAt: meta.observedAt,
3195+ priority: 100,
3196+ source: meta.source
3197+ }));
3198+ return;
3199+ }
3200+
3201+ if (lowerPath.includes("email")) {
3202+ candidates.push(createAccountCandidate(normalized, {
3203+ kind: "email_like",
3204+ observedAt: meta.observedAt,
3205+ priority: 90,
3206+ source: meta.source
3207+ }));
3208+ return;
3209+ }
3210+
3211+ if (/(account|user|profile|login|username|handle|identifier|name)$/u.test(lowerPath)) {
3212+ candidates.push(createAccountCandidate(normalized, {
3213+ kind: "account_hint",
3214+ observedAt: meta.observedAt,
3215+ priority: 60,
3216+ source: meta.source
3217+ }));
3218+ }
3219+
3220+ return;
3221+ }
3222+
3223+ if (Array.isArray(source)) {
3224+ for (let index = 0; index < source.length && index < 12; index += 1) {
3225+ collectAccountCandidates(source[index], candidates, meta, `${path}[${index}]`, depth + 1, scanned);
3226+ }
3227+ return;
3228+ }
3229+
3230+ if (!isRecord(source)) {
3231+ return;
3232+ }
3233+
3234+ for (const [key, value] of Object.entries(source).slice(0, 24)) {
3235+ collectAccountCandidates(value, candidates, meta, path ? `${path}.${key}` : key, depth + 1, scanned);
3236+ }
3237+}
3238+
3239+function extractAccountCandidateFromBody(url, body) {
3240+ if (!pathLooksLikeAccountMetadata(url) || typeof body !== "string" || !body.trim()) {
3241+ return null;
3242+ }
3243+
3244+ const observedAt = Date.now();
3245+ const candidates = [];
3246+ let parsed = null;
3247+
3248+ try {
3249+ parsed = JSON.parse(body);
3250+ } catch (_) {
3251+ parsed = null;
3252+ }
3253+
3254+ if (parsed != null) {
3255+ collectAccountCandidates(parsed, candidates, {
3256+ observedAt,
3257+ source: "response_body"
3258+ });
3259+ } else {
3260+ const match = body.match(ACCOUNT_EMAIL_RE);
3261+ if (match?.[0]) {
3262+ candidates.push(createAccountCandidate(match[0], {
3263+ kind: "email",
3264+ observedAt,
3265+ priority: 100,
3266+ source: "response_body"
3267+ }));
3268+ }
3269+ }
3270+
3271+ let best = null;
3272+ for (const candidate of candidates) {
3273+ best = selectAccountCandidate(best, candidate);
3274+ }
3275+ return best;
3276+}
3277+
3278+function observeAccountHint(platform, url, body) {
3279+ const candidate = extractAccountCandidateFromBody(url, body);
3280+ if (!candidate) {
3281+ return false;
3282+ }
3283+
3284+ const credential = runtimeState.platforms[platform].credential;
3285+ const currentCandidate = credential.account_hint
3286+ ? {
3287+ kind: credential.account_kind,
3288+ observedAt: credential.account_last_seen_at || credential.account_captured_at || 0,
3289+ priority: credential.account_hint === candidate.value ? candidate.priority : 0,
3290+ source: credential.account_source,
3291+ value: credential.account_hint
3292+ }
3293+ : null;
3294+ const selected = selectAccountCandidate(currentCandidate, candidate);
3295+
3296+ if (!selected?.value) {
3297+ return false;
3298+ }
3299+
3300+ const sameValue = credential.account_hint === selected.value;
3301+ runtimeState.platforms[platform].credential = {
3302+ ...credential,
3303+ account_captured_at: sameValue && credential.account_captured_at != null
3304+ ? credential.account_captured_at
3305+ : selected.observedAt,
3306+ account_hint: selected.value,
3307+ account_kind: selected.kind,
3308+ account_last_seen_at: selected.observedAt,
3309+ account_source: selected.source
3310+ };
3311+ touchState();
3312+ return true;
3313+}
3314+
3315+function summarizeEvent(detail) {
3316+ return {
3317+ done: detail?.done === true,
3318+ error: trimToNull(detail?.error),
3319+ method: trimToNull(detail?.method),
3320+ open: detail?.open === true,
3321+ source: trimToNull(detail?.source),
3322+ status: Number.isFinite(Number(detail?.status)) ? Number(detail.status) : null,
3323+ tabId: Number.isInteger(detail?.tabId) ? detail.tabId : null,
3324+ ts: Number.isFinite(Number(detail?.ts)) ? Number(detail.ts) : Date.now(),
3325+ url: trimToNull(detail?.url)
3326+ };
3327+}
3328+
3329+function updateEndpointMetadata(platform, input) {
3330+ const method = trimToNull(input?.method)?.toUpperCase() || "GET";
3331+ const path = normalizeEndpointPath(input?.url);
3332+
3333+ if (!path || !shouldTrackRequest(platform, getRequestPath(path))) {
3334+ return false;
3335+ }
3336+
3337+ const observedAt = Date.now();
3338+ const current = runtimeState.platforms[platform].endpoint_metadata;
3339+ const key = `${method} ${path}`;
3340+ const existing = current.find((entry) => entry.key === key) || null;
3341+ const next = {
3342+ first_seen_at: existing?.first_seen_at || observedAt,
3343+ key,
3344+ last_seen_at: observedAt,
3345+ last_status: Number.isFinite(Number(input?.status)) ? Number(input.status) : (existing?.last_status ?? null),
3346+ method,
3347+ path,
3348+ source: trimToNull(input?.source) || existing?.source || "network"
3349+ };
3350+ const remaining = current.filter((entry) => entry.key !== key);
3351+
3352+ runtimeState.platforms[platform].endpoint_metadata = [next, ...remaining]
3353+ .sort((left, right) => (right.last_seen_at || 0) - (left.last_seen_at || 0))
3354+ .slice(0, ENDPOINT_LIMIT);
3355+ runtimeState.platforms[platform].credential.endpoint_count = runtimeState.platforms[platform].endpoint_metadata.length;
3356+ touchState();
3357+
3358+ return existing == null;
3359+}
3360+
3361+async function getStoredControllerTabId() {
3362+ const data = await browser.storage.local.get(CONTROLLER_TAB_STORAGE_KEY);
3363+ return Number.isInteger(data[CONTROLLER_TAB_STORAGE_KEY]) ? data[CONTROLLER_TAB_STORAGE_KEY] : null;
3364+}
3365+
3366+async function setStoredControllerTabId(tabId) {
3367+ if (Number.isInteger(tabId)) {
3368+ await browser.storage.local.set({ [CONTROLLER_TAB_STORAGE_KEY]: tabId });
3369+ return;
3370+ }
3371+
3372+ await browser.storage.local.remove(CONTROLLER_TAB_STORAGE_KEY);
3373+}
3374+
3375+function setWsStatus(status, detail = {}) {
3376+ runtimeState.ws = {
3377+ ...runtimeState.ws,
3378+ connected: status === "connected",
3379+ lastError: Object.prototype.hasOwnProperty.call(detail, "lastError")
3380+ ? trimToNull(detail.lastError)
3381+ : runtimeState.ws.lastError,
3382+ lastHelloAckAt: Object.prototype.hasOwnProperty.call(detail, "lastHelloAckAt")
3383+ ? detail.lastHelloAckAt
3384+ : runtimeState.ws.lastHelloAckAt,
3385+ lastConnectedAt: Object.prototype.hasOwnProperty.call(detail, "lastConnectedAt")
3386+ ? detail.lastConnectedAt
3387+ : runtimeState.ws.lastConnectedAt,
3388+ status,
3389+ wsUrl: Object.prototype.hasOwnProperty.call(detail, "wsUrl")
3390+ ? normalizeBrowserWsUrl(detail.wsUrl)
3391+ : runtimeState.ws.wsUrl
3392+ };
3393+ touchState();
3394+}
3395+
3396+function syncPendingRelayCount() {
3397+ runtimeState.ws.pendingRelayCount = pendingRelayQueue.length;
3398+ touchState();
3399+}
3400+
3401+function queuePendingRelay(platform, relay, source, context) {
3402+ const dedupeKey = trimToNull(relay?.dedupeKey);
3403+ if (!dedupeKey) {
3404+ return false;
3405+ }
3406+
3407+ if (pendingRelayQueue.some((entry) => entry.dedupeKey === dedupeKey)) {
3408+ return false;
3409+ }
3410+
3411+ pendingRelayQueue.push({
3412+ context,
3413+ dedupeKey,
3414+ platform,
3415+ relay,
3416+ source
3417+ });
3418+ syncPendingRelayCount();
3419+ return true;
3420+}
3421+
3422+function clearReconnectTimer() {
3423+ if (wsRuntime.reconnectTimer != null) {
3424+ clearTimeout(wsRuntime.reconnectTimer);
3425+ wsRuntime.reconnectTimer = null;
3426+ }
3427+}
3428+
3429+function scheduleWsReconnect(delayMs = WS_RECONNECT_DELAY_MS) {
3430+ if (TEST_FLAGS.disableReconnect === true) {
3431+ return;
3432+ }
3433+
3434+ clearReconnectTimer();
3435+ wsRuntime.reconnectTimer = globalThis.setTimeout(() => {
3436+ wsRuntime.reconnectTimer = null;
3437+ void connectBrowserWs();
3438+ }, Math.max(250, Number(delayMs) || WS_RECONNECT_DELAY_MS));
3439+}
3440+
3441+function wsSend(payload) {
3442+ if (!wsRuntime.socket || wsRuntime.socket.readyState !== WebSocket.OPEN) {
3443+ return false;
3444+ }
3445+
3446+ wsRuntime.socket.send(JSON.stringify(payload));
3447+ return true;
3448+}
3449+
3450+function buildBrowserHelloPayload() {
3451+ return {
3452+ type: "hello",
3453+ clientId: runtimeState.ws.clientId,
3454+ nodeType: "browser",
3455+ nodeCategory: "proxy",
3456+ nodePlatform: "safari",
3457+ capabilities: {
3458+ credential_metadata: true,
3459+ final_message_relay: true,
3460+ page_bridge: true,
3461+ implemented_platforms: ["chatgpt"]
3462+ }
3463+ };
3464+}
3465+
3466+function sendBrowserHello() {
3467+ return wsSend(buildBrowserHelloPayload());
3468+}
3469+
3470+function buildFinalMessageContext(sender, fallbackUrl = null) {
3471+ return {
3472+ isShellPage: false,
3473+ pageTitle: trimToNull(sender?.tab?.title),
3474+ senderUrl: trimToNull(sender?.tab?.url) || trimToNull(fallbackUrl),
3475+ tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null
3476+ };
3477+}
3478+
3479+function recordFinalMessageRelay(platform, relay, source, context = null) {
3480+ runtimeState.platforms[platform].lastFinalMessage = {
3481+ assistant_message_id: trimToNull(relay?.payload?.assistant_message_id),
3482+ conversation_id: trimToNull(relay?.payload?.conversation_id),
3483+ observed_at: Number.isFinite(Number(relay?.payload?.observed_at)) ? Number(relay.payload.observed_at) : null,
3484+ page_url: trimToNull(context?.senderUrl),
3485+ raw_text: trimToNull(relay?.payload?.raw_text),
3486+ seenAt: Date.now(),
3487+ source: trimToNull(source) || "page_observed",
3488+ tab_id: Number.isInteger(context?.tabId) ? context.tabId : null
3489+ };
3490+ touchState();
3491+}
3492+
3493+function sendObservedFinalMessage(platform, relay, source = "page_observed", context = null) {
3494+ const observer = finalMessageRelayObservers[platform];
3495+ if (!observer || !relay?.payload) {
3496+ return false;
3497+ }
3498+
3499+ const payload = {
3500+ ...relay.payload,
3501+ page_title: trimToNull(context?.pageTitle),
3502+ page_url: trimToNull(context?.senderUrl),
3503+ shell_page: context?.isShellPage === true,
3504+ tab_id: Number.isInteger(context?.tabId) ? context.tabId : null
3505+ };
3506+
3507+ if (!wsSend(payload)) {
3508+ return false;
3509+ }
3510+
3511+ FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
3512+ void persistFinalMessageRelayCache().catch(() => {});
3513+ recordFinalMessageRelay(platform, relay, source, context);
3514+ appendLog("info", "browser_final_message_relayed", {
3515+ assistant_message_id: relay.payload.assistant_message_id,
3516+ platform,
3517+ raw_text_length: relay.payload.raw_text?.length ?? 0,
3518+ source,
3519+ url: trimToNull(context?.senderUrl)
3520+ });
3521+ return true;
3522+}
3523+
3524+function flushPendingRelayQueue() {
3525+ if (pendingRelayQueue.length === 0) {
3526+ return;
3527+ }
3528+
3529+ const queued = pendingRelayQueue.splice(0, pendingRelayQueue.length);
3530+ syncPendingRelayCount();
3531+
3532+ for (const entry of queued) {
3533+ if (!sendObservedFinalMessage(entry.platform, entry.relay, entry.source, entry.context)) {
3534+ pendingRelayQueue.unshift(entry);
3535+ syncPendingRelayCount();
3536+ break;
3537+ }
3538+ }
3539+}
3540+
3541+function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null) {
3542+ const observer = finalMessageRelayObservers[platform];
3543+ if (!observer || !relay?.payload) {
3544+ return false;
3545+ }
3546+
3547+ if (sendObservedFinalMessage(platform, relay, source, context)) {
3548+ return true;
3549+ }
3550+
3551+ const queued = queuePendingRelay(platform, relay, source, context);
3552+ appendLog("warn", "browser_final_message_buffered", {
3553+ assistant_message_id: relay.payload.assistant_message_id,
3554+ platform,
3555+ queued,
3556+ source,
3557+ ws_status: runtimeState.ws.status
3558+ });
3559+ void connectBrowserWs();
3560+ return false;
3561+}
3562+
3563+function observeFinalMessageFromPageNetwork(data, sender) {
3564+ if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
3565+ return;
3566+ }
3567+
3568+ const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3569+ const observer = platform ? finalMessageRelayObservers[platform] : null;
3570+ if (!observer) {
3571+ return;
3572+ }
3573+
3574+ const relay = FINAL_MESSAGE_HELPERS.observeNetwork(observer, data, {
3575+ observedAt: Date.now(),
3576+ pageUrl: sender?.tab?.url || ""
3577+ });
3578+
3579+ if (relay) {
3580+ relayObservedFinalMessage(platform, relay, "page_network", buildFinalMessageContext(sender, data?.url));
3581+ }
3582+}
3583+
3584+function observeFinalMessageFromPageSse(data, sender) {
3585+ if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
3586+ return;
3587+ }
3588+
3589+ const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3590+ const observer = platform ? finalMessageRelayObservers[platform] : null;
3591+ if (!observer) {
3592+ return;
3593+ }
3594+
3595+ const relay = FINAL_MESSAGE_HELPERS.observeSse(observer, data, {
3596+ observedAt: Date.now(),
3597+ pageUrl: sender?.tab?.url || ""
3598+ });
3599+
3600+ if (relay) {
3601+ relayObservedFinalMessage(platform, relay, "page_sse", buildFinalMessageContext(sender, data?.url));
3602+ }
3603+}
3604+
3605+function handleBrowserWsMessage(rawData) {
3606+ let message = null;
3607+
3608+ try {
3609+ message = JSON.parse(String(rawData || ""));
3610+ } catch (error) {
3611+ appendLog("warn", "browser_ws_invalid_message", {
3612+ error: error instanceof Error ? error.message : String(error || "invalid_json")
3613+ });
3614+ return;
3615+ }
3616+
3617+ if (!isRecord(message)) {
3618+ return;
3619+ }
3620+
3621+ if (message.type === "hello_ack") {
3622+ const wsUrl = normalizeBrowserWsUrl(message.wsUrl || message.ws_url || runtimeState.ws.wsUrl);
3623+ setWsStatus("connected", {
3624+ lastError: null,
3625+ lastHelloAckAt: Date.now(),
3626+ wsUrl
3627+ });
3628+ appendLog("info", "browser_ws_hello_ack", {
3629+ clientId: trimToNull(message.clientId) || runtimeState.ws.clientId,
3630+ protocol: trimToNull(message.protocol),
3631+ wsUrl
3632+ });
3633+ void browser.storage.local.set({
3634+ [SAFARI_BROWSER_WS_URL_STORAGE_KEY]: wsUrl
3635+ }).catch(() => {});
3636+ flushPendingRelayQueue();
3637+ return;
3638+ }
3639+
3640+ if (message.type === "state_snapshot") {
3641+ appendLog("debug", "browser_ws_state_snapshot", {
3642+ reason: trimToNull(message.reason)
3643+ });
3644+ }
3645+}
3646+
3647+async function loadStoredRuntimeState() {
3648+ const saved = await browser.storage.local.get([
3649+ CONTROLLER_TAB_STORAGE_KEY,
3650+ FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY,
3651+ SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY,
3652+ SAFARI_BROWSER_WS_URL_STORAGE_KEY
3653+ ]);
3654+ const updates = {};
3655+ const storedClientId = trimToNull(saved[SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY]);
3656+ const clientId = storedClientId || buildBrowserWsClientId();
3657+ const wsUrl = normalizeBrowserWsUrl(saved[SAFARI_BROWSER_WS_URL_STORAGE_KEY]);
3658+
3659+ runtimeState.controller.tabId = Number.isInteger(saved[CONTROLLER_TAB_STORAGE_KEY])
3660+ ? saved[CONTROLLER_TAB_STORAGE_KEY]
3661+ : null;
3662+ runtimeState.ws.clientId = clientId;
3663+ runtimeState.ws.wsUrl = wsUrl;
3664+ restoreFinalMessageRelayCache(saved[FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY]);
3665+ touchState();
3666+
3667+ if (!storedClientId) {
3668+ updates[SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY] = clientId;
3669+ }
3670+
3671+ if (saved[SAFARI_BROWSER_WS_URL_STORAGE_KEY] !== wsUrl) {
3672+ updates[SAFARI_BROWSER_WS_URL_STORAGE_KEY] = wsUrl;
3673+ }
3674+
3675+ if (Object.keys(updates).length > 0) {
3676+ await browser.storage.local.set(updates);
3677+ }
3678+}
3679+
3680+async function connectBrowserWs() {
3681+ if (!runtimeState.ws.clientId) {
3682+ await loadStoredRuntimeState();
3683+ }
3684+
3685+ if (wsRuntime.socket && (
3686+ wsRuntime.socket.readyState === WebSocket.CONNECTING
3687+ || wsRuntime.socket.readyState === WebSocket.OPEN
3688+ )) {
3689+ return;
3690+ }
3691+
3692+ clearReconnectTimer();
3693+ const wsUrl = normalizeBrowserWsUrl(runtimeState.ws.wsUrl);
3694+ setWsStatus("connecting", {
3695+ lastError: null,
3696+ wsUrl
3697+ });
3698+ appendLog("info", "browser_ws_connecting", {
3699+ clientId: runtimeState.ws.clientId,
3700+ wsUrl
3701+ });
3702+
3703+ let socket = null;
3704+
3705+ try {
3706+ socket = new WebSocket(wsUrl, BROWSER_WS_PROTOCOLS);
3707+ } catch (error) {
3708+ setWsStatus("error", {
3709+ lastError: error instanceof Error ? error.message : String(error || "websocket_init_failed"),
3710+ wsUrl
3711+ });
3712+ appendLog("warn", "browser_ws_init_failed", {
3713+ error: error instanceof Error ? error.message : String(error || "websocket_init_failed"),
3714+ wsUrl
3715+ });
3716+ scheduleWsReconnect();
3717+ return;
3718+ }
3719+
3720+ wsRuntime.socket = socket;
3721+
3722+ socket.addEventListener("open", () => {
3723+ if (wsRuntime.socket !== socket) {
3724+ return;
3725+ }
3726+
3727+ setWsStatus("open", {
3728+ lastConnectedAt: Date.now(),
3729+ lastError: null,
3730+ wsUrl
3731+ });
3732+ sendBrowserHello();
3733+ });
3734+
3735+ socket.addEventListener("message", (event) => {
3736+ handleBrowserWsMessage(event?.data);
3737+ });
3738+
3739+ socket.addEventListener("error", () => {
3740+ if (wsRuntime.socket !== socket) {
3741+ return;
3742+ }
3743+
3744+ setWsStatus("error", {
3745+ lastError: "websocket_error",
3746+ wsUrl
3747+ });
3748+ appendLog("warn", "browser_ws_error", {
3749+ wsUrl
3750+ });
3751+ });
3752+
3753+ socket.addEventListener("close", (event) => {
3754+ if (wsRuntime.socket === socket) {
3755+ wsRuntime.socket = null;
3756+ }
3757+
3758+ setWsStatus("disconnected", {
3759+ lastError: trimToNull(event?.reason) || runtimeState.ws.lastError,
3760+ wsUrl
3761+ });
3762+ appendLog("warn", "browser_ws_closed", {
3763+ code: Number.isFinite(Number(event?.code)) ? Number(event.code) : null,
3764+ reason: trimToNull(event?.reason),
3765+ wsUrl
3766+ });
3767+ scheduleWsReconnect();
3768+ });
3769+}
3770+
3771+async function initializeBackgroundRuntime() {
3772+ await loadStoredRuntimeState();
3773+
3774+ if (!FINAL_MESSAGE_HELPERS) {
3775+ appendLog("warn", "final_message_helper_missing");
3776+ return;
3777+ }
3778+
3779+ if (TEST_FLAGS.disableWsConnect === true) {
3780+ return;
3781+ }
3782+
3783+ await connectBrowserWs();
3784+}
3785+
3786+async function queryControllerTabs() {
3787+ return browser.tabs.query({ url: CONTROLLER_URL });
3788+}
3789+
3790+async function resolveControllerTab() {
3791+ const tabs = await queryControllerTabs();
3792+
3793+ if (tabs.length === 0) {
3794+ runtimeState.controller.tabId = null;
3795+ touchState();
3796+ await setStoredControllerTabId(null);
3797+ return null;
3798+ }
3799+
3800+ const storedTabId = await getStoredControllerTabId();
3801+ const canonical =
3802+ tabs.find((tab) => tab.id === storedTabId)
3803+ || tabs.find((tab) => Number.isInteger(tab.id))
3804+ || null;
3805+
3806+ if (!canonical || !Number.isInteger(canonical.id)) {
3807+ return null;
3808+ }
3809+
3810+ const duplicateIds = tabs
3811+ .map((tab) => tab.id)
3812+ .filter((tabId) => Number.isInteger(tabId) && tabId !== canonical.id);
3813+
3814+ if (duplicateIds.length > 0) {
3815+ await browser.tabs.remove(duplicateIds);
3816+ }
3817+
3818+ runtimeState.controller.tabId = canonical.id;
3819+ touchState();
3820+ await setStoredControllerTabId(canonical.id);
3821+ return canonical;
3822+}
3823+
3824+async function ensureControllerTab(options = {}) {
3825+ const activate = options.activate === true;
3826+ let tab = await resolveControllerTab();
3827+
3828+ if (tab == null) {
3829+ tab = await browser.tabs.create({
3830+ active: activate,
3831+ url: CONTROLLER_URL
3832+ });
3833+ runtimeState.controller.tabId = Number.isInteger(tab?.id) ? tab.id : null;
3834+ touchState();
3835+ await setStoredControllerTabId(runtimeState.controller.tabId);
3836+ return tab;
3837+ }
3838+
3839+ if (activate) {
3840+ await browser.tabs.update(tab.id, { active: true });
3841+
3842+ if (Number.isInteger(tab.windowId)) {
3843+ await browser.windows.update(tab.windowId, { focused: true });
3844+ }
3845+ }
3846+
3847+ return tab;
3848+}
3849+
3850+async function refreshPermissionHints() {
3851+ const tabs = await browser.tabs.query({});
3852+ const hints = [];
3853+ const now = Date.now();
3854+
3855+ for (const tab of tabs) {
3856+ if (!Number.isInteger(tab?.id) || typeof tab?.url !== "string") {
3857+ continue;
3858+ }
3859+
3860+ const platform = detectPlatformFromUrl(tab.url, "page");
3861+ if (!platform) {
3862+ continue;
3863+ }
3864+
3865+ const platformState = runtimeState.platforms[platform];
3866+ const lastContentScript = platformState.lastContentScript;
3867+ const lastBridgeReady = platformState.lastPageBridgeReady;
3868+
3869+ if (!lastContentScript || lastContentScript.tabId !== tab.id || now - lastContentScript.seenAt > CONTENT_SCRIPT_STALE_MS) {
3870+ hints.push({
3871+ level: "warn",
3872+ message: `${platformState.label} 标签页已打开,但没有收到 content script 回报。优先检查 Safari -> Settings -> Extensions -> Website Access。`,
3873+ platform,
3874+ reason: "website_access_or_content_script_missing",
3875+ tabId: tab.id,
3876+ url: tab.url
3877+ });
3878+ continue;
3879+ }
3880+
3881+ if (
3882+ !lastBridgeReady
3883+ || lastBridgeReady.tabId !== tab.id
3884+ || now - lastBridgeReady.seenAt > PAGE_BRIDGE_STALE_MS
3885+ ) {
3886+ hints.push({
3887+ level: "warn",
3888+ message: `${platformState.label} content script 已触达,但页面主世界没有返回 ready 事件。优先检查 page-interceptor 注入或站点 CSP。`,
3889+ platform,
3890+ reason: "page_bridge_not_ready",
3891+ tabId: tab.id,
3892+ url: tab.url
3893+ });
3894+ }
3895+ }
3896+
3897+ runtimeState.permissionHints = hints;
3898+ touchState();
3899+}
3900+
3901+function handleContentScriptSeen(message, sender) {
3902+ const platform = trimToNull(message?.platform) || detectPlatformFromUrl(message?.url || sender?.tab?.url || "", "page");
3903+
3904+ if (!platform || !runtimeState.platforms[platform]) {
3905+ return;
3906+ }
3907+
3908+ runtimeState.platforms[platform].lastContentScript = {
3909+ seenAt: Date.now(),
3910+ tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
3911+ url: trimToNull(message?.url) || trimToNull(sender?.tab?.url)
3912+ };
3913+ touchState();
3914+}
3915+
3916+function handlePageBridgeReady(data, sender) {
3917+ const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "page");
3918+
3919+ if (!platform || !runtimeState.platforms[platform]) {
3920+ return;
3921+ }
3922+
3923+ const source = trimToNull(data?.source) || "content-script";
3924+ if (source !== "content-script") {
3925+ runtimeState.platforms[platform].lastPageBridgeReady = {
3926+ seenAt: Date.now(),
3927+ source,
3928+ tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
3929+ url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
3930+ };
3931+ touchState();
3932+ }
3933+
3934+ appendLog("info", source === "content-script" ? "content_script_ready" : "page_bridge_ready", {
3935+ platform,
3936+ source,
3937+ tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
3938+ url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
3939+ });
3940+}
3941+
3942+function handlePageNetwork(data, sender) {
3943+ const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3944+
3945+ if (!platform || !runtimeState.platforms[platform]) {
3946+ return;
3947+ }
3948+
3949+ runtimeState.platforms[platform].lastNetwork = {
3950+ ...summarizeEvent(data),
3951+ error: trimToNull(data?.error)
3952+ };
3953+ touchState();
3954+
3955+ const discovered = updateEndpointMetadata(platform, {
3956+ method: data?.method,
3957+ source: trimToNull(data?.source) || "page",
3958+ status: data?.status,
3959+ url: data?.url
3960+ });
3961+ if (discovered) {
3962+ appendLog("info", "endpoint_metadata_seen", {
3963+ endpoint_metadata: runtimeState.platforms[platform].endpoint_metadata[0] || null,
3964+ platform
3965+ });
3966+ }
3967+
3968+ if (platform === "chatgpt" && observeAccountHint(platform, data?.url, data?.resBody)) {
3969+ appendLog("info", "account_hint_updated", {
3970+ account_hint: runtimeState.platforms[platform].credential.account_hint,
3971+ platform,
3972+ source: runtimeState.platforms[platform].credential.account_source
3973+ });
3974+ }
3975+
3976+ observeFinalMessageFromPageNetwork(data, sender);
3977+}
3978+
3979+function handlePageSse(data, sender) {
3980+ const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3981+
3982+ if (!platform || !runtimeState.platforms[platform]) {
3983+ return;
3984+ }
3985+
3986+ runtimeState.platforms[platform].lastSse = summarizeEvent(data);
3987+ touchState();
3988+
3989+ if (data?.open === true || data?.done === true || trimToNull(data?.error)) {
3990+ appendLog(trimToNull(data?.error) ? "warn" : "debug", "page_sse", {
3991+ done: data?.done === true,
3992+ error: trimToNull(data?.error),
3993+ platform,
3994+ source: trimToNull(data?.source),
3995+ status: Number.isFinite(Number(data?.status)) ? Number(data.status) : null,
3996+ url: trimToNull(data?.url)
3997+ });
3998+ }
3999+
4000+ observeFinalMessageFromPageSse(data, sender);
4001+}
4002+
4003+function handlePageProxyResponse(data, sender) {
4004+ const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
4005+
4006+ if (!platform || !runtimeState.platforms[platform]) {
4007+ return;
4008+ }
4009+
4010+ runtimeState.platforms[platform].lastProxyResponse = {
4011+ error: trimToNull(data?.error),
4012+ id: trimToNull(data?.id),
4013+ ok: data?.ok === true,
4014+ seenAt: Date.now(),
4015+ status: Number.isFinite(Number(data?.status)) ? Number(data.status) : null,
4016+ tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
4017+ url: trimToNull(data?.url)
4018+ };
4019+ touchState();
4020+}
4021+
4022+function handleDiagnosticLog(data, sender) {
4023+ const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "page");
4024+ const event = trimToNull(data?.event);
4025+
4026+ if (!platform || !runtimeState.platforms[platform] || !event) {
4027+ return;
4028+ }
4029+
4030+ runtimeState.platforms[platform].lastDiagnostic = {
4031+ event,
4032+ method: trimToNull(data?.method),
4033+ seenAt: Date.now(),
4034+ source: trimToNull(data?.source) || "content-script",
4035+ tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
4036+ url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
4037+ };
4038+ touchState();
4039+
4040+ appendLog(
4041+ /timeout|error|missing|failed/u.test(event) ? "warn" : "debug",
4042+ event,
4043+ {
4044+ method: trimToNull(data?.method),
4045+ platform,
4046+ source: trimToNull(data?.source) || "content-script",
4047+ tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
4048+ url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
4049+ }
4050+ );
4051+}
4052+
4053+function handleBeforeSendHeaders(details) {
4054+ const platform = detectPlatformFromUrl(details?.url, "request");
4055+
4056+ if (!platform || !runtimeState.platforms[platform]) {
4057+ return;
4058+ }
4059+
4060+ const changed = updateCredentialSnapshot(platform, {
4061+ requestHeaders: details.requestHeaders,
4062+ tabId: details.tabId,
4063+ url: details.url
4064+ });
4065+
4066+ if (changed) {
4067+ appendLog("debug", "credential_snapshot_seen", {
4068+ credential_fingerprint: runtimeState.platforms[platform].credential.credential_fingerprint,
4069+ header_count: runtimeState.platforms[platform].credential.header_count,
4070+ platform,
4071+ tabId: Number.isInteger(details?.tabId) ? details.tabId : null,
4072+ url: trimToNull(details?.url)
4073+ });
4074+ }
4075+}
4076+
4077+function handleCompleted(details) {
4078+ const platform = detectPlatformFromUrl(details?.url, "request");
4079+
4080+ if (!platform || !runtimeState.platforms[platform]) {
4081+ return;
4082+ }
4083+
4084+ updateEndpointMetadata(platform, {
4085+ method: details.method,
4086+ source: "webRequest",
4087+ status: details.statusCode,
4088+ url: details.url
4089+ });
4090+}
4091+
4092+function handleErrorOccurred(details) {
4093+ const platform = detectPlatformFromUrl(details?.url, "request");
4094+
4095+ if (!platform || !runtimeState.platforms[platform]) {
4096+ return;
4097+ }
4098+
4099+ appendLog("warn", "request_error", {
4100+ error: trimToNull(details?.error),
4101+ method: trimToNull(details?.method),
4102+ platform,
4103+ tabId: Number.isInteger(details?.tabId) ? details.tabId : null,
4104+ url: trimToNull(details?.url)
4105+ });
4106+}
4107+
4108+browser.runtime.onInstalled.addListener(() => {
4109+ void ensureControllerTab({ activate: true });
4110+});
4111+
4112+browser.action.onClicked.addListener(() => {
4113+ void ensureControllerTab({ activate: true });
4114+});
4115+
4116+browser.tabs.onRemoved.addListener((tabId) => {
4117+ if (runtimeState.controller.tabId !== tabId) {
4118+ return;
4119+ }
4120+
4121+ runtimeState.controller.lastReadyAt = null;
4122+ runtimeState.controller.tabId = null;
4123+ touchState();
4124+ void setStoredControllerTabId(null);
4125+});
4126+
4127+browser.webRequest.onBeforeSendHeaders.addListener(
4128+ handleBeforeSendHeaders,
4129+ { urls: REQUEST_URL_PATTERNS },
4130+ ["requestHeaders"]
4131+);
4132+
4133+browser.webRequest.onCompleted.addListener(
4134+ handleCompleted,
4135+ { urls: REQUEST_URL_PATTERNS }
4136+);
4137+
4138+browser.webRequest.onErrorOccurred.addListener(
4139+ handleErrorOccurred,
4140+ { urls: REQUEST_URL_PATTERNS }
4141+);
4142+
4143+browser.runtime.onMessage.addListener((message, sender) => {
4144+ if (!isRecord(message)) {
4145+ return undefined;
4146+ }
4147+
4148+ switch (message.type) {
4149+ case "baa_safari.controller_ready":
4150+ runtimeState.controller.lastReadyAt = Date.now();
4151+ runtimeState.controller.tabId = Number.isInteger(message.tabId) ? message.tabId : runtimeState.controller.tabId;
4152+ touchState();
4153+ if (Number.isInteger(message.tabId)) {
4154+ void setStoredControllerTabId(message.tabId);
4155+ }
4156+ return Promise.resolve({
4157+ ok: true,
4158+ runtimeOwner: runtimeState.runtimeOwner
4159+ });
4160+ case "baa_safari.get_state":
4161+ return refreshPermissionHints().then(() => cloneState());
4162+ case "baa_safari.focus_controller":
4163+ return ensureControllerTab({ activate: true }).then((tab) => ({
4164+ ok: true,
4165+ tabId: tab?.id ?? null
4166+ }));
4167+ case "baa_safari.content_script_seen":
4168+ handleContentScriptSeen(message, sender);
4169+ return Promise.resolve({ ok: true });
4170+ case "baa_page_bridge_ready":
4171+ handlePageBridgeReady(message.data || {}, sender);
4172+ return Promise.resolve({ ok: true });
4173+ case "baa_page_network":
4174+ handlePageNetwork(message.data || {}, sender);
4175+ return Promise.resolve({ ok: true });
4176+ case "baa_page_sse":
4177+ handlePageSse(message.data || {}, sender);
4178+ return Promise.resolve({ ok: true });
4179+ case "baa_diagnostic_log":
4180+ handleDiagnosticLog(message.data || {}, sender);
4181+ return Promise.resolve({ ok: true });
4182+ case "baa_page_proxy_response":
4183+ handlePageProxyResponse(message.data || {}, sender);
4184+ return Promise.resolve({ ok: true });
4185+ default:
4186+ return undefined;
4187+ }
4188+});
4189+
4190+if (TEST_FLAGS.disableBoot !== true) {
4191+ void initializeBackgroundRuntime().catch((error) => {
4192+ appendLog("warn", "background_boot_failed", {
4193+ error: error instanceof Error ? error.message : String(error || "unknown_error")
4194+ });
4195+ });
4196+}
4197+
4198+if (typeof module !== "undefined" && module.exports) {
4199+ module.exports = {
4200+ __test__: {
4201+ buildBrowserHelloPayload,
4202+ connectBrowserWs,
4203+ handlePageNetwork,
4204+ handlePageSse,
4205+ initializeBackgroundRuntime,
4206+ normalizeBrowserWsUrl,
4207+ relayObservedFinalMessage,
4208+ restoreFinalMessageRelayCache,
4209+ runtimeState,
4210+ sendObservedFinalMessage,
4211+ serializeFinalMessageRelayCache,
4212+ setWsStatus,
4213+ syncPendingRelayCount,
4214+ pendingRelayQueue,
4215+ finalMessageRelayObservers,
4216+ wsRuntime,
4217+ reset() {
4218+ clearReconnectTimer();
4219+ if (wsRuntime.socket && typeof wsRuntime.socket.close === "function") {
4220+ try {
4221+ wsRuntime.socket.close();
4222+ } catch (_) {}
4223+ }
4224+ wsRuntime.socket = null;
4225+ pendingRelayQueue.splice(0, pendingRelayQueue.length);
4226+ runtimeState.background.startedAt = Date.now();
4227+ runtimeState.controller.lastReadyAt = null;
4228+ runtimeState.controller.tabId = null;
4229+ runtimeState.lastUpdatedAt = Date.now();
4230+ runtimeState.permissionHints = [];
4231+ runtimeState.platforms = createPlatformMap((platform) => createDefaultPlatformState(platform));
4232+ runtimeState.recentLogs = [];
4233+ runtimeState.runtimeOwner = "controller.html";
4234+ runtimeState.ws = createDefaultWsState();
4235+
4236+ for (const platform of PLATFORM_ORDER) {
4237+ const observer = finalMessageRelayObservers[platform];
4238+ if (!observer) {
4239+ continue;
4240+ }
4241+
4242+ observer.activeStream = null;
4243+ observer.recentRelayKeys = [];
4244+ }
4245+ }
4246+ }
4247+ };
4248+}
4249diff --git a/plugins/baa-safari/background.test.cjs b/plugins/baa-safari/background.test.cjs
4250new file mode 100644
4251index 0000000000000000000000000000000000000000..7d113da00a52a5ad134b8d6fee09396ab7f69531
4252--- /dev/null
4253+++ b/plugins/baa-safari/background.test.cjs
4254@@ -0,0 +1,226 @@
4255+const assert = require("node:assert/strict");
4256+const test = require("node:test");
4257+
4258+function createStorageArea() {
4259+ const data = {};
4260+
4261+ return {
4262+ _data: data,
4263+ async get(keys) {
4264+ if (Array.isArray(keys)) {
4265+ return Object.fromEntries(keys.map((key) => [key, data[key]]));
4266+ }
4267+
4268+ if (typeof keys === "string") {
4269+ return { [keys]: data[keys] };
4270+ }
4271+
4272+ if (keys && typeof keys === "object") {
4273+ return Object.fromEntries(Object.keys(keys).map((key) => [key, data[key] ?? keys[key]]));
4274+ }
4275+
4276+ return { ...data };
4277+ },
4278+ async remove(keys) {
4279+ const items = Array.isArray(keys) ? keys : [keys];
4280+ for (const key of items) {
4281+ delete data[key];
4282+ }
4283+ },
4284+ async set(input) {
4285+ Object.assign(data, input || {});
4286+ }
4287+ };
4288+}
4289+
4290+function createBrowserStub() {
4291+ return {
4292+ action: {
4293+ onClicked: {
4294+ addListener() {}
4295+ }
4296+ },
4297+ runtime: {
4298+ getURL(path) {
4299+ return `safari-web-extension://${path}`;
4300+ },
4301+ onInstalled: {
4302+ addListener() {}
4303+ },
4304+ onMessage: {
4305+ addListener() {}
4306+ }
4307+ },
4308+ storage: {
4309+ local: createStorageArea()
4310+ },
4311+ tabs: {
4312+ onRemoved: {
4313+ addListener() {}
4314+ },
4315+ async create() {
4316+ return { id: 100, url: "safari-web-extension://controller.html", windowId: 1 };
4317+ },
4318+ async query() {
4319+ return [];
4320+ },
4321+ async remove() {},
4322+ async update(id, patch = {}) {
4323+ return { id, windowId: 1, ...patch };
4324+ }
4325+ },
4326+ webRequest: {
4327+ onBeforeSendHeaders: {
4328+ addListener() {}
4329+ },
4330+ onCompleted: {
4331+ addListener() {}
4332+ },
4333+ onErrorOccurred: {
4334+ addListener() {}
4335+ }
4336+ },
4337+ windows: {
4338+ async update() {}
4339+ }
4340+ };
4341+}
4342+
4343+class FakeWebSocket {}
4344+FakeWebSocket.CONNECTING = 0;
4345+FakeWebSocket.OPEN = 1;
4346+FakeWebSocket.CLOSING = 2;
4347+FakeWebSocket.CLOSED = 3;
4348+
4349+function loadBackgroundHarness() {
4350+ delete require.cache[require.resolve("./background.js")];
4351+ delete require.cache[require.resolve("./final-message.js")];
4352+
4353+ globalThis.__BAA_SAFARI_TEST_FLAGS__ = {
4354+ disableBoot: true,
4355+ disableReconnect: true,
4356+ disableWsConnect: true
4357+ };
4358+ globalThis.browser = createBrowserStub();
4359+ globalThis.WebSocket = FakeWebSocket;
4360+ globalThis.BAAFinalMessage = require("./final-message.js");
4361+
4362+ return require("./background.js").__test__;
4363+}
4364+
4365+function createOpenSocket() {
4366+ return {
4367+ readyState: FakeWebSocket.OPEN,
4368+ sent: [],
4369+ close() {},
4370+ send(payload) {
4371+ this.sent.push(JSON.parse(payload));
4372+ }
4373+ };
4374+}
4375+
4376+function buildChatgptChunk({
4377+ assistantMessageId = "msg_abort",
4378+ conversationId = "conv_abort",
4379+ text = "@conductor::status"
4380+} = {}) {
4381+ return `data: ${JSON.stringify({
4382+ conversation_id: conversationId,
4383+ message: {
4384+ author: {
4385+ role: "assistant"
4386+ },
4387+ content: {
4388+ parts: [text]
4389+ },
4390+ end_turn: true,
4391+ id: assistantMessageId
4392+ }
4393+ })}`;
4394+}
4395+
4396+test("Safari background relays ChatGPT final_message through browser WS", () => {
4397+ const harness = loadBackgroundHarness();
4398+ harness.reset();
4399+ const socket = createOpenSocket();
4400+
4401+ harness.runtimeState.ws.clientId = "safari-test";
4402+ harness.setWsStatus("connected", {
4403+ lastHelloAckAt: Date.now(),
4404+ wsUrl: "ws://127.0.0.1:4317/ws/browser"
4405+ });
4406+ harness.wsRuntime.socket = socket;
4407+
4408+ harness.handlePageSse({
4409+ chunk: buildChatgptChunk(),
4410+ reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4411+ url: "https://chatgpt.com/backend-api/f/conversation"
4412+ }, {
4413+ tab: {
4414+ id: 7,
4415+ title: "ChatGPT",
4416+ url: "https://chatgpt.com/c/conv_abort"
4417+ }
4418+ });
4419+
4420+ harness.handlePageSse({
4421+ error: "The operation was aborted.",
4422+ reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4423+ url: "https://chatgpt.com/backend-api/f/conversation"
4424+ }, {
4425+ tab: {
4426+ id: 7,
4427+ title: "ChatGPT",
4428+ url: "https://chatgpt.com/c/conv_abort"
4429+ }
4430+ });
4431+
4432+ assert.equal(socket.sent.length, 1);
4433+ assert.equal(socket.sent[0].type, "browser.final_message");
4434+ assert.equal(socket.sent[0].platform, "chatgpt");
4435+ assert.equal(socket.sent[0].assistant_message_id, "msg_abort");
4436+ assert.equal(socket.sent[0].raw_text, "@conductor::status");
4437+ assert.equal(harness.runtimeState.platforms.chatgpt.lastFinalMessage.assistant_message_id, "msg_abort");
4438+});
4439+
4440+test("Safari background suppresses stale replay when relay cache already contains the ChatGPT message", () => {
4441+ const harness = loadBackgroundHarness();
4442+ harness.reset();
4443+ const socket = createOpenSocket();
4444+
4445+ harness.runtimeState.ws.clientId = "safari-test";
4446+ harness.setWsStatus("connected", {
4447+ lastHelloAckAt: Date.now(),
4448+ wsUrl: "ws://127.0.0.1:4317/ws/browser"
4449+ });
4450+ harness.wsRuntime.socket = socket;
4451+ harness.restoreFinalMessageRelayCache({
4452+ chatgpt: ["chatgpt|conv_abort|msg_abort|@conductor::status"]
4453+ });
4454+
4455+ harness.handlePageSse({
4456+ chunk: buildChatgptChunk(),
4457+ reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4458+ url: "https://chatgpt.com/backend-api/f/conversation"
4459+ }, {
4460+ tab: {
4461+ id: 7,
4462+ title: "ChatGPT",
4463+ url: "https://chatgpt.com/c/conv_abort"
4464+ }
4465+ });
4466+
4467+ harness.handlePageSse({
4468+ error: "The operation was aborted.",
4469+ reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4470+ url: "https://chatgpt.com/backend-api/f/conversation"
4471+ }, {
4472+ tab: {
4473+ id: 7,
4474+ title: "ChatGPT",
4475+ url: "https://chatgpt.com/c/conv_abort"
4476+ }
4477+ });
4478+
4479+ assert.equal(socket.sent.length, 0);
4480+});
4481diff --git a/plugins/baa-safari/content-script.js b/plugins/baa-safari/content-script.js
4482new file mode 100644
4483index 0000000000000000000000000000000000000000..44c3b2a700e1ebb11ce2ebd2c5e2ca9ad50a9f02
4484--- /dev/null
4485+++ b/plugins/baa-safari/content-script.js
4486@@ -0,0 +1,197 @@
4487+const CONTENT_SCRIPT_RUNTIME_KEY = "__baaSafariContentScriptRuntime__";
4488+const PAGE_READY_TIMEOUT_MS = 4_000;
4489+
4490+const previousRuntime = window[CONTENT_SCRIPT_RUNTIME_KEY];
4491+if (previousRuntime && typeof previousRuntime.dispose === "function") {
4492+ previousRuntime.dispose();
4493+}
4494+
4495+function trimToNull(value) {
4496+ if (typeof value !== "string") {
4497+ return null;
4498+ }
4499+
4500+ const normalized = value.trim();
4501+ return normalized === "" ? null : normalized;
4502+}
4503+
4504+function detectPlatformFromLocation() {
4505+ const host = globalThis.location?.hostname || "";
4506+
4507+ if (host === "claude.ai") {
4508+ return "claude";
4509+ }
4510+
4511+ if (host === "chatgpt.com" || host === "chat.openai.com" || host.endsWith(".chatgpt.com") || host.endsWith(".chat.openai.com")) {
4512+ return "chatgpt";
4513+ }
4514+
4515+ if (host === "gemini.google.com") {
4516+ return "gemini";
4517+ }
4518+
4519+ return "unknown";
4520+}
4521+
4522+function sendBridgeMessage(type, data) {
4523+ browser.runtime.sendMessage({
4524+ type,
4525+ data
4526+ }).catch(() => {});
4527+}
4528+
4529+function sendDiagnosticLog(eventName, detail = {}) {
4530+ const event = trimToNull(eventName);
4531+
4532+ if (!event) {
4533+ return;
4534+ }
4535+
4536+ sendBridgeMessage("baa_diagnostic_log", {
4537+ ...(detail || {}),
4538+ event,
4539+ platform: trimToNull(detail?.platform) || detectPlatformFromLocation(),
4540+ source: trimToNull(detail?.source) || "content-script",
4541+ url: trimToNull(detail?.url) || globalThis.location?.href || null
4542+ });
4543+}
4544+
4545+function handlePageReady(event) {
4546+ const detail = event?.detail && typeof event.detail === "object" ? event.detail : {};
4547+
4548+ if (contentScriptRuntime.readyTimeout != null) {
4549+ clearTimeout(contentScriptRuntime.readyTimeout);
4550+ contentScriptRuntime.readyTimeout = null;
4551+ }
4552+
4553+ sendBridgeMessage("baa_page_bridge_ready", {
4554+ ...detail,
4555+ platform: trimToNull(detail.platform) || detectPlatformFromLocation(),
4556+ source: trimToNull(detail.source) || "page-interceptor",
4557+ url: trimToNull(detail.url) || globalThis.location?.href || null
4558+ });
4559+}
4560+
4561+function handlePageNetwork(event) {
4562+ sendBridgeMessage("baa_page_network", event?.detail || {});
4563+}
4564+
4565+function handlePageSse(event) {
4566+ sendBridgeMessage("baa_page_sse", event?.detail || {});
4567+}
4568+
4569+function handlePageProxyResponse(event) {
4570+ sendBridgeMessage("baa_page_proxy_response", event?.detail || {});
4571+}
4572+
4573+function handleDiagnosticEvent(event) {
4574+ const detail = event?.detail && typeof event.detail === "object" ? event.detail : {};
4575+ sendDiagnosticLog(detail.event, detail);
4576+}
4577+
4578+function handleRuntimeMessage(message) {
4579+ if (!message || typeof message !== "object") {
4580+ return undefined;
4581+ }
4582+
4583+ if (message.type === "baa_page_proxy_request") {
4584+ window.dispatchEvent(new CustomEvent("__baa_proxy_request__", {
4585+ detail: JSON.stringify(message.data || {})
4586+ }));
4587+ return undefined;
4588+ }
4589+
4590+ if (message.type === "baa_page_proxy_cancel") {
4591+ window.dispatchEvent(new CustomEvent("__baa_proxy_cancel__", {
4592+ detail: JSON.stringify(message.data || {})
4593+ }));
4594+ return undefined;
4595+ }
4596+
4597+ return undefined;
4598+}
4599+
4600+function injectPageInterceptor() {
4601+ const root = document.documentElement;
4602+
4603+ if (!root) {
4604+ sendDiagnosticLog("page_interceptor_injection_failed", {
4605+ reason: "missing_document_root"
4606+ });
4607+ return;
4608+ }
4609+
4610+ const script = document.createElement("script");
4611+ script.src = browser.runtime.getURL("page-interceptor.js");
4612+ script.async = false;
4613+ script.dataset.baaSafari = "page-interceptor";
4614+ script.onload = () => {
4615+ script.remove();
4616+ sendDiagnosticLog("page_interceptor_script_loaded", {
4617+ source: "content-script"
4618+ });
4619+ };
4620+ script.onerror = () => {
4621+ sendDiagnosticLog("page_interceptor_script_error", {
4622+ source: "content-script"
4623+ });
4624+ script.remove();
4625+ };
4626+
4627+ (document.head || root).appendChild(script);
4628+}
4629+
4630+window.addEventListener("__baa_ready__", handlePageReady);
4631+window.addEventListener("__baa_net__", handlePageNetwork);
4632+window.addEventListener("__baa_sse__", handlePageSse);
4633+window.addEventListener("__baa_diagnostic__", handleDiagnosticEvent);
4634+window.addEventListener("__baa_proxy_response__", handlePageProxyResponse);
4635+browser.runtime.onMessage.addListener(handleRuntimeMessage);
4636+
4637+browser.runtime.sendMessage({
4638+ platform: detectPlatformFromLocation(),
4639+ type: "baa_safari.content_script_seen",
4640+ url: globalThis.location?.href || null
4641+}).catch(() => {});
4642+
4643+sendBridgeMessage("baa_page_bridge_ready", {
4644+ platform: detectPlatformFromLocation(),
4645+ source: "content-script",
4646+ url: globalThis.location?.href || null
4647+});
4648+sendDiagnosticLog("page_bridge_ready", {
4649+ source: "content-script"
4650+});
4651+
4652+const contentScriptRuntime = {
4653+ readyTimeout: setTimeout(() => {
4654+ sendDiagnosticLog("page_interceptor_timeout", {
4655+ reason: "no __baa_ready__ event received",
4656+ source: "content-script"
4657+ });
4658+ }, PAGE_READY_TIMEOUT_MS),
4659+ dispose() {
4660+ if (contentScriptRuntime.readyTimeout != null) {
4661+ clearTimeout(contentScriptRuntime.readyTimeout);
4662+ contentScriptRuntime.readyTimeout = null;
4663+ }
4664+
4665+ window.removeEventListener("__baa_ready__", handlePageReady);
4666+ window.removeEventListener("__baa_net__", handlePageNetwork);
4667+ window.removeEventListener("__baa_sse__", handlePageSse);
4668+ window.removeEventListener("__baa_diagnostic__", handleDiagnosticEvent);
4669+ window.removeEventListener("__baa_proxy_response__", handlePageProxyResponse);
4670+ browser.runtime.onMessage.removeListener(handleRuntimeMessage);
4671+
4672+ if (window[CONTENT_SCRIPT_RUNTIME_KEY] === contentScriptRuntime) {
4673+ try {
4674+ delete window[CONTENT_SCRIPT_RUNTIME_KEY];
4675+ } catch (_) {
4676+ window[CONTENT_SCRIPT_RUNTIME_KEY] = null;
4677+ }
4678+ }
4679+ }
4680+};
4681+
4682+window[CONTENT_SCRIPT_RUNTIME_KEY] = contentScriptRuntime;
4683+injectPageInterceptor();
4684diff --git a/plugins/baa-safari/controller.css b/plugins/baa-safari/controller.css
4685new file mode 100644
4686index 0000000000000000000000000000000000000000..60a18009fe48faec0fccc04b40d484cd770e6b66
4687--- /dev/null
4688+++ b/plugins/baa-safari/controller.css
4689@@ -0,0 +1,129 @@
4690+* {
4691+ box-sizing: border-box;
4692+}
4693+
4694+body {
4695+ margin: 0;
4696+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
4697+ background:
4698+ radial-gradient(circle at top left, rgba(21, 92, 159, 0.18), transparent 34%),
4699+ linear-gradient(180deg, #f4f8fc 0%, #eef4f8 100%);
4700+ color: #173247;
4701+}
4702+
4703+.shell {
4704+ max-width: 980px;
4705+ margin: 0 auto;
4706+ padding: 32px 20px 48px;
4707+}
4708+
4709+.hero,
4710+.actions,
4711+.grid,
4712+.panel {
4713+ margin-bottom: 20px;
4714+}
4715+
4716+.eyebrow {
4717+ margin: 0 0 8px;
4718+ font-size: 12px;
4719+ font-weight: 700;
4720+ letter-spacing: 0.08em;
4721+ text-transform: uppercase;
4722+ color: #245e8a;
4723+}
4724+
4725+h1,
4726+h2,
4727+p {
4728+ margin: 0;
4729+}
4730+
4731+.lead {
4732+ margin-top: 10px;
4733+ max-width: 720px;
4734+ line-height: 1.6;
4735+ color: #36566f;
4736+}
4737+
4738+.actions {
4739+ display: flex;
4740+ gap: 12px;
4741+}
4742+
4743+button {
4744+ border: 0;
4745+ border-radius: 999px;
4746+ padding: 11px 16px;
4747+ font: inherit;
4748+ font-weight: 600;
4749+ color: #fff;
4750+ background: #1f6f5f;
4751+ cursor: pointer;
4752+}
4753+
4754+button:last-child {
4755+ background: #245e8a;
4756+}
4757+
4758+.grid {
4759+ display: grid;
4760+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
4761+ gap: 14px;
4762+}
4763+
4764+.card,
4765+.panel {
4766+ border: 1px solid rgba(23, 50, 71, 0.08);
4767+ border-radius: 18px;
4768+ padding: 16px;
4769+ background: rgba(255, 255, 255, 0.84);
4770+ backdrop-filter: blur(12px);
4771+}
4772+
4773+.label {
4774+ font-size: 12px;
4775+ font-weight: 700;
4776+ letter-spacing: 0.06em;
4777+ text-transform: uppercase;
4778+ color: #6a879d;
4779+}
4780+
4781+.value {
4782+ margin-top: 10px;
4783+ font-size: 24px;
4784+ font-weight: 700;
4785+}
4786+
4787+.value.off {
4788+ color: #7d8d99;
4789+}
4790+
4791+.meta {
4792+ margin-top: 10px;
4793+ line-height: 1.5;
4794+ color: #4b6578;
4795+}
4796+
4797+.panel-head {
4798+ margin-bottom: 12px;
4799+}
4800+
4801+.code {
4802+ margin: 0;
4803+ overflow: auto;
4804+ white-space: pre-wrap;
4805+ font: 13px/1.6 "SFMono-Regular", ui-monospace, monospace;
4806+ color: #204157;
4807+}
4808+
4809+.steps {
4810+ margin: 0;
4811+ padding-left: 20px;
4812+ color: #36566f;
4813+ line-height: 1.7;
4814+}
4815+
4816+code {
4817+ font-family: "SFMono-Regular", ui-monospace, monospace;
4818+}
4819diff --git a/plugins/baa-safari/controller.html b/plugins/baa-safari/controller.html
4820new file mode 100644
4821index 0000000000000000000000000000000000000000..9f05f1fbcf1a5d129494c6373a97f253bed10d94
4822--- /dev/null
4823+++ b/plugins/baa-safari/controller.html
4824@@ -0,0 +1,133 @@
4825+<!doctype html>
4826+<html lang="zh-CN">
4827+<head>
4828+ <meta charset="utf-8">
4829+ <meta name="viewport" content="width=device-width,initial-scale=1">
4830+ <title>BAA Safari 控制页</title>
4831+ <link rel="stylesheet" href="controller.css">
4832+</head>
4833+<body>
4834+ <main class="shell">
4835+ <section class="hero">
4836+ <p class="eyebrow">BAA Safari 控制页</p>
4837+ <h1>Safari 页面桥接与 Final Message</h1>
4838+ <p class="lead">
4839+ 这张卡收口页面注入、桥接事件、ChatGPT final-message relay、本地 WS 和权限诊断。
4840+ <code>controller.html</code> 负责显示 bridge 健康度,<code>background.js</code> 聚合页面、<code>webRequest</code> 元数据并上送 <code>browser.final_message</code>。
4841+ </p>
4842+ </section>
4843+
4844+ <section class="actions">
4845+ <button id="refresh-btn" type="button">刷新状态</button>
4846+ <button id="focus-btn" type="button">聚焦控制页</button>
4847+ </section>
4848+
4849+ <section class="grid">
4850+ <article class="card">
4851+ <p class="label">Runtime Owner</p>
4852+ <p id="runtime-owner" class="value">controller.html</p>
4853+ <p class="meta">长期运行时 owner 仍按 Safari Phase 1 合同保留在控制页。</p>
4854+ </article>
4855+ <article class="card">
4856+ <p class="label">Background</p>
4857+ <p id="background-status" class="value off">等待同步</p>
4858+ <p id="background-meta" class="meta">聚合页面桥接、权限提示和 metadata。</p>
4859+ </article>
4860+ <article class="card">
4861+ <p class="label">Content Script</p>
4862+ <p id="content-status" class="value off">暂无页面回报</p>
4863+ <p id="content-meta" class="meta">等待 AI 页面触发 content script 入口。</p>
4864+ </article>
4865+ <article class="card">
4866+ <p class="label">Page Bridge</p>
4867+ <p id="page-bridge-status" class="value off">未 ready</p>
4868+ <p id="page-bridge-meta" class="meta">等待页面主世界返回 <code>__baa_ready__</code>。</p>
4869+ </article>
4870+ <article class="card">
4871+ <p class="label">ChatGPT Metadata</p>
4872+ <p id="metadata-status" class="value off">未采集</p>
4873+ <p id="metadata-meta" class="meta">关注 <code>credential_fingerprint</code> 和 <code>endpoint_metadata</code>。</p>
4874+ </article>
4875+ <article class="card">
4876+ <p class="label">Local WS</p>
4877+ <p id="ws-status" class="value off">未连接</p>
4878+ <p id="ws-meta" class="meta">等待 Safari background 连接 <code>/ws/browser</code>。</p>
4879+ </article>
4880+ <article class="card">
4881+ <p class="label">Final Message</p>
4882+ <p id="final-message-status" class="value off">未转发</p>
4883+ <p id="final-message-meta" class="meta">关注 ChatGPT 回复完成后是否产出 <code>browser.final_message</code>。</p>
4884+ </article>
4885+ <article class="card">
4886+ <p class="label">Website Access</p>
4887+ <p id="hint-status" class="value off">待检查</p>
4888+ <p class="meta">如果这里持续告警,先去 Safari 扩展设置检查网站授权。</p>
4889+ </article>
4890+ </section>
4891+
4892+ <section class="panel">
4893+ <div class="panel-head">
4894+ <h2>职责边界</h2>
4895+ </div>
4896+ <pre id="contract-view" class="code"></pre>
4897+ </section>
4898+
4899+ <section class="panel">
4900+ <div class="panel-head">
4901+ <h2>当前支持范围</h2>
4902+ </div>
4903+ <pre id="support-view" class="code"></pre>
4904+ </section>
4905+
4906+ <section class="panel">
4907+ <div class="panel-head">
4908+ <h2>权限与注入提示</h2>
4909+ </div>
4910+ <pre id="hint-view" class="code"></pre>
4911+ </section>
4912+
4913+ <section class="panel">
4914+ <div class="panel-head">
4915+ <h2>最近诊断日志</h2>
4916+ </div>
4917+ <pre id="log-view" class="code"></pre>
4918+ </section>
4919+
4920+ <section class="panel">
4921+ <div class="panel-head">
4922+ <h2>ChatGPT Credential Snapshot</h2>
4923+ </div>
4924+ <pre id="credential-view" class="code"></pre>
4925+ </section>
4926+
4927+ <section class="panel">
4928+ <div class="panel-head">
4929+ <h2>ChatGPT Endpoint Metadata</h2>
4930+ </div>
4931+ <pre id="endpoint-view" class="code"></pre>
4932+ </section>
4933+
4934+ <section class="panel">
4935+ <div class="panel-head">
4936+ <h2>完整状态</h2>
4937+ </div>
4938+ <pre id="state-view" class="code"></pre>
4939+ </section>
4940+
4941+ <section class="panel">
4942+ <div class="panel-head">
4943+ <h2>本地启用步骤</h2>
4944+ </div>
4945+ <ol class="steps">
4946+ <li>先构建并启动 <code>BAASafariHost</code> 宿主 App。</li>
4947+ <li>在 Safari 设置里启用 BAA 扩展。</li>
4948+ <li>在 <code>Website Access</code> 里给 ChatGPT 授权;Claude / Gemini 先只做桥接占位。</li>
4949+ <li>打开 ChatGPT 页面,确认本页出现 Content Script 和 Page Bridge 状态。</li>
4950+ <li>如果一直没有页面回报,优先检查网站授权,再看诊断日志里的注入错误。</li>
4951+ </ol>
4952+ </section>
4953+ </main>
4954+ <script src="final-message.js"></script>
4955+ <script src="controller.js"></script>
4956+</body>
4957+</html>
4958diff --git a/plugins/baa-safari/controller.js b/plugins/baa-safari/controller.js
4959new file mode 100644
4960index 0000000000000000000000000000000000000000..9cba9b990c713be0ce2a276d0e3e9faac7666d1b
4961--- /dev/null
4962+++ b/plugins/baa-safari/controller.js
4963@@ -0,0 +1,275 @@
4964+const REFRESH_INTERVAL_MS = 2_000;
4965+
4966+const elements = {
4967+ backgroundMeta: document.getElementById("background-meta"),
4968+ backgroundStatus: document.getElementById("background-status"),
4969+ contentMeta: document.getElementById("content-meta"),
4970+ contentStatus: document.getElementById("content-status"),
4971+ contractView: document.getElementById("contract-view"),
4972+ credentialView: document.getElementById("credential-view"),
4973+ endpointView: document.getElementById("endpoint-view"),
4974+ finalMessageMeta: document.getElementById("final-message-meta"),
4975+ finalMessageStatus: document.getElementById("final-message-status"),
4976+ focusButton: document.getElementById("focus-btn"),
4977+ hintStatus: document.getElementById("hint-status"),
4978+ hintView: document.getElementById("hint-view"),
4979+ metadataMeta: document.getElementById("metadata-meta"),
4980+ metadataStatus: document.getElementById("metadata-status"),
4981+ pageBridgeMeta: document.getElementById("page-bridge-meta"),
4982+ pageBridgeStatus: document.getElementById("page-bridge-status"),
4983+ refreshButton: document.getElementById("refresh-btn"),
4984+ runtimeOwner: document.getElementById("runtime-owner"),
4985+ stateView: document.getElementById("state-view"),
4986+ supportView: document.getElementById("support-view"),
4987+ logView: document.getElementById("log-view"),
4988+ wsMeta: document.getElementById("ws-meta"),
4989+ wsStatus: document.getElementById("ws-status")
4990+};
4991+
4992+function formatTimestamp(value) {
4993+ return Number.isFinite(value) && value > 0
4994+ ? new Date(value).toLocaleString("zh-CN", { hour12: false })
4995+ : "未记录";
4996+}
4997+
4998+function stringify(value) {
4999+ return JSON.stringify(value, null, 2);
5000+}
5001+
5002+function setValue(node, text, active = true) {
5003+ if (!node) {
5004+ return;
5005+ }
5006+
5007+ node.textContent = text;
5008+ node.classList.toggle("off", !active);
5009+}
5010+
5011+function buildContractSnapshot() {
5012+ return {
5013+ background_js: "collects page bridge events, webRequest metadata, ChatGPT final-message relay, and browser WS state",
5014+ content_script_js: "bridges __baa_ready__/__baa_net__/__baa_sse__/__baa_proxy_response__ to runtime messages",
5015+ controller_html: "operator view for bridge health, metadata, and Safari website-access diagnostics",
5016+ final_message_js: "shared pure-JS relay helper copied from Firefox; background owns observer + dedupe cache",
5017+ page_interceptor_js: "shared Firefox-style fetch/XHR interceptor with __baa_proxy_request__ support",
5018+ runtime_owner: "controller.html",
5019+ supported_now: {
5020+ chatgpt: [
5021+ "page bridge ready",
5022+ "network + SSE event forwarding",
5023+ "credential_fingerprint",
5024+ "endpoint_metadata",
5025+ "account_hint",
5026+ "browser.final_message relay"
5027+ ],
5028+ claude: ["page bridge foundation only"],
5029+ gemini: ["page bridge foundation only"]
5030+ }
5031+ };
5032+}
5033+
5034+function readMostRecentPlatformReport(state, key) {
5035+ const platforms = Object.values(state?.platforms || {});
5036+ const reports = platforms
5037+ .map((platformState) => ({
5038+ platform: platformState.platform,
5039+ report: platformState[key]
5040+ }))
5041+ .filter((entry) => entry.report && Number.isFinite(entry.report.seenAt || entry.report.ts));
5042+
5043+ reports.sort((left, right) => {
5044+ const leftTime = left.report.seenAt || left.report.ts || 0;
5045+ const rightTime = right.report.seenAt || right.report.ts || 0;
5046+ return rightTime - leftTime;
5047+ });
5048+
5049+ return reports[0] || null;
5050+}
5051+
5052+function formatHintText(hints = []) {
5053+ if (!Array.isArray(hints) || hints.length === 0) {
5054+ return "未发现明显的网站权限或注入阻塞。";
5055+ }
5056+
5057+ return hints
5058+ .map((hint) => `- [${hint.platform}] ${hint.message}${hint.tabId != null ? ` (tab=${hint.tabId})` : ""}`)
5059+ .join("\n");
5060+}
5061+
5062+function formatLogText(logs = []) {
5063+ if (!Array.isArray(logs) || logs.length === 0) {
5064+ return "暂无桥接日志。";
5065+ }
5066+
5067+ return logs
5068+ .slice(0, 24)
5069+ .map((entry) => {
5070+ const timestamp = formatTimestamp(entry.timestamp);
5071+ const platform = entry?.detail?.platform ? ` [${entry.detail.platform}]` : "";
5072+ const source = entry?.detail?.source ? ` source=${entry.detail.source}` : "";
5073+ const url = entry?.detail?.url ? ` url=${entry.detail.url}` : "";
5074+ return `${timestamp} ${String(entry.level || "info").toUpperCase()} ${entry.event}${platform}${source}${url}`;
5075+ })
5076+ .join("\n");
5077+}
5078+
5079+function formatEndpointText(entries = []) {
5080+ if (!Array.isArray(entries) || entries.length === 0) {
5081+ return "还没有 ChatGPT endpoint metadata。";
5082+ }
5083+
5084+ return entries
5085+ .map((entry) =>
5086+ `${entry.method} ${entry.path}\n first=${formatTimestamp(entry.first_seen_at)}\n last=${formatTimestamp(entry.last_seen_at)}\n source=${entry.source || "-"}\n status=${entry.last_status ?? "-"}`
5087+ )
5088+ .join("\n\n");
5089+}
5090+
5091+function formatFinalMessageText(entry) {
5092+ if (!entry) {
5093+ return "还没有 ChatGPT final-message relay。";
5094+ }
5095+
5096+ return [
5097+ `assistant=${entry.assistant_message_id || "无"}`,
5098+ `conversation=${entry.conversation_id || "无"}`,
5099+ `source=${entry.source || "-"}`,
5100+ `page=${entry.page_url || "-"}`,
5101+ `seen=${formatTimestamp(entry.seenAt)}`,
5102+ "",
5103+ entry.raw_text || ""
5104+ ].join("\n");
5105+}
5106+
5107+async function readCurrentTabId() {
5108+ if (!browser?.tabs?.getCurrent) {
5109+ return null;
5110+ }
5111+
5112+ try {
5113+ const tab = await browser.tabs.getCurrent();
5114+ return Number.isInteger(tab?.id) ? tab.id : null;
5115+ } catch {
5116+ return null;
5117+ }
5118+}
5119+
5120+async function fetchState() {
5121+ return browser.runtime.sendMessage({
5122+ type: "baa_safari.get_state"
5123+ });
5124+}
5125+
5126+function renderState(state) {
5127+ const latestContent = readMostRecentPlatformReport(state, "lastContentScript");
5128+ const latestBridge = readMostRecentPlatformReport(state, "lastPageBridgeReady");
5129+ const chatgpt = state?.platforms?.chatgpt || null;
5130+ const credential = chatgpt?.credential || null;
5131+ const lastFinalMessage = chatgpt?.lastFinalMessage || null;
5132+ const metadataReady = Boolean(credential?.credential_fingerprint || (chatgpt?.endpoint_metadata || []).length > 0);
5133+ const ws = state?.ws || null;
5134+ const wsReady = ws?.connected === true || ws?.status === "connected";
5135+
5136+ elements.runtimeOwner.textContent = state.runtimeOwner;
5137+
5138+ setValue(
5139+ elements.backgroundStatus,
5140+ state.background?.startedAt == null ? "未启动" : "已启动",
5141+ state.background?.startedAt != null
5142+ );
5143+ elements.backgroundMeta.textContent =
5144+ `最近同步: ${formatTimestamp(state.lastUpdatedAt)}\ncontrollerTabId: ${state.controller?.tabId ?? "无"}`;
5145+
5146+ setValue(
5147+ elements.contentStatus,
5148+ latestContent == null ? "暂无页面回报" : latestContent.platform,
5149+ latestContent != null
5150+ );
5151+ elements.contentMeta.textContent =
5152+ latestContent == null
5153+ ? "等待 Claude / ChatGPT / Gemini 页面触发 content script。"
5154+ : `${latestContent.report.url || "unknown"}\n最近触达: ${formatTimestamp(latestContent.report.seenAt)}`;
5155+
5156+ setValue(
5157+ elements.pageBridgeStatus,
5158+ latestBridge == null ? "未 ready" : `${latestBridge.platform} / ${latestBridge.report.source || "page-interceptor"}`,
5159+ latestBridge != null
5160+ );
5161+ elements.pageBridgeMeta.textContent =
5162+ latestBridge == null
5163+ ? "还没有页面主世界返回 __baa_ready__ 或 content-script ready。"
5164+ : `${latestBridge.report.url || "unknown"}\n最近 ready: ${formatTimestamp(latestBridge.report.seenAt)}`;
5165+
5166+ setValue(
5167+ elements.metadataStatus,
5168+ metadataReady ? "ChatGPT 已采集" : "ChatGPT 未采集",
5169+ metadataReady
5170+ );
5171+ elements.metadataMeta.textContent =
5172+ credential == null
5173+ ? "当前没有 ChatGPT metadata state。"
5174+ : `credential_fingerprint: ${credential.credential_fingerprint || "无"}\nendpoint_count: ${credential.endpoint_count ?? 0}\naccount_hint: ${credential.account_hint || "无"}`;
5175+
5176+ setValue(
5177+ elements.wsStatus,
5178+ wsReady ? "已连接" : (ws?.status || "未连接"),
5179+ wsReady
5180+ );
5181+ elements.wsMeta.textContent =
5182+ ws == null
5183+ ? "当前没有本地 WS 状态。"
5184+ : `clientId: ${ws.clientId || "无"}\nwsUrl: ${ws.wsUrl || "无"}\nlastHelloAck: ${formatTimestamp(ws.lastHelloAckAt)}\npendingRelays: ${ws.pendingRelayCount ?? 0}`;
5185+
5186+ setValue(
5187+ elements.finalMessageStatus,
5188+ lastFinalMessage == null ? "未转发" : "已转发",
5189+ lastFinalMessage != null
5190+ );
5191+ elements.finalMessageMeta.textContent = formatFinalMessageText(lastFinalMessage);
5192+
5193+ setValue(
5194+ elements.hintStatus,
5195+ Array.isArray(state.permissionHints) && state.permissionHints.length > 0 ? "需要排查" : "无阻塞提示",
5196+ !(Array.isArray(state.permissionHints) && state.permissionHints.length > 0)
5197+ );
5198+
5199+ elements.contractView.textContent = stringify(buildContractSnapshot());
5200+ elements.supportView.textContent =
5201+ "当前验收下限:ChatGPT 页面桥接、browser.final_message relay、本地 WS 和脱敏登录态元数据。\nClaude / Gemini 只保留页面桥接基础,不宣称已支持 final-message 或 proxy_delivery。";
5202+ elements.hintView.textContent = formatHintText(state.permissionHints);
5203+ elements.logView.textContent = formatLogText(state.recentLogs);
5204+ elements.credentialView.textContent = stringify(credential || {});
5205+ elements.endpointView.textContent = formatEndpointText(chatgpt?.endpoint_metadata || []);
5206+ elements.stateView.textContent = stringify(state);
5207+}
5208+
5209+async function refreshState() {
5210+ const state = await fetchState();
5211+ renderState(state);
5212+}
5213+
5214+async function boot() {
5215+ const tabId = await readCurrentTabId();
5216+
5217+ await browser.runtime.sendMessage({
5218+ tabId,
5219+ type: "baa_safari.controller_ready"
5220+ });
5221+
5222+ await refreshState();
5223+
5224+ elements.refreshButton?.addEventListener("click", () => {
5225+ void refreshState();
5226+ });
5227+ elements.focusButton?.addEventListener("click", () => {
5228+ void browser.runtime.sendMessage({
5229+ type: "baa_safari.focus_controller"
5230+ });
5231+ });
5232+
5233+ globalThis.setInterval(() => {
5234+ void refreshState();
5235+ }, REFRESH_INTERVAL_MS);
5236+}
5237+
5238+void boot();
5239diff --git a/plugins/baa-safari/final-message.js b/plugins/baa-safari/final-message.js
5240new file mode 100644
5241index 0000000000000000000000000000000000000000..21676aba8cd73759817bfbe0e404cb499d6f1ea4
5242--- /dev/null
5243+++ b/plugins/baa-safari/final-message.js
5244@@ -0,0 +1,1119 @@
5245+(function initBaaFinalMessage(globalScope) {
5246+ const CHATGPT_TERMINAL_STATUSES = new Set([
5247+ "completed",
5248+ "finished",
5249+ "finished_successfully",
5250+ "incomplete",
5251+ "max_tokens",
5252+ "stopped"
5253+ ]);
5254+ const CLAUDE_SSE_PAYLOAD_TYPES = new Set([
5255+ "completion",
5256+ "content_block_delta",
5257+ "content_block_start",
5258+ "content_block_stop",
5259+ "message_delta",
5260+ "message_start",
5261+ "message_stop",
5262+ "thinking"
5263+ ]);
5264+ const RECENT_RELAY_LIMIT = 20;
5265+ const MAX_WALK_DEPTH = 8;
5266+ const MAX_WALK_NODES = 400;
5267+
5268+ function isRecord(value) {
5269+ return value !== null && typeof value === "object" && !Array.isArray(value);
5270+ }
5271+
5272+ function trimToNull(value) {
5273+ return typeof value === "string" && value.trim() ? value.trim() : null;
5274+ }
5275+
5276+ function parseJson(text) {
5277+ if (typeof text !== "string" || !text.trim()) return null;
5278+
5279+ try {
5280+ return JSON.parse(text);
5281+ } catch (_) {
5282+ return null;
5283+ }
5284+ }
5285+
5286+ function splitSseBlocks(text) {
5287+ return String(text || "")
5288+ .split(/\r?\n\r?\n+/u)
5289+ .filter((block) => block.trim());
5290+ }
5291+
5292+ function simpleHash(input) {
5293+ const text = String(input || "");
5294+ let hash = 2166136261;
5295+
5296+ for (let index = 0; index < text.length; index += 1) {
5297+ hash ^= text.charCodeAt(index);
5298+ hash = Math.imul(hash, 16777619);
5299+ }
5300+
5301+ return (hash >>> 0).toString(16).padStart(8, "0");
5302+ }
5303+
5304+ function normalizeUrlForSignature(url) {
5305+ const raw = trimToNull(url);
5306+ if (!raw) return "-";
5307+
5308+ try {
5309+ const parsed = new URL(raw, "https://platform.invalid/");
5310+ return `${parsed.origin}${parsed.pathname || "/"}${parsed.search || ""}`;
5311+ } catch (_) {
5312+ return raw;
5313+ }
5314+ }
5315+
5316+ function extractUrlPathname(url) {
5317+ const raw = trimToNull(url);
5318+ if (!raw) return "";
5319+
5320+ try {
5321+ return (new URL(raw, "https://platform.invalid/").pathname || "/").toLowerCase();
5322+ } catch (_) {
5323+ return raw.toLowerCase().split(/[?#]/u)[0] || "";
5324+ }
5325+ }
5326+
5327+ function normalizeMessageText(value) {
5328+ if (typeof value !== "string") return null;
5329+
5330+ const normalized = value
5331+ .replace(/\r\n?/gu, "\n")
5332+ .replace(/[ \t]+\n/gu, "\n")
5333+ .replace(/\u200b/gu, "")
5334+ .trim();
5335+
5336+ return normalized ? normalized : null;
5337+ }
5338+
5339+ function flattenTextFragments(value, depth = 0) {
5340+ if (depth > 5 || value == null) return [];
5341+
5342+ if (typeof value === "string") {
5343+ const text = normalizeMessageText(value);
5344+ return text ? [text] : [];
5345+ }
5346+
5347+ if (Array.isArray(value)) {
5348+ return value.flatMap((entry) => flattenTextFragments(entry, depth + 1));
5349+ }
5350+
5351+ if (!isRecord(value)) {
5352+ return [];
5353+ }
5354+
5355+ const out = [];
5356+ for (const key of ["text", "value", "content", "parts", "segments"]) {
5357+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
5358+ out.push(...flattenTextFragments(value[key], depth + 1));
5359+ }
5360+ return out;
5361+ }
5362+
5363+ function extractChatgptConversationIdFromUrl(url) {
5364+ const raw = trimToNull(url);
5365+ if (!raw) return null;
5366+
5367+ try {
5368+ const parsed = new URL(raw, "https://chatgpt.com/");
5369+ const pathname = parsed.pathname || "/";
5370+ const match = pathname.match(/\/c\/([^/?#]+)/u);
5371+ if (match?.[1]) return match[1];
5372+
5373+ return trimToNull(parsed.searchParams.get("conversation_id"));
5374+ } catch (_) {
5375+ return null;
5376+ }
5377+ }
5378+
5379+ function extractGeminiConversationIdFromUrl(url) {
5380+ const raw = trimToNull(url);
5381+ if (!raw) return null;
5382+
5383+ try {
5384+ const parsed = new URL(raw, "https://gemini.google.com/");
5385+ const pathname = parsed.pathname || "/";
5386+ const match = pathname.match(/\/app\/([^/?#]+)/u);
5387+ if (match?.[1]) return match[1];
5388+
5389+ return trimToNull(parsed.searchParams.get("conversation_id"));
5390+ } catch (_) {
5391+ return null;
5392+ }
5393+ }
5394+
5395+ function extractClaudeConversationIdFromUrl(url) {
5396+ const raw = trimToNull(url);
5397+ if (!raw) return null;
5398+
5399+ try {
5400+ const parsed = new URL(raw, "https://claude.ai/");
5401+ const pathname = parsed.pathname || "/";
5402+ const completionMatch = pathname.match(/\/chat_conversations\/([^/?#]+)\/completion(?:\/)?$/iu);
5403+ if (completionMatch?.[1]) return completionMatch[1];
5404+
5405+ const pageMatch = pathname.match(/\/chats?\/([^/?#]+)/iu);
5406+ if (pageMatch?.[1]) return pageMatch[1];
5407+
5408+ return trimToNull(parsed.searchParams.get("conversation_id"))
5409+ || trimToNull(parsed.searchParams.get("conversationId"));
5410+ } catch (_) {
5411+ return null;
5412+ }
5413+ }
5414+
5415+ function extractChatgptConversationIdFromReqBody(reqBody) {
5416+ const parsed = parseJson(reqBody);
5417+ if (!isRecord(parsed)) return null;
5418+ return trimToNull(parsed.conversation_id) || trimToNull(parsed.conversationId) || null;
5419+ }
5420+
5421+ function extractGeminiPromptFromReqBody(reqBody) {
5422+ if (typeof reqBody !== "string" || !reqBody) return null;
5423+
5424+ try {
5425+ const params = new URLSearchParams(reqBody);
5426+ const outerPayload = params.get("f.req");
5427+ if (!outerPayload) return null;
5428+
5429+ const outer = JSON.parse(outerPayload);
5430+ if (!Array.isArray(outer) || typeof outer[1] !== "string") return null;
5431+
5432+ const inner = JSON.parse(outer[1]);
5433+ if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
5434+
5435+ return trimToNull(inner[0][0]);
5436+ } catch (_) {
5437+ return null;
5438+ }
5439+ }
5440+
5441+ function parseSseChunkPayload(chunk) {
5442+ const source = String(chunk || "");
5443+ const dataLines = source
5444+ .split(/\r?\n/u)
5445+ .filter((line) => line.startsWith("data:"))
5446+ .map((line) => line.slice(5).trimStart());
5447+ const payloadText = dataLines.join("\n").trim();
5448+
5449+ if (!payloadText || payloadText === "[DONE]") {
5450+ return null;
5451+ }
5452+
5453+ return parseJson(payloadText) || parseJson(source.trim()) || null;
5454+ }
5455+
5456+ function parseSseChunkEvent(chunk) {
5457+ for (const rawLine of String(chunk || "").split(/\r?\n/u)) {
5458+ const line = rawLine.trim();
5459+ if (!line.startsWith("event:")) continue;
5460+ return trimToNull(line.slice(6));
5461+ }
5462+
5463+ return null;
5464+ }
5465+
5466+ function extractChatgptMessageText(message) {
5467+ if (!isRecord(message)) return null;
5468+
5469+ if (Array.isArray(message.content?.parts)) {
5470+ const parts = message.content.parts
5471+ .flatMap((entry) => flattenTextFragments(entry))
5472+ .filter(Boolean);
5473+ return normalizeMessageText(parts.join("\n"));
5474+ }
5475+
5476+ if (typeof message.content?.text === "string") {
5477+ return normalizeMessageText(message.content.text);
5478+ }
5479+
5480+ if (typeof message.text === "string") {
5481+ return normalizeMessageText(message.text);
5482+ }
5483+
5484+ const fragments = flattenTextFragments(message.content);
5485+ return normalizeMessageText(fragments.join("\n"));
5486+ }
5487+
5488+ function normalizeCandidatePath(path) {
5489+ return String(path || "").replace(/^\./u, "");
5490+ }
5491+
5492+ function isHistoricalChatgptPath(path) {
5493+ return /(?:^|\.)(?:history|items|linear_conversation|mapping|message_map|message_nodes|messages|nodes|entries|turns)(?:$|[.[\]])/iu
5494+ .test(normalizeCandidatePath(path));
5495+ }
5496+
5497+ function scoreChatgptCandidatePath(path) {
5498+ const normalizedPath = normalizeCandidatePath(path);
5499+ if (!normalizedPath) {
5500+ return 0;
5501+ }
5502+
5503+ let score = 0;
5504+
5505+ if (normalizedPath === "message") {
5506+ score += 280;
5507+ }
5508+
5509+ if (normalizedPath.endsWith(".message")) {
5510+ score += 140;
5511+ }
5512+
5513+ if (/(?:^|\.)(?:assistant|completion|current|message|output|response)(?:$|[.[\]])/iu.test(normalizedPath)) {
5514+ score += isHistoricalChatgptPath(normalizedPath) ? 0 : 120;
5515+ }
5516+
5517+ if (isHistoricalChatgptPath(normalizedPath)) {
5518+ score -= 260;
5519+ }
5520+
5521+ return score;
5522+ }
5523+
5524+ function buildChatgptCandidate(message, envelope, path, context) {
5525+ if (!isRecord(message)) return null;
5526+
5527+ const role = trimToNull(message.author?.role) || trimToNull(message.role);
5528+ if (role !== "assistant") {
5529+ return null;
5530+ }
5531+
5532+ const rawText = extractChatgptMessageText(message);
5533+ if (!rawText) {
5534+ return null;
5535+ }
5536+
5537+ const status = trimToNull(message.status)?.toLowerCase() || null;
5538+ const metadata = isRecord(message.metadata) ? message.metadata : {};
5539+ const terminal = Boolean(
5540+ (status && CHATGPT_TERMINAL_STATUSES.has(status))
5541+ || message.end_turn === true
5542+ || metadata.is_complete === true
5543+ || trimToNull(metadata.finish_details?.type)
5544+ || trimToNull(metadata.finish_details?.stop)
5545+ );
5546+ const conversationId =
5547+ trimToNull(envelope?.conversation_id)
5548+ || trimToNull(envelope?.conversationId)
5549+ || trimToNull(message.conversation_id)
5550+ || trimToNull(message.conversationId)
5551+ || extractChatgptConversationIdFromReqBody(context.reqBody)
5552+ || extractChatgptConversationIdFromUrl(context.pageUrl || context.url)
5553+ || null;
5554+ const assistantMessageId =
5555+ trimToNull(message.id)
5556+ || trimToNull(message.message_id)
5557+ || trimToNull(message.messageId)
5558+ || trimToNull(envelope?.message_id)
5559+ || trimToNull(envelope?.messageId)
5560+ || null;
5561+
5562+ let score = rawText.length + scoreChatgptCandidatePath(path);
5563+ if (assistantMessageId) score += 120;
5564+ if (conversationId) score += 80;
5565+ if (terminal) score += 160;
5566+ if ((path || "").includes(".message")) score += 40;
5567+
5568+ return {
5569+ assistantMessageId,
5570+ conversationId,
5571+ path: normalizeCandidatePath(path),
5572+ rawText,
5573+ score
5574+ };
5575+ }
5576+
5577+ function collectChatgptCandidates(root, context) {
5578+ const candidates = [];
5579+ const queue = [{ value: root, path: "", depth: 0 }];
5580+ let walked = 0;
5581+
5582+ while (queue.length > 0 && walked < MAX_WALK_NODES) {
5583+ const current = queue.shift();
5584+ walked += 1;
5585+ if (!current) continue;
5586+
5587+ const { depth, path, value } = current;
5588+ if (depth > MAX_WALK_DEPTH || value == null) {
5589+ continue;
5590+ }
5591+
5592+ if (Array.isArray(value)) {
5593+ for (let index = 0; index < value.length && index < 32; index += 1) {
5594+ queue.push({
5595+ value: value[index],
5596+ path: `${path}[${index}]`,
5597+ depth: depth + 1
5598+ });
5599+ }
5600+ continue;
5601+ }
5602+
5603+ if (!isRecord(value)) {
5604+ continue;
5605+ }
5606+
5607+ const directCandidate = buildChatgptCandidate(value, value, path, context);
5608+ if (directCandidate) {
5609+ candidates.push(directCandidate);
5610+ }
5611+
5612+ if (isRecord(value.message)) {
5613+ const wrappedCandidate = buildChatgptCandidate(value.message, value, `${path}.message`, context);
5614+ if (wrappedCandidate) {
5615+ candidates.push(wrappedCandidate);
5616+ }
5617+ }
5618+
5619+ for (const [key, child] of Object.entries(value).slice(0, 40)) {
5620+ queue.push({
5621+ value: child,
5622+ path: path ? `${path}.${key}` : key,
5623+ depth: depth + 1
5624+ });
5625+ }
5626+ }
5627+
5628+ return candidates.sort((left, right) => compareCandidates(left, right))[0] || null;
5629+ }
5630+
5631+ function compareCandidates(current, next) {
5632+ const currentScore = Number(current?.score) || 0;
5633+ const nextScore = Number(next?.score) || 0;
5634+ if (nextScore !== currentScore) {
5635+ return nextScore - currentScore;
5636+ }
5637+
5638+ const currentTextLength = (current?.rawText || "").length;
5639+ const nextTextLength = (next?.rawText || "").length;
5640+ if (nextTextLength !== currentTextLength) {
5641+ return nextTextLength - currentTextLength;
5642+ }
5643+
5644+ const currentAssistant = trimToNull(current?.assistantMessageId) ? 1 : 0;
5645+ const nextAssistant = trimToNull(next?.assistantMessageId) ? 1 : 0;
5646+ if (nextAssistant !== currentAssistant) {
5647+ return nextAssistant - currentAssistant;
5648+ }
5649+
5650+ const currentConversation = trimToNull(current?.conversationId) ? 1 : 0;
5651+ const nextConversation = trimToNull(next?.conversationId) ? 1 : 0;
5652+ return nextConversation - currentConversation;
5653+ }
5654+
5655+ function pickPreferredCandidate(current, next) {
5656+ if (!current) return next ? { ...next } : null;
5657+ if (!next) return { ...current };
5658+ return compareCandidates(current, next) > 0 ? { ...next } : { ...current };
5659+ }
5660+
5661+ function candidateTextsOverlap(current, next) {
5662+ const currentText = normalizeMessageText(current?.rawText);
5663+ const nextText = normalizeMessageText(next?.rawText);
5664+ if (!currentText || !nextText) {
5665+ return false;
5666+ }
5667+
5668+ return currentText === nextText || currentText.includes(nextText) || nextText.includes(currentText);
5669+ }
5670+
5671+ function shouldMergeCandidatePair(current, next) {
5672+ if (!current || !next) {
5673+ return true;
5674+ }
5675+
5676+ const currentAssistant = trimToNull(current?.assistantMessageId);
5677+ const nextAssistant = trimToNull(next?.assistantMessageId);
5678+ if (currentAssistant && nextAssistant) {
5679+ return currentAssistant === nextAssistant;
5680+ }
5681+
5682+ const currentConversation = trimToNull(current?.conversationId);
5683+ const nextConversation = trimToNull(next?.conversationId);
5684+ if (currentConversation && nextConversation && currentConversation !== nextConversation) {
5685+ return false;
5686+ }
5687+
5688+ if (candidateTextsOverlap(current, next)) {
5689+ return true;
5690+ }
5691+
5692+ if (!current?.rawText || !next?.rawText) {
5693+ return Boolean(currentAssistant || nextAssistant || (currentConversation && nextConversation));
5694+ }
5695+
5696+ return false;
5697+ }
5698+
5699+ function mergeCandidates(current, next) {
5700+ if (!next) return current;
5701+ if (!current) return { ...next };
5702+
5703+ if (!shouldMergeCandidatePair(current, next)) {
5704+ return pickPreferredCandidate(current, next);
5705+ }
5706+
5707+ return {
5708+ assistantMessageId: next.assistantMessageId || current.assistantMessageId || null,
5709+ conversationId: next.conversationId || current.conversationId || null,
5710+ path: next.path || current.path || "",
5711+ rawText:
5712+ (next.rawText && next.rawText.length >= (current.rawText || "").length)
5713+ ? next.rawText
5714+ : current.rawText,
5715+ score: Math.max(Number(current.score) || 0, Number(next.score) || 0)
5716+ };
5717+ }
5718+
5719+ function extractChatgptCandidateFromChunk(chunk, context) {
5720+ const payload = parseSseChunkPayload(chunk);
5721+ if (!payload) return null;
5722+ return collectChatgptCandidates(payload, context);
5723+ }
5724+
5725+ function extractChatgptCandidateFromText(text, context) {
5726+ const parsed = parseJson(text);
5727+ if (parsed != null) {
5728+ return collectChatgptCandidates(parsed, context);
5729+ }
5730+
5731+ let merged = null;
5732+ for (const block of splitSseBlocks(text)) {
5733+ merged = mergeCandidates(merged, extractChatgptCandidateFromChunk(block, context));
5734+ }
5735+ return merged;
5736+ }
5737+
5738+ function looksLikeUrl(text) {
5739+ return /^https?:\/\//iu.test(text);
5740+ }
5741+
5742+ function looksIdLike(text) {
5743+ if (!text || /\s/u.test(text) || looksLikeUrl(text)) return false;
5744+ return /^[A-Za-z0-9:_./-]{6,120}$/u.test(text);
5745+ }
5746+
5747+ function looksOpaqueGeminiTextToken(text) {
5748+ if (!text || /\s/u.test(text) || looksLikeUrl(text)) return false;
5749+
5750+ const normalized = String(text || "").trim();
5751+ if (!normalized) return false;
5752+
5753+ if (/^[0-9a-f]{8,}$/iu.test(normalized)) return true;
5754+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(normalized)) {
5755+ return true;
5756+ }
5757+ if (/^(?:msg|req|resp|conv|conversation|assistant|candidate|turn|chatcmpl|chatcompl|cmpl|id)[-_:][A-Za-z0-9:_-]{4,}$/iu.test(normalized)) {
5758+ return true;
5759+ }
5760+
5761+ const letters = (normalized.match(/[A-Za-z]/gu) || []).length;
5762+ const digits = (normalized.match(/[0-9]/gu) || []).length;
5763+ const separators = (normalized.match(/[-_:./]/gu) || []).length;
5764+
5765+ if (letters === 0 && digits >= 6) return true;
5766+ if (/^[A-Za-z0-9:_./-]{16,}$/u.test(normalized) && !/[aeiou]/iu.test(normalized) && digits > 0) {
5767+ return true;
5768+ }
5769+ if (digits >= 6 && separators >= 2 && letters <= 6) return true;
5770+
5771+ return false;
5772+ }
5773+
5774+ function looksLikePromptEcho(text, prompt) {
5775+ const normalizedText = normalizeMessageText(text);
5776+ const normalizedPrompt = normalizeMessageText(prompt);
5777+ if (!normalizedText || !normalizedPrompt) return false;
5778+ if (normalizedText === normalizedPrompt) return true;
5779+
5780+ const wordCount = normalizedText.split(/\s+/u).filter(Boolean).length;
5781+ return wordCount >= 4 && normalizedText.length >= 32 && normalizedPrompt.includes(normalizedText);
5782+ }
5783+
5784+ function looksLikeGeminiProtocolFragment(text) {
5785+ const normalized = normalizeMessageText(text);
5786+ if (!normalized || !/^(?:\[|\{)/u.test(normalized)) return false;
5787+
5788+ const bracketCount = (normalized.match(/[\[\]\{\}]/gu) || []).length;
5789+ const digitCount = (normalized.match(/[0-9]/gu) || []).length;
5790+ const alphaCount = (normalized.match(/[A-Za-z]/gu) || []).length;
5791+
5792+ if (/\b(?:wrb\.fr|generic|af\.httprm|di)\b/iu.test(normalized)) return true;
5793+ if (bracketCount >= 6 && digitCount >= 4 && alphaCount <= Math.max(12, Math.floor(normalized.length / 6))) {
5794+ return true;
5795+ }
5796+
5797+ return false;
5798+ }
5799+
5800+ function readGeminiLineRoots(text) {
5801+ const roots = [];
5802+ const source = String(text || "")
5803+ .replace(/^\)\]\}'\s*/u, "")
5804+ .trim();
5805+
5806+ if (!source) {
5807+ return roots;
5808+ }
5809+
5810+ const wholePayload = parseJson(source);
5811+ if (wholePayload != null) {
5812+ roots.push(wholePayload);
5813+ }
5814+
5815+ for (const rawLine of source.split(/\r?\n/u)) {
5816+ const line = rawLine.trim();
5817+ if (!line || /^\d+$/u.test(line)) continue;
5818+
5819+ const parsedLine = parseJson(line);
5820+ if (parsedLine != null) {
5821+ roots.push(parsedLine);
5822+ }
5823+ }
5824+
5825+ return roots;
5826+ }
5827+
5828+ function collectGeminiIdCandidate(target, kind, value) {
5829+ const normalized = trimToNull(value);
5830+ if (!normalized || !looksIdLike(normalized)) return;
5831+ target.push({
5832+ kind,
5833+ score: kind === "message" ? 120 : 100,
5834+ value: normalized
5835+ });
5836+ }
5837+
5838+ function scoreGeminiTextCandidate(text, path, prompt) {
5839+ if (!text) return -1;
5840+
5841+ const normalizedText = normalizeMessageText(text);
5842+ if (!normalizedText) return -1;
5843+
5844+ if (looksLikePromptEcho(normalizedText, prompt)) return -1;
5845+ if (looksLikeUrl(normalizedText)) return -1;
5846+ if (looksOpaqueGeminiTextToken(normalizedText)) return -1;
5847+ if (looksLikeGeminiProtocolFragment(normalizedText)) return -1;
5848+ if (/^(wrb\.fr|generic|di|af\.httprm)$/iu.test(normalizedText)) return -1;
5849+ if (/^[\[\]{}",:0-9.\s_-]+$/u.test(normalizedText)) return -1;
5850+
5851+ const lowerPath = String(path || "").toLowerCase();
5852+ let score = Math.min(normalizedText.length, 220);
5853+
5854+ if (/\s/u.test(normalizedText)) score += 60;
5855+ if (/[A-Za-z\u4e00-\u9fff]/u.test(normalizedText)) score += 50;
5856+ if (/\n/u.test(normalizedText)) score += 30;
5857+ if (/(text|content|message|response|answer|markdown|candidate)/u.test(lowerPath)) score += 80;
5858+ if (/(prompt|query|request|input|user)/u.test(lowerPath)) score -= 90;
5859+ if (/(url|image|token|safety|metadata|source)/u.test(lowerPath)) score -= 40;
5860+
5861+ return score;
5862+ }
5863+
5864+ function maybeParseNestedJson(text) {
5865+ const normalized = trimToNull(text);
5866+ if (!normalized || normalized.length > 200_000) return null;
5867+ if (!/^(?:\[|\{)/u.test(normalized)) return null;
5868+ return parseJson(normalized);
5869+ }
5870+
5871+ function walkGeminiValue(value, context, bucket, path = "", depth = 0, state = { walked: 0 }) {
5872+ if (depth > MAX_WALK_DEPTH || state.walked >= MAX_WALK_NODES || value == null) {
5873+ return;
5874+ }
5875+
5876+ state.walked += 1;
5877+
5878+ if (typeof value === "string") {
5879+ const nested = maybeParseNestedJson(value);
5880+ if (nested != null) {
5881+ walkGeminiValue(nested, context, bucket, `${path}.$json`, depth + 1, state);
5882+ }
5883+
5884+ const normalized = normalizeMessageText(value);
5885+ if (!normalized) return;
5886+ if (nested != null && /^(?:\[|\{)/u.test(normalized)) return;
5887+
5888+ const score = scoreGeminiTextCandidate(normalized, path, context.prompt);
5889+ if (score > 0) {
5890+ bucket.texts.push({
5891+ path,
5892+ score,
5893+ text: normalized
5894+ });
5895+ }
5896+ return;
5897+ }
5898+
5899+ if (Array.isArray(value)) {
5900+ for (let index = 0; index < value.length && index < 40; index += 1) {
5901+ walkGeminiValue(value[index], context, bucket, `${path}[${index}]`, depth + 1, state);
5902+ }
5903+ return;
5904+ }
5905+
5906+ if (!isRecord(value)) {
5907+ return;
5908+ }
5909+
5910+ for (const [key, child] of Object.entries(value).slice(0, 40)) {
5911+ const nextPath = path ? `${path}.${key}` : key;
5912+ const lowerKey = key.toLowerCase();
5913+
5914+ if (typeof child === "string") {
5915+ if (/(conversation|conv|chat).*id/u.test(lowerKey)) {
5916+ collectGeminiIdCandidate(bucket.conversationIds, "conversation", child);
5917+ } else if (/(message|response|candidate|turn).*id/u.test(lowerKey)) {
5918+ collectGeminiIdCandidate(bucket.messageIds, "message", child);
5919+ } else if (lowerKey === "id") {
5920+ collectGeminiIdCandidate(bucket.genericIds, "generic", child);
5921+ }
5922+ }
5923+
5924+ walkGeminiValue(child, context, bucket, nextPath, depth + 1, state);
5925+ }
5926+ }
5927+
5928+ function pickBestId(primary, secondary = []) {
5929+ const source = [...primary, ...secondary]
5930+ .sort((left, right) => right.score - left.score)
5931+ .map((entry) => entry.value);
5932+
5933+ return trimToNull(source[0]) || null;
5934+ }
5935+
5936+ function extractGeminiCandidateFromText(text, context) {
5937+ const roots = readGeminiLineRoots(text);
5938+ const bucket = {
5939+ conversationIds: [],
5940+ genericIds: [],
5941+ messageIds: [],
5942+ texts: []
5943+ };
5944+ const geminiContext = {
5945+ pageUrl: context.pageUrl || context.url || "",
5946+ prompt: extractGeminiPromptFromReqBody(context.reqBody)
5947+ };
5948+
5949+ if (roots.length > 0) {
5950+ for (const root of roots) {
5951+ walkGeminiValue(root, geminiContext, bucket);
5952+ }
5953+ } else {
5954+ walkGeminiValue(String(text || ""), geminiContext, bucket);
5955+ }
5956+
5957+ const bestText = bucket.texts.sort((left, right) =>
5958+ (right.score - left.score) || (right.text.length - left.text.length)
5959+ )[0] || null;
5960+
5961+ if (!bestText?.text) {
5962+ return null;
5963+ }
5964+
5965+ return {
5966+ assistantMessageId: pickBestId(bucket.messageIds, bucket.genericIds),
5967+ conversationId: extractGeminiConversationIdFromUrl(geminiContext.pageUrl) || pickBestId(bucket.conversationIds),
5968+ rawText: bestText.text,
5969+ score: bestText.score
5970+ };
5971+ }
5972+
5973+ function parseClaudeSsePayload(chunk) {
5974+ const payload = parseSseChunkPayload(chunk);
5975+ if (!isRecord(payload)) return null;
5976+
5977+ const payloadType = trimToNull(payload.type)?.toLowerCase() || null;
5978+ const eventType = trimToNull(parseSseChunkEvent(chunk))?.toLowerCase() || null;
5979+ if (!payloadType && eventType !== "completion") {
5980+ return null;
5981+ }
5982+
5983+ if (payloadType && !CLAUDE_SSE_PAYLOAD_TYPES.has(payloadType) && eventType !== "completion") {
5984+ return null;
5985+ }
5986+
5987+ return {
5988+ eventType,
5989+ payload,
5990+ payloadType
5991+ };
5992+ }
5993+
5994+ function extractClaudeAssistantMessageId(payload) {
5995+ return trimToNull(payload?.id)
5996+ || trimToNull(payload?.uuid)
5997+ || trimToNull(payload?.message_id)
5998+ || trimToNull(payload?.messageId)
5999+ || trimToNull(payload?.message?.uuid)
6000+ || trimToNull(payload?.message?.id)
6001+ || trimToNull(payload?.message?.message_id)
6002+ || trimToNull(payload?.message?.messageId)
6003+ || null;
6004+ }
6005+
6006+ function extractClaudeConversationId(payload, context) {
6007+ return trimToNull(payload?.conversation_id)
6008+ || trimToNull(payload?.conversationId)
6009+ || trimToNull(payload?.conversation_uuid)
6010+ || trimToNull(payload?.conversationUuid)
6011+ || trimToNull(payload?.conversation?.id)
6012+ || trimToNull(payload?.conversation?.uuid)
6013+ || trimToNull(payload?.message?.conversation_id)
6014+ || trimToNull(payload?.message?.conversationId)
6015+ || trimToNull(payload?.message?.conversation_uuid)
6016+ || trimToNull(payload?.message?.conversationUuid)
6017+ || trimToNull(payload?.message?.conversation?.id)
6018+ || trimToNull(payload?.message?.conversation?.uuid)
6019+ || extractClaudeConversationIdFromUrl(context.url)
6020+ || extractClaudeConversationIdFromUrl(context.pageUrl)
6021+ || null;
6022+ }
6023+
6024+ function extractClaudeTextFragment(parsedChunk) {
6025+ if (!parsedChunk?.payload || !isRecord(parsedChunk.payload)) {
6026+ return null;
6027+ }
6028+
6029+ if (parsedChunk.payloadType === "completion" || (!parsedChunk.payloadType && parsedChunk.eventType === "completion")) {
6030+ if (typeof parsedChunk.payload.completion !== "string") {
6031+ return null;
6032+ }
6033+
6034+ return {
6035+ kind: "completion",
6036+ text: parsedChunk.payload.completion
6037+ };
6038+ }
6039+
6040+ if (parsedChunk.payloadType === "content_block_delta") {
6041+ const deltaType = trimToNull(parsedChunk.payload.delta?.type)?.toLowerCase() || null;
6042+ if (deltaType !== "text_delta" || typeof parsedChunk.payload.delta.text !== "string") {
6043+ return null;
6044+ }
6045+
6046+ return {
6047+ kind: "text_delta",
6048+ text: parsedChunk.payload.delta.text
6049+ };
6050+ }
6051+
6052+ return null;
6053+ }
6054+
6055+ function buildClaudeCandidate(rawText, payload, context) {
6056+ const assistantMessageId = extractClaudeAssistantMessageId(payload);
6057+ const conversationId = extractClaudeConversationId(payload, context);
6058+ const normalizedRawText = typeof rawText === "string" && rawText.length > 0 ? rawText : null;
6059+
6060+ if (!normalizedRawText && !assistantMessageId && !conversationId) {
6061+ return null;
6062+ }
6063+
6064+ let score = normalizedRawText ? normalizedRawText.length : 0;
6065+ if (assistantMessageId) score += 120;
6066+ if (conversationId) score += 80;
6067+
6068+ return {
6069+ assistantMessageId,
6070+ conversationId,
6071+ rawText: normalizedRawText,
6072+ score
6073+ };
6074+ }
6075+
6076+ function extractClaudeMetadataFromText(text, context) {
6077+ let merged = null;
6078+
6079+ for (const block of splitSseBlocks(text)) {
6080+ const parsedChunk = parseClaudeSsePayload(block);
6081+ if (!parsedChunk) continue;
6082+ merged = mergeCandidates(merged, buildClaudeCandidate(null, parsedChunk.payload, context));
6083+ }
6084+
6085+ return merged;
6086+ }
6087+
6088+ function extractClaudeCandidateFromText(text, context) {
6089+ let completionText = "";
6090+ let deltaText = "";
6091+ let metadata = null;
6092+ let matched = false;
6093+
6094+ for (const block of splitSseBlocks(text)) {
6095+ const parsedChunk = parseClaudeSsePayload(block);
6096+ if (!parsedChunk) continue;
6097+
6098+ matched = true;
6099+ metadata = mergeCandidates(metadata, buildClaudeCandidate(null, parsedChunk.payload, context));
6100+
6101+ const fragment = extractClaudeTextFragment(parsedChunk);
6102+ if (!fragment) {
6103+ continue;
6104+ }
6105+
6106+ if (fragment.kind === "completion") {
6107+ completionText += fragment.text;
6108+ } else {
6109+ deltaText += fragment.text;
6110+ }
6111+ }
6112+
6113+ if (!matched) {
6114+ return null;
6115+ }
6116+
6117+ const preferredText = normalizeMessageText(completionText) || normalizeMessageText(deltaText) || null;
6118+ return buildClaudeCandidate(
6119+ preferredText,
6120+ {
6121+ conversation_id: metadata?.conversationId,
6122+ id: metadata?.assistantMessageId
6123+ },
6124+ context
6125+ ) || metadata;
6126+ }
6127+
6128+ function createRelayState(platform) {
6129+ return {
6130+ activeStream: null,
6131+ platform,
6132+ recentRelayKeys: []
6133+ };
6134+ }
6135+
6136+ function isRelevantStreamUrl(platform, url) {
6137+ const lower = String(url || "").toLowerCase();
6138+ const pathname = extractUrlPathname(url);
6139+
6140+ if (platform === "chatgpt") {
6141+ return /^\/conversation\/?$/iu.test(pathname)
6142+ || /^\/(?:backend-api|backend-anon|public-api)\/conversation\/?$/iu.test(pathname)
6143+ || /^\/(?:backend-api|backend-anon|public-api)\/f\/conversation\/?$/iu.test(pathname);
6144+ }
6145+
6146+ if (platform === "claude") {
6147+ return lower.includes("/completion");
6148+ }
6149+
6150+ if (platform === "gemini") {
6151+ return lower.includes("streamgenerate")
6152+ || lower.includes("generatecontent")
6153+ || lower.includes("modelresponse")
6154+ || lower.includes("bardchatui");
6155+ }
6156+
6157+ return false;
6158+ }
6159+
6160+ function buildStreamSignature(state, detail, meta) {
6161+ return [
6162+ state.platform,
6163+ normalizeUrlForSignature(detail.url),
6164+ simpleHash(detail.reqBody || ""),
6165+ normalizeUrlForSignature(meta.pageUrl || "")
6166+ ].join("|");
6167+ }
6168+
6169+ function ensureActiveStream(state, detail, meta) {
6170+ const signature = buildStreamSignature(state, detail, meta);
6171+
6172+ if (!state.activeStream || state.activeStream.signature !== signature) {
6173+ state.activeStream = {
6174+ chunks: [],
6175+ latestCandidate: null,
6176+ pageUrl: meta.pageUrl || "",
6177+ reqBody: detail.reqBody || "",
6178+ signature,
6179+ url: detail.url || ""
6180+ };
6181+ }
6182+
6183+ return state.activeStream;
6184+ }
6185+
6186+ function extractSseCandidateFromText(platform, text, context) {
6187+ if (platform === "chatgpt") {
6188+ return extractChatgptCandidateFromText(text, context);
6189+ }
6190+
6191+ if (platform === "claude") {
6192+ return extractClaudeCandidateFromText(text, context);
6193+ }
6194+
6195+ return extractGeminiCandidateFromText(text, context);
6196+ }
6197+
6198+ function finalizeObservedSseRelay(state, stream, meta = {}) {
6199+ if (!state || !stream) {
6200+ return null;
6201+ }
6202+
6203+ const context = {
6204+ pageUrl: stream.pageUrl,
6205+ reqBody: stream.reqBody,
6206+ url: stream.url
6207+ };
6208+ const finalCandidate = extractSseCandidateFromText(
6209+ state.platform,
6210+ stream.chunks.join("\n\n"),
6211+ context
6212+ );
6213+ const relay = buildRelayEnvelope(
6214+ state.platform,
6215+ mergeCandidates(stream.latestCandidate, finalCandidate),
6216+ meta.observedAt
6217+ );
6218+
6219+ state.activeStream = null;
6220+ if (!relay || hasSeenRelay(state, relay)) {
6221+ return null;
6222+ }
6223+
6224+ return relay;
6225+ }
6226+
6227+ function buildRelayEnvelope(platform, candidate, observedAt) {
6228+ const rawText = normalizeMessageText(candidate?.rawText);
6229+ if (!rawText) return null;
6230+
6231+ const conversationId = trimToNull(candidate?.conversationId) || null;
6232+ const assistantMessageId =
6233+ trimToNull(candidate?.assistantMessageId)
6234+ || `synthetic_${simpleHash(`${platform}|${conversationId || "-"}|${rawText}`)}`;
6235+ const dedupeKey = `${platform}|${conversationId || "-"}|${assistantMessageId}|${rawText}`;
6236+
6237+ return {
6238+ dedupeKey,
6239+ payload: {
6240+ type: "browser.final_message",
6241+ platform,
6242+ conversation_id: conversationId,
6243+ assistant_message_id: assistantMessageId,
6244+ raw_text: rawText,
6245+ observed_at: Number.isFinite(observedAt) ? Math.round(observedAt) : Date.now()
6246+ }
6247+ };
6248+ }
6249+
6250+ function hasSeenRelay(state, relay) {
6251+ if (!relay?.dedupeKey) return false;
6252+ return state.recentRelayKeys.includes(relay.dedupeKey);
6253+ }
6254+
6255+ function rememberRelay(state, relay) {
6256+ if (!relay?.dedupeKey || hasSeenRelay(state, relay)) {
6257+ return false;
6258+ }
6259+
6260+ state.recentRelayKeys.push(relay.dedupeKey);
6261+ if (state.recentRelayKeys.length > RECENT_RELAY_LIMIT) {
6262+ state.recentRelayKeys.splice(0, state.recentRelayKeys.length - RECENT_RELAY_LIMIT);
6263+ }
6264+ return true;
6265+ }
6266+
6267+ function observeSse(state, detail, meta = {}) {
6268+ if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
6269+ return null;
6270+ }
6271+
6272+ const stream = ensureActiveStream(state, detail, meta);
6273+ const context = {
6274+ pageUrl: stream.pageUrl,
6275+ reqBody: stream.reqBody,
6276+ url: stream.url
6277+ };
6278+
6279+ if (typeof detail.chunk === "string" && detail.chunk) {
6280+ stream.chunks.push(detail.chunk);
6281+
6282+ if (state.platform === "chatgpt") {
6283+ stream.latestCandidate = mergeCandidates(
6284+ stream.latestCandidate,
6285+ extractChatgptCandidateFromChunk(detail.chunk, context)
6286+ );
6287+ } else if (state.platform === "claude") {
6288+ stream.latestCandidate = mergeCandidates(
6289+ stream.latestCandidate,
6290+ extractClaudeMetadataFromText(detail.chunk, context)
6291+ );
6292+ }
6293+ }
6294+
6295+ if (detail.done === true) {
6296+ return finalizeObservedSseRelay(state, stream, meta);
6297+ }
6298+
6299+ if (detail.error) {
6300+ if (!stream.latestCandidate && stream.chunks.length === 0) {
6301+ state.activeStream = null;
6302+ return null;
6303+ }
6304+
6305+ return finalizeObservedSseRelay(state, stream, meta);
6306+ }
6307+
6308+ if (detail.done !== true) {
6309+ return null;
6310+ }
6311+
6312+ return null;
6313+ }
6314+
6315+ function observeNetwork(state, detail, meta = {}) {
6316+ if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
6317+ return null;
6318+ }
6319+
6320+ if (typeof detail.resBody !== "string" || !detail.resBody) {
6321+ return null;
6322+ }
6323+
6324+ const context = {
6325+ pageUrl: meta.pageUrl || "",
6326+ reqBody: detail.reqBody || "",
6327+ url: detail.url || ""
6328+ };
6329+ let candidate = null;
6330+ if (state.platform === "chatgpt") {
6331+ candidate = extractChatgptCandidateFromText(detail.resBody, context);
6332+ } else if (state.platform === "claude") {
6333+ candidate = extractClaudeCandidateFromText(detail.resBody, context);
6334+ } else {
6335+ candidate = extractGeminiCandidateFromText(detail.resBody, context);
6336+ }
6337+ const relay = buildRelayEnvelope(state.platform, candidate, meta.observedAt);
6338+
6339+ if (!relay || hasSeenRelay(state, relay)) {
6340+ return null;
6341+ }
6342+
6343+ return relay;
6344+ }
6345+
6346+ const api = {
6347+ createRelayState,
6348+ extractClaudeCandidateFromText,
6349+ extractChatgptCandidateFromChunk,
6350+ extractChatgptCandidateFromText,
6351+ extractGeminiCandidateFromText,
6352+ isRelevantStreamUrl,
6353+ observeNetwork,
6354+ observeSse,
6355+ rememberRelay
6356+ };
6357+
6358+ if (typeof module !== "undefined" && module.exports) {
6359+ module.exports = api;
6360+ }
6361+
6362+ globalScope.BAAFinalMessage = api;
6363+})(typeof globalThis !== "undefined" ? globalThis : this);
6364diff --git a/plugins/baa-safari/final-message.test.cjs b/plugins/baa-safari/final-message.test.cjs
6365new file mode 100644
6366index 0000000000000000000000000000000000000000..034462345c8bbe2d02e74abeaaea59baf8054e48
6367--- /dev/null
6368+++ b/plugins/baa-safari/final-message.test.cjs
6369@@ -0,0 +1,77 @@
6370+const assert = require("node:assert/strict");
6371+const test = require("node:test");
6372+
6373+const {
6374+ createRelayState,
6375+ isRelevantStreamUrl,
6376+ observeSse
6377+} = require("./final-message.js");
6378+
6379+function buildChatgptChunk({
6380+ assistantMessageId = "msg_abort",
6381+ conversationId = "conv_abort",
6382+ text = "@conductor::status"
6383+} = {}) {
6384+ return `data: ${JSON.stringify({
6385+ conversation_id: conversationId,
6386+ message: {
6387+ author: {
6388+ role: "assistant"
6389+ },
6390+ content: {
6391+ parts: [text]
6392+ },
6393+ end_turn: true,
6394+ id: assistantMessageId
6395+ }
6396+ })}`;
6397+}
6398+
6399+test("Safari final-message helper only accepts ChatGPT root conversation streams", () => {
6400+ assert.equal(
6401+ isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/conversation"),
6402+ true
6403+ );
6404+ assert.equal(
6405+ isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/f/conversation?oai-device-id=test"),
6406+ true
6407+ );
6408+ assert.equal(
6409+ isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/conversation/implicit_message_feedback"),
6410+ false
6411+ );
6412+});
6413+
6414+test("Safari final-message helper recovers ChatGPT final message after aborted SSE", () => {
6415+ const state = createRelayState("chatgpt");
6416+ const meta = {
6417+ observedAt: 1743206400000,
6418+ pageUrl: "https://chatgpt.com/c/conv_abort"
6419+ };
6420+ const url = "https://chatgpt.com/backend-api/f/conversation";
6421+ const reqBody = JSON.stringify({
6422+ conversation_id: "conv_abort"
6423+ });
6424+
6425+ assert.equal(
6426+ observeSse(state, {
6427+ chunk: buildChatgptChunk(),
6428+ reqBody,
6429+ url
6430+ }, meta),
6431+ null
6432+ );
6433+
6434+ const relay = observeSse(state, {
6435+ error: "The operation was aborted.",
6436+ reqBody,
6437+ url
6438+ }, meta);
6439+
6440+ assert.ok(relay);
6441+ assert.equal(relay.payload.type, "browser.final_message");
6442+ assert.equal(relay.payload.platform, "chatgpt");
6443+ assert.equal(relay.payload.conversation_id, "conv_abort");
6444+ assert.equal(relay.payload.assistant_message_id, "msg_abort");
6445+ assert.equal(relay.payload.raw_text, "@conductor::status");
6446+});
6447diff --git a/plugins/baa-safari/icons/128.png b/plugins/baa-safari/icons/128.png
6448new file mode 100644
6449index 0000000000000000000000000000000000000000..423b491de9f0c035d4c9d7736c535bebfe078c8e
6450Binary files /dev/null and b/plugins/baa-safari/icons/128.png differ
6451diff --git a/plugins/baa-safari/icons/48.png b/plugins/baa-safari/icons/48.png
6452new file mode 100644
6453index 0000000000000000000000000000000000000000..e5bb1f5ad3a8b1842c8a6020d85f3aab85586956
6454Binary files /dev/null and b/plugins/baa-safari/icons/48.png differ
6455diff --git a/plugins/baa-safari/icons/96.png b/plugins/baa-safari/icons/96.png
6456new file mode 100644
6457index 0000000000000000000000000000000000000000..604f9c2730425810c11ca374123c4819bc82b9a4
6458Binary files /dev/null and b/plugins/baa-safari/icons/96.png differ
6459diff --git a/plugins/baa-safari/manifest.json b/plugins/baa-safari/manifest.json
6460new file mode 100644
6461index 0000000000000000000000000000000000000000..2549cf5cfb32d75dd8d88af6b068a39a7169d9df
6462--- /dev/null
6463+++ b/plugins/baa-safari/manifest.json
6464@@ -0,0 +1,56 @@
6465+{
6466+ "manifest_version": 2,
6467+ "name": "BAA Safari",
6468+ "version": "0.1.0",
6469+ "description": "Safari page bridge foundation for BAA conductor integration.",
6470+ "icons": {
6471+ "48": "icons/48.png",
6472+ "96": "icons/96.png",
6473+ "128": "icons/128.png"
6474+ },
6475+ "permissions": [
6476+ "storage",
6477+ "tabs",
6478+ "webNavigation",
6479+ "webRequest",
6480+ "https://claude.ai/*",
6481+ "https://chatgpt.com/*",
6482+ "https://*.chatgpt.com/*",
6483+ "https://openai.com/*",
6484+ "https://*.openai.com/*",
6485+ "https://chat.openai.com/*",
6486+ "https://*.chat.openai.com/*",
6487+ "https://oaiusercontent.com/*",
6488+ "https://*.oaiusercontent.com/*",
6489+ "https://gemini.google.com/*"
6490+ ],
6491+ "background": {
6492+ "scripts": [
6493+ "final-message.js",
6494+ "background.js"
6495+ ],
6496+ "persistent": true
6497+ },
6498+ "browser_action": {
6499+ "default_title": "BAA Safari"
6500+ },
6501+ "content_scripts": [
6502+ {
6503+ "matches": [
6504+ "https://claude.ai/*",
6505+ "https://chatgpt.com/*",
6506+ "https://*.chatgpt.com/*",
6507+ "https://chat.openai.com/*",
6508+ "https://*.chat.openai.com/*",
6509+ "https://gemini.google.com/*"
6510+ ],
6511+ "js": [
6512+ "content-script.js"
6513+ ],
6514+ "run_at": "document_start"
6515+ }
6516+ ],
6517+ "web_accessible_resources": [
6518+ "page-interceptor.js"
6519+ ]
6520+}
6521diff --git a/plugins/baa-safari/page-interceptor.js b/plugins/baa-safari/page-interceptor.js
6522new file mode 100644
6523index 0000000000000000000000000000000000000000..a152576dd18ff6490244232600c8506613ba57c2
6524--- /dev/null
6525+++ b/plugins/baa-safari/page-interceptor.js
6526@@ -0,0 +1,895 @@
6527+(function () {
6528+ const previousRuntime = window.__baaSafariIntercepted__;
6529+ const hasManagedRuntime = !!previousRuntime
6530+ && typeof previousRuntime === "object"
6531+ && typeof previousRuntime.teardown === "function";
6532+ const legacyIntercepted = previousRuntime === true
6533+ || (hasManagedRuntime && previousRuntime.legacyIntercepted === true);
6534+
6535+ if (hasManagedRuntime) {
6536+ try {
6537+ previousRuntime.teardown();
6538+ } catch (_) {}
6539+ }
6540+
6541+ const BODY_LIMIT = 5000;
6542+ const originalFetch = hasManagedRuntime ? previousRuntime.originalFetch : window.fetch;
6543+ const originalXhrOpen = hasManagedRuntime ? previousRuntime.originalXhrOpen : XMLHttpRequest.prototype.open;
6544+ const originalXhrSend = hasManagedRuntime ? previousRuntime.originalXhrSend : XMLHttpRequest.prototype.send;
6545+ const originalXhrSetRequestHeader = hasManagedRuntime ? previousRuntime.originalXhrSetRequestHeader : XMLHttpRequest.prototype.setRequestHeader;
6546+ const activeProxyControllers = new Map();
6547+ const cleanupHandlers = [];
6548+
6549+ function addWindowListener(type, listener) {
6550+ window.addEventListener(type, listener);
6551+ cleanupHandlers.push(() => {
6552+ window.removeEventListener(type, listener);
6553+ });
6554+ }
6555+
6556+ function hostnameMatches(hostname, hosts) {
6557+ return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
6558+ }
6559+
6560+ function isLikelyStaticPath(pathname = "") {
6561+ const lower = pathname.toLowerCase();
6562+ return lower.startsWith("/_next/")
6563+ || lower.startsWith("/assets/")
6564+ || lower.startsWith("/static/")
6565+ || lower.startsWith("/images/")
6566+ || lower.startsWith("/fonts/")
6567+ || lower.startsWith("/favicon")
6568+ || /\.(?:js|mjs|css|map|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|mp4|webm|txt)$/i.test(lower);
6569+ }
6570+
6571+ const PLATFORM_RULES = [
6572+ {
6573+ platform: "claude",
6574+ pageHosts: ["claude.ai"],
6575+ requestHosts: ["claude.ai"],
6576+ matchesPageHost(hostname) {
6577+ return hostnameMatches(hostname, this.pageHosts);
6578+ },
6579+ matchesRequestHost(hostname) {
6580+ return hostnameMatches(hostname, this.requestHosts);
6581+ },
6582+ shouldTrack(pathname) {
6583+ return pathname.includes("/api/");
6584+ },
6585+ isSse(pathname, contentType) {
6586+ return contentType.includes("text/event-stream") || pathname.endsWith("/completion");
6587+ }
6588+ },
6589+ {
6590+ platform: "chatgpt",
6591+ pageHosts: ["chatgpt.com", "chat.openai.com"],
6592+ requestHosts: ["chatgpt.com", "chat.openai.com", "openai.com", "oaiusercontent.com"],
6593+ matchesPageHost(hostname) {
6594+ return hostnameMatches(hostname, this.pageHosts);
6595+ },
6596+ matchesRequestHost(hostname) {
6597+ return hostnameMatches(hostname, this.requestHosts);
6598+ },
6599+ shouldTrack(pathname) {
6600+ const lower = pathname.toLowerCase();
6601+ return pathname.includes("/backend-api/")
6602+ || pathname.includes("/backend-anon/")
6603+ || pathname.includes("/public-api/")
6604+ || lower.includes("/conversation")
6605+ || lower.includes("/models")
6606+ || lower.includes("/files")
6607+ || (!isLikelyStaticPath(pathname) && (lower.includes("/api/") || lower.includes("/backend")));
6608+ },
6609+ isSse(pathname, contentType) {
6610+ const lower = pathname.toLowerCase();
6611+ return contentType.includes("text/event-stream")
6612+ || lower.includes("/conversation")
6613+ || lower.includes("/backend-api/conversation");
6614+ }
6615+ },
6616+ {
6617+ platform: "gemini",
6618+ pageHosts: ["gemini.google.com"],
6619+ requestHosts: ["gemini.google.com"],
6620+ matchesPageHost(hostname) {
6621+ return hostnameMatches(hostname, this.pageHosts);
6622+ },
6623+ matchesRequestHost(hostname) {
6624+ return hostnameMatches(hostname, this.requestHosts);
6625+ },
6626+ shouldTrack(pathname) {
6627+ const lower = pathname.toLowerCase();
6628+ return pathname.startsWith("/_/")
6629+ || pathname.includes("/api/")
6630+ || lower.includes("bardchatui")
6631+ || lower.includes("streamgenerate")
6632+ || lower.includes("generatecontent")
6633+ || lower.includes("modelresponse")
6634+ || (!isLikelyStaticPath(pathname) && lower.includes("assistant"));
6635+ },
6636+ isSse(pathname, contentType) {
6637+ return contentType.includes("text/event-stream") || pathname.toLowerCase().includes("streamgenerate");
6638+ }
6639+ }
6640+ ];
6641+
6642+ function findPageRule(hostname) {
6643+ return PLATFORM_RULES.find((rule) => rule.matchesPageHost(hostname)) || null;
6644+ }
6645+
6646+ function findRequestRule(hostname) {
6647+ return PLATFORM_RULES.find((rule) => rule.matchesRequestHost(hostname)) || null;
6648+ }
6649+
6650+ const pageRule = findPageRule(location.hostname);
6651+ if (!pageRule) return;
6652+
6653+ function getRequestContext(url) {
6654+ try {
6655+ const parsed = new URL(url, location.href);
6656+ const rule = findRequestRule(parsed.hostname);
6657+ if (!rule || rule.platform !== pageRule.platform) return null;
6658+ return { parsed, rule };
6659+ } catch (_) {
6660+ return null;
6661+ }
6662+ }
6663+
6664+ function shouldTrack(url) {
6665+ const context = getRequestContext(url);
6666+ return context ? context.rule.shouldTrack(context.parsed.pathname) : false;
6667+ }
6668+
6669+ function readHeaders(headersLike) {
6670+ const out = {};
6671+ try {
6672+ const headers = new Headers(headersLike || {});
6673+ headers.forEach((value, key) => {
6674+ out[key] = value;
6675+ });
6676+ } catch (_) {}
6677+ return out;
6678+ }
6679+
6680+ function readRawHeaders(rawHeaders) {
6681+ const out = {};
6682+ for (const line of String(rawHeaders || "").split(/\r?\n/)) {
6683+ const index = line.indexOf(":");
6684+ if (index <= 0) continue;
6685+ const name = line.slice(0, index).trim().toLowerCase();
6686+ const value = line.slice(index + 1).trim();
6687+ if (name) out[name] = value;
6688+ }
6689+ return out;
6690+ }
6691+
6692+ function trim(text) {
6693+ if (text == null) return null;
6694+ return text.length > BODY_LIMIT ? text.slice(0, BODY_LIMIT) : text;
6695+ }
6696+
6697+ function trimToNull(value) {
6698+ if (typeof value !== "string") return null;
6699+ const normalized = value.trim();
6700+ return normalized ? normalized : null;
6701+ }
6702+
6703+ function describeError(error) {
6704+ if (typeof error === "string") {
6705+ return trimToNull(error) || "unknown_error";
6706+ }
6707+
6708+ return trimToNull(error?.message) || String(error || "unknown_error");
6709+ }
6710+
6711+ function emit(type, detail, rule = pageRule) {
6712+ window.dispatchEvent(new CustomEvent(type, {
6713+ detail: {
6714+ platform: rule.platform,
6715+ ...detail
6716+ }
6717+ }));
6718+ }
6719+
6720+ function emitNet(detail, rule = pageRule) {
6721+ emit("__baa_net__", detail, rule);
6722+ }
6723+
6724+ function emitSse(detail, rule = pageRule) {
6725+ emit("__baa_sse__", detail, rule);
6726+ }
6727+
6728+ function emitDiagnostic(event, detail = {}, rule = pageRule) {
6729+ emit("__baa_diagnostic__", {
6730+ event,
6731+ ...detail
6732+ }, rule);
6733+ }
6734+
6735+ function isForbiddenProxyHeader(name) {
6736+ const lower = String(name || "").toLowerCase();
6737+ return lower === "accept-encoding"
6738+ || lower === "connection"
6739+ || lower === "content-length"
6740+ || lower === "cookie"
6741+ || lower === "host"
6742+ || lower === "origin"
6743+ || lower === "referer"
6744+ || lower === "user-agent"
6745+ || lower.startsWith("sec-");
6746+ }
6747+
6748+ emit("__baa_ready__", {
6749+ platform: pageRule.platform,
6750+ url: location.href,
6751+ source: "page-interceptor"
6752+ }, pageRule);
6753+
6754+ try { console.log("[BAA]", "interceptor_active", pageRule.platform, location.href.slice(0, 120)); } catch (_) {}
6755+ emitDiagnostic("interceptor_active", {
6756+ source: "page-interceptor",
6757+ url: location.href
6758+ }, pageRule);
6759+
6760+ function trimBodyValue(body) {
6761+ try {
6762+ if (body == null) return null;
6763+ if (typeof body === "string") return trim(body);
6764+ if (body instanceof URLSearchParams) return trim(body.toString());
6765+ if (body instanceof FormData) {
6766+ const pairs = [];
6767+ for (const [key, value] of body.entries()) {
6768+ pairs.push([key, typeof value === "string" ? value : `[${value?.constructor?.name || "binary"}]`]);
6769+ }
6770+ return trim(JSON.stringify(pairs));
6771+ }
6772+ if (body instanceof Blob) return `[blob ${body.type || "application/octet-stream"} ${body.size}]`;
6773+ if (body instanceof ArrayBuffer) return `[arraybuffer ${body.byteLength}]`;
6774+ if (ArrayBuffer.isView(body)) return `[typedarray ${body.byteLength}]`;
6775+ return trim(JSON.stringify(body));
6776+ } catch (_) {
6777+ return null;
6778+ }
6779+ }
6780+
6781+ async function readRequestBody(input, init) {
6782+ try {
6783+ if (init && Object.prototype.hasOwnProperty.call(init, "body")) {
6784+ return trimBodyValue(init.body);
6785+ }
6786+ if (input instanceof Request && !input.bodyUsed) {
6787+ return trim(await input.clone().text());
6788+ }
6789+ } catch (_) {}
6790+ return null;
6791+ }
6792+
6793+ async function readResponseText(response) {
6794+ try {
6795+ return trim(await response.clone().text());
6796+ } catch (_) {
6797+ return null;
6798+ }
6799+ }
6800+
6801+ async function streamSse(url, method, requestBody, response, startedAt, rule) {
6802+ try { console.log("[BAA]", "sse_stream_start", method, url.slice(0, 120)); } catch (_) {}
6803+ emitDiagnostic("sse_stream_start", {
6804+ method,
6805+ source: "page-interceptor",
6806+ url
6807+ }, rule);
6808+ let buffer = "";
6809+ const decoder = new TextDecoder();
6810+ const emitBufferedChunk = () => {
6811+ if (!buffer.trim()) return false;
6812+
6813+ emitSse({
6814+ url,
6815+ method,
6816+ reqBody: requestBody,
6817+ chunk: buffer,
6818+ ts: Date.now()
6819+ }, rule);
6820+ buffer = "";
6821+ return true;
6822+ };
6823+
6824+ try {
6825+ const clone = response.clone();
6826+ if (!clone.body) return;
6827+
6828+ const reader = clone.body.getReader();
6829+
6830+ while (true) {
6831+ const { done, value } = await reader.read();
6832+ if (done) break;
6833+ buffer += decoder.decode(value, { stream: true });
6834+
6835+ const chunks = buffer.split("\n\n");
6836+ buffer = chunks.pop() || "";
6837+
6838+ for (const chunk of chunks) {
6839+ if (!chunk.trim()) continue;
6840+ emitSse({
6841+ url,
6842+ method,
6843+ reqBody: requestBody,
6844+ chunk,
6845+ ts: Date.now()
6846+ }, rule);
6847+ }
6848+ }
6849+
6850+ buffer += decoder.decode();
6851+ emitBufferedChunk();
6852+
6853+ const duration = Date.now() - startedAt;
6854+ try { console.log("[BAA]", "sse_stream_done", method, url.slice(0, 120), "duration=" + duration + "ms"); } catch (_) {}
6855+ emitDiagnostic("sse_stream_done", {
6856+ duration,
6857+ method,
6858+ source: "page-interceptor",
6859+ url
6860+ }, rule);
6861+ emitSse({
6862+ url,
6863+ method,
6864+ reqBody: requestBody,
6865+ done: true,
6866+ ts: Date.now(),
6867+ duration
6868+ }, rule);
6869+ } catch (error) {
6870+ buffer += decoder.decode();
6871+ emitBufferedChunk();
6872+
6873+ emitSse({
6874+ url,
6875+ method,
6876+ reqBody: requestBody,
6877+ error: describeError(error),
6878+ ts: Date.now(),
6879+ duration: Date.now() - startedAt
6880+ }, rule);
6881+ }
6882+ }
6883+
6884+ async function streamProxyResponse(detail, response, startedAt, rule, requestBody) {
6885+ const contentType = response.headers.get("content-type") || "";
6886+ const shouldSplitChunks = contentType.includes("text/event-stream");
6887+ const streamId = detail.stream_id || detail.streamId || detail.id;
6888+ const proxySource = trimToNull(detail.source) || "proxy";
6889+ let seq = 0;
6890+
6891+ emitSse({
6892+ id: detail.id,
6893+ stream_id: streamId,
6894+ method: detail.method,
6895+ open: true,
6896+ reqBody: requestBody,
6897+ source: proxySource,
6898+ status: response.status,
6899+ ts: Date.now(),
6900+ url: detail.url
6901+ }, rule);
6902+
6903+ try {
6904+ if (!response.body) {
6905+ emitSse(
6906+ response.status >= 400
6907+ ? {
6908+ error: `upstream_status_${response.status}`,
6909+ id: detail.id,
6910+ method: detail.method,
6911+ reqBody: requestBody,
6912+ source: proxySource,
6913+ status: response.status,
6914+ stream_id: streamId,
6915+ ts: Date.now(),
6916+ url: detail.url
6917+ }
6918+ : {
6919+ id: detail.id,
6920+ stream_id: streamId,
6921+ done: true,
6922+ duration: Date.now() - startedAt,
6923+ method: detail.method,
6924+ reqBody: requestBody,
6925+ source: proxySource,
6926+ status: response.status,
6927+ ts: Date.now(),
6928+ url: detail.url
6929+ },
6930+ rule
6931+ );
6932+ return;
6933+ }
6934+
6935+ const reader = response.body.getReader();
6936+ const decoder = new TextDecoder();
6937+ let buffer = "";
6938+
6939+ while (true) {
6940+ const { done, value } = await reader.read();
6941+ if (done) break;
6942+
6943+ buffer += decoder.decode(value, { stream: true });
6944+ const chunks = shouldSplitChunks ? buffer.split("\n\n") : [buffer];
6945+ buffer = shouldSplitChunks ? (chunks.pop() || "") : "";
6946+
6947+ for (const chunk of chunks) {
6948+ if (!chunk.trim()) continue;
6949+ seq += 1;
6950+ emitSse({
6951+ chunk,
6952+ id: detail.id,
6953+ method: detail.method,
6954+ reqBody: requestBody,
6955+ seq,
6956+ source: proxySource,
6957+ status: response.status,
6958+ stream_id: streamId,
6959+ ts: Date.now(),
6960+ url: detail.url
6961+ }, rule);
6962+ }
6963+ }
6964+
6965+ buffer += decoder.decode();
6966+ if (buffer.trim()) {
6967+ seq += 1;
6968+ emitSse({
6969+ chunk: buffer,
6970+ id: detail.id,
6971+ method: detail.method,
6972+ reqBody: requestBody,
6973+ seq,
6974+ source: proxySource,
6975+ status: response.status,
6976+ stream_id: streamId,
6977+ ts: Date.now(),
6978+ url: detail.url
6979+ }, rule);
6980+ }
6981+
6982+ emitSse(
6983+ response.status >= 400
6984+ ? {
6985+ error: `upstream_status_${response.status}`,
6986+ id: detail.id,
6987+ method: detail.method,
6988+ reqBody: requestBody,
6989+ seq,
6990+ source: proxySource,
6991+ status: response.status,
6992+ stream_id: streamId,
6993+ ts: Date.now(),
6994+ url: detail.url
6995+ }
6996+ : {
6997+ done: true,
6998+ duration: Date.now() - startedAt,
6999+ id: detail.id,
7000+ method: detail.method,
7001+ reqBody: requestBody,
7002+ seq,
7003+ source: proxySource,
7004+ status: response.status,
7005+ stream_id: streamId,
7006+ ts: Date.now(),
7007+ url: detail.url
7008+ },
7009+ rule
7010+ );
7011+ } catch (error) {
7012+ emitSse({
7013+ error: error.message,
7014+ id: detail.id,
7015+ method: detail.method,
7016+ reqBody: requestBody,
7017+ source: proxySource,
7018+ seq,
7019+ status: response.status,
7020+ stream_id: streamId,
7021+ ts: Date.now(),
7022+ url: detail.url
7023+ }, rule);
7024+ }
7025+ }
7026+
7027+ if (!legacyIntercepted) {
7028+ addWindowListener("__baa_proxy_request__", async (event) => {
7029+ let detail = event.detail || {};
7030+ if (typeof detail === "string") {
7031+ try {
7032+ detail = JSON.parse(detail);
7033+ } catch (_) {
7034+ detail = {};
7035+ }
7036+ }
7037+
7038+ const id = detail.id;
7039+ const method = String(detail.method || "GET").toUpperCase();
7040+ const rawPath = detail.path || detail.url || location.href;
7041+ const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
7042+ const proxySource = trimToNull(detail.source) || "proxy";
7043+
7044+ if (!id) return;
7045+
7046+ const proxyAbortController = new AbortController();
7047+ activeProxyControllers.set(id, proxyAbortController);
7048+
7049+ try {
7050+ const url = new URL(rawPath, location.origin).href;
7051+ try { console.log("[BAA]", "proxy_fetch", method, new URL(url).pathname); } catch (_) {}
7052+ const context = getRequestContext(url);
7053+ const headers = new Headers();
7054+ const startedAt = Date.now();
7055+
7056+ for (const [name, value] of Object.entries(detail.headers || {})) {
7057+ if (!name || value == null || value === "") continue;
7058+ if (isForbiddenProxyHeader(name)) continue;
7059+ headers.set(String(name).toLowerCase(), String(value));
7060+ }
7061+
7062+ let body = null;
7063+ if (method !== "GET" && method !== "HEAD" && Object.prototype.hasOwnProperty.call(detail, "body")) {
7064+ if (typeof detail.body === "string") {
7065+ body = detail.body;
7066+ } else if (detail.body != null) {
7067+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
7068+ body = JSON.stringify(detail.body);
7069+ }
7070+ }
7071+
7072+ const response = await originalFetch.call(window, url, {
7073+ method,
7074+ headers,
7075+ body,
7076+ credentials: "include",
7077+ signal: proxyAbortController.signal
7078+ });
7079+ const resHeaders = readHeaders(response.headers);
7080+ const contentType = response.headers.get("content-type") || "";
7081+ const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
7082+ const reqHeaders = readHeaders(headers);
7083+ const reqBody = typeof body === "string" ? trim(body) : null;
7084+
7085+ if (responseMode === "sse") {
7086+ emitNet({
7087+ url,
7088+ method,
7089+ reqHeaders,
7090+ reqBody,
7091+ status: response.status,
7092+ resHeaders,
7093+ resBody: null,
7094+ duration: Date.now() - startedAt,
7095+ sse: true,
7096+ source: proxySource
7097+ }, pageRule);
7098+
7099+ const replayDetail = {
7100+ ...detail,
7101+ id,
7102+ method,
7103+ stream_id: detail.stream_id || detail.streamId || id,
7104+ source: proxySource,
7105+ url
7106+ };
7107+ await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
7108+ return;
7109+ }
7110+
7111+ const responseBody = await response.text();
7112+ const trimmedResponseBody = trim(responseBody);
7113+
7114+ emitNet({
7115+ url,
7116+ method,
7117+ reqHeaders,
7118+ reqBody,
7119+ status: response.status,
7120+ resHeaders,
7121+ resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
7122+ duration: Date.now() - startedAt,
7123+ sse: isSse,
7124+ source: proxySource
7125+ }, pageRule);
7126+
7127+ if (isSse && trimmedResponseBody) {
7128+ emitSse({
7129+ url,
7130+ method,
7131+ reqBody,
7132+ chunk: trimmedResponseBody,
7133+ source: proxySource,
7134+ ts: Date.now()
7135+ }, pageRule);
7136+ emitSse({
7137+ url,
7138+ method,
7139+ reqBody,
7140+ done: true,
7141+ source: proxySource,
7142+ ts: Date.now(),
7143+ duration: Date.now() - startedAt
7144+ }, pageRule);
7145+ }
7146+
7147+ emit("__baa_proxy_response__", {
7148+ id,
7149+ platform: pageRule.platform,
7150+ url,
7151+ method,
7152+ ok: response.ok,
7153+ status: response.status,
7154+ body: responseBody
7155+ }, pageRule);
7156+ } catch (error) {
7157+ emitNet({
7158+ url: rawPath,
7159+ method,
7160+ reqHeaders: readHeaders(detail.headers || {}),
7161+ reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
7162+ error: error.message,
7163+ source: proxySource
7164+ }, pageRule);
7165+
7166+ if (responseMode === "sse") {
7167+ emitSse({
7168+ error: error.message,
7169+ id,
7170+ method,
7171+ reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
7172+ source: proxySource,
7173+ status: null,
7174+ stream_id: detail.stream_id || detail.streamId || id,
7175+ ts: Date.now(),
7176+ url: rawPath
7177+ }, pageRule);
7178+ return;
7179+ }
7180+
7181+ emit("__baa_proxy_response__", {
7182+ id,
7183+ platform: pageRule.platform,
7184+ url: rawPath,
7185+ method,
7186+ ok: false,
7187+ error: error.message
7188+ }, pageRule);
7189+ } finally {
7190+ activeProxyControllers.delete(id);
7191+ }
7192+ });
7193+
7194+ addWindowListener("__baa_proxy_cancel__", (event) => {
7195+ let detail = event.detail || {};
7196+
7197+ if (typeof detail === "string") {
7198+ try {
7199+ detail = JSON.parse(detail);
7200+ } catch (_) {
7201+ detail = {};
7202+ }
7203+ }
7204+
7205+ const id = detail?.id || detail?.requestId;
7206+ if (!id) return;
7207+
7208+ const controller = activeProxyControllers.get(id);
7209+ if (!controller) return;
7210+
7211+ activeProxyControllers.delete(id);
7212+ controller.abort(detail?.reason || "browser_request_cancelled");
7213+ });
7214+ }
7215+
7216+ window.fetch = async function patchedFetch(input, init) {
7217+ const url = input instanceof Request ? input.url : String(input);
7218+ const context = getRequestContext(url);
7219+ if (!context || !context.rule.shouldTrack(context.parsed.pathname)) {
7220+ return originalFetch.apply(this, arguments);
7221+ }
7222+
7223+ const method = ((init && init.method) || (input instanceof Request ? input.method : "GET")).toUpperCase();
7224+ const startedAt = Date.now();
7225+ const reqHeaders = readHeaders(init && init.headers ? init.headers : (input instanceof Request ? input.headers : null));
7226+ const reqBody = await readRequestBody(input, init);
7227+ emitDiagnostic("fetch_intercepted", {
7228+ method,
7229+ source: "page-interceptor",
7230+ url
7231+ }, context.rule);
7232+
7233+ try {
7234+ const response = await originalFetch.apply(this, arguments);
7235+ const resHeaders = readHeaders(response.headers);
7236+ const contentType = response.headers.get("content-type") || "";
7237+ const isSse = context.rule.isSse(context.parsed.pathname, contentType);
7238+ try { console.log("[BAA]", "fetch", method, context.parsed.pathname, isSse ? "SSE" : "buffered", response.status); } catch (_) {}
7239+
7240+ if (isSse) {
7241+ emitNet({
7242+ url,
7243+ method,
7244+ reqHeaders,
7245+ reqBody,
7246+ status: response.status,
7247+ resHeaders,
7248+ duration: Date.now() - startedAt,
7249+ sse: true,
7250+ source: "page"
7251+ }, context.rule);
7252+ streamSse(url, method, reqBody, response, startedAt, context.rule);
7253+ return response;
7254+ }
7255+
7256+ const resBody = await readResponseText(response);
7257+ emitNet({
7258+ url,
7259+ method,
7260+ reqHeaders,
7261+ reqBody,
7262+ status: response.status,
7263+ resHeaders,
7264+ resBody,
7265+ duration: Date.now() - startedAt,
7266+ source: "page"
7267+ }, context.rule);
7268+ return response;
7269+ } catch (error) {
7270+ emitNet({
7271+ url,
7272+ method,
7273+ reqHeaders,
7274+ reqBody,
7275+ error: error.message,
7276+ duration: Date.now() - startedAt,
7277+ source: "page"
7278+ }, context.rule);
7279+ throw error;
7280+ }
7281+ };
7282+
7283+ XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
7284+ this.__baaMethod = String(method || "GET").toUpperCase();
7285+ this.__baaUrl = typeof url === "string" ? url : String(url);
7286+ this.__baaRequestHeaders = {};
7287+ return originalXhrOpen.apply(this, arguments);
7288+ };
7289+
7290+ XMLHttpRequest.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
7291+ if (this.__baaRequestHeaders && name) {
7292+ this.__baaRequestHeaders[String(name).toLowerCase()] = String(value || "");
7293+ }
7294+ return originalXhrSetRequestHeader.apply(this, arguments);
7295+ };
7296+
7297+ XMLHttpRequest.prototype.send = function patchedSend(body) {
7298+ const url = this.__baaUrl;
7299+ const context = getRequestContext(url);
7300+ if (!context || !context.rule.shouldTrack(context.parsed.pathname)) {
7301+ return originalXhrSend.apply(this, arguments);
7302+ }
7303+
7304+ const method = this.__baaMethod || "GET";
7305+ try { console.log("[BAA]", "xhr", method, context.parsed.pathname); } catch (_) {}
7306+ const reqBody = trimBodyValue(body);
7307+ const reqHeaders = { ...(this.__baaRequestHeaders || {}) };
7308+ const startedAt = Date.now();
7309+ let finalized = false;
7310+ let terminalError = null;
7311+
7312+ const finalize = () => {
7313+ if (finalized) return;
7314+ finalized = true;
7315+
7316+ const resHeaders = readRawHeaders(this.getAllResponseHeaders());
7317+ const contentType = String(this.getResponseHeader("content-type") || "");
7318+ const duration = Date.now() - startedAt;
7319+ const error = terminalError || (this.status === 0 ? "xhr_failed" : null);
7320+ const isSse = context.rule.isSse(context.parsed.pathname, contentType);
7321+ let responseText = null;
7322+
7323+ try {
7324+ responseText = typeof this.responseText === "string" ? trim(this.responseText) : null;
7325+ } catch (_) {
7326+ responseText = null;
7327+ }
7328+
7329+ emitNet({
7330+ url,
7331+ method,
7332+ reqHeaders,
7333+ reqBody,
7334+ status: this.status || null,
7335+ resHeaders,
7336+ resBody: isSse && context.rule.platform !== "gemini" ? null : responseText,
7337+ error,
7338+ duration,
7339+ sse: isSse,
7340+ source: "xhr"
7341+ }, context.rule);
7342+
7343+ if (isSse && responseText) {
7344+ emitSse({
7345+ url,
7346+ method,
7347+ reqBody,
7348+ chunk: responseText,
7349+ ts: Date.now()
7350+ }, context.rule);
7351+ }
7352+
7353+ if (isSse) {
7354+ emitSse({
7355+ url,
7356+ method,
7357+ reqBody,
7358+ done: true,
7359+ ts: Date.now(),
7360+ duration
7361+ }, context.rule);
7362+ }
7363+ };
7364+
7365+ this.addEventListener("error", () => {
7366+ terminalError = "xhr_error";
7367+ }, { once: true });
7368+ this.addEventListener("abort", () => {
7369+ terminalError = "xhr_aborted";
7370+ }, { once: true });
7371+ this.addEventListener("timeout", () => {
7372+ terminalError = "xhr_timeout";
7373+ }, { once: true });
7374+ this.addEventListener("loadend", finalize, { once: true });
7375+
7376+ return originalXhrSend.apply(this, arguments);
7377+ };
7378+
7379+ const runtime = {
7380+ legacyIntercepted,
7381+ originalFetch,
7382+ originalXhrOpen,
7383+ originalXhrSend,
7384+ originalXhrSetRequestHeader,
7385+ teardown() {
7386+ for (const controller of activeProxyControllers.values()) {
7387+ try {
7388+ controller.abort("observer_reinject");
7389+ } catch (_) {}
7390+ }
7391+ activeProxyControllers.clear();
7392+
7393+ window.fetch = originalFetch;
7394+ XMLHttpRequest.prototype.open = originalXhrOpen;
7395+ XMLHttpRequest.prototype.send = originalXhrSend;
7396+ XMLHttpRequest.prototype.setRequestHeader = originalXhrSetRequestHeader;
7397+
7398+ while (cleanupHandlers.length > 0) {
7399+ const cleanup = cleanupHandlers.pop();
7400+ try {
7401+ cleanup();
7402+ } catch (_) {}
7403+ }
7404+
7405+ if (window.__baaSafariIntercepted__ === runtime) {
7406+ if (legacyIntercepted) {
7407+ window.__baaSafariIntercepted__ = true;
7408+ return;
7409+ }
7410+
7411+ try {
7412+ delete window.__baaSafariIntercepted__;
7413+ } catch (_) {
7414+ window.__baaSafariIntercepted__ = null;
7415+ }
7416+ }
7417+ }
7418+ };
7419+
7420+ window.__baaSafariIntercepted__ = runtime;
7421+})();
7422diff --git a/tasks/T-S077.md b/tasks/T-S077.md
7423new file mode 100644
7424index 0000000000000000000000000000000000000000..6265dfd04a7098ea971b989a16957d11a6a31be4
7425--- /dev/null
7426+++ b/tasks/T-S077.md
7427@@ -0,0 +1,176 @@
7428+# Task T-S077:Safari ChatGPT final-message — 共享 relay 接线
7429+
7430+## 状态
7431+
7432+- 当前状态:`已完成`
7433+- 规模预估:`M`
7434+- 依赖任务:`T-S075`、`T-S076`
7435+- 建议执行者:`Codex`(涉及 shared final-message helper、Safari controller 状态机和 conductor live ingest)
7436+
7437+## 直接给对话的提示词
7438+
7439+读 `/Users/george/code/baa-conductor-safari-plugin/tasks/T-S077.md` 任务文档,完成开发任务。
7440+
7441+如需补背景,再读:
7442+
7443+- `/Users/george/code/baa-conductor-safari-plugin/plans/BAA_SAFARI_PLUGIN_REQUIREMENTS.md`
7444+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/final-message.js`
7445+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/content-script.js`
7446+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/controller.js`
7447+- `/Users/george/code/baa-conductor-safari-plugin/apps/conductor-daemon/src/firefox-ws.ts`
7448+
7449+## 当前基线
7450+
7451+- 仓库:`/Users/george/code/baa-conductor-safari-plugin`
7452+- 分支基线:`main`
7453+- 提交:`9674c30`
7454+
7455+## 分支与 worktree(强制)
7456+
7457+- 分支名:`feat/safari-chatgpt-final-message`
7458+- worktree 路径:`/Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message`
7459+
7460+开工步骤:
7461+
7462+1. `cd /Users/george/code/baa-conductor-safari-plugin`
7463+2. `git worktree add ../baa-conductor-safari-plugin-safari-chatgpt-final-message -b feat/safari-chatgpt-final-message main`
7464+3. `cd ../baa-conductor-safari-plugin-safari-chatgpt-final-message`
7465+
7466+## 目标
7467+
7468+让 Safari 版 ChatGPT 页面在回复完成后,稳定向 conductor 发送与 Firefox 相同合同的 `browser.final_message`。
7469+
7470+## 背景
7471+
7472+Phase 1 的硬验收之一就是:
7473+
7474+- 在 ChatGPT 中发消息并收到回复后,conductor 日志中出现 `browser.final_message`
7475+
7476+Firefox 版这条链路已经存在,但依赖:
7477+
7478+- `page-interceptor.js` 上报 SSE
7479+- `content-script.js` 桥接页面事件
7480+- `controller.js` 持有 final-message relay observer 和 dedupe cache
7481+- WS bridge 把消息继续送进 conductor live ingest
7482+
7483+Safari 版需要复用这条合同,而不是重新发明一套消息格式。
7484+
7485+## 涉及仓库
7486+
7487+- `/Users/george/code/baa-conductor-safari-plugin`
7488+
7489+## 范围
7490+
7491+- 复用或共享 `final-message.js`
7492+- Safari controller 接入 ChatGPT final-message observer
7493+- 维护 replay / dedupe cache
7494+- 通过 Safari WS bridge 发送 `browser.final_message`
7495+- 为后续 Claude / Gemini 复用保留平台抽象
7496+
7497+## 路径约束
7498+
7499+- `final-message.js` 保持纯 JS,不引入 Safari 专属 API
7500+- 发送给 conductor 的 `browser.final_message` 字段合同必须与现有 Firefox 路径兼容
7501+- 首版先以 ChatGPT 打通为准,不要把 Claude / Gemini 假装成已完成
7502+
7503+## 推荐实现边界
7504+
7505+建议优先做:
7506+
7507+- `plugins/baa-safari/final-message.js`
7508+- `plugins/baa-safari/content-script.js`
7509+- `plugins/baa-safari/controller.js`
7510+- `plugins/baa-safari/*.test.cjs`
7511+
7512+## 允许修改的目录
7513+
7514+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-safari/`
7515+- `/Users/george/code/baa-conductor-safari-plugin/tasks/T-S077.md`
7516+- `/Users/george/code/baa-conductor-safari-plugin/tasks/TASK_OVERVIEW.md`
7517+
7518+## 尽量不要修改
7519+
7520+- `/Users/george/code/baa-conductor-safari-plugin/apps/conductor-daemon/src/`
7521+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/`
7522+
7523+## 必须完成
7524+
7525+### 1. 共享 final-message helper 接线
7526+
7527+- Safari controller 能创建与 Firefox 相同的平台 relay observer
7528+- ChatGPT SSE 片段能进入 shared helper 并产出最终 assistant message
7529+- relay cache 能抑制页面重载或扩展重载后的 stale replay
7530+
7531+### 2. WS 上送
7532+
7533+- Safari controller 能把 `browser.final_message` 发到 conductor
7534+- 消息里至少包含 `platform`、`assistant_message_id`、`raw_text`
7535+- `client_id` / `node_platform` 维度在 conductor 侧可分辨 Safari 来源
7536+
7537+### 3. 测试
7538+
7539+- 至少补一组 Safari final-message 的单测或 harness 覆盖
7540+- 不得破坏现有 Firefox final-message 语义
7541+
7542+## 需要特别注意
7543+
7544+- 这张卡的目标是 final-message relay,不是 BAA parser
7545+- 不要让 Safari 为了接线去改 shared helper 的平台无关行为
7546+- 如果 ChatGPT 当前路径必须补 Safari 专属兼容层,应收口在 Safari controller,而不是污染 shared helper
7547+- 所有开发必须在 worktree 中进行,不要在主仓库目录修改代码
7548+
7549+## 验收标准
7550+
7551+- Safari 上 ChatGPT 回复完成后,conductor 能收到一条 `browser.final_message`
7552+- 页面刷新或扩展重载后,不会把旧 final-message 当成新消息重复上报
7553+- Safari 与 Firefox 的 `browser.final_message` 合同保持一致
7554+
7555+## 推荐验证命令
7556+
7557+- `cd /Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message && node --test plugins/baa-safari/*.test.cjs`
7558+- `cd /Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message && rg -n "browser.final_message|createRelayState|assistant_message_id|raw_text" plugins/baa-safari`
7559+
7560+## 执行记录
7561+
7562+> 以下内容由执行任务的 AI 填写,创建任务时留空。
7563+
7564+### 开始执行
7565+
7566+- 执行者:`Codex`
7567+- 开始时间:`2026-04-03 08:20:00 +0800`
7568+- 状态变更:`待开始` → `进行中`
7569+
7570+### 完成摘要
7571+
7572+- 完成时间:`2026-04-03 08:56:16 +0800`
7573+- 状态变更:`进行中` → `已完成`
7574+- 修改了哪些文件:
7575+ - `plugins/baa-safari/final-message.js`
7576+ - `plugins/baa-safari/background.js`
7577+ - `plugins/baa-safari/controller.html`
7578+ - `plugins/baa-safari/controller.js`
7579+ - `plugins/baa-safari/manifest.json`
7580+ - `plugins/baa-safari/README.md`
7581+ - `plugins/baa-safari/background.test.cjs`
7582+ - `plugins/baa-safari/final-message.test.cjs`
7583+ - `plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj`
7584+- 核心实现思路:
7585+ - 直接把 Firefox 的 pure-JS `final-message.js` 复制到 Safari 插件目录,保持 ChatGPT SSE/network 归并和 dedupe 语义一致。
7586+ - 在 `background.js` 建立 ChatGPT final-message relay observer、持久化 replay cache、本地 browser WS 客户端和 `browser.final_message` 上送。
7587+ - controller 页面新增 Local WS / Final Message 观测卡,便于现场确认 Safari 来源的 relay 是否已经到达 conductor。
7588+- 跑了哪些测试:
7589+ - `node --check plugins/baa-safari/background.js`
7590+ - `node --check plugins/baa-safari/controller.js`
7591+ - `node --check plugins/baa-safari/content-script.js`
7592+ - `node --check plugins/baa-safari/final-message.js`
7593+ - `node --check plugins/baa-safari/page-interceptor.js`
7594+ - `node --test plugins/baa-safari/*.test.cjs`
7595+ - `env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 xcodebuild -project plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj -scheme BAASafariHost -configuration Debug build`
7596+
7597+### 执行过程中遇到的问题
7598+
7599+- 任务卡文件不在当前 worktree 基线里,执行时从任务源目录补拷到本分支,避免后续分支看不到执行记录。
7600+
7601+### 剩余风险
7602+
7603+- Claude / Gemini 的 Safari final-message 接入仍可能暴露不同的 SSE 或页面结构兼容问题,需要在 Phase 2 单独收口
7604diff --git a/tests/browser/browser-control-e2e-smoke.test.mjs b/tests/browser/browser-control-e2e-smoke.test.mjs
7605index 7b93d28967dd877abf8fd9e14838adadf0f76bd5..14671eed1b14e4d064c2f04b9ed6dc94e795c605 100644
7606--- a/tests/browser/browser-control-e2e-smoke.test.mjs
7607+++ b/tests/browser/browser-control-e2e-smoke.test.mjs
7608@@ -179,7 +179,7 @@ async function expectQueueTimeout(queue, predicate, timeoutMs = 400) {
7609 );
7610 }
7611
7612-async function connectFirefoxBridgeClient(wsUrl, clientId) {
7613+async function connectBrowserBridgeClient(wsUrl, clientId, nodePlatform = "firefox") {
7614 const socket = new WebSocket(wsUrl);
7615 const queue = createWebSocketMessageQueue(socket);
7616
7617@@ -190,7 +190,7 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
7618 clientId,
7619 nodeType: "browser",
7620 nodeCategory: "proxy",
7621- nodePlatform: "firefox"
7622+ nodePlatform
7623 })
7624 );
7625
7626@@ -213,6 +213,10 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
7627 };
7628 }
7629
7630+async function connectFirefoxBridgeClient(wsUrl, clientId) {
7631+ return await connectBrowserBridgeClient(wsUrl, clientId, "firefox");
7632+}
7633+
7634 async function fetchJson(url, init) {
7635 const response = await fetch(url, init);
7636 const text = await response.text();
7637@@ -2614,6 +2618,70 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
7638 }
7639 });
7640
7641+test("browser control smoke accepts Safari bridge clients on the canonical websocket path", async () => {
7642+ const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-safari-ws-smoke-"));
7643+ const runtime = new ConductorRuntime(
7644+ {
7645+ nodeId: "mini-main",
7646+ host: "mini",
7647+ role: "primary",
7648+ controlApiBase: "https://conductor.example.test",
7649+ localApiBase: "http://127.0.0.1:0",
7650+ sharedToken: "replace-me",
7651+ paths: {
7652+ runsDir: "/tmp/runs",
7653+ stateDir
7654+ }
7655+ },
7656+ {
7657+ autoStartLoops: false,
7658+ now: () => 100
7659+ }
7660+ );
7661+
7662+ let client = null;
7663+
7664+ try {
7665+ const snapshot = await runtime.start();
7666+ const baseUrl = snapshot.controlApi.localApiBase;
7667+
7668+ client = await connectBrowserBridgeClient(
7669+ snapshot.controlApi.browserWsUrl,
7670+ "safari-browser-control-smoke",
7671+ "safari"
7672+ );
7673+
7674+ assert.equal(client.helloAck.clientId, "safari-browser-control-smoke");
7675+ assert.equal(client.helloAck.protocol, "baa.browser.local");
7676+ assert.equal(client.helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
7677+ assert.deepEqual(client.helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
7678+ assert.equal(client.initialSnapshot.snapshot.browser.client_count, 1);
7679+ assert.equal(client.initialSnapshot.snapshot.browser.clients[0]?.node_platform, "safari");
7680+ assert.equal(client.initialSnapshot.snapshot.browser.ws_path, "/ws/browser");
7681+ assert.equal(client.credentialRequest.reason, "hello");
7682+
7683+ const browserStatus = await fetchJson(`${baseUrl}/v1/browser`);
7684+ assert.equal(browserStatus.response.status, 200);
7685+ assert.equal(browserStatus.payload.data.bridge.transport, "local_browser_ws");
7686+ assert.equal(browserStatus.payload.data.bridge.ws_path, "/ws/browser");
7687+ assert.equal(browserStatus.payload.data.bridge.ws_url, snapshot.controlApi.browserWsUrl);
7688+ assert.equal(browserStatus.payload.data.current_client.client_id, "safari-browser-control-smoke");
7689+ assert.equal(browserStatus.payload.data.current_client.node_platform, "safari");
7690+ } finally {
7691+ client?.queue.stop();
7692+
7693+ if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
7694+ client.socket.close(1000, "done");
7695+ }
7696+
7697+ await runtime.stop();
7698+ rmSync(stateDir, {
7699+ force: true,
7700+ recursive: true
7701+ });
7702+ }
7703+});
7704+
7705 test("browser delivery bridge uses proxy delivery on the routed business page and records target context", async () => {
7706 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
7707 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
7708@@ -2681,9 +2749,15 @@ test("browser delivery bridge uses proxy delivery on the routed business page an
7709 assert.equal(proxyDelivery.shell_page, false);
7710 assert.equal(proxyDelivery.target_tab_id, 51);
7711 assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
7712- assert.match(proxyDelivery.message_text, /line-1/u);
7713+ assert.equal(
7714+ proxyDelivery.message_text.includes("\"command\": \"i=1; while [ $i -le 260"),
7715+ true
7716+ );
7717 assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
7718- assert.match(proxyDelivery.message_text, /超长截断$/u);
7719+ assert.match(
7720+ proxyDelivery.message_text,
7721+ /完整结果:https:\/\/conductor\.example\.test\/artifact\/exec\/[^ \n]+\.txt/u
7722+ );
7723
7724 await expectQueueTimeout(
7725 client.queue,