@empir3/empir3-bridge 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1531 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/SECURITY.md +130 -0
- package/assets/accuracy-lab.html +2639 -0
- package/assets/api-clis-real.jpg +0 -0
- package/assets/bridge-console-hero.jpg +0 -0
- package/assets/browser-privacy.svg +151 -0
- package/assets/demo-orchestration.svg +74 -0
- package/assets/desktop-select-region.jpg +0 -0
- package/assets/in-page-chat.gif +0 -0
- package/assets/orchestration-hero.svg +126 -0
- package/assets/social-preview.png +0 -0
- package/assets/zara-accent.png +0 -0
- package/build/bootstrap.js +548 -0
- package/build/build.js +680 -0
- package/build/payload-entry.js +649 -0
- package/build/payload-signing-pub.json +7 -0
- package/docs/AGENT_GUIDE.md +259 -0
- package/docs/RELEASE.md +106 -0
- package/docs/SAFETY.md +112 -0
- package/docs/TESTING.md +181 -0
- package/installer/server.js +231 -0
- package/installer/ui/app.js +278 -0
- package/installer/ui/index.html +24 -0
- package/installer/ui/styles.css +146 -0
- package/package.json +95 -0
- package/scripts/bootstrap-e2e.mjs +650 -0
- package/scripts/certify-bridge.mjs +636 -0
- package/scripts/check-companion-surface.mjs +118 -0
- package/scripts/extract-welcome.mjs +64 -0
- package/scripts/gh-route-handler-check.mjs +57 -0
- package/scripts/gh-wire-test.mjs +107 -0
- package/scripts/publish-downloads.mjs +180 -0
- package/scripts/smoke-all-tools.mjs +509 -0
- package/scripts/smoke-live-bridge.mjs +696 -0
- package/scripts/splice-welcome.mjs +63 -0
- package/scripts/welcome-body.txt +2733 -0
- package/src/anthropic-client.ts +192 -0
- package/src/bootstrap-exe.ts +69 -0
- package/src/bridge.ts +2444 -0
- package/src/chat.ts +345 -0
- package/src/cli-runner.ts +239 -0
- package/src/cli.ts +649 -0
- package/src/config.ts +199 -0
- package/src/desktop-overlay.ps1 +121 -0
- package/src/executable-resolver.ts +330 -0
- package/src/handlers/agy-imagegen.ts +179 -0
- package/src/handlers/github-cli.ts +399 -0
- package/src/handlers/higgsfield-cli.ts +783 -0
- package/src/launch.js +337 -0
- package/src/mcp-server.ts +1265 -0
- package/src/pair-claim.ts +218 -0
- package/src/payload-daemon.ts +168 -0
- package/src/server.ts +21036 -0
- package/src/tool-defaults.ts +230 -0
- package/src/update-check.js +136 -0
- package/tray/build.py +76 -0
- package/tray/requirements.txt +2 -0
- package/tray/tray.py +1843 -0
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,2444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empir3 Browser Bridge — CDP Server
|
|
3
|
+
*
|
|
4
|
+
* Empir3's own CDP bridge. Launches Chrome with remote debugging,
|
|
5
|
+
* exposes the same HTTP API surface on the same port (default 9867).
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies — uses Node built-ins + raw CDP WebSocket.
|
|
8
|
+
*
|
|
9
|
+
* Endpoints:
|
|
10
|
+
* GET /health — readiness check
|
|
11
|
+
* GET /screenshot — viewport capture (JPEG)
|
|
12
|
+
* GET /snapshot — accessibility tree element refs
|
|
13
|
+
* GET /text — extract readable page text
|
|
14
|
+
* GET /tabs — list open tabs
|
|
15
|
+
* POST /navigate — load URL
|
|
16
|
+
* POST /action — click/type/press/scroll by ref
|
|
17
|
+
* POST /evaluate — run JavaScript
|
|
18
|
+
* POST /cookies — set cookies
|
|
19
|
+
* GET /welcome — Empir3-branded splash page
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createServer, IncomingMessage, ServerResponse, request as httpRequest } from 'http';
|
|
23
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
24
|
+
import { WebSocket } from 'ws';
|
|
25
|
+
import { join, resolve } from 'path';
|
|
26
|
+
import { mkdirSync, existsSync, readFileSync } from 'fs';
|
|
27
|
+
|
|
28
|
+
// ─── Config ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const PORT = parseInt(process.env.BRIDGE_PORT || '9867');
|
|
31
|
+
const WRAPPER_PORT = parseInt(process.env.PW_PORT || process.env.EMPIR3_PW_PORT || '3006');
|
|
32
|
+
const HOST = process.env.EMPIR3_BRIDGE_HOST || '127.0.0.1';
|
|
33
|
+
const HEADLESS = process.env.BRIDGE_HEADLESS === 'true';
|
|
34
|
+
const CDP_PORT = parseInt(process.env.CDP_PORT || '9222');
|
|
35
|
+
const CHROME_PATHS = [
|
|
36
|
+
process.env.CHROME_PATH,
|
|
37
|
+
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
38
|
+
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
39
|
+
'/usr/bin/google-chrome',
|
|
40
|
+
'/usr/bin/chromium-browser',
|
|
41
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
42
|
+
].filter(Boolean) as string[];
|
|
43
|
+
|
|
44
|
+
const PROFILE_DIR = process.env.BRIDGE_PROFILE || process.env.EMPIR3_BRIDGE_CHROME_PROFILE || join(
|
|
45
|
+
process.env.HOME || process.env.USERPROFILE || '.',
|
|
46
|
+
'.empir3-bridge', 'profile'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const SESSION_TOKEN = process.env.BRIDGE_TOKEN || '';
|
|
50
|
+
const NAV_TIMEOUT = parseInt(process.env.BRIDGE_NAV_TIMEOUT || '60') * 1000;
|
|
51
|
+
const CDP_COMMAND_TIMEOUT_MS = parseInt(process.env.BRIDGE_CDP_COMMAND_TIMEOUT_MS || '10000');
|
|
52
|
+
const CDP_LIVENESS_MAX_AGE_MS = parseInt(process.env.BRIDGE_CDP_LIVENESS_MAX_AGE_MS || '2000');
|
|
53
|
+
const CDP_LIVENESS_TIMEOUT_MS = parseInt(process.env.BRIDGE_CDP_LIVENESS_TIMEOUT_MS || '2500');
|
|
54
|
+
const CDP_PERMISSION_TIMEOUT_MS = parseInt(process.env.BRIDGE_CDP_PERMISSION_TIMEOUT_MS || '500');
|
|
55
|
+
const CHROME_LAUNCH_TIMEOUT_MS = Math.max(
|
|
56
|
+
15000,
|
|
57
|
+
Number.parseInt(process.env.CHROME_LAUNCH_TIMEOUT_MS || process.env.BRIDGE_CHROME_LAUNCH_TIMEOUT_MS || '90000', 10) || 90000,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// ─── State ───────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
let chromeProcess: ChildProcess | null = null;
|
|
63
|
+
let cdpWs: WebSocket | null = null;
|
|
64
|
+
let cdpId = 1;
|
|
65
|
+
const cdpCallbacks = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void; ws: WebSocket }>();
|
|
66
|
+
let currentTargetId = '';
|
|
67
|
+
let currentSessionId = '';
|
|
68
|
+
let connected = false;
|
|
69
|
+
|
|
70
|
+
// Target tracking — detect new tabs and inject scripts
|
|
71
|
+
const knownTargets = new Map<string, string>(); // targetId → last known URL
|
|
72
|
+
let autoInjectScript = ''; // script to inject into every new page target
|
|
73
|
+
let targetPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
74
|
+
|
|
75
|
+
// Browser-level CDP connection for target discovery
|
|
76
|
+
let browserWs: WebSocket | null = null;
|
|
77
|
+
let browserCdpId = 100000; // offset from page-level IDs to avoid collision
|
|
78
|
+
const browserCallbacks = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
79
|
+
let browserWsConnecting = false;
|
|
80
|
+
let browserReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
81
|
+
let launchPromise: Promise<void> | null = null;
|
|
82
|
+
let lastCdpLivenessAt = 0;
|
|
83
|
+
let chromeEverStarted = false;
|
|
84
|
+
let chromeExitCode: number | null = null;
|
|
85
|
+
let chromeClosedByUser = false;
|
|
86
|
+
let shuttingDown = false;
|
|
87
|
+
let cdpCommandQueue: Promise<void> = Promise.resolve();
|
|
88
|
+
|
|
89
|
+
// Element ref tracking
|
|
90
|
+
let refMap = new Map<string, number>(); // ref string → CDP nodeId
|
|
91
|
+
let refCounter = 0;
|
|
92
|
+
|
|
93
|
+
// --fresh state — only wipe storage on the FIRST CDP connect of this Chrome
|
|
94
|
+
// launch. CDP can reconnect mid-session (Chrome reload, target switch); we
|
|
95
|
+
// don't want each reconnect re-wiping the user's just-typed-in cookies.
|
|
96
|
+
let freshConsumed = false;
|
|
97
|
+
|
|
98
|
+
// ─── Chrome Launcher ─────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function findChrome(): string {
|
|
101
|
+
for (const p of CHROME_PATHS) {
|
|
102
|
+
if (existsSync(p)) return p;
|
|
103
|
+
}
|
|
104
|
+
throw new Error('Chrome not found. Set CHROME_PATH env var.');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function chromeStatus(): 'running' | 'exited' | 'not-started' {
|
|
108
|
+
if (chromeProcess) return 'running';
|
|
109
|
+
return chromeEverStarted ? 'exited' : 'not-started';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function closedBrowserError(): Error {
|
|
113
|
+
return new Error('Bridge browser is closed. Use browser_navigate or Open Bridge to reopen it.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function markChromeClosedByUser(reason: string) {
|
|
117
|
+
if (!chromeClosedByUser) {
|
|
118
|
+
console.log(`[Empir3 Bridge] Bridge browser closed by user (${reason})`);
|
|
119
|
+
}
|
|
120
|
+
chromeClosedByUser = true;
|
|
121
|
+
connected = false;
|
|
122
|
+
lastCdpLivenessAt = 0;
|
|
123
|
+
currentTargetId = '';
|
|
124
|
+
knownTargets.clear();
|
|
125
|
+
if (cdpWs) { try { cdpWs.close(); } catch {} }
|
|
126
|
+
cdpWs = null;
|
|
127
|
+
stopTargetPolling();
|
|
128
|
+
if (browserReconnectTimer) {
|
|
129
|
+
clearTimeout(browserReconnectTimer);
|
|
130
|
+
browserReconnectTimer = null;
|
|
131
|
+
}
|
|
132
|
+
if (browserWs) { try { browserWs.close(); } catch {} }
|
|
133
|
+
browserWs = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Guards against stacking concurrent close-confirmation checks (pollTargets can
|
|
137
|
+
// fire one every 500ms while a check is still mid-flight).
|
|
138
|
+
let closeCheckInFlight = false;
|
|
139
|
+
|
|
140
|
+
// A single empty /json read is NOT proof the user closed the browser: during a
|
|
141
|
+
// cross-process navigation Chrome briefly destroys the old page target before the
|
|
142
|
+
// new one appears, and a refresh momentarily yields zero/changing targets. Latch
|
|
143
|
+
// "closed by user" only after several consecutive confirmations (~500ms) while the
|
|
144
|
+
// Chrome PROCESS is still alive — a genuine close also ends the process and is
|
|
145
|
+
// caught separately by the chromeProcess 'exit' handler (markChromeClosedByUser
|
|
146
|
+
// 'process exit'). This kills the post-refresh ONLINE->OFFLINE flap, where one
|
|
147
|
+
// transient empty read used to latch the bridge "disconnected" permanently.
|
|
148
|
+
async function markClosedIfNoPageTargets(reason: string): Promise<void> {
|
|
149
|
+
if (!chromeProcess || chromeClosedByUser || shuttingDown) return;
|
|
150
|
+
if (closeCheckInFlight) return;
|
|
151
|
+
closeCheckInFlight = true;
|
|
152
|
+
try {
|
|
153
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
154
|
+
if (!chromeProcess || chromeClosedByUser || shuttingDown) return;
|
|
155
|
+
try {
|
|
156
|
+
const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
157
|
+
if (targets.some((t: any) => t.type === 'page')) return; // a page reappeared — not closed
|
|
158
|
+
} catch {
|
|
159
|
+
return; // /json hiccup — inconclusive; never latch closed on an error
|
|
160
|
+
}
|
|
161
|
+
if (attempt < 2) await sleep(250);
|
|
162
|
+
}
|
|
163
|
+
if (!chromeProcess || chromeClosedByUser || shuttingDown) return;
|
|
164
|
+
markChromeClosedByUser(reason);
|
|
165
|
+
} finally {
|
|
166
|
+
closeCheckInFlight = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function terminateClosedChromeForRelaunch(): Promise<void> {
|
|
171
|
+
const proc = chromeProcess;
|
|
172
|
+
if (!proc) return;
|
|
173
|
+
try { proc.kill(); } catch {}
|
|
174
|
+
const deadline = Date.now() + 3000;
|
|
175
|
+
while (chromeProcess === proc && Date.now() < deadline) {
|
|
176
|
+
await sleep(100);
|
|
177
|
+
}
|
|
178
|
+
if (chromeProcess === proc) {
|
|
179
|
+
chromeProcess = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function waitForChromeCDP(timeoutMs = CHROME_LAUNCH_TIMEOUT_MS): Promise<void> {
|
|
184
|
+
const start = Date.now();
|
|
185
|
+
let lastError: any = null;
|
|
186
|
+
while (Date.now() - start < timeoutMs) {
|
|
187
|
+
if (!chromeProcess && chromeEverStarted) {
|
|
188
|
+
const suffix = chromeExitCode == null ? '' : ` (code ${chromeExitCode})`;
|
|
189
|
+
throw new Error(`Chrome exited before CDP connected${suffix}`);
|
|
190
|
+
}
|
|
191
|
+
await sleep(500);
|
|
192
|
+
try {
|
|
193
|
+
await connectCDP();
|
|
194
|
+
return;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
lastError = e;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const detail = lastError?.message ? `: ${lastError.message}` : '';
|
|
200
|
+
throw new Error(`Chrome did not expose CDP within ${Math.round(timeoutMs / 1000)}s${detail}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function launchChrome(timeoutMs = CHROME_LAUNCH_TIMEOUT_MS): Promise<void> {
|
|
204
|
+
if (chromeProcess) {
|
|
205
|
+
await waitForChromeCDP(timeoutMs);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const chromePath = findChrome();
|
|
210
|
+
|
|
211
|
+
// Ensure profile directory exists
|
|
212
|
+
mkdirSync(PROFILE_DIR, { recursive: true });
|
|
213
|
+
|
|
214
|
+
// Clear session restore data to prevent Chrome from reloading previous tabs
|
|
215
|
+
const sessionsDir = join(PROFILE_DIR, 'Default', 'Sessions');
|
|
216
|
+
try {
|
|
217
|
+
if (existsSync(sessionsDir)) {
|
|
218
|
+
const { readdirSync, unlinkSync } = require('fs');
|
|
219
|
+
for (const f of readdirSync(sessionsDir)) {
|
|
220
|
+
try { unlinkSync(join(sessionsDir, f)); } catch {}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
|
|
225
|
+
const args = [
|
|
226
|
+
`--remote-debugging-port=${CDP_PORT}`,
|
|
227
|
+
'--remote-debugging-address=127.0.0.1',
|
|
228
|
+
`--user-data-dir=${PROFILE_DIR}`,
|
|
229
|
+
'--no-first-run',
|
|
230
|
+
'--no-default-browser-check',
|
|
231
|
+
'--disable-background-networking',
|
|
232
|
+
'--disable-default-apps',
|
|
233
|
+
'--disable-sync',
|
|
234
|
+
'--disable-translate',
|
|
235
|
+
'--metrics-recording-only',
|
|
236
|
+
'--safebrowsing-disable-auto-update',
|
|
237
|
+
'--disable-session-crashed-bubble',
|
|
238
|
+
'--restore-last-session=false',
|
|
239
|
+
'--hide-crash-restore-bubble',
|
|
240
|
+
'--disable-popup-blocking',
|
|
241
|
+
'--disable-notifications',
|
|
242
|
+
'--autoplay-policy=no-user-gesture-required',
|
|
243
|
+
'--deny-permission-prompts',
|
|
244
|
+
'--disable-permissions-api',
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
if (HEADLESS) {
|
|
248
|
+
args.push('--headless=new');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Start with welcome page. If a per-launch nonce was provided, stamp it on
|
|
252
|
+
// the URL — page scripts read it and use /api/identity to pick the right
|
|
253
|
+
// wrapper port when multiple bridges are running.
|
|
254
|
+
const nonce = process.env.EMPIR3_BRIDGE_NONCE || '';
|
|
255
|
+
const welcomePort = String(WRAPPER_PORT || PORT);
|
|
256
|
+
args.push(nonce
|
|
257
|
+
? `http://localhost:${welcomePort}/welcome?bridgeNonce=${encodeURIComponent(nonce)}`
|
|
258
|
+
: `http://localhost:${welcomePort}/welcome`);
|
|
259
|
+
|
|
260
|
+
console.log(`[Empir3 Bridge] Launching Chrome: ${chromePath}`);
|
|
261
|
+
console.log(`[Empir3 Bridge] Chrome profile: ${PROFILE_DIR}`);
|
|
262
|
+
chromeEverStarted = true;
|
|
263
|
+
chromeExitCode = null;
|
|
264
|
+
chromeClosedByUser = false;
|
|
265
|
+
chromeProcess = spawn(chromePath, args, {
|
|
266
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
267
|
+
detached: false,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
chromeProcess.stderr?.on('data', (d: Buffer) => {
|
|
271
|
+
const line = d.toString().trim();
|
|
272
|
+
if (line && !line.includes('DevTools listening')) {
|
|
273
|
+
// Suppress noisy Chrome stderr
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
chromeProcess.on('exit', (code) => {
|
|
278
|
+
console.log(`[Empir3 Bridge] Chrome exited (code ${code})`);
|
|
279
|
+
chromeExitCode = code;
|
|
280
|
+
if (!shuttingDown) {
|
|
281
|
+
markChromeClosedByUser('process exit');
|
|
282
|
+
} else {
|
|
283
|
+
connected = false;
|
|
284
|
+
cdpWs = null;
|
|
285
|
+
stopTargetPolling();
|
|
286
|
+
if (browserReconnectTimer) {
|
|
287
|
+
clearTimeout(browserReconnectTimer);
|
|
288
|
+
browserReconnectTimer = null;
|
|
289
|
+
}
|
|
290
|
+
if (browserWs) { try { browserWs.close(); } catch {} }
|
|
291
|
+
browserWs = null;
|
|
292
|
+
}
|
|
293
|
+
chromeProcess = null;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await waitForChromeCDP(timeoutMs);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function ensureChromeReady(opts: { allowRelaunch?: boolean; launchTimeoutMs?: number } = {}): Promise<void> {
|
|
300
|
+
const allowRelaunch = opts.allowRelaunch === true;
|
|
301
|
+
const launchTimeoutMs = Math.max(1000, opts.launchTimeoutMs || CHROME_LAUNCH_TIMEOUT_MS);
|
|
302
|
+
if (chromeClosedByUser && !allowRelaunch) {
|
|
303
|
+
throw closedBrowserError();
|
|
304
|
+
}
|
|
305
|
+
if (await hasReachablePageTarget()) return;
|
|
306
|
+
if (chromeClosedByUser && allowRelaunch && chromeProcess) {
|
|
307
|
+
await terminateClosedChromeForRelaunch();
|
|
308
|
+
}
|
|
309
|
+
if (chromeClosedByUser && !allowRelaunch) {
|
|
310
|
+
throw closedBrowserError();
|
|
311
|
+
}
|
|
312
|
+
if (launchPromise) {
|
|
313
|
+
await launchPromise;
|
|
314
|
+
if (await hasReachablePageTarget()) return;
|
|
315
|
+
if (chromeClosedByUser && !allowRelaunch) {
|
|
316
|
+
throw closedBrowserError();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
launchPromise = (async () => {
|
|
321
|
+
if (chromeProcess) {
|
|
322
|
+
try {
|
|
323
|
+
await waitForChromeCDP(launchTimeoutMs);
|
|
324
|
+
startTargetPolling();
|
|
325
|
+
connectBrowserWs().catch(() => {});
|
|
326
|
+
return;
|
|
327
|
+
} catch (e: any) {
|
|
328
|
+
if (chromeProcess) throw e;
|
|
329
|
+
console.warn(`[Empir3 Bridge] Existing Chrome exited during startup: ${e?.message || e}`);
|
|
330
|
+
await sleep(800);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (chromeClosedByUser && !allowRelaunch) {
|
|
335
|
+
throw closedBrowserError();
|
|
336
|
+
}
|
|
337
|
+
await launchChrome(launchTimeoutMs);
|
|
338
|
+
startTargetPolling();
|
|
339
|
+
connectBrowserWs().catch(() => {});
|
|
340
|
+
})();
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await launchPromise;
|
|
344
|
+
} finally {
|
|
345
|
+
launchPromise = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function showChromeWindow(preferredUrl?: string): Promise<string> {
|
|
350
|
+
let href = preferredUrl || `http://localhost:${WRAPPER_PORT || PORT}/welcome`;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
await ensureChromeReady({ allowRelaunch: true, launchTimeoutMs: 5000 });
|
|
354
|
+
} catch (e: any) {
|
|
355
|
+
if (chromeProcess) {
|
|
356
|
+
console.warn(`[Empir3 Bridge] Open Bridge launched Chrome, but CDP was not ready yet: ${e?.message || e}`);
|
|
357
|
+
return href;
|
|
358
|
+
}
|
|
359
|
+
throw e;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!preferredUrl) {
|
|
363
|
+
href = '';
|
|
364
|
+
}
|
|
365
|
+
if (!href) {
|
|
366
|
+
try {
|
|
367
|
+
const current = await cdpEvaluate('location.href', 1000);
|
|
368
|
+
href = typeof current === 'string' && current ? current : '';
|
|
369
|
+
} catch {}
|
|
370
|
+
}
|
|
371
|
+
if (!href || href === 'about:blank') href = `http://localhost:${WRAPPER_PORT || PORT}/welcome`;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const info = await cdpSend('Browser.getWindowForTarget', { targetId: currentTargetId }, 1000);
|
|
375
|
+
if (info?.windowId) {
|
|
376
|
+
await cdpSend('Browser.setWindowBounds', {
|
|
377
|
+
windowId: info.windowId,
|
|
378
|
+
bounds: { windowState: 'normal' },
|
|
379
|
+
}, 1000);
|
|
380
|
+
}
|
|
381
|
+
} catch {}
|
|
382
|
+
|
|
383
|
+
try { await cdpSend('Page.bringToFront', {}, 1000); } catch {}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const current = await cdpEvaluate('location.href', 1000);
|
|
387
|
+
if (!current || current === 'about:blank') {
|
|
388
|
+
await cdpNavigate(href);
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
await cdpNavigate(href);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
try { await cdpSend('Page.bringToFront', {}, 1000); } catch {}
|
|
395
|
+
return href;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── CDP Connection ──────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Pick the best initial CDP target. The naive "just take pages[0]" path
|
|
402
|
+
* silently binds the bridge to a chrome:// or chrome-extension:// tab when
|
|
403
|
+
* one happens to be active — e.g. if chrome://extensions/ or another internal
|
|
404
|
+
* page is in the foreground. Once attached there, every navigate / evaluate
|
|
405
|
+
* fails with "Not connected" because content scripts can't reach internal pages.
|
|
406
|
+
*
|
|
407
|
+
* Strategy:
|
|
408
|
+
* 1. Prefer an http(s) page if any is open
|
|
409
|
+
* 2. Fall back to an existing about:blank
|
|
410
|
+
* 3. Otherwise PUT /json/new?about:blank and use that
|
|
411
|
+
* 4. As a last resort, return whatever was first (better than throwing)
|
|
412
|
+
*/
|
|
413
|
+
async function pickInitialTarget(): Promise<any> {
|
|
414
|
+
let res = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
415
|
+
let pages = res.filter((t: any) => t.type === 'page');
|
|
416
|
+
if (pages.length === 0) throw new Error('No page targets');
|
|
417
|
+
|
|
418
|
+
// 1. Prefer an http/https tab
|
|
419
|
+
const httpTab = pages.find((t: any) => /^https?:/i.test(t.url));
|
|
420
|
+
if (httpTab) return httpTab;
|
|
421
|
+
|
|
422
|
+
// 2. Fall back to about:blank
|
|
423
|
+
const blankTab = pages.find((t: any) => t.url === 'about:blank' || t.url === '');
|
|
424
|
+
if (blankTab) return blankTab;
|
|
425
|
+
|
|
426
|
+
// 3. Every visible tab is a trap (chrome://extensions/, devtools://, etc).
|
|
427
|
+
// Open a fresh about:blank via /json/new and re-poll.
|
|
428
|
+
try {
|
|
429
|
+
await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json/new?about:blank`, 'PUT');
|
|
430
|
+
// Tiny wait so Chrome registers the new target before we re-list
|
|
431
|
+
await sleep(150);
|
|
432
|
+
res = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
433
|
+
pages = res.filter((t: any) => t.type === 'page');
|
|
434
|
+
const fresh = pages.find((t: any) => t.url === 'about:blank' || t.url === '');
|
|
435
|
+
if (fresh) {
|
|
436
|
+
console.log('[Empir3 Bridge] All existing tabs were chrome:// — opened fresh about:blank');
|
|
437
|
+
return fresh;
|
|
438
|
+
}
|
|
439
|
+
} catch (e: any) {
|
|
440
|
+
console.log(`[Empir3 Bridge] /json/new failed: ${e.message} — falling back to first target`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 4. Last resort: use whatever was first. Better than throwing.
|
|
444
|
+
return pages[0];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function connectCDP(): Promise<void> {
|
|
448
|
+
const target = await pickInitialTarget();
|
|
449
|
+
currentTargetId = target.id;
|
|
450
|
+
|
|
451
|
+
if (cdpWs) {
|
|
452
|
+
cdpWs.close();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return new Promise((resolve, reject) => {
|
|
456
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
457
|
+
let settled = false;
|
|
458
|
+
const timer = setTimeout(() => fail(new Error('CDP WebSocket timeout')), 5000);
|
|
459
|
+
const fail = (e: Error) => {
|
|
460
|
+
if (settled) return;
|
|
461
|
+
settled = true;
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
if (cdpWs === ws) {
|
|
464
|
+
connected = false;
|
|
465
|
+
cdpWs = null;
|
|
466
|
+
lastCdpLivenessAt = 0;
|
|
467
|
+
}
|
|
468
|
+
try { ws.close(); } catch {}
|
|
469
|
+
reject(e);
|
|
470
|
+
};
|
|
471
|
+
const done = () => {
|
|
472
|
+
if (settled) return;
|
|
473
|
+
settled = true;
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
resolve();
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
ws.on('open', async () => {
|
|
479
|
+
cdpWs = ws;
|
|
480
|
+
connected = true;
|
|
481
|
+
lastCdpLivenessAt = 0;
|
|
482
|
+
console.log(`[Empir3 Bridge] CDP connected to: ${target.url}`);
|
|
483
|
+
|
|
484
|
+
if (!(await verifyCdpConnection())) {
|
|
485
|
+
fail(new Error('CDP liveness check failed'));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (process.env.BRIDGE_AUTO_DENY_PERMISSIONS === '1') {
|
|
490
|
+
// Permission setup is opt-in because Browser.setPermission can wedge page CDP sockets.
|
|
491
|
+
setTimeout(() => {
|
|
492
|
+
autoDenyPermissions().catch(() => {});
|
|
493
|
+
}, 0);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (process.env.BRIDGE_LEGACY_BLOCKING_PERMISSION_DENY === '1') {
|
|
497
|
+
// Legacy blocking permission path kept only for targeted debugging.
|
|
498
|
+
try {
|
|
499
|
+
const perms = ['geolocation','notifications','midi','midi-sysex','clipboard-read',
|
|
500
|
+
'clipboard-write','camera','microphone','background-sync','ambient-light-sensor',
|
|
501
|
+
'accelerometer','gyroscope','magnetometer','accessibility-events','payment-handler',
|
|
502
|
+
'idle-detection','storage-access','window-management'];
|
|
503
|
+
for (const name of perms) {
|
|
504
|
+
try {
|
|
505
|
+
await cdpSend('Browser.setPermission', {
|
|
506
|
+
permission: { name },
|
|
507
|
+
setting: 'denied',
|
|
508
|
+
});
|
|
509
|
+
} catch {} // some permissions may not be supported — ignore
|
|
510
|
+
}
|
|
511
|
+
console.log('[Empir3 Bridge] Auto-denied all permission prompts');
|
|
512
|
+
} catch {}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --fresh from launcher: wipe cookies + localStorage + IndexedDB across
|
|
516
|
+
// ALL origins. Runs once per Chrome launch (not per CDP reconnect — see
|
|
517
|
+
// freshConsumed below). Preserves the profile dir / extensions / settings.
|
|
518
|
+
if (process.env.EMPIR3_BRIDGE_FRESH === '1' && !freshConsumed) {
|
|
519
|
+
await wipeAllStorage();
|
|
520
|
+
freshConsumed = true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
done();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
ws.on('message', (data: Buffer) => {
|
|
527
|
+
try {
|
|
528
|
+
const msg = JSON.parse(data.toString());
|
|
529
|
+
if (msg.id && cdpCallbacks.has(msg.id)) {
|
|
530
|
+
const cb = cdpCallbacks.get(msg.id)!;
|
|
531
|
+
cdpCallbacks.delete(msg.id);
|
|
532
|
+
if (msg.error) {
|
|
533
|
+
cb.reject(new Error(msg.error.message));
|
|
534
|
+
} else {
|
|
535
|
+
lastCdpLivenessAt = Date.now();
|
|
536
|
+
cb.resolve(msg.result);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} catch {}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
ws.on('close', () => {
|
|
543
|
+
if (cdpWs === ws) {
|
|
544
|
+
connected = false;
|
|
545
|
+
cdpWs = null;
|
|
546
|
+
lastCdpLivenessAt = 0;
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
ws.on('error', (e) => {
|
|
551
|
+
fail(e);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function switchToTarget(targetId: string): Promise<void> {
|
|
557
|
+
const res = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
558
|
+
const target = res.find((t: any) => t.id === targetId);
|
|
559
|
+
if (!target) throw new Error(`Target ${targetId} not found`);
|
|
560
|
+
|
|
561
|
+
currentTargetId = targetId;
|
|
562
|
+
connected = true;
|
|
563
|
+
lastCdpLivenessAt = Date.now();
|
|
564
|
+
try { await cdpSend('Page.enable', {}, 2000); } catch {}
|
|
565
|
+
try { await cdpSend('Page.bringToFront', {}, 2000); } catch {}
|
|
566
|
+
console.log(`[Empir3 Bridge] Switched to target: ${target.url}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function markCdpDisconnected(reason: string) {
|
|
570
|
+
if (connected || cdpWs) {
|
|
571
|
+
console.warn(`[Empir3 Bridge] CDP connection reset: ${reason}`);
|
|
572
|
+
}
|
|
573
|
+
connected = false;
|
|
574
|
+
lastCdpLivenessAt = 0;
|
|
575
|
+
if (cdpWs) {
|
|
576
|
+
try { cdpWs.close(); } catch {}
|
|
577
|
+
}
|
|
578
|
+
cdpWs = null;
|
|
579
|
+
const err = new Error(`CDP connection reset: ${reason}`);
|
|
580
|
+
for (const [, cb] of cdpCallbacks) {
|
|
581
|
+
try { cb.reject(err); } catch {}
|
|
582
|
+
}
|
|
583
|
+
cdpCallbacks.clear();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function closePrimaryCdpForDirectCommand(): Promise<void> {
|
|
587
|
+
const ws = cdpWs;
|
|
588
|
+
if (!ws) return;
|
|
589
|
+
cdpWs = null;
|
|
590
|
+
connected = false;
|
|
591
|
+
const state = ws.readyState;
|
|
592
|
+
if (state === WebSocket.CLOSED || state === WebSocket.CLOSING) return;
|
|
593
|
+
await new Promise<void>((resolve) => {
|
|
594
|
+
let settled = false;
|
|
595
|
+
const finish = () => {
|
|
596
|
+
if (settled) return;
|
|
597
|
+
settled = true;
|
|
598
|
+
resolve();
|
|
599
|
+
};
|
|
600
|
+
ws.once('close', finish);
|
|
601
|
+
ws.once('error', finish);
|
|
602
|
+
try { ws.close(); } catch { finish(); }
|
|
603
|
+
setTimeout(finish, 250);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function withCdpCommandLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
608
|
+
const run = cdpCommandQueue.then(fn, fn);
|
|
609
|
+
cdpCommandQueue = run.then(() => undefined, () => undefined);
|
|
610
|
+
return run;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function cdpSendRaw(method: string, params: any = {}, timeoutMs = NAV_TIMEOUT, resetOnTimeout = true): Promise<any> {
|
|
614
|
+
return new Promise((resolve, reject) => {
|
|
615
|
+
if (!cdpWs || cdpWs.readyState !== WebSocket.OPEN) {
|
|
616
|
+
return reject(new Error('CDP not connected'));
|
|
617
|
+
}
|
|
618
|
+
const ws = cdpWs;
|
|
619
|
+
const id = cdpId++;
|
|
620
|
+
cdpCallbacks.set(id, { resolve, reject, ws });
|
|
621
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
622
|
+
setTimeout(() => {
|
|
623
|
+
if (cdpCallbacks.has(id)) {
|
|
624
|
+
cdpCallbacks.delete(id);
|
|
625
|
+
if (resetOnTimeout && cdpWs === ws) markCdpDisconnected(`timeout waiting for ${method}`);
|
|
626
|
+
reject(new Error(`CDP timeout: ${method}`));
|
|
627
|
+
}
|
|
628
|
+
}, timeoutMs);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function sendDirectCdpCommand(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
633
|
+
const target = await currentPageTarget();
|
|
634
|
+
await closePrimaryCdpForDirectCommand();
|
|
635
|
+
const result = await cdpSendViaDirectWs(target.webSocketDebuggerUrl, method, params, timeoutMs);
|
|
636
|
+
connected = true;
|
|
637
|
+
lastCdpLivenessAt = Date.now();
|
|
638
|
+
currentTargetId = target.id;
|
|
639
|
+
return result;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function sendDetachedCdpCommand(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
643
|
+
const target = await currentPageTarget();
|
|
644
|
+
const result = await cdpSendViaDirectWs(target.webSocketDebuggerUrl, method, params, timeoutMs);
|
|
645
|
+
connected = true;
|
|
646
|
+
lastCdpLivenessAt = Date.now();
|
|
647
|
+
currentTargetId = target.id;
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function ensureBrowserWsReady(timeoutMs = 5000): Promise<void> {
|
|
652
|
+
const deadline = Date.now() + Math.max(500, timeoutMs);
|
|
653
|
+
while (Date.now() < deadline) {
|
|
654
|
+
if (browserWs && browserWs.readyState === WebSocket.OPEN) return;
|
|
655
|
+
await connectBrowserWs().catch(() => {});
|
|
656
|
+
if (browserWs && browserWs.readyState === WebSocket.OPEN) return;
|
|
657
|
+
await sleep(100);
|
|
658
|
+
}
|
|
659
|
+
throw new Error('Browser WS not connected');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function isBrowserDomainMethod(method: string): boolean {
|
|
663
|
+
return method.startsWith('Browser.') || method.startsWith('Target.');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function sendCdpCommandViaBrowserSession(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
667
|
+
await ensureBrowserWsReady(timeoutMs);
|
|
668
|
+
|
|
669
|
+
if (isBrowserDomainMethod(method)) {
|
|
670
|
+
const result = await browserSend(method, params, timeoutMs);
|
|
671
|
+
connected = true;
|
|
672
|
+
lastCdpLivenessAt = Date.now();
|
|
673
|
+
return result;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const target = await currentPageTarget();
|
|
677
|
+
currentTargetId = target.id;
|
|
678
|
+
const attachResult = await browserSend('Target.attachToTarget', { targetId: target.id, flatten: true }, timeoutMs);
|
|
679
|
+
const sessionId = attachResult?.sessionId;
|
|
680
|
+
if (!sessionId) throw new Error('No sessionId from attachToTarget');
|
|
681
|
+
|
|
682
|
+
try {
|
|
683
|
+
const result = await browserSendWithSession(sessionId, method, params, timeoutMs);
|
|
684
|
+
connected = true;
|
|
685
|
+
lastCdpLivenessAt = Date.now();
|
|
686
|
+
return result;
|
|
687
|
+
} finally {
|
|
688
|
+
try { await browserSend('Target.detachFromTarget', { sessionId }, 1500); } catch {}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function verifyCdpConnection(): Promise<boolean> {
|
|
693
|
+
if (!connected || !cdpWs || cdpWs.readyState !== WebSocket.OPEN) return false;
|
|
694
|
+
if (Date.now() - lastCdpLivenessAt < CDP_LIVENESS_MAX_AGE_MS) return true;
|
|
695
|
+
try {
|
|
696
|
+
await cdpSendRaw('Runtime.evaluate', {
|
|
697
|
+
expression: '1',
|
|
698
|
+
returnByValue: true,
|
|
699
|
+
}, CDP_LIVENESS_TIMEOUT_MS, true);
|
|
700
|
+
lastCdpLivenessAt = Date.now();
|
|
701
|
+
return true;
|
|
702
|
+
} catch (e: any) {
|
|
703
|
+
console.warn(`[Empir3 Bridge] CDP liveness check failed: ${e?.message || e}`);
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function currentPageTarget(): Promise<any> {
|
|
709
|
+
const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
710
|
+
let target = targets.find((t: any) => t.type === 'page' && t.id === currentTargetId);
|
|
711
|
+
if (!target) {
|
|
712
|
+
target = await pickInitialTarget();
|
|
713
|
+
currentTargetId = target.id;
|
|
714
|
+
}
|
|
715
|
+
return target;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function hasReachablePageTarget(): Promise<boolean> {
|
|
719
|
+
try {
|
|
720
|
+
const target = await currentPageTarget();
|
|
721
|
+
if (!target?.webSocketDebuggerUrl) return false;
|
|
722
|
+
return true;
|
|
723
|
+
} catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function cdpSendViaDirectWs(wsUrl: string, method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
729
|
+
return new Promise((resolve, reject) => {
|
|
730
|
+
const ws = new WebSocket(wsUrl);
|
|
731
|
+
const id = cdpId++;
|
|
732
|
+
let settled = false;
|
|
733
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
734
|
+
const finish = (err?: Error, result?: any) => {
|
|
735
|
+
if (settled) return;
|
|
736
|
+
settled = true;
|
|
737
|
+
clearTimeout(timer);
|
|
738
|
+
try {
|
|
739
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
|
|
740
|
+
} catch {}
|
|
741
|
+
if (err) reject(err);
|
|
742
|
+
else resolve(result);
|
|
743
|
+
};
|
|
744
|
+
timer = setTimeout(() => {
|
|
745
|
+
try { (ws as any).terminate?.(); } catch {}
|
|
746
|
+
finish(new Error(`CDP direct timeout: ${method}`));
|
|
747
|
+
}, timeoutMs);
|
|
748
|
+
|
|
749
|
+
ws.on('open', () => {
|
|
750
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
ws.on('message', (data: Buffer) => {
|
|
754
|
+
if (settled) return;
|
|
755
|
+
try {
|
|
756
|
+
const msg = JSON.parse(data.toString());
|
|
757
|
+
if (msg.id !== id) return;
|
|
758
|
+
if (msg.error) finish(new Error(msg.error.message));
|
|
759
|
+
else {
|
|
760
|
+
lastCdpLivenessAt = Date.now();
|
|
761
|
+
finish(undefined, msg.result);
|
|
762
|
+
}
|
|
763
|
+
} catch (e: any) {
|
|
764
|
+
finish(e);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
ws.on('error', (e) => {
|
|
769
|
+
finish(e instanceof Error ? e : new Error(String(e)));
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
ws.on('close', () => {
|
|
773
|
+
finish(new Error(`CDP direct connection closed: ${method}`));
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function cdpSend(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
779
|
+
return withCdpCommandLock(async () => {
|
|
780
|
+
try {
|
|
781
|
+
return await sendDetachedCdpCommand(method, params, timeoutMs);
|
|
782
|
+
} catch (e: any) {
|
|
783
|
+
const message = String(e?.message || e);
|
|
784
|
+
if (!/CDP not connected|CDP timeout|CDP connection reset|WebSocket|Browser WS|connection closed/i.test(message)) {
|
|
785
|
+
throw e;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
await connectCDP().catch(() => {});
|
|
789
|
+
return sendDetachedCdpCommand(method, params, timeoutMs);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function cdpSendNoReset(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
795
|
+
return withCdpCommandLock(() => sendDetachedCdpCommand(method, params, timeoutMs));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function captureScreenshot(params: any): Promise<any> {
|
|
799
|
+
return withCdpCommandLock(async () => {
|
|
800
|
+
try { await sendDetachedCdpCommand('Page.enable', {}, 2000); } catch {}
|
|
801
|
+
try {
|
|
802
|
+
return await sendDetachedCdpCommand('Page.captureScreenshot', params, Math.max(CDP_COMMAND_TIMEOUT_MS, 15000));
|
|
803
|
+
} catch (e: any) {
|
|
804
|
+
if (!/timeout|not connected|connection reset|connection closed/i.test(String(e?.message || e))) throw e;
|
|
805
|
+
await connectCDP().catch(() => {});
|
|
806
|
+
try { await sendDetachedCdpCommand('Page.enable', {}, 2000); } catch {}
|
|
807
|
+
return sendDetachedCdpCommand('Page.captureScreenshot', params, Math.max(CDP_COMMAND_TIMEOUT_MS, 15000));
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function autoDenyPermissions(): Promise<void> {
|
|
813
|
+
const perms = ['geolocation','notifications','midi','midi-sysex','clipboard-read',
|
|
814
|
+
'clipboard-write','camera','microphone','background-sync','ambient-light-sensor',
|
|
815
|
+
'accelerometer','gyroscope','magnetometer','accessibility-events','payment-handler',
|
|
816
|
+
'idle-detection','storage-access','window-management'];
|
|
817
|
+
let denied = 0;
|
|
818
|
+
for (const name of perms) {
|
|
819
|
+
try {
|
|
820
|
+
await cdpSendRaw('Browser.setPermission', {
|
|
821
|
+
permission: { name },
|
|
822
|
+
setting: 'denied',
|
|
823
|
+
}, CDP_PERMISSION_TIMEOUT_MS, false);
|
|
824
|
+
denied++;
|
|
825
|
+
} catch {}
|
|
826
|
+
}
|
|
827
|
+
if (denied > 0) {
|
|
828
|
+
console.log(`[Empir3 Bridge] Auto-denied ${denied}/${perms.length} permission prompts`);
|
|
829
|
+
} else {
|
|
830
|
+
console.log('[Empir3 Bridge] Permission auto-deny skipped (Chrome did not acknowledge Browser.setPermission)');
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ─── --fresh: wipe site data ─────────────────────────────────
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Clear cookies, localStorage, IndexedDB, service workers, and cache for
|
|
838
|
+
* every origin that has stored data in this profile. Called once per Chrome
|
|
839
|
+
* launch when EMPIR3_BRIDGE_FRESH=1 (set by `npm start -- --fresh`).
|
|
840
|
+
*
|
|
841
|
+
* Strategy:
|
|
842
|
+
* 1. Network.clearBrowserCookies — wipes cookies for all origins
|
|
843
|
+
* 2. Network.clearBrowserCache — wipes the HTTP cache
|
|
844
|
+
* 3. Storage.clearDataForOrigin('*') with all data types — covers
|
|
845
|
+
* localStorage, IndexedDB, service workers, cache storage, etc.
|
|
846
|
+
*
|
|
847
|
+
* Extensions, settings, history, autofill, and the profile dir itself are
|
|
848
|
+
* preserved — only site-data is wiped. This matches the user-visible
|
|
849
|
+
* meaning of "fresh user state".
|
|
850
|
+
*/
|
|
851
|
+
async function wipeAllStorage(): Promise<void> {
|
|
852
|
+
console.log('[Empir3 Bridge] --fresh: clearing cookies + localStorage + IndexedDB...');
|
|
853
|
+
const dataTypes = [
|
|
854
|
+
'cookies',
|
|
855
|
+
'local_storage',
|
|
856
|
+
'indexeddb',
|
|
857
|
+
'service_workers',
|
|
858
|
+
'cache_storage',
|
|
859
|
+
'websql',
|
|
860
|
+
'file_systems',
|
|
861
|
+
'shader_cache',
|
|
862
|
+
].join(',');
|
|
863
|
+
|
|
864
|
+
let cleared = 0;
|
|
865
|
+
try {
|
|
866
|
+
await cdpSend('Network.clearBrowserCookies', {});
|
|
867
|
+
cleared++;
|
|
868
|
+
} catch (e: any) {
|
|
869
|
+
console.log(`[Empir3 Bridge] clearBrowserCookies failed: ${e.message}`);
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
await cdpSend('Network.clearBrowserCache', {});
|
|
873
|
+
cleared++;
|
|
874
|
+
} catch (e: any) {
|
|
875
|
+
console.log(`[Empir3 Bridge] clearBrowserCache failed: ${e.message}`);
|
|
876
|
+
}
|
|
877
|
+
// CDP requires a real origin URL — '*' isn't a wildcard. Pass an empty
|
|
878
|
+
// origin so Chrome treats it as a profile-wide clear when supported,
|
|
879
|
+
// and ALSO walk the storage list to catch every origin explicitly.
|
|
880
|
+
try {
|
|
881
|
+
await cdpSend('Storage.clearDataForOrigin', { origin: '*', storageTypes: dataTypes });
|
|
882
|
+
cleared++;
|
|
883
|
+
} catch {
|
|
884
|
+
// 'all' wildcard isn't supported on every Chrome build — fall through to
|
|
885
|
+
// the per-origin loop below.
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
const usage = await cdpSend('Storage.getUsageAndQuota', { origin: 'about:blank' });
|
|
889
|
+
// Some Chrome builds expose origins via Storage.trackIndexedDBForOrigin
|
|
890
|
+
// notifications, but the cheap path is enumerating navigation history.
|
|
891
|
+
// Skip if usage call failed — clearDataForOrigin('*') already did the job.
|
|
892
|
+
void usage;
|
|
893
|
+
} catch {}
|
|
894
|
+
console.log(`[Empir3 Bridge] --fresh: ${cleared}/3 wipe steps succeeded`);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ─── CDP Helpers ─────────────────────────────────────────────
|
|
898
|
+
|
|
899
|
+
async function cdpEvaluate(expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
900
|
+
const result = await cdpSend('Runtime.evaluate', {
|
|
901
|
+
expression,
|
|
902
|
+
returnByValue: true,
|
|
903
|
+
awaitPromise: true,
|
|
904
|
+
}, timeoutMs);
|
|
905
|
+
if (result.exceptionDetails) {
|
|
906
|
+
// CDP's exceptionDetails.text is usually just "Uncaught" — useless on its own.
|
|
907
|
+
// The actual error message + stack lives in exception.description.
|
|
908
|
+
// Trim trailing newlines and cap at 500 chars so error fits in one line.
|
|
909
|
+
const ex = result.exceptionDetails;
|
|
910
|
+
const desc = (ex.exception && ex.exception.description) || ex.text || 'JS evaluation error';
|
|
911
|
+
const trimmed = String(desc).split('\n')[0].slice(0, 500);
|
|
912
|
+
throw new Error(trimmed);
|
|
913
|
+
}
|
|
914
|
+
return result.result?.value;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function cdpNavigate(url: string): Promise<void> {
|
|
918
|
+
try {
|
|
919
|
+
await cdpSend('Page.navigate', { url }, Math.min(CDP_COMMAND_TIMEOUT_MS, 5000));
|
|
920
|
+
} catch (e: any) {
|
|
921
|
+
if (!(await waitForTargetUrl(url, 5000))) throw e;
|
|
922
|
+
}
|
|
923
|
+
// Chrome target metadata normally updates before page-level Runtime.evaluate
|
|
924
|
+
// is ready. That is enough for browser_control.open; text/snapshot can read
|
|
925
|
+
// the page afterward without making open wait on a slow eval loop.
|
|
926
|
+
await waitForTargetUrl(url, 8000);
|
|
927
|
+
try { await cdpSend('Page.enable'); } catch {}
|
|
928
|
+
try { await cdpEvaluate('document.readyState', 1200); } catch {}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function waitForTargetUrl(expectedUrl: string, timeoutMs: number): Promise<boolean> {
|
|
932
|
+
const deadline = Date.now() + timeoutMs;
|
|
933
|
+
const normalizeUrl = (u: string) => u.replace(/[#/]+$/, '');
|
|
934
|
+
while (Date.now() < deadline) {
|
|
935
|
+
try {
|
|
936
|
+
const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
937
|
+
const target = targets.find((t: any) => t.type === 'page' && t.id === currentTargetId)
|
|
938
|
+
|| targets.find((t: any) => t.type === 'page' && normalizeUrl(String(t.url || '')) === normalizeUrl(expectedUrl));
|
|
939
|
+
if (target && normalizeUrl(String(target.url || '')) === normalizeUrl(expectedUrl)) {
|
|
940
|
+
currentTargetId = target.id;
|
|
941
|
+
return true;
|
|
942
|
+
}
|
|
943
|
+
} catch {}
|
|
944
|
+
await sleep(250);
|
|
945
|
+
}
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ─── Per-Target Evaluation (for injecting into non-active tabs) ──
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Evaluate JS on a specific target by opening a temporary CDP WS connection.
|
|
953
|
+
* Does NOT switch the active target — the main cdpWs stays on currentTargetId.
|
|
954
|
+
*/
|
|
955
|
+
async function evaluateOnTarget(targetId: string, expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
956
|
+
// If it's the current target, just use the existing connection
|
|
957
|
+
if (targetId === currentTargetId && cdpWs) {
|
|
958
|
+
return cdpEvaluate(expression, timeoutMs);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Try direct WS via HTTP /json endpoint (works for bridge-known targets)
|
|
962
|
+
try {
|
|
963
|
+
const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
964
|
+
const target = targets.find((t: any) => t.id === targetId && t.type === 'page');
|
|
965
|
+
if (target?.webSocketDebuggerUrl) {
|
|
966
|
+
return await evaluateViaDirectWs(target.webSocketDebuggerUrl, expression, timeoutMs);
|
|
967
|
+
}
|
|
968
|
+
} catch {}
|
|
969
|
+
|
|
970
|
+
// Fallback: use browser WS with Target.attachToTarget (for user-opened tabs not in /json)
|
|
971
|
+
if (browserWs && browserWs.readyState === WebSocket.OPEN) {
|
|
972
|
+
return await evaluateViaBrowserSession(targetId, expression, timeoutMs);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
throw new Error(`Target ${targetId} not reachable via /json or browser WS`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Evaluate via a temporary direct WS connection to a target */
|
|
979
|
+
function evaluateViaDirectWs(wsUrl: string, expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
980
|
+
return withCdpCommandLock(() => new Promise((resolve, reject) => {
|
|
981
|
+
const ws = new WebSocket(wsUrl);
|
|
982
|
+
let settled = false;
|
|
983
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
984
|
+
const finish = (err?: Error, result?: any) => {
|
|
985
|
+
if (settled) return;
|
|
986
|
+
settled = true;
|
|
987
|
+
clearTimeout(timer);
|
|
988
|
+
try {
|
|
989
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
|
|
990
|
+
} catch {}
|
|
991
|
+
if (err) reject(err);
|
|
992
|
+
else resolve(result);
|
|
993
|
+
};
|
|
994
|
+
timer = setTimeout(() => {
|
|
995
|
+
try { (ws as any).terminate?.(); } catch {}
|
|
996
|
+
finish(new Error('evaluate-on-target timeout'));
|
|
997
|
+
}, timeoutMs);
|
|
998
|
+
|
|
999
|
+
ws.on('open', () => {
|
|
1000
|
+
const id = cdpId++;
|
|
1001
|
+
ws.send(JSON.stringify({
|
|
1002
|
+
id,
|
|
1003
|
+
method: 'Runtime.evaluate',
|
|
1004
|
+
params: { expression, returnByValue: true, awaitPromise: true },
|
|
1005
|
+
}));
|
|
1006
|
+
ws.on('message', (data: Buffer) => {
|
|
1007
|
+
try {
|
|
1008
|
+
const msg = JSON.parse(data.toString());
|
|
1009
|
+
if (msg.id === id) {
|
|
1010
|
+
if (msg.error) finish(new Error(msg.error.message));
|
|
1011
|
+
else finish(undefined, msg.result?.result?.value);
|
|
1012
|
+
}
|
|
1013
|
+
} catch (e: any) {
|
|
1014
|
+
finish(e);
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
ws.on('error', (e) => finish(e instanceof Error ? e : new Error(String(e))));
|
|
1020
|
+
ws.on('close', () => finish(new Error('evaluate-on-target connection closed')));
|
|
1021
|
+
}));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Evaluate via browser WS using Target.attachToTarget flat session */
|
|
1025
|
+
async function evaluateViaBrowserSession(targetId: string, expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
|
|
1026
|
+
if (!browserWs || browserWs.readyState !== WebSocket.OPEN) {
|
|
1027
|
+
throw new Error('Browser WS not connected');
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Attach to target to get a session
|
|
1031
|
+
const attachResult = await browserSend('Target.attachToTarget', { targetId, flatten: true }, timeoutMs);
|
|
1032
|
+
const sessionId = attachResult?.sessionId;
|
|
1033
|
+
if (!sessionId) throw new Error('No sessionId from attachToTarget');
|
|
1034
|
+
|
|
1035
|
+
try {
|
|
1036
|
+
// Evaluate via the flat session
|
|
1037
|
+
const evalResult = await browserSendWithSession(sessionId, 'Runtime.evaluate', {
|
|
1038
|
+
expression, returnByValue: true, awaitPromise: true,
|
|
1039
|
+
}, timeoutMs);
|
|
1040
|
+
|
|
1041
|
+
return evalResult?.result?.value;
|
|
1042
|
+
} finally {
|
|
1043
|
+
// Detach to clean up
|
|
1044
|
+
try {
|
|
1045
|
+
await browserSend('Target.detachFromTarget', { sessionId }, 1500);
|
|
1046
|
+
} catch {}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Send a command on the browser WS (no session) */
|
|
1051
|
+
function browserSend(method: string, params: any = {}, timeoutMs = 10000): Promise<any> {
|
|
1052
|
+
return new Promise((resolve, reject) => {
|
|
1053
|
+
if (!browserWs || browserWs.readyState !== WebSocket.OPEN) {
|
|
1054
|
+
return reject(new Error('Browser WS not connected'));
|
|
1055
|
+
}
|
|
1056
|
+
const id = browserCdpId++;
|
|
1057
|
+
browserCallbacks.set(id, { resolve, reject });
|
|
1058
|
+
browserWs.send(JSON.stringify({ id, method, params }));
|
|
1059
|
+
setTimeout(() => {
|
|
1060
|
+
if (browserCallbacks.has(id)) {
|
|
1061
|
+
browserCallbacks.delete(id);
|
|
1062
|
+
reject(new Error(`Browser WS timeout: ${method}`));
|
|
1063
|
+
}
|
|
1064
|
+
}, timeoutMs);
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Send a command on the browser WS with a flat-session sessionId */
|
|
1069
|
+
function browserSendWithSession(sessionId: string, method: string, params: any = {}, timeoutMs = 10000): Promise<any> {
|
|
1070
|
+
return new Promise((resolve, reject) => {
|
|
1071
|
+
if (!browserWs || browserWs.readyState !== WebSocket.OPEN) {
|
|
1072
|
+
return reject(new Error('Browser WS not connected'));
|
|
1073
|
+
}
|
|
1074
|
+
const id = browserCdpId++;
|
|
1075
|
+
browserCallbacks.set(id, { resolve, reject });
|
|
1076
|
+
browserWs.send(JSON.stringify({ id, sessionId, method, params }));
|
|
1077
|
+
setTimeout(() => {
|
|
1078
|
+
if (browserCallbacks.has(id)) {
|
|
1079
|
+
browserCallbacks.delete(id);
|
|
1080
|
+
reject(new Error(`Browser session timeout: ${method}`));
|
|
1081
|
+
}
|
|
1082
|
+
}, timeoutMs);
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Evaluate JS on ALL known page targets. Returns array of {targetId, url, ok, result/error}.
|
|
1088
|
+
*/
|
|
1089
|
+
async function evaluateOnAllTargets(expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any[]> {
|
|
1090
|
+
const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
1091
|
+
const pages = targets.filter((t: any) => t.type === 'page');
|
|
1092
|
+
const results: any[] = [];
|
|
1093
|
+
|
|
1094
|
+
for (const page of pages) {
|
|
1095
|
+
try {
|
|
1096
|
+
const result = await evaluateOnTarget(page.id, expression, timeoutMs);
|
|
1097
|
+
results.push({ targetId: page.id, url: page.url, ok: true, result });
|
|
1098
|
+
} catch (e: any) {
|
|
1099
|
+
results.push({ targetId: page.id, url: page.url, ok: false, error: e.message });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return results;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// ─── Target Discovery — poll for new tabs and auto-inject ────
|
|
1106
|
+
|
|
1107
|
+
// Auto-inject must not hold the single global CDP command lock for the full 10s
|
|
1108
|
+
// default — a stalled eval against a heavy page (e.g. the bridge's own welcome
|
|
1109
|
+
// console) would block every other CDP op and starve the shared event loop, so
|
|
1110
|
+
// trivial HTTP handlers (/api/status, /api/relay-status) queue for seconds and the
|
|
1111
|
+
// tray's liveness poll times out. Cap it short so a stuck inject releases the lock fast.
|
|
1112
|
+
const AUTO_INJECT_TIMEOUT_MS = 2000;
|
|
1113
|
+
let pollInFlight = false;
|
|
1114
|
+
async function pollTargets() {
|
|
1115
|
+
// Never overlap: a single auto-inject can await up to AUTO_INJECT_TIMEOUT_MS, which
|
|
1116
|
+
// is longer than the 500ms poll interval. Without this guard, successive ticks pile
|
|
1117
|
+
// concurrent fresh-WS Runtime.evaluate calls onto the loop — the saturation storm.
|
|
1118
|
+
if (pollInFlight) return;
|
|
1119
|
+
pollInFlight = true;
|
|
1120
|
+
try {
|
|
1121
|
+
const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
1122
|
+
const pages = targets.filter((t: any) => t.type === 'page');
|
|
1123
|
+
if (pages.length === 0) {
|
|
1124
|
+
// Don't latch closed on a single empty poll — route through the debounced
|
|
1125
|
+
// confirmation, which re-polls /json a few times before concluding the user
|
|
1126
|
+
// closed the browser. Prevents the transient-empty post-refresh flap.
|
|
1127
|
+
markClosedIfNoPageTargets('no page targets in poll').catch(() => {});
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
for (const page of pages) {
|
|
1132
|
+
const prevUrl = knownTargets.get(page.id);
|
|
1133
|
+
const isNew = prevUrl === undefined;
|
|
1134
|
+
const urlChanged = prevUrl !== undefined && prevUrl !== page.url;
|
|
1135
|
+
|
|
1136
|
+
// Skip chrome:// and about: pages — can't inject JS into them.
|
|
1137
|
+
// Don't mark them as known so we re-check when they navigate to a real URL.
|
|
1138
|
+
if (page.url.startsWith('chrome://') || page.url.startsWith('about:') || page.url.startsWith('devtools://')) {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (isNew || urlChanged) {
|
|
1143
|
+
knownTargets.set(page.id, page.url);
|
|
1144
|
+
if (isNew) {
|
|
1145
|
+
console.log(`[Empir3 Bridge] New tab detected: ${page.url} (${page.id})`);
|
|
1146
|
+
} else {
|
|
1147
|
+
console.log(`[Empir3 Bridge] Tab navigated: ${prevUrl} → ${page.url} (${page.id})`);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Auto-inject registered script (short timeout — see AUTO_INJECT_TIMEOUT_MS).
|
|
1151
|
+
if (autoInjectScript) {
|
|
1152
|
+
try {
|
|
1153
|
+
await evaluateOnTarget(page.id, autoInjectScript, AUTO_INJECT_TIMEOUT_MS);
|
|
1154
|
+
console.log(`[Empir3 Bridge] Auto-injected into: ${page.url}`);
|
|
1155
|
+
} catch (e: any) {
|
|
1156
|
+
console.log(`[Empir3 Bridge] Auto-inject failed for ${page.url}: ${e.message?.slice(0, 60)}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Prune destroyed targets
|
|
1163
|
+
const currentIds = new Set(pages.map((p: any) => p.id));
|
|
1164
|
+
for (const id of knownTargets.keys()) {
|
|
1165
|
+
if (!currentIds.has(id)) {
|
|
1166
|
+
knownTargets.delete(id);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
} catch {
|
|
1170
|
+
// Bridge may be busy or Chrome not ready
|
|
1171
|
+
} finally {
|
|
1172
|
+
pollInFlight = false;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function startTargetPolling() {
|
|
1177
|
+
if (targetPollTimer) return;
|
|
1178
|
+
// Seed known targets
|
|
1179
|
+
pollTargets();
|
|
1180
|
+
// Poll every 500ms for new tabs — fast enough to inject overlay before user interacts
|
|
1181
|
+
targetPollTimer = setInterval(pollTargets, 500);
|
|
1182
|
+
console.log('[Empir3 Bridge] Target polling started (500ms interval)');
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function stopTargetPolling() {
|
|
1186
|
+
if (targetPollTimer) {
|
|
1187
|
+
clearInterval(targetPollTimer);
|
|
1188
|
+
targetPollTimer = null;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ─── Browser-Level Target Discovery ─────────────────────────
|
|
1193
|
+
// Connects to Chrome's browser WS to receive Target.targetCreated events
|
|
1194
|
+
// for ALL tabs — including ones the user opens manually.
|
|
1195
|
+
|
|
1196
|
+
async function connectBrowserWs(): Promise<void> {
|
|
1197
|
+
if (browserWs && browserWs.readyState === WebSocket.OPEN) return;
|
|
1198
|
+
if (browserWsConnecting) return;
|
|
1199
|
+
browserWsConnecting = true;
|
|
1200
|
+
try {
|
|
1201
|
+
const versionInfo = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json/version`);
|
|
1202
|
+
const wsUrl = versionInfo.webSocketDebuggerUrl;
|
|
1203
|
+
if (!wsUrl) {
|
|
1204
|
+
console.log('[Empir3 Bridge] No browser WS URL available');
|
|
1205
|
+
browserWsConnecting = false;
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
return new Promise((resolve) => {
|
|
1210
|
+
const ws = new WebSocket(wsUrl);
|
|
1211
|
+
let settled = false;
|
|
1212
|
+
const finish = () => {
|
|
1213
|
+
if (settled) return;
|
|
1214
|
+
settled = true;
|
|
1215
|
+
browserWsConnecting = false;
|
|
1216
|
+
resolve();
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
ws.on('open', () => {
|
|
1220
|
+
browserWs = ws;
|
|
1221
|
+
if (browserReconnectTimer) {
|
|
1222
|
+
clearTimeout(browserReconnectTimer);
|
|
1223
|
+
browserReconnectTimer = null;
|
|
1224
|
+
}
|
|
1225
|
+
console.log('[Empir3 Bridge] Browser-level WS connected');
|
|
1226
|
+
// Subscribe to all target events
|
|
1227
|
+
const id = browserCdpId++;
|
|
1228
|
+
ws.send(JSON.stringify({ id, method: 'Target.setDiscoverTargets', params: { discover: true } }));
|
|
1229
|
+
finish();
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
ws.on('message', (data: Buffer) => {
|
|
1233
|
+
try {
|
|
1234
|
+
const msg = JSON.parse(data.toString());
|
|
1235
|
+
|
|
1236
|
+
// Handle responses to our commands
|
|
1237
|
+
if (msg.id && browserCallbacks.has(msg.id)) {
|
|
1238
|
+
const cb = browserCallbacks.get(msg.id)!;
|
|
1239
|
+
browserCallbacks.delete(msg.id);
|
|
1240
|
+
if (msg.error) cb.reject(new Error(msg.error.message));
|
|
1241
|
+
else cb.resolve(msg.result);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Handle target events
|
|
1245
|
+
if (msg.method === 'Target.targetCreated') {
|
|
1246
|
+
const info = msg.params?.targetInfo;
|
|
1247
|
+
if (info?.type === 'page') {
|
|
1248
|
+
handleNewTarget(info.targetId, info.url);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (msg.method === 'Target.targetInfoChanged') {
|
|
1253
|
+
const info = msg.params?.targetInfo;
|
|
1254
|
+
if (info?.type === 'page') {
|
|
1255
|
+
handleTargetUrlChange(info.targetId, info.url);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (msg.method === 'Target.targetDestroyed') {
|
|
1260
|
+
knownTargets.delete(msg.params?.targetId);
|
|
1261
|
+
markClosedIfNoPageTargets('last page target destroyed').catch(() => {});
|
|
1262
|
+
}
|
|
1263
|
+
} catch {}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
ws.on('close', () => {
|
|
1267
|
+
if (browserWs === ws) {
|
|
1268
|
+
browserWs = null;
|
|
1269
|
+
if (!chromeProcess || chromeClosedByUser || shuttingDown) {
|
|
1270
|
+
console.log('[Empir3 Bridge] Browser WS disconnected');
|
|
1271
|
+
} else if (!browserReconnectTimer) {
|
|
1272
|
+
console.log('[Empir3 Bridge] Browser WS disconnected - reconnecting in 3s');
|
|
1273
|
+
browserReconnectTimer = setTimeout(() => {
|
|
1274
|
+
browserReconnectTimer = null;
|
|
1275
|
+
connectBrowserWs().catch(() => {});
|
|
1276
|
+
}, 3000);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
finish();
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
ws.on('error', () => {
|
|
1283
|
+
if (browserWs === ws) browserWs = null;
|
|
1284
|
+
finish(); // don't block startup
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
setTimeout(finish, 5000); // timeout fallback
|
|
1288
|
+
});
|
|
1289
|
+
} catch (e: any) {
|
|
1290
|
+
browserWsConnecting = false;
|
|
1291
|
+
console.log(`[Empir3 Bridge] Browser WS connect failed: ${e.message?.slice(0, 60)}`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function handleNewTarget(targetId: string, url: string) {
|
|
1296
|
+
// Skip chrome:// and internal pages
|
|
1297
|
+
if (url.startsWith('chrome://') || url.startsWith('about:') || url.startsWith('devtools://')) return;
|
|
1298
|
+
|
|
1299
|
+
if (!knownTargets.has(targetId)) {
|
|
1300
|
+
knownTargets.set(targetId, url);
|
|
1301
|
+
console.log(`[Empir3 Bridge] [Browser WS] New tab: ${url} (${targetId.slice(0, 8)})`);
|
|
1302
|
+
autoInjectIntoTarget(targetId, url);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function handleTargetUrlChange(targetId: string, url: string) {
|
|
1307
|
+
if (url.startsWith('chrome://') || url.startsWith('about:') || url.startsWith('devtools://')) return;
|
|
1308
|
+
|
|
1309
|
+
const prevUrl = knownTargets.get(targetId);
|
|
1310
|
+
if (prevUrl !== url) {
|
|
1311
|
+
knownTargets.set(targetId, url);
|
|
1312
|
+
console.log(`[Empir3 Bridge] [Browser WS] Tab navigated: ${(prevUrl || '(new)').slice(0, 40)} → ${url.slice(0, 40)}`);
|
|
1313
|
+
autoInjectIntoTarget(targetId, url);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
async function autoInjectIntoTarget(targetId: string, url: string) {
|
|
1318
|
+
if (!autoInjectScript) return;
|
|
1319
|
+
// Small delay — page may still be loading
|
|
1320
|
+
await sleep(300);
|
|
1321
|
+
try {
|
|
1322
|
+
await evaluateOnTarget(targetId, autoInjectScript);
|
|
1323
|
+
console.log(`[Empir3 Bridge] Auto-injected into: ${url.slice(0, 50)}`);
|
|
1324
|
+
} catch (e: any) {
|
|
1325
|
+
// Retry once after a longer delay (page might not be ready)
|
|
1326
|
+
await sleep(1000);
|
|
1327
|
+
try {
|
|
1328
|
+
await evaluateOnTarget(targetId, autoInjectScript);
|
|
1329
|
+
console.log(`[Empir3 Bridge] Auto-injected into (retry): ${url.slice(0, 50)}`);
|
|
1330
|
+
} catch {
|
|
1331
|
+
console.log(`[Empir3 Bridge] Auto-inject failed for ${url.slice(0, 40)}: ${e.message?.slice(0, 40)}`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
async function cdpScreenshot(maxWidth?: number): Promise<Buffer> {
|
|
1337
|
+
const params: any = { format: 'jpeg', quality: 80 };
|
|
1338
|
+
// If maxWidth specified, cap output image dimensions (accounting for devicePixelRatio)
|
|
1339
|
+
if (maxWidth && maxWidth > 0) {
|
|
1340
|
+
try {
|
|
1341
|
+
const evalResult = await cdpSend('Runtime.evaluate', {
|
|
1342
|
+
expression: 'JSON.stringify({w:window.innerWidth,h:window.innerHeight,dpr:window.devicePixelRatio||1})',
|
|
1343
|
+
returnByValue: true,
|
|
1344
|
+
});
|
|
1345
|
+
const vp = JSON.parse(evalResult.result.value);
|
|
1346
|
+
if (vp.w > 0 && vp.h > 0 && vp.dpr > 0) {
|
|
1347
|
+
const physicalWidth = vp.w * vp.dpr;
|
|
1348
|
+
if (physicalWidth > maxWidth) {
|
|
1349
|
+
const scale = Math.max(0.1, Math.min(2.0, maxWidth / physicalWidth));
|
|
1350
|
+
params.clip = { x: 0, y: 0, width: vp.w, height: vp.h, scale };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
} catch {}
|
|
1354
|
+
}
|
|
1355
|
+
const result = await captureScreenshot(params);
|
|
1356
|
+
return Buffer.from(result.data, 'base64');
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function getAccessibilityTree(filter: string = 'interactive'): Promise<any[]> {
|
|
1360
|
+
// Use DOM + accessibility API to build element refs
|
|
1361
|
+
const nodes: any[] = [];
|
|
1362
|
+
refMap.clear();
|
|
1363
|
+
refCounter = 0;
|
|
1364
|
+
|
|
1365
|
+
const jsCode = `(function() {
|
|
1366
|
+
const results = [];
|
|
1367
|
+
const interactiveRoles = new Set([
|
|
1368
|
+
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
1369
|
+
'menuitem', 'tab', 'switch', 'slider', 'spinbutton', 'searchbox',
|
|
1370
|
+
'option', 'menuitemcheckbox', 'menuitemradio', 'treeitem'
|
|
1371
|
+
]);
|
|
1372
|
+
const interactiveTags = new Set([
|
|
1373
|
+
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY'
|
|
1374
|
+
]);
|
|
1375
|
+
|
|
1376
|
+
const walker = document.createTreeWalker(
|
|
1377
|
+
document.body,
|
|
1378
|
+
NodeFilter.SHOW_ELEMENT,
|
|
1379
|
+
null
|
|
1380
|
+
);
|
|
1381
|
+
|
|
1382
|
+
let node = walker.currentNode;
|
|
1383
|
+
let refIdx = 0;
|
|
1384
|
+
const visited = new Set();
|
|
1385
|
+
|
|
1386
|
+
function processNode(el) {
|
|
1387
|
+
if (visited.has(el)) return;
|
|
1388
|
+
visited.add(el);
|
|
1389
|
+
|
|
1390
|
+
// Skip the bridge's own injected overlay UI (id="empir3-*" roots and
|
|
1391
|
+
// everything inside them) — its chat input, toolbar, and mode buttons
|
|
1392
|
+
// are "interactive" and would otherwise pollute the snapshot with refs
|
|
1393
|
+
// that don't belong to the page under test.
|
|
1394
|
+
if (el.closest && el.closest('[id^="empir3-"]')) return;
|
|
1395
|
+
|
|
1396
|
+
const role = el.getAttribute('role') || '';
|
|
1397
|
+
const tag = el.tagName;
|
|
1398
|
+
// Interactive-state ARIA attributes mark click targets that are otherwise
|
|
1399
|
+
// plain <div>/<span> with addEventListener handlers (which can't be read
|
|
1400
|
+
// from the DOM). Widening on these + contenteditable + an explicit onclick
|
|
1401
|
+
// attribute catches more styled-div click targets without flooding.
|
|
1402
|
+
const interactiveAttrs = ['aria-haspopup', 'aria-expanded', 'aria-pressed', 'aria-checked', 'aria-selected', 'onclick'];
|
|
1403
|
+
const hasInteractiveAttr = interactiveAttrs.some(a => el.hasAttribute(a));
|
|
1404
|
+
const editable = el.isContentEditable === true;
|
|
1405
|
+
const isInteractive = interactiveRoles.has(role) ||
|
|
1406
|
+
interactiveTags.has(tag) ||
|
|
1407
|
+
el.onclick ||
|
|
1408
|
+
el.hasAttribute('tabindex') ||
|
|
1409
|
+
(el.hasAttribute('data-testid')) ||
|
|
1410
|
+
hasInteractiveAttr ||
|
|
1411
|
+
editable ||
|
|
1412
|
+
getComputedStyle(el).cursor === 'pointer';
|
|
1413
|
+
|
|
1414
|
+
if (${filter === 'all' ? 'true' : 'isInteractive'}) {
|
|
1415
|
+
const rect = el.getBoundingClientRect();
|
|
1416
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
1417
|
+
if (rect.right < 0 || rect.bottom < 0 || rect.left > window.innerWidth || rect.top > window.innerHeight) return;
|
|
1418
|
+
if (getComputedStyle(el).display === 'none') return;
|
|
1419
|
+
if (getComputedStyle(el).visibility === 'hidden') return;
|
|
1420
|
+
|
|
1421
|
+
const name = el.getAttribute('aria-label') ||
|
|
1422
|
+
el.getAttribute('title') ||
|
|
1423
|
+
el.getAttribute('placeholder') ||
|
|
1424
|
+
el.innerText?.slice(0, 80)?.trim() || '';
|
|
1425
|
+
|
|
1426
|
+
const ref = 'e' + refIdx++;
|
|
1427
|
+
// Store a unique path for later retrieval
|
|
1428
|
+
el.setAttribute('data-empir3-ref', ref);
|
|
1429
|
+
|
|
1430
|
+
results.push({
|
|
1431
|
+
ref: ref,
|
|
1432
|
+
role: role || tag.toLowerCase(),
|
|
1433
|
+
name: name,
|
|
1434
|
+
bounds: {
|
|
1435
|
+
x: Math.round(rect.x),
|
|
1436
|
+
y: Math.round(rect.y),
|
|
1437
|
+
width: Math.round(rect.width),
|
|
1438
|
+
height: Math.round(rect.height),
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
processNode(node);
|
|
1445
|
+
while (node = walker.nextNode()) {
|
|
1446
|
+
processNode(node);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return JSON.stringify(results);
|
|
1450
|
+
})()`;
|
|
1451
|
+
|
|
1452
|
+
const resultStr = await cdpEvaluate(jsCode);
|
|
1453
|
+
return JSON.parse(resultStr || '[]');
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function clickByRef(ref: string): Promise<void> {
|
|
1457
|
+
// Scroll the element into view, resolve its CSS-pixel center, then issue a
|
|
1458
|
+
// REAL mouse press/release there via clickByXY. The old path used el.click(),
|
|
1459
|
+
// a synthetic event that silently no-ops on React-Native-Web Pressables
|
|
1460
|
+
// (they listen for pointer down/up, not a synthetic click) — the root cause
|
|
1461
|
+
// of "click_ref did nothing" on RN-Web. clickByXY already fires a trusted
|
|
1462
|
+
// CDP mouse sequence at CSS coords, so this reuses the verified click path
|
|
1463
|
+
// without touching its (CSS-pixel) coordinate contract.
|
|
1464
|
+
const centerStr = await cdpEvaluate(`(function() {
|
|
1465
|
+
const el = document.querySelector('[data-empir3-ref="${ref}"]');
|
|
1466
|
+
if (!el) throw new Error('Element not found: ${ref}');
|
|
1467
|
+
(el.scrollIntoViewIfNeeded ? el.scrollIntoViewIfNeeded() : el.scrollIntoView({ block: 'center', inline: 'center' }));
|
|
1468
|
+
const r = el.getBoundingClientRect();
|
|
1469
|
+
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
1470
|
+
})()`);
|
|
1471
|
+
const c = JSON.parse(centerStr || '{}');
|
|
1472
|
+
if (typeof c.x !== 'number' || typeof c.y !== 'number') throw new Error('Element not found: ' + ref);
|
|
1473
|
+
await clickByXY(c.x, c.y);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function clickByXY(x: number, y: number): Promise<void> {
|
|
1477
|
+
await cdpSend('Input.dispatchMouseEvent', {
|
|
1478
|
+
type: 'mouseMoved',
|
|
1479
|
+
x,
|
|
1480
|
+
y,
|
|
1481
|
+
button: 'none',
|
|
1482
|
+
});
|
|
1483
|
+
await sleep(40);
|
|
1484
|
+
await cdpSend('Input.dispatchMouseEvent', {
|
|
1485
|
+
type: 'mousePressed',
|
|
1486
|
+
x,
|
|
1487
|
+
y,
|
|
1488
|
+
button: 'left',
|
|
1489
|
+
buttons: 1,
|
|
1490
|
+
clickCount: 1,
|
|
1491
|
+
});
|
|
1492
|
+
await sleep(40);
|
|
1493
|
+
await cdpSend('Input.dispatchMouseEvent', {
|
|
1494
|
+
type: 'mouseReleased',
|
|
1495
|
+
x,
|
|
1496
|
+
y,
|
|
1497
|
+
button: 'left',
|
|
1498
|
+
buttons: 0,
|
|
1499
|
+
clickCount: 1,
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
async function typeText(text: string): Promise<void> {
|
|
1504
|
+
for (const char of text) {
|
|
1505
|
+
await cdpSend('Input.dispatchKeyEvent', {
|
|
1506
|
+
type: 'keyDown',
|
|
1507
|
+
text: char,
|
|
1508
|
+
key: char,
|
|
1509
|
+
unmodifiedText: char,
|
|
1510
|
+
});
|
|
1511
|
+
await cdpSend('Input.dispatchKeyEvent', {
|
|
1512
|
+
type: 'keyUp',
|
|
1513
|
+
key: char,
|
|
1514
|
+
});
|
|
1515
|
+
await sleep(20);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
async function pressKey(key: string): Promise<void> {
|
|
1520
|
+
// Map common key names to CDP key codes
|
|
1521
|
+
// `text` matters: CDP only fires a key's DEFAULT ACTION (form submit on
|
|
1522
|
+
// Enter, newline in a textarea, the space char) when the keyDown carries the
|
|
1523
|
+
// produced character — same reason typeText() sets text per char. Without it
|
|
1524
|
+
// the event reaches JS listeners but the browser does nothing (verified
|
|
1525
|
+
// 2026-06-02: Enter on a focused search field never submitted). Keys with no
|
|
1526
|
+
// character (Tab/Escape/arrows) intentionally have no text.
|
|
1527
|
+
const keyMap: Record<string, { key: string; code: string; keyCode: number; text?: string }> = {
|
|
1528
|
+
'Enter': { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
|
|
1529
|
+
'Tab': { key: 'Tab', code: 'Tab', keyCode: 9 },
|
|
1530
|
+
'Escape': { key: 'Escape', code: 'Escape', keyCode: 27 },
|
|
1531
|
+
'Backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 },
|
|
1532
|
+
'Delete': { key: 'Delete', code: 'Delete', keyCode: 46 },
|
|
1533
|
+
'ArrowUp': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
|
1534
|
+
'ArrowDown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
|
1535
|
+
'ArrowLeft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
|
1536
|
+
'ArrowRight': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
|
1537
|
+
'Space': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
// Handle modifier combos like "Control+a"
|
|
1541
|
+
if (key.includes('+')) {
|
|
1542
|
+
const parts = key.split('+');
|
|
1543
|
+
const modifier = parts[0].toLowerCase();
|
|
1544
|
+
const mainKey = parts[1];
|
|
1545
|
+
|
|
1546
|
+
const modifiers = modifier === 'control' || modifier === 'ctrl' ? 2 :
|
|
1547
|
+
modifier === 'shift' ? 8 :
|
|
1548
|
+
modifier === 'alt' ? 1 : 0;
|
|
1549
|
+
|
|
1550
|
+
await cdpSend('Input.dispatchKeyEvent', {
|
|
1551
|
+
type: 'keyDown',
|
|
1552
|
+
key: mainKey.length === 1 ? mainKey : mainKey,
|
|
1553
|
+
code: `Key${mainKey.toUpperCase()}`,
|
|
1554
|
+
modifiers,
|
|
1555
|
+
});
|
|
1556
|
+
await cdpSend('Input.dispatchKeyEvent', {
|
|
1557
|
+
type: 'keyUp',
|
|
1558
|
+
key: mainKey,
|
|
1559
|
+
modifiers,
|
|
1560
|
+
});
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const mapped = keyMap[key] || { key, code: key, keyCode: 0 };
|
|
1565
|
+
// A bare single character passed to press ("a") should also type, so give it
|
|
1566
|
+
// text too; mapped.text wins for the named keys above.
|
|
1567
|
+
const text = mapped.text !== undefined ? mapped.text : (key.length === 1 ? key : undefined);
|
|
1568
|
+
const down: any = {
|
|
1569
|
+
type: 'keyDown',
|
|
1570
|
+
key: mapped.key,
|
|
1571
|
+
code: mapped.code,
|
|
1572
|
+
windowsVirtualKeyCode: mapped.keyCode,
|
|
1573
|
+
};
|
|
1574
|
+
if (text !== undefined) { down.text = text; down.unmodifiedText = text; }
|
|
1575
|
+
await cdpSend('Input.dispatchKeyEvent', down);
|
|
1576
|
+
await cdpSend('Input.dispatchKeyEvent', {
|
|
1577
|
+
type: 'keyUp',
|
|
1578
|
+
key: mapped.key,
|
|
1579
|
+
code: mapped.code,
|
|
1580
|
+
windowsVirtualKeyCode: mapped.keyCode,
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// ─── Welcome Page ────────────────────────────────────────────
|
|
1585
|
+
|
|
1586
|
+
function getWelcomeHtml() {
|
|
1587
|
+
const api = '';
|
|
1588
|
+
return `<!doctype html>
|
|
1589
|
+
<html>
|
|
1590
|
+
<head>
|
|
1591
|
+
<meta charset="utf-8">
|
|
1592
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1593
|
+
<title>empir3 Bridge Setup</title>
|
|
1594
|
+
<style>
|
|
1595
|
+
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
|
1596
|
+
* { box-sizing: border-box; }
|
|
1597
|
+
:root { --bg:#f5efe2; --bg2:#ede5d3; --surface:rgba(255,252,244,.88); --surface-strong:rgba(255,252,244,.96); --surface-muted:rgba(245,238,224,.72); --line:rgba(28,22,10,.12); --line-strong:rgba(28,22,10,.18); --text:#1c160a; --muted:#5a4f3d; --soft:#8a8070; --accent:#6b4ef0; --accent-2:#8c6bff; --good:#10b981; --shadow:rgba(28,22,10,.08); }
|
|
1598
|
+
body { margin:0; min-height:100vh; background:linear-gradient(rgba(28,22,10,.035) 1px, transparent 1px) 0 0/42px 42px, linear-gradient(90deg, rgba(28,22,10,.035) 1px, transparent 1px) 0 0/42px 42px, var(--bg); color:var(--text); font-family:'Outfit',system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
|
1599
|
+
main { width:min(1180px, calc(100vw - 40px)); margin:0 auto; padding:64px 0; display:grid; grid-template-columns:minmax(280px,380px) 1fr; gap:48px; align-items:start; }
|
|
1600
|
+
.brand { padding-top:8px; }
|
|
1601
|
+
.wordmark { display:inline-flex; align-items:baseline; gap:0; font-weight:900; letter-spacing:-.055em; line-height:.9; color:var(--text); }
|
|
1602
|
+
.wordmark .three, .brand-inline .three { color:var(--accent); }
|
|
1603
|
+
.wordmark-xl { font-size:clamp(58px, 7vw, 88px); margin-bottom:8px; }
|
|
1604
|
+
.brand-inline { display:inline-flex; align-items:baseline; gap:0; font-weight:800; letter-spacing:-.035em; color:var(--text); }
|
|
1605
|
+
.product-kicker { margin:0 0 42px; color:var(--soft); text-transform:uppercase; font-size:12px; font-weight:700; letter-spacing:.18em; }
|
|
1606
|
+
h1 { margin:0 0 16px; font-size:34px; line-height:1.05; letter-spacing:0; max-width:10ch; }
|
|
1607
|
+
h2 { margin:0; font-size:23px; line-height:1.2; letter-spacing:-.01em; }
|
|
1608
|
+
p { margin:0; color:var(--muted); line-height:1.55; }
|
|
1609
|
+
.lede { max-width:35ch; font-size:17px; line-height:1.62; }
|
|
1610
|
+
.shell { display:grid; gap:18px; }
|
|
1611
|
+
.mode-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
|
1612
|
+
.mode { text-align:left; min-height:132px; padding:18px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--text); cursor:pointer; box-shadow:0 10px 28px var(--shadow); }
|
|
1613
|
+
.mode strong { display:block; font-size:18px; margin-bottom:8px; font-weight:800; letter-spacing:-.01em; }
|
|
1614
|
+
.mode > span { display:block; color:var(--muted); line-height:1.45; }
|
|
1615
|
+
.mode.active { border-color:color-mix(in srgb, var(--accent) 60%, var(--line)); background:color-mix(in srgb, var(--accent) 9%, var(--surface-strong)); }
|
|
1616
|
+
.panel { display:none; padding:22px; border:1px solid var(--line); border-radius:8px; background:var(--surface-strong); box-shadow:0 14px 36px var(--shadow); }
|
|
1617
|
+
.panel.active { display:grid; gap:16px; }
|
|
1618
|
+
ol { margin:0; padding-left:22px; color:var(--muted); line-height:1.6; }
|
|
1619
|
+
code, pre { font-family:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:13px; }
|
|
1620
|
+
pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; padding:14px; border:1px solid var(--line); border-radius:8px; background:rgba(28,22,10,.055); color:var(--text); }
|
|
1621
|
+
.actions, .row { display:flex; flex-wrap:wrap; gap:10px; }
|
|
1622
|
+
.fields { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
|
1623
|
+
label { display:grid; gap:6px; color:var(--muted); font-size:13px; }
|
|
1624
|
+
input, select { width:100%; border:1px solid var(--line-strong); background:var(--surface); color:var(--text); border-radius:8px; padding:11px 12px; font:inherit; }
|
|
1625
|
+
.account-state { border:1px solid var(--line); border-radius:8px; background:var(--surface-muted); color:var(--muted); padding:12px 14px; font-size:13px; line-height:1.45; overflow-wrap:anywhere; }
|
|
1626
|
+
.server-grid { display:grid; grid-template-columns:minmax(180px,220px) 1fr; gap:10px; }
|
|
1627
|
+
button, a.button { appearance:none; border:1px solid var(--line-strong); background:var(--surface); color:var(--text); border-radius:8px; padding:10px 14px; font:inherit; font-weight:600; text-decoration:none; cursor:pointer; }
|
|
1628
|
+
button:hover, a.button:hover { border-color:var(--accent); }
|
|
1629
|
+
button.primary { background:var(--text); color:var(--bg); border-color:var(--text); }
|
|
1630
|
+
.status { min-height:18px; color:var(--muted); font-size:13px; }
|
|
1631
|
+
.status.info { color:var(--accent); }
|
|
1632
|
+
.status.ok { color:var(--good); }
|
|
1633
|
+
.status.error { color:#dc2626; }
|
|
1634
|
+
.meta { color:var(--soft); font-size:12px; }
|
|
1635
|
+
@media (max-width:820px) { main { grid-template-columns:1fr; gap:28px; padding:32px 0; } .mode-grid,.fields,.server-grid { grid-template-columns:1fr; } }
|
|
1636
|
+
html.empir3-chat-split-active main { grid-template-columns:1fr; gap:28px; padding:32px 0; }
|
|
1637
|
+
html.empir3-chat-split-active .mode-grid,
|
|
1638
|
+
html.empir3-chat-split-active .fields,
|
|
1639
|
+
html.empir3-chat-split-active .server-grid { grid-template-columns:1fr; }
|
|
1640
|
+
html.empir3-chat-split-active .wordmark-xl { font-size:56px; }
|
|
1641
|
+
html.empir3-chat-split-active h1 { max-width:15ch; font-size:30px; }
|
|
1642
|
+
</style>
|
|
1643
|
+
</head>
|
|
1644
|
+
<body>
|
|
1645
|
+
<main>
|
|
1646
|
+
<section class="brand">
|
|
1647
|
+
<div class="wordmark wordmark-xl" aria-label="empir3">empir<span class="three">3</span></div>
|
|
1648
|
+
<div class="product-kicker">Browser Bridge</div>
|
|
1649
|
+
<h1>Choose how this bridge should run</h1>
|
|
1650
|
+
<p class="lede">Use it locally through MCP, or pair it with an <span class="brand-inline">empir<span class="three">3</span></span> user account. This screen runs inside the controlled bridge window.</p>
|
|
1651
|
+
</section>
|
|
1652
|
+
<section class="shell">
|
|
1653
|
+
<div class="mode-grid">
|
|
1654
|
+
<button class="mode active" data-mode="mcp"><strong>MCP mode</strong><span>Claude Code, OpenAI, or another MCP client controls this local browser.</span></button>
|
|
1655
|
+
<button class="mode" data-mode="empir3"><strong><span class="brand-inline">empir<span class="three">3</span></span> user mode</strong><span>Store an <span class="brand-inline">empir<span class="three">3</span></span> user token on this bridge for future launches.</span></button>
|
|
1656
|
+
</div>
|
|
1657
|
+
<section class="panel active" id="panel-mcp">
|
|
1658
|
+
<h2>Use with Claude Code or OpenAI tools</h2>
|
|
1659
|
+
<p>MCP mode keeps everything local. Add this bridge as a stdio MCP server, then ask your client to use the <span class="brand-inline">empir<span class="three">3</span></span> Bridge tools.</p>
|
|
1660
|
+
<div class="actions"><button class="primary" id="loadMcp">Show MCP config</button><button id="copyMcp">Copy config</button></div>
|
|
1661
|
+
<pre id="mcpSnippet">Click "Show MCP config" to generate the command for this install.</pre>
|
|
1662
|
+
<ol id="mcpSteps"><li>For Claude Code, save the config as <code>.mcp.json</code>.</li><li>For OpenAI or another MCP client, use the same stdio server command.</li></ol>
|
|
1663
|
+
<p class="status" id="mcpStatus"></p>
|
|
1664
|
+
</section>
|
|
1665
|
+
<section class="panel" id="panel-empir3">
|
|
1666
|
+
<h2>Pair with an <span class="brand-inline">empir<span class="three">3</span></span> user</h2>
|
|
1667
|
+
<p>Use browser login if it is already the right account. Use direct login to override a stale or different browser session.</p>
|
|
1668
|
+
<div class="account-state" id="accountState">Checking stored bridge account...</div>
|
|
1669
|
+
<div class="server-grid">
|
|
1670
|
+
<label><span class="brand-inline">empir<span class="three">3</span></span> server
|
|
1671
|
+
<select id="serverPreset">
|
|
1672
|
+
<option value="production">Production - app.empir3.com</option>
|
|
1673
|
+
<option value="local-dev">Local dev - localhost:3005</option>
|
|
1674
|
+
<option value="custom">Custom server</option>
|
|
1675
|
+
</select>
|
|
1676
|
+
</label>
|
|
1677
|
+
<label id="customServerLabel">Server URL<input id="serverUrl" type="url" value="https://app.empir3.com"></label>
|
|
1678
|
+
</div>
|
|
1679
|
+
<div class="actions"><button class="primary" id="pairEmpir3">Use browser empir3 login</button><button id="signOutBridge" type="button">Sign out stored bridge account</button></div>
|
|
1680
|
+
<form id="loginForm">
|
|
1681
|
+
<div class="fields"><label>Email<input id="email" type="email" autocomplete="username"></label><label>Password<input id="password" type="password" autocomplete="current-password"></label></div>
|
|
1682
|
+
<div class="actions" style="margin-top:10px"><button type="submit">Sign in and store on this bridge</button></div>
|
|
1683
|
+
</form>
|
|
1684
|
+
<p class="status" id="pairStatus"></p>
|
|
1685
|
+
</section>
|
|
1686
|
+
<div class="meta">bridge ${PORT} - setup API ${WRAPPER_PORT}</div>
|
|
1687
|
+
</section>
|
|
1688
|
+
</main>
|
|
1689
|
+
<script>
|
|
1690
|
+
const API = ${JSON.stringify(api)};
|
|
1691
|
+
const PROD_SERVER = 'https://app.empir3.com';
|
|
1692
|
+
const DEV_SERVER = 'http://localhost:3005';
|
|
1693
|
+
const $ = (id) => document.getElementById(id);
|
|
1694
|
+
let mcpText = '';
|
|
1695
|
+
function setStatus(id, message, tone = 'info') {
|
|
1696
|
+
const el = $(id);
|
|
1697
|
+
if (!el) return;
|
|
1698
|
+
el.classList.remove('info', 'ok', 'error');
|
|
1699
|
+
el.classList.add(tone);
|
|
1700
|
+
el.textContent = message;
|
|
1701
|
+
}
|
|
1702
|
+
function selectedServer() {
|
|
1703
|
+
const preset = $('serverPreset')?.value || 'production';
|
|
1704
|
+
if (preset === 'production') return PROD_SERVER;
|
|
1705
|
+
if (preset === 'local-dev') return DEV_SERVER;
|
|
1706
|
+
return ($('serverUrl')?.value || PROD_SERVER).trim();
|
|
1707
|
+
}
|
|
1708
|
+
function syncServerUi(serverUrl) {
|
|
1709
|
+
const normalized = (serverUrl || PROD_SERVER).replace(/\\/+$/, '');
|
|
1710
|
+
if (normalized === PROD_SERVER) $('serverPreset').value = 'production';
|
|
1711
|
+
else if (normalized === DEV_SERVER) $('serverPreset').value = 'local-dev';
|
|
1712
|
+
else $('serverPreset').value = 'custom';
|
|
1713
|
+
$('serverUrl').value = normalized;
|
|
1714
|
+
$('customServerLabel').style.display = $('serverPreset').value === 'custom' ? 'grid' : 'none';
|
|
1715
|
+
}
|
|
1716
|
+
async function refreshAccountState() {
|
|
1717
|
+
try {
|
|
1718
|
+
const r = await fetch(API + '/api/relay-status');
|
|
1719
|
+
const j = await r.json();
|
|
1720
|
+
if (j.serverUrl) syncServerUi(j.serverUrl);
|
|
1721
|
+
const account = j.authUser?.email ? j.authUser.email : 'No stored empir3 account';
|
|
1722
|
+
const server = (j.serverUrl || PROD_SERVER).replace(/\\/+$/, '');
|
|
1723
|
+
const mode = j.mode || 'unknown';
|
|
1724
|
+
const relay = j.relay?.connected ? 'connected' : (j.hasAuth ? 'not connected yet' : 'not paired');
|
|
1725
|
+
$('accountState').textContent = account + ' - ' + server + ' - ' + mode + ' - ' + relay;
|
|
1726
|
+
} catch {
|
|
1727
|
+
$('accountState').textContent = 'Bridge daemon is reachable, but account status could not be read.';
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
syncServerUi(PROD_SERVER);
|
|
1731
|
+
$('serverPreset').addEventListener('change', () => {
|
|
1732
|
+
const preset = $('serverPreset').value;
|
|
1733
|
+
if (preset === 'production') $('serverUrl').value = PROD_SERVER;
|
|
1734
|
+
if (preset === 'local-dev') $('serverUrl').value = DEV_SERVER;
|
|
1735
|
+
$('customServerLabel').style.display = preset === 'custom' ? 'grid' : 'none';
|
|
1736
|
+
});
|
|
1737
|
+
refreshAccountState();
|
|
1738
|
+
document.querySelectorAll('.mode').forEach((btn) => btn.addEventListener('click', () => {
|
|
1739
|
+
document.querySelectorAll('.mode').forEach((b) => b.classList.toggle('active', b === btn));
|
|
1740
|
+
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
|
|
1741
|
+
$('panel-' + btn.dataset.mode).classList.add('active');
|
|
1742
|
+
}));
|
|
1743
|
+
$('loadMcp').addEventListener('click', async () => {
|
|
1744
|
+
setStatus('mcpStatus', 'Generating MCP config...', 'info');
|
|
1745
|
+
try {
|
|
1746
|
+
const r = await fetch(API + '/api/install/claude-code', { method:'POST' });
|
|
1747
|
+
const j = await r.json();
|
|
1748
|
+
if (!j.ok) throw new Error(j.error || 'could not generate config');
|
|
1749
|
+
mcpText = JSON.stringify(j.snippet, null, 2);
|
|
1750
|
+
$('mcpSnippet').textContent = mcpText;
|
|
1751
|
+
$('mcpSteps').innerHTML = (j.instructions || []).map((s) => '<li>' + s.replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c])) + '</li>').join('');
|
|
1752
|
+
setStatus('mcpStatus', 'MCP mode is selected for this bridge.', 'ok');
|
|
1753
|
+
} catch (e) { setStatus('mcpStatus', 'Could not generate MCP config: ' + e.message, 'error'); }
|
|
1754
|
+
});
|
|
1755
|
+
$('copyMcp').addEventListener('click', async () => {
|
|
1756
|
+
if (!mcpText) mcpText = $('mcpSnippet').textContent;
|
|
1757
|
+
try { await navigator.clipboard.writeText(mcpText); setStatus('mcpStatus', 'Config copied.', 'ok'); }
|
|
1758
|
+
catch { setStatus('mcpStatus', 'Select the config text and copy it manually.', 'error'); }
|
|
1759
|
+
});
|
|
1760
|
+
$('pairEmpir3').addEventListener('click', async () => {
|
|
1761
|
+
setStatus('pairStatus', 'Starting browser-based empir3 pairing...', 'info');
|
|
1762
|
+
try {
|
|
1763
|
+
const r = await fetch(API + '/api/install/empir3-pair', {
|
|
1764
|
+
method:'POST',
|
|
1765
|
+
headers:{'Content-Type':'application/json'},
|
|
1766
|
+
body:JSON.stringify({ serverUrl:selectedServer() })
|
|
1767
|
+
});
|
|
1768
|
+
const j = await r.json();
|
|
1769
|
+
if (!j.ok || !j.redirectUrl) throw new Error(j.error || 'pairing failed');
|
|
1770
|
+
setStatus('pairStatus', 'Pairing code ' + j.code + ' created. Opening empir3...', 'ok');
|
|
1771
|
+
setTimeout(() => { location.href = j.redirectUrl; }, 400);
|
|
1772
|
+
} catch (e) { setStatus('pairStatus', 'Could not start pairing: ' + e.message, 'error'); }
|
|
1773
|
+
});
|
|
1774
|
+
$('signOutBridge').addEventListener('click', async () => {
|
|
1775
|
+
setStatus('pairStatus', 'Signing out stored bridge account...', 'info');
|
|
1776
|
+
try {
|
|
1777
|
+
const r = await fetch(API + '/api/install/sign-out', { method:'POST' });
|
|
1778
|
+
const j = await r.json();
|
|
1779
|
+
if (!j.ok) throw new Error(j.error || 'sign out failed');
|
|
1780
|
+
setStatus('pairStatus', 'Signed out. Restarting the bridge...', 'ok');
|
|
1781
|
+
} catch (e) { setStatus('pairStatus', 'Could not sign out: ' + e.message, 'error'); }
|
|
1782
|
+
});
|
|
1783
|
+
$('loginForm').addEventListener('submit', async (e) => {
|
|
1784
|
+
e.preventDefault();
|
|
1785
|
+
setStatus('pairStatus', 'Signing this bridge into empir3...', 'info');
|
|
1786
|
+
try {
|
|
1787
|
+
const r = await fetch(API + '/api/install/empir3-login', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ email:$('email').value, password:$('password').value, serverUrl:selectedServer() }) });
|
|
1788
|
+
const j = await r.json();
|
|
1789
|
+
if (!j.ok) throw new Error(j.error || 'login failed');
|
|
1790
|
+
setStatus('pairStatus', 'Signed in. Restarting the bridge...', 'ok');
|
|
1791
|
+
} catch (e) { setStatus('pairStatus', 'Could not sign in: ' + e.message, 'error'); }
|
|
1792
|
+
});
|
|
1793
|
+
</script>
|
|
1794
|
+
</body>
|
|
1795
|
+
</html>`;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// ─── HTTP Server ─────────────────────────────────────────────
|
|
1799
|
+
|
|
1800
|
+
function parseBody(req: IncomingMessage): Promise<any> {
|
|
1801
|
+
return new Promise((resolve) => {
|
|
1802
|
+
let body = '';
|
|
1803
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
1804
|
+
req.on('end', () => {
|
|
1805
|
+
try { resolve(JSON.parse(body)); }
|
|
1806
|
+
catch { resolve({}); }
|
|
1807
|
+
});
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function sendJSON(res: ServerResponse, data: any, status = 200) {
|
|
1812
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
1813
|
+
res.end(JSON.stringify(data));
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function sendError(res: ServerResponse, msg: string, status = 500) {
|
|
1817
|
+
sendJSON(res, { error: msg }, status);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function readRawBody(req: IncomingMessage): Promise<Buffer> {
|
|
1821
|
+
return new Promise((resolve, reject) => {
|
|
1822
|
+
const chunks: Buffer[] = [];
|
|
1823
|
+
req.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
|
|
1824
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
1825
|
+
req.on('error', reject);
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async function proxyWrapperRequest(req: IncomingMessage, res: ServerResponse, targetPath: string) {
|
|
1830
|
+
const method = req.method || 'GET';
|
|
1831
|
+
const body = method === 'GET' || method === 'HEAD' ? Buffer.alloc(0) : await readRawBody(req);
|
|
1832
|
+
const headers: Record<string, string> = {
|
|
1833
|
+
'Accept': 'application/json',
|
|
1834
|
+
};
|
|
1835
|
+
const contentType = req.headers['content-type'];
|
|
1836
|
+
if (contentType) headers['Content-Type'] = Array.isArray(contentType) ? contentType[0] : contentType;
|
|
1837
|
+
if (body.length) headers['Content-Length'] = String(body.length);
|
|
1838
|
+
|
|
1839
|
+
await new Promise<void>((resolve) => {
|
|
1840
|
+
const proxy = httpRequest({
|
|
1841
|
+
hostname: '127.0.0.1',
|
|
1842
|
+
port: WRAPPER_PORT,
|
|
1843
|
+
path: targetPath,
|
|
1844
|
+
method,
|
|
1845
|
+
headers,
|
|
1846
|
+
}, (proxyRes) => {
|
|
1847
|
+
const responseHeaders = {
|
|
1848
|
+
...proxyRes.headers,
|
|
1849
|
+
'Access-Control-Allow-Origin': '*',
|
|
1850
|
+
};
|
|
1851
|
+
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
|
|
1852
|
+
proxyRes.pipe(res);
|
|
1853
|
+
proxyRes.on('end', resolve);
|
|
1854
|
+
});
|
|
1855
|
+
proxy.on('error', (e: Error) => {
|
|
1856
|
+
if (!res.headersSent) {
|
|
1857
|
+
sendJSON(res, { ok: false, error: `Bridge setup API unavailable: ${e.message}` }, 502);
|
|
1858
|
+
} else {
|
|
1859
|
+
res.end();
|
|
1860
|
+
}
|
|
1861
|
+
resolve();
|
|
1862
|
+
});
|
|
1863
|
+
if (body.length) proxy.write(body);
|
|
1864
|
+
proxy.end();
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function checkAuth(req: IncomingMessage): boolean {
|
|
1869
|
+
if (!SESSION_TOKEN) return true; // No token configured = no auth
|
|
1870
|
+
const auth = req.headers.authorization || '';
|
|
1871
|
+
return auth === `Bearer ${SESSION_TOKEN}`;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
|
1875
|
+
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
|
|
1876
|
+
const path = url.pathname;
|
|
1877
|
+
const method = req.method || 'GET';
|
|
1878
|
+
|
|
1879
|
+
// CORS preflight
|
|
1880
|
+
if (method === 'OPTIONS') {
|
|
1881
|
+
res.writeHead(204, {
|
|
1882
|
+
'Access-Control-Allow-Origin': '*',
|
|
1883
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1884
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
1885
|
+
});
|
|
1886
|
+
res.end();
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Welcome page — no auth needed
|
|
1891
|
+
if (path === '/welcome') {
|
|
1892
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1893
|
+
res.end(getWelcomeHtml());
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Health — no auth needed
|
|
1898
|
+
if (path === '/health') {
|
|
1899
|
+
const hasOpenCdpSocket = !!cdpWs && cdpWs.readyState === WebSocket.OPEN;
|
|
1900
|
+
const hasRecentCdpCommand = connected && lastCdpLivenessAt > 0 && Date.now() - lastCdpLivenessAt < 15000;
|
|
1901
|
+
const hasKnownPage = !!currentTargetId || knownTargets.size > 0;
|
|
1902
|
+
const cdpConnected = !chromeClosedByUser && chromeStatus() === 'running' && (hasOpenCdpSocket || hasRecentCdpCommand || hasKnownPage);
|
|
1903
|
+
sendJSON(res, {
|
|
1904
|
+
status: cdpConnected ? 'connected' : 'disconnected',
|
|
1905
|
+
port: PORT,
|
|
1906
|
+
cdpPort: CDP_PORT,
|
|
1907
|
+
chrome: chromeStatus(),
|
|
1908
|
+
closedByUser: chromeClosedByUser,
|
|
1909
|
+
cdpConnected,
|
|
1910
|
+
});
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (
|
|
1915
|
+
path === '/api/relay-status' ||
|
|
1916
|
+
path === '/api/command' ||
|
|
1917
|
+
path === '/api/install/claude-code' ||
|
|
1918
|
+
path === '/api/install/empir3-pair' ||
|
|
1919
|
+
path === '/api/install/empir3-login' ||
|
|
1920
|
+
path === '/api/install/sign-out'
|
|
1921
|
+
) {
|
|
1922
|
+
await proxyWrapperRequest(req, res, path + url.search);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// Auth check for everything else
|
|
1927
|
+
if (!checkAuth(req)) {
|
|
1928
|
+
sendError(res, 'Unauthorized', 401);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
try {
|
|
1933
|
+
// ── GET endpoints ──────────────────────────
|
|
1934
|
+
|
|
1935
|
+
if (method === 'GET' && path === '/screenshot') {
|
|
1936
|
+
await ensureChromeReady();
|
|
1937
|
+
const quality = parseInt(url.searchParams.get('quality') || '80');
|
|
1938
|
+
const maxWidth = url.searchParams.get('maxWidth') ? parseInt(url.searchParams.get('maxWidth')!) : undefined;
|
|
1939
|
+
const params: any = { format: 'jpeg', quality };
|
|
1940
|
+
if (maxWidth && maxWidth > 0) {
|
|
1941
|
+
try {
|
|
1942
|
+
const evalResult = await cdpSend('Runtime.evaluate', {
|
|
1943
|
+
expression: 'JSON.stringify({w:window.innerWidth,h:window.innerHeight,dpr:window.devicePixelRatio||1})',
|
|
1944
|
+
returnByValue: true,
|
|
1945
|
+
});
|
|
1946
|
+
const vp = JSON.parse(evalResult.result.value);
|
|
1947
|
+
if (vp.w > 0 && vp.h > 0 && vp.dpr > 0) {
|
|
1948
|
+
const physicalWidth = vp.w * vp.dpr;
|
|
1949
|
+
if (physicalWidth > maxWidth) {
|
|
1950
|
+
const scale = Math.max(0.1, Math.min(2.0, maxWidth / physicalWidth));
|
|
1951
|
+
params.clip = { x: 0, y: 0, width: vp.w, height: vp.h, scale };
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
} catch {}
|
|
1955
|
+
}
|
|
1956
|
+
const result = await captureScreenshot(params);
|
|
1957
|
+
const buf = Buffer.from(result.data, 'base64');
|
|
1958
|
+
if (url.searchParams.get('format') === 'json') {
|
|
1959
|
+
// Only return JSON if explicitly requested
|
|
1960
|
+
sendJSON(res, { data: result.data, format: 'jpeg' });
|
|
1961
|
+
} else {
|
|
1962
|
+
// Default: return raw JPEG bytes (works with ?raw=true for backwards compat)
|
|
1963
|
+
res.writeHead(200, {
|
|
1964
|
+
'Content-Type': 'image/jpeg',
|
|
1965
|
+
'Content-Length': buf.length.toString(),
|
|
1966
|
+
'Access-Control-Allow-Origin': '*',
|
|
1967
|
+
});
|
|
1968
|
+
res.end(buf);
|
|
1969
|
+
}
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
if (method === 'GET' && path === '/snapshot') {
|
|
1974
|
+
await ensureChromeReady();
|
|
1975
|
+
const filter = url.searchParams.get('filter') || 'interactive';
|
|
1976
|
+
const nodes = await getAccessibilityTree(filter);
|
|
1977
|
+
sendJSON(res, { count: nodes.length, nodes });
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
if (method === 'GET' && path === '/text') {
|
|
1982
|
+
await ensureChromeReady();
|
|
1983
|
+
const text = await cdpEvaluate(`(function() {
|
|
1984
|
+
const title = document.title || '';
|
|
1985
|
+
// The bridge injects its overlay UI (chat sidebar, toolbar, ghost
|
|
1986
|
+
// cursor, etc.) into the page's light DOM under id="empir3-*" roots.
|
|
1987
|
+
// Its text ("Bridge Disconnected / Snap / Draw / Send / …") otherwise
|
|
1988
|
+
// pollutes innerText and breaks downstream parsing for agents. Detach
|
|
1989
|
+
// the top-level overlay roots, read innerText (forces a synchronous
|
|
1990
|
+
// reflow without them), then restore them in place — all within one JS
|
|
1991
|
+
// turn so there is no visible flicker and overlay state is preserved.
|
|
1992
|
+
const roots = Array.prototype.slice.call(document.querySelectorAll('[id^="empir3-"]'))
|
|
1993
|
+
.filter(function(el){ return !(el.parentElement && el.parentElement.closest('[id^="empir3-"]')); });
|
|
1994
|
+
const saved = roots.map(function(el){ return { el: el, parent: el.parentNode, next: el.nextSibling }; });
|
|
1995
|
+
saved.forEach(function(s){ if (s.parent) s.parent.removeChild(s.el); });
|
|
1996
|
+
let body = '';
|
|
1997
|
+
try { body = document.body ? document.body.innerText : ''; }
|
|
1998
|
+
finally {
|
|
1999
|
+
saved.forEach(function(s){
|
|
2000
|
+
if (!s.parent) return;
|
|
2001
|
+
if (s.next && s.next.parentNode === s.parent) s.parent.insertBefore(s.el, s.next);
|
|
2002
|
+
else s.parent.appendChild(s.el);
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
return JSON.stringify({ title, text: body.slice(0, 50000) });
|
|
2006
|
+
})()`);
|
|
2007
|
+
sendJSON(res, JSON.parse(text));
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (method === 'GET' && path === '/tabs') {
|
|
2012
|
+
let targets: any[] = [];
|
|
2013
|
+
try {
|
|
2014
|
+
targets = await Promise.race([
|
|
2015
|
+
fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`),
|
|
2016
|
+
sleep(1500).then(() => null),
|
|
2017
|
+
]) as any[] | null || [];
|
|
2018
|
+
} catch {
|
|
2019
|
+
sendJSON(res, { tabs: [], currentTargetId: '', chrome: chromeStatus(), closedByUser: chromeClosedByUser });
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (!targets.length && knownTargets.size > 0) {
|
|
2023
|
+
const tabs = Array.from(knownTargets.entries()).map(([id, url]) => ({
|
|
2024
|
+
id,
|
|
2025
|
+
title: '',
|
|
2026
|
+
url,
|
|
2027
|
+
type: 'page',
|
|
2028
|
+
active: id === currentTargetId,
|
|
2029
|
+
}));
|
|
2030
|
+
sendJSON(res, { tabs, currentTargetId, chrome: chromeStatus(), closedByUser: chromeClosedByUser });
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
const tabs = targets
|
|
2034
|
+
.filter((t: any) => t.type === 'page')
|
|
2035
|
+
.map((t: any) => ({
|
|
2036
|
+
id: t.id,
|
|
2037
|
+
title: t.title,
|
|
2038
|
+
url: t.url,
|
|
2039
|
+
type: t.type,
|
|
2040
|
+
active: t.id === currentTargetId,
|
|
2041
|
+
}));
|
|
2042
|
+
sendJSON(res, { tabs, currentTargetId, chrome: chromeStatus(), closedByUser: chromeClosedByUser });
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// ── POST endpoints ─────────────────────────
|
|
2047
|
+
|
|
2048
|
+
if (method === 'POST') {
|
|
2049
|
+
const body = await parseBody(req);
|
|
2050
|
+
|
|
2051
|
+
// Evaluate JS on a specific target without switching active tab
|
|
2052
|
+
if (path === '/evaluate-on-target') {
|
|
2053
|
+
const { targetId, expression } = body;
|
|
2054
|
+
if (!targetId || !expression) throw new Error('targetId and expression required');
|
|
2055
|
+
const timeoutMs = Math.max(250, Math.min(10000, Number(body.timeoutMs) || CDP_COMMAND_TIMEOUT_MS));
|
|
2056
|
+
const result = await evaluateOnTarget(targetId, expression, timeoutMs);
|
|
2057
|
+
sendJSON(res, { ok: true, result });
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Evaluate JS on ALL page targets (used for overlay injection)
|
|
2062
|
+
if (path === '/evaluate-all') {
|
|
2063
|
+
const { expression } = body;
|
|
2064
|
+
if (!expression) throw new Error('expression required');
|
|
2065
|
+
const timeoutMs = Math.max(250, Math.min(10000, Number(body.timeoutMs) || CDP_COMMAND_TIMEOUT_MS));
|
|
2066
|
+
const results = await evaluateOnAllTargets(expression, timeoutMs);
|
|
2067
|
+
sendJSON(res, { ok: true, results });
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// Register a script to auto-inject into every new tab
|
|
2072
|
+
if (path === '/register-auto-inject') {
|
|
2073
|
+
autoInjectScript = body.script || '';
|
|
2074
|
+
// Immediately inject into all existing tabs
|
|
2075
|
+
let results: any[] = [];
|
|
2076
|
+
if (autoInjectScript) {
|
|
2077
|
+
results = await evaluateOnAllTargets(autoInjectScript);
|
|
2078
|
+
startTargetPolling();
|
|
2079
|
+
} else {
|
|
2080
|
+
stopTargetPolling();
|
|
2081
|
+
}
|
|
2082
|
+
sendJSON(res, { ok: true, registered: !!autoInjectScript, injected: results });
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
if (path === '/activate-target') {
|
|
2087
|
+
await ensureChromeReady({ allowRelaunch: false });
|
|
2088
|
+
const targetId = typeof body.targetId === 'string' ? body.targetId : '';
|
|
2089
|
+
if (!targetId) throw new Error('activate-target requires targetId');
|
|
2090
|
+
await switchToTarget(targetId);
|
|
2091
|
+
if (body.bringToFront !== false) {
|
|
2092
|
+
try { await cdpSend('Page.bringToFront'); } catch {}
|
|
2093
|
+
}
|
|
2094
|
+
const target = await currentPageTarget();
|
|
2095
|
+
let url = String(target.url || '');
|
|
2096
|
+
let title = String(target.title || '');
|
|
2097
|
+
try { url = await cdpEvaluate('location.href', 1200) || url; } catch {}
|
|
2098
|
+
try { title = await cdpEvaluate('document.title', 1200) || title; } catch {}
|
|
2099
|
+
sendJSON(res, { ok: true, targetId: currentTargetId, url, title });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
if (path === '/show') {
|
|
2104
|
+
const url = typeof body.url === 'string' ? body.url : undefined;
|
|
2105
|
+
const shownUrl = await showChromeWindow(url);
|
|
2106
|
+
sendJSON(res, { ok: true, shown: true, url: shownUrl });
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
if (path === '/navigate') {
|
|
2111
|
+
await ensureChromeReady({ allowRelaunch: true });
|
|
2112
|
+
const targetUrl = typeof body.url === 'string' && body.url.trim() ? body.url.trim() : '';
|
|
2113
|
+
if (!targetUrl) throw new Error('navigate requires a non-empty url');
|
|
2114
|
+
// Check if URL is already open in another tab
|
|
2115
|
+
const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
2116
|
+
const normalizeUrl = (u: string) => u.replace(/[#/]+$/, '');
|
|
2117
|
+
const existing = targets.find((t: any) =>
|
|
2118
|
+
t.type === 'page' && normalizeUrl(String(t.url || '')) === normalizeUrl(targetUrl) && t.id !== currentTargetId
|
|
2119
|
+
);
|
|
2120
|
+
if (existing) {
|
|
2121
|
+
// Switch to existing tab
|
|
2122
|
+
await switchToTarget(existing.id);
|
|
2123
|
+
// Activate the tab visually
|
|
2124
|
+
await cdpSend('Page.bringToFront');
|
|
2125
|
+
} else {
|
|
2126
|
+
await cdpNavigate(targetUrl);
|
|
2127
|
+
}
|
|
2128
|
+
let currentUrl = '';
|
|
2129
|
+
let title = '';
|
|
2130
|
+
try { currentUrl = await cdpEvaluate('location.href', 1200); } catch {}
|
|
2131
|
+
try { title = await cdpEvaluate('document.title', 1200); } catch {}
|
|
2132
|
+
if (!currentUrl || !title) {
|
|
2133
|
+
const target = await currentPageTarget();
|
|
2134
|
+
currentUrl = currentUrl || String(target.url || targetUrl);
|
|
2135
|
+
title = title || String(target.title || '');
|
|
2136
|
+
}
|
|
2137
|
+
sendJSON(res, { title, url: currentUrl });
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
if (path === '/action') {
|
|
2142
|
+
await ensureChromeReady();
|
|
2143
|
+
const kind = body.kind;
|
|
2144
|
+
|
|
2145
|
+
switch (kind) {
|
|
2146
|
+
case 'click':
|
|
2147
|
+
if (body.ref) {
|
|
2148
|
+
await clickByRef(body.ref);
|
|
2149
|
+
} else if (body.selector) {
|
|
2150
|
+
await cdpEvaluate(`document.querySelector(${JSON.stringify(body.selector)})?.click()`);
|
|
2151
|
+
} else if (typeof body.x === 'number' && typeof body.y === 'number') {
|
|
2152
|
+
await clickByXY(body.x, body.y);
|
|
2153
|
+
}
|
|
2154
|
+
sendJSON(res, { success: true });
|
|
2155
|
+
break;
|
|
2156
|
+
|
|
2157
|
+
case 'type': {
|
|
2158
|
+
const typeTarget = body.ref
|
|
2159
|
+
? `[data-empir3-ref="${body.ref}"]`
|
|
2160
|
+
: body.selector || null;
|
|
2161
|
+
|
|
2162
|
+
if (typeTarget) {
|
|
2163
|
+
// Use native value setter + input/change events (works with React, Vue, plain HTML)
|
|
2164
|
+
await cdpEvaluate(`(function() {
|
|
2165
|
+
const el = document.querySelector(${JSON.stringify(typeTarget)});
|
|
2166
|
+
if (!el) return 'not_found';
|
|
2167
|
+
el.scrollIntoViewIfNeeded ? el.scrollIntoViewIfNeeded() : el.scrollIntoView();
|
|
2168
|
+
el.focus();
|
|
2169
|
+
if (el.isContentEditable) {
|
|
2170
|
+
document.execCommand('selectAll', false, null);
|
|
2171
|
+
document.execCommand('insertText', false, ${JSON.stringify(body.text || '')});
|
|
2172
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2173
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2174
|
+
return 'typed_contenteditable';
|
|
2175
|
+
}
|
|
2176
|
+
const isTextarea = el.tagName === 'TEXTAREA';
|
|
2177
|
+
const proto = isTextarea ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
2178
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
2179
|
+
if (setter) {
|
|
2180
|
+
const tracker = el._valueTracker;
|
|
2181
|
+
if (tracker) tracker.setValue('');
|
|
2182
|
+
setter.call(el, ${JSON.stringify(body.text || '')});
|
|
2183
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2184
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2185
|
+
return 'typed';
|
|
2186
|
+
}
|
|
2187
|
+
if ('value' in el) {
|
|
2188
|
+
el.value = ${JSON.stringify(body.text || '')};
|
|
2189
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2190
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2191
|
+
return 'typed_basic';
|
|
2192
|
+
}
|
|
2193
|
+
el.textContent = ${JSON.stringify(body.text || '')};
|
|
2194
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2195
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2196
|
+
return 'typed_basic';
|
|
2197
|
+
})()`);
|
|
2198
|
+
} else {
|
|
2199
|
+
// No target — type into whatever is focused via keyboard events
|
|
2200
|
+
await typeText(body.text || '');
|
|
2201
|
+
}
|
|
2202
|
+
sendJSON(res, { success: true });
|
|
2203
|
+
break;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
case 'selectAll':
|
|
2207
|
+
await pressKey('Control+a');
|
|
2208
|
+
sendJSON(res, { success: true });
|
|
2209
|
+
break;
|
|
2210
|
+
|
|
2211
|
+
case 'press':
|
|
2212
|
+
await pressKey(body.key || body.text || '');
|
|
2213
|
+
sendJSON(res, { success: true });
|
|
2214
|
+
break;
|
|
2215
|
+
|
|
2216
|
+
case 'scroll': {
|
|
2217
|
+
const dx = Number(body.x || 0);
|
|
2218
|
+
const dy = Number(body.y || 0);
|
|
2219
|
+
const result = await cdpEvaluate(`(function(){
|
|
2220
|
+
var dx=${JSON.stringify(dx)},dy=${JSON.stringify(dy)};
|
|
2221
|
+
function pickScroller(axis){
|
|
2222
|
+
var cands=[document.scrollingElement,document.documentElement,document.body];
|
|
2223
|
+
for(var i=0;i<cands.length;i++){
|
|
2224
|
+
var el=cands[i]; if(!el) continue;
|
|
2225
|
+
if(axis==='y' && el.scrollHeight>el.clientHeight) return el;
|
|
2226
|
+
if(axis==='x' && el.scrollWidth>el.clientWidth) return el;
|
|
2227
|
+
}
|
|
2228
|
+
var all=document.querySelectorAll('*');
|
|
2229
|
+
for(var j=0;j<all.length;j++){
|
|
2230
|
+
var el2=all[j], s=getComputedStyle(el2);
|
|
2231
|
+
if(axis==='y' && (s.overflowY==='auto'||s.overflowY==='scroll') && el2.scrollHeight>el2.clientHeight) return el2;
|
|
2232
|
+
if(axis==='x' && (s.overflowX==='auto'||s.overflowX==='scroll') && el2.scrollWidth>el2.clientWidth) return el2;
|
|
2233
|
+
}
|
|
2234
|
+
return document.scrollingElement||document.documentElement;
|
|
2235
|
+
}
|
|
2236
|
+
var scY=pickScroller('y'), scX=dx?pickScroller('x'):scY;
|
|
2237
|
+
var prevBehaviorHtml=document.documentElement.style.scrollBehavior;
|
|
2238
|
+
document.documentElement.style.scrollBehavior='auto';
|
|
2239
|
+
var beforeX=(scX&&scX.scrollLeft)||0,beforeY=(scY&&scY.scrollTop)||0;
|
|
2240
|
+
if(scY) scY.scrollTop += dy;
|
|
2241
|
+
if(scX && dx) scX.scrollLeft += dx;
|
|
2242
|
+
var afterX=(scX&&scX.scrollLeft)||0,afterY=(scY&&scY.scrollTop)||0;
|
|
2243
|
+
var maxX=scX?Math.max(0,(scX.scrollWidth||0)-(scX.clientWidth||window.innerWidth||0)):0;
|
|
2244
|
+
var maxY=scY?Math.max(0,(scY.scrollHeight||0)-(scY.clientHeight||window.innerHeight||0)):0;
|
|
2245
|
+
document.documentElement.style.scrollBehavior=prevBehaviorHtml;
|
|
2246
|
+
return JSON.stringify({
|
|
2247
|
+
requested:{x:dx,y:dy},
|
|
2248
|
+
before:{x:beforeX,y:beforeY},
|
|
2249
|
+
after:{x:afterX,y:afterY},
|
|
2250
|
+
delta:{x:afterX-beforeX,y:afterY-beforeY},
|
|
2251
|
+
max:{x:maxX,y:maxY},
|
|
2252
|
+
canScroll:maxX>0||maxY>0,
|
|
2253
|
+
moved:afterX!==beforeX||afterY!==beforeY
|
|
2254
|
+
});
|
|
2255
|
+
})()`);
|
|
2256
|
+
let scroll: any = result;
|
|
2257
|
+
try { scroll = JSON.parse(result); } catch {}
|
|
2258
|
+
sendJSON(res, { success: true, position: scroll?.after || scroll, scroll, moved: scroll?.moved === true });
|
|
2259
|
+
break;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
case 'focus':
|
|
2263
|
+
if (body.ref) {
|
|
2264
|
+
await cdpEvaluate(`(function() {
|
|
2265
|
+
const el = document.querySelector('[data-empir3-ref="${body.ref}"]');
|
|
2266
|
+
if (el) el.focus();
|
|
2267
|
+
})()`);
|
|
2268
|
+
}
|
|
2269
|
+
sendJSON(res, { success: true });
|
|
2270
|
+
break;
|
|
2271
|
+
|
|
2272
|
+
case 'hover':
|
|
2273
|
+
if (body.ref) {
|
|
2274
|
+
const bounds = await cdpEvaluate(`(function() {
|
|
2275
|
+
const el = document.querySelector('[data-empir3-ref="${body.ref}"]');
|
|
2276
|
+
if (!el) return null;
|
|
2277
|
+
const r = el.getBoundingClientRect();
|
|
2278
|
+
return JSON.stringify({ x: r.x + r.width/2, y: r.y + r.height/2 });
|
|
2279
|
+
})()`);
|
|
2280
|
+
if (bounds) {
|
|
2281
|
+
const { x, y } = JSON.parse(bounds);
|
|
2282
|
+
await cdpSend('Input.dispatchMouseEvent', {
|
|
2283
|
+
type: 'mouseMoved', x, y,
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
sendJSON(res, { success: true });
|
|
2288
|
+
break;
|
|
2289
|
+
|
|
2290
|
+
default:
|
|
2291
|
+
sendError(res, `Unknown action: ${kind}`, 400);
|
|
2292
|
+
}
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
if (path === '/evaluate') {
|
|
2297
|
+
await ensureChromeReady();
|
|
2298
|
+
const result = await cdpEvaluate(body.expression);
|
|
2299
|
+
sendJSON(res, { result });
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
if (path === '/cookies') {
|
|
2304
|
+
await ensureChromeReady();
|
|
2305
|
+
for (const cookie of (body.cookies || [])) {
|
|
2306
|
+
await cdpSend('Network.setCookie', {
|
|
2307
|
+
name: cookie.name,
|
|
2308
|
+
value: cookie.value,
|
|
2309
|
+
url: body.url,
|
|
2310
|
+
domain: cookie.domain,
|
|
2311
|
+
path: cookie.path || '/',
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
sendJSON(res, { success: true });
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
sendError(res, `Not found: ${method} ${path}`, 404);
|
|
2320
|
+
} catch (e: any) {
|
|
2321
|
+
console.error(`[Empir3 Bridge] Error: ${e.message}`);
|
|
2322
|
+
sendError(res, e.message);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// ─── Utilities ───────────────────────────────────────────────
|
|
2327
|
+
|
|
2328
|
+
function sleep(ms: number): Promise<void> {
|
|
2329
|
+
return new Promise(r => setTimeout(r, ms));
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
async function fetchJSON(url: string, method: string = 'GET'): Promise<any> {
|
|
2333
|
+
const { default: http } = await import('http');
|
|
2334
|
+
const { URL } = await import('url');
|
|
2335
|
+
return new Promise((resolve, reject) => {
|
|
2336
|
+
let settled = false;
|
|
2337
|
+
const u = new URL(url);
|
|
2338
|
+
const req = http.request({
|
|
2339
|
+
hostname: u.hostname,
|
|
2340
|
+
port: u.port,
|
|
2341
|
+
// Chrome's /json/new takes the URL as the raw query string (not encoded),
|
|
2342
|
+
// so preserve u.search verbatim. http.request would re-serialize if we
|
|
2343
|
+
// passed pathname/search separately.
|
|
2344
|
+
path: u.pathname + u.search,
|
|
2345
|
+
method,
|
|
2346
|
+
}, (res) => {
|
|
2347
|
+
let data = '';
|
|
2348
|
+
res.on('data', (chunk: Buffer) => { data += chunk; });
|
|
2349
|
+
res.on('end', () => {
|
|
2350
|
+
if (settled) return;
|
|
2351
|
+
settled = true;
|
|
2352
|
+
try { resolve(JSON.parse(data)); }
|
|
2353
|
+
catch { reject(new Error(`Invalid JSON from ${url}`)); }
|
|
2354
|
+
});
|
|
2355
|
+
});
|
|
2356
|
+
req.setTimeout(3000, () => {
|
|
2357
|
+
if (settled) return;
|
|
2358
|
+
settled = true;
|
|
2359
|
+
req.destroy();
|
|
2360
|
+
reject(new Error(`Timeout fetching ${url}`));
|
|
2361
|
+
});
|
|
2362
|
+
req.on('error', (e) => {
|
|
2363
|
+
if (settled) return;
|
|
2364
|
+
settled = true;
|
|
2365
|
+
reject(e);
|
|
2366
|
+
});
|
|
2367
|
+
req.end();
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// ─── Shutdown ────────────────────────────────────────────────
|
|
2372
|
+
|
|
2373
|
+
function shutdown() {
|
|
2374
|
+
console.log('[Empir3 Bridge] Shutting down...');
|
|
2375
|
+
shuttingDown = true;
|
|
2376
|
+
stopTargetPolling();
|
|
2377
|
+
if (browserWs) { try { browserWs.close(); } catch {} }
|
|
2378
|
+
if (cdpWs) cdpWs.close();
|
|
2379
|
+
if (chromeProcess) {
|
|
2380
|
+
chromeProcess.kill();
|
|
2381
|
+
}
|
|
2382
|
+
process.exit(0);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
process.on('SIGINT', shutdown);
|
|
2386
|
+
process.on('SIGTERM', shutdown);
|
|
2387
|
+
|
|
2388
|
+
// ─── Main ────────────────────────────────────────────────────
|
|
2389
|
+
|
|
2390
|
+
async function main() {
|
|
2391
|
+
const server = createServer(handleRequest);
|
|
2392
|
+
|
|
2393
|
+
const ownsHttpPort = await new Promise<boolean>((resolve, reject) => {
|
|
2394
|
+
let settled = false;
|
|
2395
|
+
server.once('error', (e: NodeJS.ErrnoException) => {
|
|
2396
|
+
if (settled) return;
|
|
2397
|
+
settled = true;
|
|
2398
|
+
if (e.code === 'EADDRINUSE') {
|
|
2399
|
+
console.warn(`[Empir3 Bridge] HTTP server ${HOST}:${PORT} is already in use; using the existing bridge if its /health check passes.`);
|
|
2400
|
+
resolve(false);
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
reject(e);
|
|
2404
|
+
});
|
|
2405
|
+
server.listen(PORT, HOST, () => {
|
|
2406
|
+
if (settled) return;
|
|
2407
|
+
settled = true;
|
|
2408
|
+
console.log(`[Empir3 Bridge] HTTP server on ${HOST}:${PORT}`);
|
|
2409
|
+
resolve(true);
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2412
|
+
|
|
2413
|
+
if (!ownsHttpPort) return;
|
|
2414
|
+
|
|
2415
|
+
// Launch Chrome
|
|
2416
|
+
try {
|
|
2417
|
+
await launchChrome();
|
|
2418
|
+
console.log(`[Empir3 Bridge] Ready — Chrome connected via CDP on port ${CDP_PORT}`);
|
|
2419
|
+
startTargetPolling();
|
|
2420
|
+
// Connect browser-level WS for real-time target discovery (sees ALL tabs)
|
|
2421
|
+
connectBrowserWs().catch(() => {});
|
|
2422
|
+
} catch (e: any) {
|
|
2423
|
+
console.error(`[Empir3 Bridge] Failed to launch Chrome: ${e.message}`);
|
|
2424
|
+
console.log('[Empir3 Bridge] Waiting for Chrome to connect...');
|
|
2425
|
+
|
|
2426
|
+
// Poll for Chrome
|
|
2427
|
+
const poll = async () => {
|
|
2428
|
+
try {
|
|
2429
|
+
await connectCDP();
|
|
2430
|
+
console.log('[Empir3 Bridge] Chrome connected');
|
|
2431
|
+
startTargetPolling();
|
|
2432
|
+
connectBrowserWs().catch(() => {});
|
|
2433
|
+
} catch {
|
|
2434
|
+
setTimeout(poll, 3000);
|
|
2435
|
+
}
|
|
2436
|
+
};
|
|
2437
|
+
poll();
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
main().catch((e) => {
|
|
2442
|
+
console.error('[Empir3 Bridge] Fatal:', e);
|
|
2443
|
+
process.exit(1);
|
|
2444
|
+
});
|