@browserbridge/bbx 1.0.1 → 1.1.0
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/README.md +4 -4
- package/package.json +11 -13
- package/packages/agent-client/src/cli-helpers.js +33 -0
- package/packages/agent-client/src/cli.js +116 -41
- package/packages/agent-client/src/client.js +29 -4
- package/packages/agent-client/src/command-registry.js +3 -0
- package/packages/agent-client/src/detect.js +159 -48
- package/packages/agent-client/src/install.js +24 -1
- package/packages/agent-client/src/mcp-config.js +29 -10
- package/packages/agent-client/src/setup-status.js +12 -4
- package/packages/mcp-server/src/bin.js +57 -5
- package/packages/mcp-server/src/handlers.js +28 -7
- package/packages/mcp-server/src/server.js +12 -2
- package/packages/native-host/bin/bridge-daemon.js +33 -4
- package/packages/native-host/bin/install-manifest.js +24 -2
- package/packages/native-host/src/config.js +131 -6
- package/packages/native-host/src/daemon-process.js +396 -0
- package/packages/native-host/src/daemon.js +217 -68
- package/packages/native-host/src/framing.js +131 -11
- package/packages/native-host/src/install-manifest.js +121 -7
- package/packages/native-host/src/native-host.js +110 -73
- package/packages/protocol/src/capabilities.js +3 -0
- package/packages/protocol/src/defaults.js +1 -0
- package/packages/protocol/src/errors.js +4 -0
- package/packages/protocol/src/payload-cost.js +19 -6
- package/packages/protocol/src/protocol.js +143 -7
- package/packages/protocol/src/registry.js +11 -0
- package/packages/protocol/src/summary.js +18 -10
- package/packages/protocol/src/types.js +28 -3
- package/skills/browser-bridge/SKILL.md +2 -1
- package/skills/browser-bridge/references/interaction.md +1 -0
- package/skills/browser-bridge/references/protocol.md +2 -1
- package/CHANGELOG.md +0 -55
- package/assets/banner.jpg +0 -0
- package/assets/logo.png +0 -0
- package/assets/logo.svg +0 -65
- package/docs/api-reference.md +0 -157
- package/docs/cli-guide.md +0 -128
- package/docs/index.md +0 -25
- package/docs/manual-setup.md +0 -140
- package/docs/mcp-vs-cli.md +0 -258
- package/docs/publishing.md +0 -112
- package/docs/quickstart.md +0 -104
- package/docs/troubleshooting.md +0 -59
- package/docs/unpacked-extension.md +0 -72
- package/docs/usage-scenarios.md +0 -136
- package/manifest.json +0 -38
- package/packages/extension/assets/icon-128.png +0 -0
- package/packages/extension/assets/icon-16.png +0 -0
- package/packages/extension/assets/icon-32.png +0 -0
- package/packages/extension/assets/icon-48.png +0 -0
- package/packages/extension/src/background-helpers.js +0 -474
- package/packages/extension/src/background-routing.js +0 -89
- package/packages/extension/src/background.js +0 -3490
- package/packages/extension/src/content-script-helpers.js +0 -282
- package/packages/extension/src/content-script.js +0 -2043
- package/packages/extension/src/debugger-coordinator.js +0 -188
- package/packages/extension/src/sidepanel-helpers.js +0 -104
- package/packages/extension/ui/popup.html +0 -35
- package/packages/extension/ui/popup.js +0 -298
- package/packages/extension/ui/sidepanel.html +0 -102
- package/packages/extension/ui/sidepanel.js +0 -1771
- package/packages/extension/ui/ui.css +0 -1160
|
@@ -1,3490 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
bridgeMethodNeedsTab,
|
|
5
|
-
ERROR_CODES,
|
|
6
|
-
createFailure,
|
|
7
|
-
createRequest,
|
|
8
|
-
createRuntimeContext,
|
|
9
|
-
createSuccess,
|
|
10
|
-
estimateJsonPayloadCost,
|
|
11
|
-
normalizeAccessibilityTreeParams,
|
|
12
|
-
normalizeCheckedAction,
|
|
13
|
-
normalizeConsoleParams,
|
|
14
|
-
normalizeDomQuery,
|
|
15
|
-
normalizeDragParams,
|
|
16
|
-
normalizeEvaluateParams,
|
|
17
|
-
normalizeFindByRoleParams,
|
|
18
|
-
normalizeFindByTextParams,
|
|
19
|
-
normalizeGetHtmlParams,
|
|
20
|
-
normalizeHoverParams,
|
|
21
|
-
normalizeInputAction,
|
|
22
|
-
normalizeNavigationAction,
|
|
23
|
-
normalizeNetworkParams,
|
|
24
|
-
normalizePageTextParams,
|
|
25
|
-
normalizePatchOperation,
|
|
26
|
-
normalizeSelectAction,
|
|
27
|
-
normalizeStorageParams,
|
|
28
|
-
normalizeStyleQuery,
|
|
29
|
-
normalizeTabCloseParams,
|
|
30
|
-
normalizeTabCreateParams,
|
|
31
|
-
normalizeViewportAction,
|
|
32
|
-
normalizeViewportResizeParams,
|
|
33
|
-
normalizeWaitForLoadStateParams,
|
|
34
|
-
normalizeWaitForParams,
|
|
35
|
-
SUPPORTED_VERSIONS,
|
|
36
|
-
} from '../../protocol/src/index.js';
|
|
37
|
-
import { summarizeBridgeResponse } from '../../protocol/src/index.js';
|
|
38
|
-
import {
|
|
39
|
-
enforceTokenBudget,
|
|
40
|
-
getResponseDiagnostics,
|
|
41
|
-
getErrorMessage,
|
|
42
|
-
matchesConsoleLevel,
|
|
43
|
-
normalizeRuntimeErrorMessage,
|
|
44
|
-
safeOrigin,
|
|
45
|
-
shouldLogAction,
|
|
46
|
-
simplifyAXNode,
|
|
47
|
-
summarizeActionResult,
|
|
48
|
-
summarizeTabResult,
|
|
49
|
-
} from './background-helpers.js';
|
|
50
|
-
import {
|
|
51
|
-
isRestrictedAutomationUrl,
|
|
52
|
-
normalizeRequestedAccessTab,
|
|
53
|
-
resolveWindowScopedTab,
|
|
54
|
-
selectRequestTabCandidate,
|
|
55
|
-
} from './background-routing.js';
|
|
56
|
-
import { TabDebuggerCoordinator } from './debugger-coordinator.js';
|
|
57
|
-
|
|
58
|
-
/** @typedef {import('../../protocol/src/types.js').BridgeRequest} BridgeRequest */
|
|
59
|
-
/** @typedef {import('../../protocol/src/types.js').BridgeResponse} BridgeResponse */
|
|
60
|
-
/** @typedef {import('../../protocol/src/types.js').ErrorCode} ErrorCode */
|
|
61
|
-
/** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* @typedef {{
|
|
65
|
-
* windowId: number,
|
|
66
|
-
* title: string,
|
|
67
|
-
* enabledAt: number
|
|
68
|
-
* }} EnabledWindowState
|
|
69
|
-
*/
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @typedef {{
|
|
73
|
-
* tabId: number,
|
|
74
|
-
* windowId: number,
|
|
75
|
-
* title: string,
|
|
76
|
-
* url: string
|
|
77
|
-
* }} ResolvedTabTarget
|
|
78
|
-
*/
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* @typedef {{
|
|
82
|
-
* id: string,
|
|
83
|
-
* at: number,
|
|
84
|
-
* method: string,
|
|
85
|
-
* source: string,
|
|
86
|
-
* tabId: number | null,
|
|
87
|
-
* url: string,
|
|
88
|
-
* ok: boolean,
|
|
89
|
-
* summary: string,
|
|
90
|
-
* responseBytes: number,
|
|
91
|
-
* approxTokens: number,
|
|
92
|
-
* imageApproxTokens: number,
|
|
93
|
-
* costClass: 'cheap' | 'moderate' | 'heavy' | 'extreme',
|
|
94
|
-
* imageBytes: number,
|
|
95
|
-
* summaryBytes: number,
|
|
96
|
-
* summaryTokens: number,
|
|
97
|
-
* summaryCostClass: 'cheap' | 'moderate' | 'heavy' | 'extreme',
|
|
98
|
-
* debuggerBacked: boolean,
|
|
99
|
-
* overBudget: boolean,
|
|
100
|
-
* hasScreenshot: boolean,
|
|
101
|
-
* nodeCount: number | null,
|
|
102
|
-
* continuationHint: string | null
|
|
103
|
-
* }} ActionLogEntry
|
|
104
|
-
*/
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* @typedef {{
|
|
108
|
-
* tabId: number,
|
|
109
|
-
* windowId: number,
|
|
110
|
-
* title: string,
|
|
111
|
-
* url: string,
|
|
112
|
-
* enabled: boolean,
|
|
113
|
-
* accessRequested: boolean,
|
|
114
|
-
* restricted: boolean
|
|
115
|
-
* }} CurrentTabState
|
|
116
|
-
*/
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* @typedef {{
|
|
120
|
-
* status?: string,
|
|
121
|
-
* title?: string,
|
|
122
|
-
* url?: string
|
|
123
|
-
* }} TabChangeInfo
|
|
124
|
-
*/
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* @param {unknown} value
|
|
128
|
-
* @returns {value is number}
|
|
129
|
-
*/
|
|
130
|
-
function isNumber(value) {
|
|
131
|
-
return typeof value === 'number' && Number.isFinite(value);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* @typedef {{
|
|
136
|
-
* scopeTabId: number | null,
|
|
137
|
-
* surface: 'popup' | 'sidepanel'
|
|
138
|
-
* }} UiPortState
|
|
139
|
-
*/
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* @typedef {{
|
|
143
|
-
* action?: 'install' | 'uninstall',
|
|
144
|
-
* kind: 'mcp' | 'skill',
|
|
145
|
-
* target: string
|
|
146
|
-
* }} SetupInstallAction
|
|
147
|
-
*/
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* @typedef {{
|
|
151
|
-
* nativePort: chrome.runtime.Port | null,
|
|
152
|
-
* enabledWindow: EnabledWindowState | null,
|
|
153
|
-
* requestedAccessWindowId: number | null,
|
|
154
|
-
* requestedAccessPopupWindowId: number | null,
|
|
155
|
-
* nativeReconnectAttempts: number,
|
|
156
|
-
* actionLog: ActionLogEntry[],
|
|
157
|
-
* uiPorts: Map<chrome.runtime.Port, UiPortState>,
|
|
158
|
-
* setupStatus: SetupStatus | null,
|
|
159
|
-
* setupStatusPending: boolean,
|
|
160
|
-
* setupStatusPendingRequestId: string | null,
|
|
161
|
-
* setupStatusUpdatedAt: number,
|
|
162
|
-
* setupStatusError: string | null,
|
|
163
|
-
* setupStatusTimeoutId: ReturnType<typeof setTimeout> | null,
|
|
164
|
-
* setupInstallPendingRequestId: string | null,
|
|
165
|
-
* setupInstallPendingAction: SetupInstallAction | null,
|
|
166
|
-
* setupInstallPendingKey: string | null,
|
|
167
|
-
* setupInstallError: string | null
|
|
168
|
-
* }} ExtensionState
|
|
169
|
-
*/
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* @returns {string}
|
|
173
|
-
*/
|
|
174
|
-
function detectBrowserName() {
|
|
175
|
-
const ua = navigator.userAgent;
|
|
176
|
-
if (ua.includes('Edg/')) return 'edge';
|
|
177
|
-
if (ua.includes('OPR/') || ua.includes('Opera')) return 'opera';
|
|
178
|
-
if (ua.includes('Brave')) return 'brave';
|
|
179
|
-
if (ua.includes('Arc/')) return 'arc';
|
|
180
|
-
if (ua.includes('Vivaldi/')) return 'vivaldi';
|
|
181
|
-
return 'chrome';
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* @returns {Promise<string>}
|
|
186
|
-
*/
|
|
187
|
-
async function getProfileLabel() {
|
|
188
|
-
const STORAGE_KEY = 'bb_profile_label';
|
|
189
|
-
try {
|
|
190
|
-
const result = await chrome.storage.session.get(STORAGE_KEY);
|
|
191
|
-
if (result[STORAGE_KEY]) {
|
|
192
|
-
return /** @type {string} */ (result[STORAGE_KEY]);
|
|
193
|
-
}
|
|
194
|
-
const label = `profile_${Math.random().toString(36).slice(2, 8)}`;
|
|
195
|
-
await chrome.storage.session.set({ [STORAGE_KEY]: label });
|
|
196
|
-
return label;
|
|
197
|
-
} catch {
|
|
198
|
-
return `profile_${Date.now().toString(36)}`;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Send browser/profile identity to the daemon via the native host.
|
|
204
|
-
*
|
|
205
|
-
* @param {chrome.runtime.Port} port
|
|
206
|
-
* @returns {void}
|
|
207
|
-
*/
|
|
208
|
-
function sendIdentity(port) {
|
|
209
|
-
const browserName = detectBrowserName();
|
|
210
|
-
void getProfileLabel().then((profileLabel) => {
|
|
211
|
-
try {
|
|
212
|
-
port.postMessage({ type: 'host.identity', browserName, profileLabel });
|
|
213
|
-
} catch {
|
|
214
|
-
/* port may have disconnected */
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Notify the daemon that this browser/profile was recently active so untargeted
|
|
221
|
-
* access prompts can be routed to one browser instead of broadcasting.
|
|
222
|
-
*
|
|
223
|
-
* @param {chrome.runtime.Port | null} [port=state.nativePort]
|
|
224
|
-
* @returns {void}
|
|
225
|
-
*/
|
|
226
|
-
function sendActivityUpdate(port = state.nativePort) {
|
|
227
|
-
if (!port) return;
|
|
228
|
-
try {
|
|
229
|
-
port.postMessage({ type: 'host.activity', at: Date.now() });
|
|
230
|
-
} catch {
|
|
231
|
-
/* port may have disconnected */
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Notify the daemon whether this extension currently has access enabled.
|
|
237
|
-
*
|
|
238
|
-
* @param {boolean} enabled
|
|
239
|
-
* @returns {void}
|
|
240
|
-
*/
|
|
241
|
-
function sendAccessUpdate(enabled) {
|
|
242
|
-
if (!state.nativePort) return;
|
|
243
|
-
try {
|
|
244
|
-
state.nativePort.postMessage({
|
|
245
|
-
type: 'host.access_update',
|
|
246
|
-
accessEnabled: enabled,
|
|
247
|
-
});
|
|
248
|
-
} catch {
|
|
249
|
-
/* port may have disconnected */
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const NATIVE_APP_NAME = 'com.browserbridge.browser_bridge';
|
|
254
|
-
const CONTENT_SCRIPT_TIMEOUT_MS = 5_000;
|
|
255
|
-
const MAX_ACTION_LOG_ENTRIES = 50;
|
|
256
|
-
const ENABLED_WINDOW_STORAGE_KEY = 'enabledWindow';
|
|
257
|
-
const ACTION_LOG_STORAGE_KEY = 'actionLog';
|
|
258
|
-
const SIDEPANEL_PATH = 'packages/extension/ui/sidepanel.html';
|
|
259
|
-
const POPUP_PATH = 'packages/extension/ui/popup.html';
|
|
260
|
-
const ENABLED_BADGE_TEXT = 'AI';
|
|
261
|
-
const ACCESS_REQUEST_BADGE_TEXT = '!';
|
|
262
|
-
const RESTRICTED_BADGE_TEXT = '!';
|
|
263
|
-
const DEBUGGER_PROTOCOL_VERSION = '1.3';
|
|
264
|
-
const SETUP_STATUS_STALE_MS = 30_000;
|
|
265
|
-
const SETUP_STATUS_TIMEOUT_MS = 5_000;
|
|
266
|
-
const ACCESS_DENIED_WINDOW_OFF = 'Browser Bridge is off for this window.';
|
|
267
|
-
const ACCESS_DENIED_TAB_CLOSE = 'tabs.close only works inside the enabled window.';
|
|
268
|
-
const KEEPALIVE_ALARM_NAME = 'bb-keepalive';
|
|
269
|
-
const NATIVE_RECONNECT_BASE_MS = 2_000;
|
|
270
|
-
const NATIVE_RECONNECT_MAX_MS = 30_000;
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* @param {string} left
|
|
274
|
-
* @param {string} right
|
|
275
|
-
* @returns {number}
|
|
276
|
-
*/
|
|
277
|
-
function compareProtocolVersions(left, right) {
|
|
278
|
-
const leftParts = left.split('.').map((part) => Number(part) || 0);
|
|
279
|
-
const rightParts = right.split('.').map((part) => Number(part) || 0);
|
|
280
|
-
const length = Math.max(leftParts.length, rightParts.length);
|
|
281
|
-
for (let index = 0; index < length; index += 1) {
|
|
282
|
-
const delta = (leftParts[index] || 0) - (rightParts[index] || 0);
|
|
283
|
-
if (delta !== 0) {
|
|
284
|
-
return delta > 0 ? 1 : -1;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return 0;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* @param {string | undefined} requestedVersion
|
|
292
|
-
* @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
|
|
293
|
-
*/
|
|
294
|
-
function getVersionNegotiationPayload(requestedVersion) {
|
|
295
|
-
const latestSupported = SUPPORTED_VERSIONS[0];
|
|
296
|
-
if (!requestedVersion || !latestSupported || SUPPORTED_VERSIONS.includes(requestedVersion)) {
|
|
297
|
-
return { supported_versions: SUPPORTED_VERSIONS };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const localIsNewer = compareProtocolVersions(latestSupported, requestedVersion) > 0;
|
|
301
|
-
return {
|
|
302
|
-
supported_versions: SUPPORTED_VERSIONS,
|
|
303
|
-
...(localIsNewer ? { deprecated_since: latestSupported } : {}),
|
|
304
|
-
migration_hint: localIsNewer
|
|
305
|
-
? `Browser Bridge extension is newer than the client protocol ${requestedVersion}. Update the Browser Bridge CLI/npm package to ${latestSupported} or later.`
|
|
306
|
-
: `Browser Bridge extension is older than the client protocol ${requestedVersion}. Update the extension to a build that supports ${requestedVersion}.`,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
311
|
-
let _nativeReconnectTimer = null;
|
|
312
|
-
let nativeReconnectDelay = NATIVE_RECONNECT_BASE_MS;
|
|
313
|
-
|
|
314
|
-
/** @type {ExtensionState} */
|
|
315
|
-
const state = {
|
|
316
|
-
nativePort: null,
|
|
317
|
-
enabledWindow: null,
|
|
318
|
-
requestedAccessWindowId: null,
|
|
319
|
-
requestedAccessPopupWindowId: null,
|
|
320
|
-
nativeReconnectAttempts: 0,
|
|
321
|
-
actionLog: [],
|
|
322
|
-
uiPorts: new Map(),
|
|
323
|
-
setupStatus: null,
|
|
324
|
-
setupStatusPending: false,
|
|
325
|
-
setupStatusPendingRequestId: null,
|
|
326
|
-
setupStatusUpdatedAt: 0,
|
|
327
|
-
setupStatusError: null,
|
|
328
|
-
setupStatusTimeoutId: null,
|
|
329
|
-
setupInstallPendingRequestId: null,
|
|
330
|
-
setupInstallPendingAction: null,
|
|
331
|
-
setupInstallPendingKey: null,
|
|
332
|
-
setupInstallError: null,
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
const tabDebugger = new TabDebuggerCoordinator({
|
|
336
|
-
attach: (target, protocolVersion) => chrome.debugger.attach(target, protocolVersion),
|
|
337
|
-
detach: (target) => chrome.debugger.detach(target),
|
|
338
|
-
protocolVersion: DEBUGGER_PROTOCOL_VERSION,
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
void initializeState().catch(reportAsyncError);
|
|
342
|
-
connectNative();
|
|
343
|
-
|
|
344
|
-
chrome.runtime.onInstalled.addListener(async () => {
|
|
345
|
-
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
chrome.tabs.onActivated.addListener(({ tabId }) => {
|
|
349
|
-
sendActivityUpdate();
|
|
350
|
-
void updateActionIndicatorForTab(tabId).catch(reportAsyncError);
|
|
351
|
-
void syncGlobalBadgeToActiveTab().catch(reportAsyncError);
|
|
352
|
-
void emitUiState().catch(reportAsyncError);
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
chrome.windows.onFocusChanged.addListener((windowId) => {
|
|
356
|
-
if (typeof windowId === 'number' && windowId >= 0) {
|
|
357
|
-
sendActivityUpdate();
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
362
|
-
void handleTabUpdated(tabId, changeInfo, tab).catch(reportAsyncError);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
|
366
|
-
void handleTabRemoved(tabId, removeInfo).catch(reportAsyncError);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
chrome.windows.onRemoved.addListener((windowId) => {
|
|
370
|
-
clearRequestedAccessPopupWindow(windowId);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
374
|
-
if (alarm.name === KEEPALIVE_ALARM_NAME) {
|
|
375
|
-
// No-op: the alarm firing is enough to wake the service worker.
|
|
376
|
-
// Verify we still need to stay alive.
|
|
377
|
-
if (!state.enabledWindow) {
|
|
378
|
-
void chrome.alarms.clear(KEEPALIVE_ALARM_NAME);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
chrome.runtime.onConnect.addListener((port) => {
|
|
384
|
-
const surface = getUiSurfaceFromPortName(port.name);
|
|
385
|
-
if (surface) {
|
|
386
|
-
state.uiPorts.set(port, { scopeTabId: null, surface });
|
|
387
|
-
port.onMessage.addListener((message) => {
|
|
388
|
-
void handleUiMessage(port, message).catch(reportAsyncError);
|
|
389
|
-
});
|
|
390
|
-
port.onDisconnect.addListener(() => {
|
|
391
|
-
state.uiPorts.delete(port);
|
|
392
|
-
});
|
|
393
|
-
void emitUiStateForPort(port);
|
|
394
|
-
}
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
398
|
-
if (
|
|
399
|
-
message?.type === 'bridge.open-sidepanel' &&
|
|
400
|
-
typeof message.tabId === 'number' &&
|
|
401
|
-
typeof message.windowId === 'number'
|
|
402
|
-
) {
|
|
403
|
-
void openSidePanelForTab(message.tabId, message.windowId)
|
|
404
|
-
.then(() => {
|
|
405
|
-
sendResponse({ ok: true });
|
|
406
|
-
})
|
|
407
|
-
.catch((error) => {
|
|
408
|
-
sendResponse({
|
|
409
|
-
ok: false,
|
|
410
|
-
error: error instanceof Error ? error.message : String(error),
|
|
411
|
-
});
|
|
412
|
-
});
|
|
413
|
-
return true;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (message?.type === 'bridge.open-sidepanel' && sender.tab?.id && sender.tab.windowId) {
|
|
417
|
-
void openSidePanelForTab(sender.tab.id, sender.tab.windowId)
|
|
418
|
-
.then(() => {
|
|
419
|
-
sendResponse({ ok: true });
|
|
420
|
-
})
|
|
421
|
-
.catch((error) => {
|
|
422
|
-
sendResponse({
|
|
423
|
-
ok: false,
|
|
424
|
-
error: error instanceof Error ? error.message : String(error),
|
|
425
|
-
});
|
|
426
|
-
});
|
|
427
|
-
return true;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
return false;
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Restore persisted window access state when the service worker starts so the
|
|
435
|
-
* current browser-run grant survives worker restarts.
|
|
436
|
-
*
|
|
437
|
-
* @returns {Promise<void>}
|
|
438
|
-
*/
|
|
439
|
-
async function initializeState() {
|
|
440
|
-
await restoreEnabledWindow();
|
|
441
|
-
await restoreActionLog();
|
|
442
|
-
await primeEnabledWindowInstrumentation();
|
|
443
|
-
await refreshActionIndicators();
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* @returns {void}
|
|
448
|
-
*/
|
|
449
|
-
function clearNativeReconnectTimer() {
|
|
450
|
-
if (!_nativeReconnectTimer) {
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
clearTimeout(_nativeReconnectTimer);
|
|
454
|
-
_nativeReconnectTimer = null;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Schedule the next native-host reconnect attempt using the shared backoff
|
|
459
|
-
* path used after runtime disconnects.
|
|
460
|
-
*
|
|
461
|
-
* @param {string} errorMessage
|
|
462
|
-
* @param {{
|
|
463
|
-
* method?: string,
|
|
464
|
-
* summaryPrefix?: string,
|
|
465
|
-
* updateDisconnectedUi?: boolean
|
|
466
|
-
* }} [options]
|
|
467
|
-
* @returns {void}
|
|
468
|
-
*/
|
|
469
|
-
function scheduleNativeReconnect(errorMessage, options = {}) {
|
|
470
|
-
const method = typeof options.method === 'string' ? options.method : 'native.disconnect';
|
|
471
|
-
const summaryPrefix =
|
|
472
|
-
typeof options.summaryPrefix === 'string' ? options.summaryPrefix : 'Native host disconnected';
|
|
473
|
-
const updateDisconnectedUi = options.updateDisconnectedUi === true;
|
|
474
|
-
|
|
475
|
-
state.nativeReconnectAttempts += 1;
|
|
476
|
-
const reconnectAttempt = state.nativeReconnectAttempts;
|
|
477
|
-
clearSetupStatus(errorMessage);
|
|
478
|
-
|
|
479
|
-
if (updateDisconnectedUi) {
|
|
480
|
-
state.nativePort = null;
|
|
481
|
-
broadcastUi({
|
|
482
|
-
type: 'native.status',
|
|
483
|
-
connected: false,
|
|
484
|
-
error: errorMessage,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
void emitUiState().catch(reportAsyncError);
|
|
488
|
-
|
|
489
|
-
void appendActionLogEntry({
|
|
490
|
-
method,
|
|
491
|
-
source: 'extension',
|
|
492
|
-
ok: false,
|
|
493
|
-
summary: `${summaryPrefix} (attempt ${reconnectAttempt}): ${errorMessage}. Reconnecting in ${nativeReconnectDelay}ms.`,
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
clearNativeReconnectTimer();
|
|
497
|
-
_nativeReconnectTimer = setTimeout(() => {
|
|
498
|
-
_nativeReconnectTimer = null;
|
|
499
|
-
connectNative();
|
|
500
|
-
}, nativeReconnectDelay);
|
|
501
|
-
nativeReconnectDelay = Math.min(nativeReconnectDelay * 2, NATIVE_RECONNECT_MAX_MS);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Connect the extension service worker to the local Native Messaging host and
|
|
506
|
-
* fan connection state out to the popup and side panel UIs.
|
|
507
|
-
*
|
|
508
|
-
* @returns {void}
|
|
509
|
-
*/
|
|
510
|
-
function connectNative() {
|
|
511
|
-
clearNativeReconnectTimer();
|
|
512
|
-
try {
|
|
513
|
-
const candidatePort = chrome.runtime.connectNative(NATIVE_APP_NAME);
|
|
514
|
-
const wasReconnect = nativeReconnectDelay > NATIVE_RECONNECT_BASE_MS;
|
|
515
|
-
const reconnectAttempts = state.nativeReconnectAttempts;
|
|
516
|
-
const stabilityTimer = setTimeout(() => {
|
|
517
|
-
state.nativePort = candidatePort;
|
|
518
|
-
nativeReconnectDelay = NATIVE_RECONNECT_BASE_MS;
|
|
519
|
-
state.nativeReconnectAttempts = 0;
|
|
520
|
-
broadcastUi({ type: 'native.status', connected: true });
|
|
521
|
-
refreshSetupStatus(true);
|
|
522
|
-
void refreshActionIndicators();
|
|
523
|
-
void emitUiState();
|
|
524
|
-
sendIdentity(candidatePort);
|
|
525
|
-
sendActivityUpdate(candidatePort);
|
|
526
|
-
if (wasReconnect && reconnectAttempts > 0) {
|
|
527
|
-
void appendActionLogEntry({
|
|
528
|
-
method: 'native.reconnect',
|
|
529
|
-
source: 'extension',
|
|
530
|
-
ok: true,
|
|
531
|
-
summary: `Native host reconnected after ${reconnectAttempts} attempt${reconnectAttempts === 1 ? '' : 's'}.`,
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
}, 500);
|
|
535
|
-
candidatePort.onMessage.addListener((request) => {
|
|
536
|
-
if (handleHostStatusMessage(request)) {
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
void handleBridgeRequest(request).catch(reportAsyncError);
|
|
540
|
-
});
|
|
541
|
-
candidatePort.onDisconnect.addListener(() => {
|
|
542
|
-
clearTimeout(stabilityTimer);
|
|
543
|
-
const disconnectError = chrome.runtime.lastError?.message ?? 'Native host disconnected.';
|
|
544
|
-
scheduleNativeReconnect(disconnectError, {
|
|
545
|
-
method: 'native.disconnect',
|
|
546
|
-
summaryPrefix: 'Native host disconnected',
|
|
547
|
-
updateDisconnectedUi: state.nativePort === candidatePort,
|
|
548
|
-
});
|
|
549
|
-
});
|
|
550
|
-
} catch (error) {
|
|
551
|
-
scheduleNativeReconnect(getErrorMessage(error), {
|
|
552
|
-
method: 'native.connect',
|
|
553
|
-
summaryPrefix: 'Native host connection failed',
|
|
554
|
-
updateDisconnectedUi: !state.nativePort,
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Route a validated bridge request to the extension capability that should
|
|
561
|
-
* satisfy it.
|
|
562
|
-
*
|
|
563
|
-
* @param {BridgeRequest} request
|
|
564
|
-
* @returns {Promise<void>}
|
|
565
|
-
*/
|
|
566
|
-
async function handleBridgeRequest(request) {
|
|
567
|
-
const actionContext = shouldLogAction(request.method) ? await getActionContext(request) : null;
|
|
568
|
-
/** @type {BridgeResponse} */
|
|
569
|
-
let response;
|
|
570
|
-
|
|
571
|
-
try {
|
|
572
|
-
response = await dispatchBridgeRequest(request);
|
|
573
|
-
} catch (error) {
|
|
574
|
-
response = toFailureResponse(request, error);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (
|
|
578
|
-
!response.ok &&
|
|
579
|
-
response.error.code === ERROR_CODES.ACCESS_DENIED &&
|
|
580
|
-
response.error.message === ACCESS_DENIED_WINDOW_OFF
|
|
581
|
-
) {
|
|
582
|
-
await requestEnableFromAgentSide(request);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
response = enrichBridgeResponse(request, response);
|
|
586
|
-
|
|
587
|
-
await logBridgeAction(request, response, actionContext);
|
|
588
|
-
reply(response);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Resolve one bridge request into a structured response.
|
|
593
|
-
*
|
|
594
|
-
* @param {BridgeRequest} request
|
|
595
|
-
* @returns {Promise<BridgeResponse>}
|
|
596
|
-
*/
|
|
597
|
-
async function dispatchBridgeRequest(request) {
|
|
598
|
-
switch (request.method) {
|
|
599
|
-
case 'health.ping':
|
|
600
|
-
return createSuccess(
|
|
601
|
-
request.id,
|
|
602
|
-
{
|
|
603
|
-
extension: 'ok',
|
|
604
|
-
access: await getAccessStatus(),
|
|
605
|
-
...getVersionNegotiationPayload(request.meta?.protocol_version),
|
|
606
|
-
},
|
|
607
|
-
{ method: request.method }
|
|
608
|
-
);
|
|
609
|
-
case 'access.request':
|
|
610
|
-
return handleAccessRequest(request);
|
|
611
|
-
case 'skill.get_runtime_context':
|
|
612
|
-
return createSuccess(request.id, createRuntimeContext(), {
|
|
613
|
-
method: request.method,
|
|
614
|
-
});
|
|
615
|
-
case 'tabs.list':
|
|
616
|
-
return handleListTabs(request);
|
|
617
|
-
case 'tabs.create':
|
|
618
|
-
return handleCreateTab(request);
|
|
619
|
-
case 'tabs.close':
|
|
620
|
-
return handleCloseTab(request);
|
|
621
|
-
case 'page.evaluate':
|
|
622
|
-
return handlePageEvaluate(request);
|
|
623
|
-
case 'page.get_console':
|
|
624
|
-
return handlePageGetConsole(request);
|
|
625
|
-
case 'page.wait_for_load_state':
|
|
626
|
-
return handleWaitForLoadState(request);
|
|
627
|
-
case 'dom.get_accessibility_tree':
|
|
628
|
-
return handleAccessibilityTree(request);
|
|
629
|
-
case 'page.get_network':
|
|
630
|
-
return handleGetNetwork(request);
|
|
631
|
-
case 'viewport.resize':
|
|
632
|
-
return handleViewportResize(request);
|
|
633
|
-
case 'performance.get_metrics':
|
|
634
|
-
return handlePerformanceMetrics(request);
|
|
635
|
-
case 'navigation.navigate':
|
|
636
|
-
case 'navigation.reload':
|
|
637
|
-
case 'navigation.go_back':
|
|
638
|
-
case 'navigation.go_forward':
|
|
639
|
-
return handleNavigationRequest(request);
|
|
640
|
-
case 'page.get_state':
|
|
641
|
-
case 'page.get_storage':
|
|
642
|
-
case 'page.get_text':
|
|
643
|
-
case 'dom.query':
|
|
644
|
-
case 'dom.describe':
|
|
645
|
-
case 'dom.get_text':
|
|
646
|
-
case 'dom.get_attributes':
|
|
647
|
-
case 'dom.wait_for':
|
|
648
|
-
case 'dom.find_by_text':
|
|
649
|
-
case 'dom.find_by_role':
|
|
650
|
-
case 'dom.get_html':
|
|
651
|
-
case 'layout.get_box_model':
|
|
652
|
-
case 'layout.hit_test':
|
|
653
|
-
case 'styles.get_computed':
|
|
654
|
-
case 'styles.get_matched_rules':
|
|
655
|
-
case 'viewport.scroll':
|
|
656
|
-
case 'input.click':
|
|
657
|
-
case 'input.focus':
|
|
658
|
-
case 'input.type':
|
|
659
|
-
case 'input.press_key':
|
|
660
|
-
case 'input.set_checked':
|
|
661
|
-
case 'input.select_option':
|
|
662
|
-
case 'input.hover':
|
|
663
|
-
case 'input.drag':
|
|
664
|
-
case 'input.scroll_into_view':
|
|
665
|
-
case 'patch.apply_styles':
|
|
666
|
-
case 'patch.apply_dom':
|
|
667
|
-
case 'patch.list':
|
|
668
|
-
case 'patch.rollback':
|
|
669
|
-
case 'patch.commit_session_baseline':
|
|
670
|
-
case 'screenshot.capture_region':
|
|
671
|
-
case 'screenshot.capture_element':
|
|
672
|
-
case 'screenshot.capture_full_page':
|
|
673
|
-
return handleTabBoundRequest(request);
|
|
674
|
-
case 'cdp.get_document':
|
|
675
|
-
case 'cdp.get_dom_snapshot':
|
|
676
|
-
case 'cdp.get_box_model':
|
|
677
|
-
case 'cdp.get_computed_styles_for_node':
|
|
678
|
-
return handleCdpRequest(request);
|
|
679
|
-
default:
|
|
680
|
-
return createFailure(
|
|
681
|
-
request.id,
|
|
682
|
-
ERROR_CODES.INVALID_REQUEST,
|
|
683
|
-
`Unhandled method ${request.method}`
|
|
684
|
-
);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/**
|
|
689
|
-
* Restore the enabled window for the current browser run.
|
|
690
|
-
*
|
|
691
|
-
* @returns {Promise<void>}
|
|
692
|
-
*/
|
|
693
|
-
async function restoreEnabledWindow() {
|
|
694
|
-
const stored = await chrome.storage.session.get(ENABLED_WINDOW_STORAGE_KEY);
|
|
695
|
-
const enabledWindow = stored[ENABLED_WINDOW_STORAGE_KEY];
|
|
696
|
-
if (!enabledWindow || typeof enabledWindow !== 'object') {
|
|
697
|
-
state.enabledWindow = null;
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const candidate = /** @type {Record<string, unknown>} */ (enabledWindow);
|
|
702
|
-
const windowId = Number(candidate.windowId);
|
|
703
|
-
if (!Number.isFinite(windowId) || windowId <= 0) {
|
|
704
|
-
state.enabledWindow = null;
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
state.enabledWindow = {
|
|
709
|
-
windowId,
|
|
710
|
-
title: typeof candidate.title === 'string' ? candidate.title : '',
|
|
711
|
-
enabledAt: Number(candidate.enabledAt) || Date.now(),
|
|
712
|
-
};
|
|
713
|
-
sendAccessUpdate(true);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/**
|
|
717
|
-
* Best-effort reinjection of passive instrumentation for tabs in the enabled
|
|
718
|
-
* window so reads like `page.get_console` can see activity that happened
|
|
719
|
-
* before the first explicit read.
|
|
720
|
-
*
|
|
721
|
-
* @returns {Promise<void>}
|
|
722
|
-
*/
|
|
723
|
-
async function primeEnabledWindowInstrumentation() {
|
|
724
|
-
if (!state.enabledWindow) {
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
await injectContentScriptsForWindow(state.enabledWindow.windowId);
|
|
728
|
-
await primeWindowConsoleCapture(state.enabledWindow.windowId);
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/**
|
|
732
|
-
* Restore the recent action log that powers the side panel activity view.
|
|
733
|
-
*
|
|
734
|
-
* @returns {Promise<void>}
|
|
735
|
-
*/
|
|
736
|
-
async function restoreActionLog() {
|
|
737
|
-
const stored = await chrome.storage.session.get(ACTION_LOG_STORAGE_KEY);
|
|
738
|
-
const entries = stored[ACTION_LOG_STORAGE_KEY];
|
|
739
|
-
if (Array.isArray(entries)) {
|
|
740
|
-
state.actionLog = entries
|
|
741
|
-
.map((entry) => normalizeActionLogEntry(entry))
|
|
742
|
-
.filter((entry) => entry !== null);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* Clear the enabled window state if the window no longer exists.
|
|
748
|
-
* Retries once after a short delay to ride over transient API errors.
|
|
749
|
-
*
|
|
750
|
-
* @returns {Promise<boolean>} true if the window was verified gone and cleared
|
|
751
|
-
*/
|
|
752
|
-
async function clearEnabledWindowIfGone() {
|
|
753
|
-
if (!state.enabledWindow) {
|
|
754
|
-
return false;
|
|
755
|
-
}
|
|
756
|
-
let gone = false;
|
|
757
|
-
try {
|
|
758
|
-
await chrome.windows.get(state.enabledWindow.windowId);
|
|
759
|
-
} catch (e) {
|
|
760
|
-
const msg = getErrorMessage(e).toLowerCase();
|
|
761
|
-
if (msg.includes('no window') || msg.includes('not found') || msg.includes('window closed')) {
|
|
762
|
-
gone = true;
|
|
763
|
-
} else {
|
|
764
|
-
await new Promise((r) => {
|
|
765
|
-
setTimeout(r, 300);
|
|
766
|
-
});
|
|
767
|
-
try {
|
|
768
|
-
await chrome.windows.get(state.enabledWindow.windowId);
|
|
769
|
-
} catch (_e2) {
|
|
770
|
-
gone = true;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
if (gone) {
|
|
775
|
-
state.enabledWindow = null;
|
|
776
|
-
await chrome.storage.session.remove(ENABLED_WINDOW_STORAGE_KEY);
|
|
777
|
-
sendAccessUpdate(false);
|
|
778
|
-
return true;
|
|
779
|
-
}
|
|
780
|
-
return false;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* Build a compact access-status payload for health and doctor flows.
|
|
785
|
-
*
|
|
786
|
-
* @returns {Promise<{
|
|
787
|
-
* enabled: boolean,
|
|
788
|
-
* windowId: number | null,
|
|
789
|
-
* routeTabId: number | null,
|
|
790
|
-
* routeReady: boolean,
|
|
791
|
-
* routeUrl: string,
|
|
792
|
-
* reason: 'enabled' | 'access_disabled' | 'enabled_window_missing' | 'no_routable_active_tab' | 'restricted_page'
|
|
793
|
-
* }>}
|
|
794
|
-
*/
|
|
795
|
-
async function getAccessStatus() {
|
|
796
|
-
if (!state.enabledWindow) {
|
|
797
|
-
return {
|
|
798
|
-
enabled: false,
|
|
799
|
-
windowId: null,
|
|
800
|
-
routeTabId: null,
|
|
801
|
-
routeReady: false,
|
|
802
|
-
routeUrl: '',
|
|
803
|
-
reason: 'access_disabled',
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
await chrome.windows.get(state.enabledWindow.windowId);
|
|
809
|
-
} catch {
|
|
810
|
-
const cleared = await clearEnabledWindowIfGone();
|
|
811
|
-
if (cleared) {
|
|
812
|
-
return {
|
|
813
|
-
enabled: false,
|
|
814
|
-
windowId: null,
|
|
815
|
-
routeTabId: null,
|
|
816
|
-
routeReady: false,
|
|
817
|
-
routeUrl: '',
|
|
818
|
-
reason: 'enabled_window_missing',
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
const tabs = await chrome.tabs.query({
|
|
824
|
-
active: true,
|
|
825
|
-
windowId: state.enabledWindow.windowId,
|
|
826
|
-
});
|
|
827
|
-
const tab = tabs[0];
|
|
828
|
-
if (!tab?.id || typeof tab.url !== 'string') {
|
|
829
|
-
return {
|
|
830
|
-
enabled: true,
|
|
831
|
-
windowId: state.enabledWindow.windowId,
|
|
832
|
-
routeTabId: null,
|
|
833
|
-
routeReady: false,
|
|
834
|
-
routeUrl: '',
|
|
835
|
-
reason: 'no_routable_active_tab',
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
if (isRestrictedAutomationUrl(tab.url)) {
|
|
840
|
-
return {
|
|
841
|
-
enabled: true,
|
|
842
|
-
windowId: state.enabledWindow.windowId,
|
|
843
|
-
routeTabId: tab.id,
|
|
844
|
-
routeReady: false,
|
|
845
|
-
routeUrl: tab.url,
|
|
846
|
-
reason: 'restricted_page',
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
return {
|
|
851
|
-
enabled: true,
|
|
852
|
-
windowId: state.enabledWindow.windowId,
|
|
853
|
-
routeTabId: tab.id,
|
|
854
|
-
routeReady: true,
|
|
855
|
-
routeUrl: tab.url,
|
|
856
|
-
reason: 'enabled',
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* Summarize the currently open tabs in the enabled window so the client can
|
|
862
|
-
* inspect or explicitly target them.
|
|
863
|
-
*
|
|
864
|
-
* @param {BridgeRequest} request
|
|
865
|
-
* @returns {Promise<BridgeResponse>}
|
|
866
|
-
*/
|
|
867
|
-
async function handleListTabs(request) {
|
|
868
|
-
if (!state.enabledWindow) {
|
|
869
|
-
return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_WINDOW_OFF, null, {
|
|
870
|
-
method: request.method,
|
|
871
|
-
});
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
const tabs = await chrome.tabs.query({
|
|
875
|
-
windowId: state.enabledWindow.windowId,
|
|
876
|
-
});
|
|
877
|
-
const summarized = tabs
|
|
878
|
-
.map((tab) => {
|
|
879
|
-
if (!isNumber(tab.id) || typeof tab.url !== 'string') {
|
|
880
|
-
return null;
|
|
881
|
-
}
|
|
882
|
-
return {
|
|
883
|
-
tabId: tab.id,
|
|
884
|
-
windowId: tab.windowId,
|
|
885
|
-
active: Boolean(tab.active),
|
|
886
|
-
title: tab.title ?? '',
|
|
887
|
-
origin: safeOrigin(tab.url),
|
|
888
|
-
url: tab.url,
|
|
889
|
-
};
|
|
890
|
-
})
|
|
891
|
-
.filter((tab) => tab !== null);
|
|
892
|
-
return createSuccess(request.id, { tabs: summarized }, { method: request.method });
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Normalizers for tab-bound request params. Each entry maps a bridge method
|
|
897
|
-
* to a function that coerces and defaults the raw request params.
|
|
898
|
-
*
|
|
899
|
-
* @type {Record<string, ((params: Record<string, unknown>) => Record<string, unknown>) | undefined>}
|
|
900
|
-
*/
|
|
901
|
-
const TAB_BOUND_NORMALIZERS = {
|
|
902
|
-
'dom.query': normalizeDomQuery,
|
|
903
|
-
'dom.wait_for': normalizeWaitForParams,
|
|
904
|
-
'dom.find_by_text': normalizeFindByTextParams,
|
|
905
|
-
'dom.find_by_role': normalizeFindByRoleParams,
|
|
906
|
-
'dom.get_html': normalizeGetHtmlParams,
|
|
907
|
-
'styles.get_computed': normalizeStyleQuery,
|
|
908
|
-
'styles.get_matched_rules': normalizeStyleQuery,
|
|
909
|
-
'viewport.scroll': normalizeViewportAction,
|
|
910
|
-
'input.click': normalizeInputAction,
|
|
911
|
-
'input.focus': normalizeInputAction,
|
|
912
|
-
'input.type': normalizeInputAction,
|
|
913
|
-
'input.press_key': normalizeInputAction,
|
|
914
|
-
'input.set_checked': normalizeCheckedAction,
|
|
915
|
-
'input.select_option': normalizeSelectAction,
|
|
916
|
-
'input.hover': normalizeHoverParams,
|
|
917
|
-
'input.drag': normalizeDragParams,
|
|
918
|
-
'patch.apply_styles': normalizePatchOperation,
|
|
919
|
-
'patch.apply_dom': normalizePatchOperation,
|
|
920
|
-
'patch.list': normalizePatchOperation,
|
|
921
|
-
'patch.rollback': normalizePatchOperation,
|
|
922
|
-
'patch.commit_session_baseline': normalizePatchOperation,
|
|
923
|
-
'page.get_storage': normalizeStorageParams,
|
|
924
|
-
'page.get_text': normalizePageTextParams,
|
|
925
|
-
};
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* Dispatch a tab-bound request to the content script after enforcing the
|
|
929
|
-
* session scope and capability requirements.
|
|
930
|
-
*
|
|
931
|
-
* @param {BridgeRequest} request
|
|
932
|
-
* @returns {Promise<BridgeResponse>}
|
|
933
|
-
*/
|
|
934
|
-
async function handleTabBoundRequest(request) {
|
|
935
|
-
const target = await resolveRequestTarget(request);
|
|
936
|
-
await ensureContentScript(target.tabId);
|
|
937
|
-
const normalizer = TAB_BOUND_NORMALIZERS[request.method];
|
|
938
|
-
const payload = normalizer ? normalizer(request.params) : request.params;
|
|
939
|
-
|
|
940
|
-
if (request.method.startsWith('screenshot.')) {
|
|
941
|
-
const result = await handleScreenshot(target, request.method, request.params);
|
|
942
|
-
return createSuccess(request.id, result, { method: request.method });
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
const timeoutMs = getContentScriptTimeout(request.method, payload);
|
|
946
|
-
const response = await sendTabMessage(
|
|
947
|
-
target.tabId,
|
|
948
|
-
{
|
|
949
|
-
type: 'bridge.execute',
|
|
950
|
-
method: request.method,
|
|
951
|
-
params: payload,
|
|
952
|
-
},
|
|
953
|
-
timeoutMs
|
|
954
|
-
);
|
|
955
|
-
if (response?.error) {
|
|
956
|
-
return toFailureResponse(request, response.error);
|
|
957
|
-
}
|
|
958
|
-
return createSuccess(request.id, response, { method: request.method });
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/**
|
|
962
|
-
* Execute a tab-level navigation action and optionally wait for the next load
|
|
963
|
-
* cycle to complete.
|
|
964
|
-
*
|
|
965
|
-
* @param {BridgeRequest} request
|
|
966
|
-
* @returns {Promise<BridgeResponse>}
|
|
967
|
-
*/
|
|
968
|
-
async function handleNavigationRequest(request) {
|
|
969
|
-
const target = await resolveRequestTarget(request);
|
|
970
|
-
const action = normalizeNavigationAction(request.params);
|
|
971
|
-
|
|
972
|
-
if (request.method === 'navigation.navigate') {
|
|
973
|
-
if (!action.url) {
|
|
974
|
-
throw new Error(ERROR_CODES.INVALID_REQUEST);
|
|
975
|
-
}
|
|
976
|
-
await chrome.tabs.update(target.tabId, { url: action.url });
|
|
977
|
-
} else if (request.method === 'navigation.reload') {
|
|
978
|
-
await chrome.tabs.reload(target.tabId);
|
|
979
|
-
} else if (request.method === 'navigation.go_back') {
|
|
980
|
-
await chrome.tabs.goBack(target.tabId);
|
|
981
|
-
} else {
|
|
982
|
-
await chrome.tabs.goForward(target.tabId);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
const tab = action.waitForLoad
|
|
986
|
-
? await waitForTabComplete(target.tabId, action.timeoutMs)
|
|
987
|
-
: await chrome.tabs.get(target.tabId);
|
|
988
|
-
await emitUiState();
|
|
989
|
-
|
|
990
|
-
return createSuccess(request.id, summarizeTabResult(tab, request.method), {
|
|
991
|
-
method: request.method,
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* Compute a per-method content script timeout that accommodates long-running
|
|
997
|
-
* operations such as dom.wait_for or hover-with-duration.
|
|
998
|
-
*
|
|
999
|
-
* @param {string} method
|
|
1000
|
-
* @param {Record<string, unknown>} params
|
|
1001
|
-
* @returns {number}
|
|
1002
|
-
*/
|
|
1003
|
-
function getContentScriptTimeout(method, params) {
|
|
1004
|
-
if (method === 'dom.wait_for') {
|
|
1005
|
-
return Math.min(Math.max(Number(params?.timeoutMs) || 5_000, 100), 30_000) + 2_000;
|
|
1006
|
-
}
|
|
1007
|
-
if (method === 'input.hover' && Number(params?.duration) > 0) {
|
|
1008
|
-
return CONTENT_SCRIPT_TIMEOUT_MS + Math.min(Number(params.duration), 5_000) + 1_000;
|
|
1009
|
-
}
|
|
1010
|
-
return CONTENT_SCRIPT_TIMEOUT_MS;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Evaluate a JavaScript expression in the page's main context using the
|
|
1015
|
-
* Chrome DevTools Protocol, avoiding content-script CSP restrictions.
|
|
1016
|
-
*
|
|
1017
|
-
* @param {BridgeRequest} request
|
|
1018
|
-
* @returns {Promise<BridgeResponse>}
|
|
1019
|
-
*/
|
|
1020
|
-
async function handlePageEvaluate(request) {
|
|
1021
|
-
const target = await resolveRequestTarget(request);
|
|
1022
|
-
const params = normalizeEvaluateParams(request.params);
|
|
1023
|
-
if (!params.expression) {
|
|
1024
|
-
return createFailure(request.id, ERROR_CODES.INVALID_REQUEST, 'expression is required.', null, {
|
|
1025
|
-
method: request.method,
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
return tabDebugger.run(target.tabId, async (debugTarget) => {
|
|
1029
|
-
const result = await chrome.debugger.sendCommand(debugTarget, 'Runtime.evaluate', {
|
|
1030
|
-
expression: params.expression,
|
|
1031
|
-
returnByValue: params.returnByValue,
|
|
1032
|
-
awaitPromise: params.awaitPromise,
|
|
1033
|
-
timeout: params.timeoutMs,
|
|
1034
|
-
userGesture: true,
|
|
1035
|
-
generatePreview: false,
|
|
1036
|
-
replMode: true,
|
|
1037
|
-
});
|
|
1038
|
-
const cdpResult =
|
|
1039
|
-
/** @type {{ result?: { type?: string, value?: unknown, description?: string }, exceptionDetails?: { text?: string, exception?: { description?: string } } }} */ (
|
|
1040
|
-
result
|
|
1041
|
-
);
|
|
1042
|
-
if (cdpResult.exceptionDetails) {
|
|
1043
|
-
const errText =
|
|
1044
|
-
cdpResult.exceptionDetails.exception?.description ||
|
|
1045
|
-
cdpResult.exceptionDetails.text ||
|
|
1046
|
-
'Evaluation failed.';
|
|
1047
|
-
return createFailure(request.id, ERROR_CODES.INTERNAL_ERROR, errText, null, {
|
|
1048
|
-
method: request.method,
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
return createSuccess(
|
|
1052
|
-
request.id,
|
|
1053
|
-
{
|
|
1054
|
-
value: cdpResult.result?.value ?? null,
|
|
1055
|
-
type: cdpResult.result?.type ?? 'undefined',
|
|
1056
|
-
},
|
|
1057
|
-
{ method: request.method }
|
|
1058
|
-
);
|
|
1059
|
-
});
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* Install a console interceptor on the page and retrieve buffered messages.
|
|
1064
|
-
* Uses chrome.scripting.executeScript in the MAIN world.
|
|
1065
|
-
*
|
|
1066
|
-
* @param {BridgeRequest} request
|
|
1067
|
-
* @returns {Promise<BridgeResponse>}
|
|
1068
|
-
*/
|
|
1069
|
-
async function handlePageGetConsole(request) {
|
|
1070
|
-
const target = await resolveRequestTarget(request);
|
|
1071
|
-
const params = normalizeConsoleParams(request.params);
|
|
1072
|
-
|
|
1073
|
-
await primeTabConsoleCapture(target.tabId);
|
|
1074
|
-
const { entries, dropped } = await readConsoleBuffer(target.tabId, params.clear);
|
|
1075
|
-
const filtered =
|
|
1076
|
-
params.level === 'all'
|
|
1077
|
-
? entries
|
|
1078
|
-
: entries.filter((/** @type {{ level: string }} */ e) =>
|
|
1079
|
-
matchesConsoleLevel(params.level, e.level)
|
|
1080
|
-
);
|
|
1081
|
-
const limited = filtered.slice(-params.limit);
|
|
1082
|
-
|
|
1083
|
-
return createSuccess(
|
|
1084
|
-
request.id,
|
|
1085
|
-
{ entries: limited, count: limited.length, total: entries.length, dropped },
|
|
1086
|
-
{ method: request.method }
|
|
1087
|
-
);
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* Create a new tab with an optional URL.
|
|
1092
|
-
*
|
|
1093
|
-
* @param {BridgeRequest} request
|
|
1094
|
-
* @returns {Promise<BridgeResponse>}
|
|
1095
|
-
*/
|
|
1096
|
-
async function handleCreateTab(request) {
|
|
1097
|
-
if (!state.enabledWindow) {
|
|
1098
|
-
return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_WINDOW_OFF, null, {
|
|
1099
|
-
method: request.method,
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
const params = normalizeTabCreateParams(request.params);
|
|
1103
|
-
const tab = await chrome.tabs.create({
|
|
1104
|
-
url: params.url,
|
|
1105
|
-
active: params.active,
|
|
1106
|
-
windowId: state.enabledWindow.windowId,
|
|
1107
|
-
});
|
|
1108
|
-
return createSuccess(request.id, summarizeTabResult(tab, request.method), {
|
|
1109
|
-
method: request.method,
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/**
|
|
1114
|
-
* Close a tab by tabId.
|
|
1115
|
-
*
|
|
1116
|
-
* @param {BridgeRequest} request
|
|
1117
|
-
* @returns {Promise<BridgeResponse>}
|
|
1118
|
-
*/
|
|
1119
|
-
async function handleCloseTab(request) {
|
|
1120
|
-
const params = normalizeTabCloseParams(request.params);
|
|
1121
|
-
if (!state.enabledWindow) {
|
|
1122
|
-
return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_WINDOW_OFF, null, {
|
|
1123
|
-
method: request.method,
|
|
1124
|
-
});
|
|
1125
|
-
}
|
|
1126
|
-
let tab;
|
|
1127
|
-
try {
|
|
1128
|
-
tab = await chrome.tabs.get(params.tabId);
|
|
1129
|
-
} catch {
|
|
1130
|
-
return createFailure(
|
|
1131
|
-
request.id,
|
|
1132
|
-
ERROR_CODES.TAB_MISMATCH,
|
|
1133
|
-
`Tab ${params.tabId} not found.`,
|
|
1134
|
-
null,
|
|
1135
|
-
{ method: request.method }
|
|
1136
|
-
);
|
|
1137
|
-
}
|
|
1138
|
-
if (tab.windowId !== state.enabledWindow.windowId) {
|
|
1139
|
-
return createFailure(request.id, ERROR_CODES.ACCESS_DENIED, ACCESS_DENIED_TAB_CLOSE, null, {
|
|
1140
|
-
method: request.method,
|
|
1141
|
-
});
|
|
1142
|
-
}
|
|
1143
|
-
await chrome.tabs.remove(params.tabId);
|
|
1144
|
-
return createSuccess(
|
|
1145
|
-
request.id,
|
|
1146
|
-
{ closed: true, tabId: params.tabId },
|
|
1147
|
-
{ method: request.method }
|
|
1148
|
-
);
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
/**
|
|
1152
|
-
* Return the full accessibility tree for the target tab via CDP
|
|
1153
|
-
* Accessibility.getFullAXTree. Returns a pruned, token-efficient tree with
|
|
1154
|
-
* roles, names, descriptions, and interactive states.
|
|
1155
|
-
*
|
|
1156
|
-
* @param {BridgeRequest} request
|
|
1157
|
-
* @returns {Promise<BridgeResponse>}
|
|
1158
|
-
*/
|
|
1159
|
-
async function handleAccessibilityTree(request) {
|
|
1160
|
-
const target = await resolveRequestTarget(request);
|
|
1161
|
-
const params = normalizeAccessibilityTreeParams(request.params);
|
|
1162
|
-
return tabDebugger.run(target.tabId, async (debugTarget) => {
|
|
1163
|
-
await chrome.debugger.sendCommand(debugTarget, 'Accessibility.enable', {});
|
|
1164
|
-
const result = await chrome.debugger.sendCommand(debugTarget, 'Accessibility.getFullAXTree', {
|
|
1165
|
-
depth: params.maxDepth,
|
|
1166
|
-
});
|
|
1167
|
-
const cdpResult = /** @type {{ nodes?: Array<Record<string, unknown>> }} */ (result);
|
|
1168
|
-
const rawNodes = cdpResult.nodes || [];
|
|
1169
|
-
const pruned = rawNodes.slice(0, params.maxNodes).map(simplifyAXNode);
|
|
1170
|
-
await chrome.debugger.sendCommand(debugTarget, 'Accessibility.disable', {});
|
|
1171
|
-
return createSuccess(
|
|
1172
|
-
request.id,
|
|
1173
|
-
{
|
|
1174
|
-
nodes: pruned,
|
|
1175
|
-
count: pruned.length,
|
|
1176
|
-
total: rawNodes.length,
|
|
1177
|
-
truncated: rawNodes.length > params.maxNodes,
|
|
1178
|
-
},
|
|
1179
|
-
{ method: request.method }
|
|
1180
|
-
);
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/**
|
|
1185
|
-
* Install a network interceptor and retrieve buffered request/response entries
|
|
1186
|
-
* via chrome.scripting.executeScript in the MAIN world, capturing fetch/XHR.
|
|
1187
|
-
*
|
|
1188
|
-
* @param {BridgeRequest} request
|
|
1189
|
-
* @returns {Promise<BridgeResponse>}
|
|
1190
|
-
*/
|
|
1191
|
-
async function handleGetNetwork(request) {
|
|
1192
|
-
const target = await resolveRequestTarget(request);
|
|
1193
|
-
const params = normalizeNetworkParams(request.params);
|
|
1194
|
-
await ensureNetworkInterceptor(target.tabId);
|
|
1195
|
-
const { entries, dropped } = await readNetworkBuffer(target.tabId, params.clear);
|
|
1196
|
-
const urlPattern = typeof params.urlPattern === 'string' ? params.urlPattern : null;
|
|
1197
|
-
const filtered = urlPattern
|
|
1198
|
-
? entries.filter((/** @type {{ url: string }} */ e) => e.url.includes(urlPattern))
|
|
1199
|
-
: entries;
|
|
1200
|
-
const limited = filtered.slice(-params.limit);
|
|
1201
|
-
return createSuccess(
|
|
1202
|
-
request.id,
|
|
1203
|
-
{ entries: limited, count: limited.length, total: entries.length, dropped },
|
|
1204
|
-
{ method: request.method }
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/**
|
|
1209
|
-
* Inject the network interceptor into the page's main world. Patches
|
|
1210
|
-
* fetch and XMLHttpRequest to capture request/response metadata.
|
|
1211
|
-
*
|
|
1212
|
-
* @param {number} tabId
|
|
1213
|
-
* @returns {Promise<void>}
|
|
1214
|
-
*/
|
|
1215
|
-
async function ensureNetworkInterceptor(tabId) {
|
|
1216
|
-
await chrome.scripting.executeScript({
|
|
1217
|
-
target: { tabId },
|
|
1218
|
-
world: 'MAIN',
|
|
1219
|
-
func: () => {
|
|
1220
|
-
// @ts-ignore
|
|
1221
|
-
if (globalThis.__bb_network_installed) return;
|
|
1222
|
-
// @ts-ignore
|
|
1223
|
-
globalThis.__bb_network_installed = true;
|
|
1224
|
-
/** @type {Array<{method: string, url: string, status: number, duration: number, type: string, ts: number, size: number}>} */
|
|
1225
|
-
const buffer = [];
|
|
1226
|
-
// @ts-ignore
|
|
1227
|
-
globalThis.__bb_network_buffer = buffer;
|
|
1228
|
-
// @ts-ignore
|
|
1229
|
-
globalThis.__bb_network_dropped = 0;
|
|
1230
|
-
const MAX = 200;
|
|
1231
|
-
|
|
1232
|
-
const origFetch = globalThis.fetch;
|
|
1233
|
-
// @ts-ignore - intentional main-world global override
|
|
1234
|
-
globalThis.fetch = async function (...args) {
|
|
1235
|
-
// @ts-ignore
|
|
1236
|
-
const req = new Request(...args);
|
|
1237
|
-
const entry = {
|
|
1238
|
-
method: req.method,
|
|
1239
|
-
url: req.url,
|
|
1240
|
-
status: 0,
|
|
1241
|
-
duration: 0,
|
|
1242
|
-
type: 'fetch',
|
|
1243
|
-
ts: Date.now(),
|
|
1244
|
-
size: 0,
|
|
1245
|
-
};
|
|
1246
|
-
const startTime = performance.now();
|
|
1247
|
-
try {
|
|
1248
|
-
const resp = await origFetch.apply(globalThis, args);
|
|
1249
|
-
entry.status = resp.status;
|
|
1250
|
-
entry.duration = Math.round(performance.now() - startTime);
|
|
1251
|
-
const cl = resp.headers.get('content-length');
|
|
1252
|
-
if (cl) entry.size = Number(cl);
|
|
1253
|
-
return resp;
|
|
1254
|
-
} catch (err) {
|
|
1255
|
-
entry.status = 0;
|
|
1256
|
-
entry.duration = Math.round(performance.now() - startTime);
|
|
1257
|
-
throw err;
|
|
1258
|
-
} finally {
|
|
1259
|
-
buffer.push(entry);
|
|
1260
|
-
if (buffer.length > MAX) {
|
|
1261
|
-
const dropped =
|
|
1262
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped;
|
|
1263
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped =
|
|
1264
|
-
(typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
|
|
1265
|
-
buffer.splice(0, buffer.length - MAX);
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
};
|
|
1269
|
-
|
|
1270
|
-
const origOpen = XMLHttpRequest.prototype.open;
|
|
1271
|
-
const origSend = XMLHttpRequest.prototype.send;
|
|
1272
|
-
/**
|
|
1273
|
-
* @this {XMLHttpRequest & { __bb_method?: string, __bb_url?: string }}
|
|
1274
|
-
* @param {string} method
|
|
1275
|
-
* @param {string | URL} url
|
|
1276
|
-
* @param {...unknown} rest
|
|
1277
|
-
* @returns {unknown}
|
|
1278
|
-
*/
|
|
1279
|
-
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
1280
|
-
// @ts-ignore - stashing method/url for XHR interception
|
|
1281
|
-
this.__bb_method = method;
|
|
1282
|
-
// @ts-ignore
|
|
1283
|
-
this.__bb_url = String(url);
|
|
1284
|
-
return /** @type {any} */ (origOpen).call(this, method, url, ...rest);
|
|
1285
|
-
};
|
|
1286
|
-
/**
|
|
1287
|
-
* @this {XMLHttpRequest & { __bb_method?: string, __bb_url?: string }}
|
|
1288
|
-
* @param {...unknown} args
|
|
1289
|
-
* @returns {unknown}
|
|
1290
|
-
*/
|
|
1291
|
-
XMLHttpRequest.prototype.send = function (...args) {
|
|
1292
|
-
// @ts-ignore
|
|
1293
|
-
const entry = {
|
|
1294
|
-
method: this.__bb_method || 'GET',
|
|
1295
|
-
url: this.__bb_url || '',
|
|
1296
|
-
status: 0,
|
|
1297
|
-
duration: 0,
|
|
1298
|
-
type: 'xhr',
|
|
1299
|
-
ts: Date.now(),
|
|
1300
|
-
size: 0,
|
|
1301
|
-
};
|
|
1302
|
-
const startTime = performance.now();
|
|
1303
|
-
this.addEventListener('loadend', () => {
|
|
1304
|
-
entry.status = this.status;
|
|
1305
|
-
entry.duration = Math.round(performance.now() - startTime);
|
|
1306
|
-
const cl = this.getResponseHeader('content-length');
|
|
1307
|
-
if (cl) entry.size = Number(cl);
|
|
1308
|
-
buffer.push(entry);
|
|
1309
|
-
if (buffer.length > MAX) {
|
|
1310
|
-
const dropped =
|
|
1311
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped;
|
|
1312
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_network_dropped =
|
|
1313
|
-
(typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
|
|
1314
|
-
buffer.splice(0, buffer.length - MAX);
|
|
1315
|
-
}
|
|
1316
|
-
});
|
|
1317
|
-
return /** @type {any} */ (origSend).apply(this, args);
|
|
1318
|
-
};
|
|
1319
|
-
},
|
|
1320
|
-
});
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
/**
|
|
1324
|
-
* Read and optionally clear the network buffer from the page's main world.
|
|
1325
|
-
*
|
|
1326
|
-
* @param {number} tabId
|
|
1327
|
-
* @param {boolean} clear
|
|
1328
|
-
* @returns {Promise<{ entries: Array<{method: string, url: string, status: number, duration: number, type: string, ts: number, size: number}>, dropped: number }>}
|
|
1329
|
-
*/
|
|
1330
|
-
async function readNetworkBuffer(tabId, clear) {
|
|
1331
|
-
const results = await chrome.scripting.executeScript({
|
|
1332
|
-
target: { tabId },
|
|
1333
|
-
world: 'MAIN',
|
|
1334
|
-
func: (shouldClear) => {
|
|
1335
|
-
// @ts-ignore
|
|
1336
|
-
const buf = globalThis.__bb_network_buffer || [];
|
|
1337
|
-
// @ts-ignore
|
|
1338
|
-
const dropped = globalThis.__bb_network_dropped || 0;
|
|
1339
|
-
const copy = [...buf];
|
|
1340
|
-
if (shouldClear) {
|
|
1341
|
-
// @ts-ignore
|
|
1342
|
-
globalThis.__bb_network_buffer = [];
|
|
1343
|
-
// @ts-ignore
|
|
1344
|
-
globalThis.__bb_network_dropped = 0;
|
|
1345
|
-
}
|
|
1346
|
-
return { entries: copy, dropped };
|
|
1347
|
-
},
|
|
1348
|
-
args: [clear],
|
|
1349
|
-
});
|
|
1350
|
-
return /** @type {any} */ (results?.[0]?.result) || { entries: [], dropped: 0 };
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
/**
|
|
1354
|
-
* Resize the browser viewport via CDP Emulation.setDeviceMetricsOverride
|
|
1355
|
-
* or reset to natural size when width/height are 0.
|
|
1356
|
-
*
|
|
1357
|
-
* @param {BridgeRequest} request
|
|
1358
|
-
* @returns {Promise<BridgeResponse>}
|
|
1359
|
-
*/
|
|
1360
|
-
async function handleViewportResize(request) {
|
|
1361
|
-
const target = await resolveRequestTarget(request);
|
|
1362
|
-
const params = normalizeViewportResizeParams(request.params);
|
|
1363
|
-
return tabDebugger.run(target.tabId, async (debugTarget) => {
|
|
1364
|
-
if (params.reset || (params.width === 0 && params.height === 0)) {
|
|
1365
|
-
await chrome.debugger.sendCommand(debugTarget, 'Emulation.clearDeviceMetricsOverride', {});
|
|
1366
|
-
} else {
|
|
1367
|
-
await chrome.debugger.sendCommand(debugTarget, 'Emulation.setDeviceMetricsOverride', {
|
|
1368
|
-
width: params.width,
|
|
1369
|
-
height: params.height,
|
|
1370
|
-
deviceScaleFactor: params.deviceScaleFactor,
|
|
1371
|
-
mobile: params.width < 768,
|
|
1372
|
-
});
|
|
1373
|
-
}
|
|
1374
|
-
return createSuccess(
|
|
1375
|
-
request.id,
|
|
1376
|
-
{
|
|
1377
|
-
resized: true,
|
|
1378
|
-
width: params.width,
|
|
1379
|
-
height: params.height,
|
|
1380
|
-
deviceScaleFactor: params.deviceScaleFactor,
|
|
1381
|
-
reset: params.reset,
|
|
1382
|
-
},
|
|
1383
|
-
{ method: request.method }
|
|
1384
|
-
);
|
|
1385
|
-
});
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
/**
|
|
1389
|
-
* Return browser performance metrics via CDP Performance.getMetrics.
|
|
1390
|
-
*
|
|
1391
|
-
* @param {BridgeRequest} request
|
|
1392
|
-
* @returns {Promise<BridgeResponse>}
|
|
1393
|
-
*/
|
|
1394
|
-
async function handlePerformanceMetrics(request) {
|
|
1395
|
-
const target = await resolveRequestTarget(request);
|
|
1396
|
-
return tabDebugger.run(target.tabId, async (debugTarget) => {
|
|
1397
|
-
await chrome.debugger.sendCommand(debugTarget, 'Performance.enable', {
|
|
1398
|
-
timeDomain: 'timeTicks',
|
|
1399
|
-
});
|
|
1400
|
-
const result = await chrome.debugger.sendCommand(debugTarget, 'Performance.getMetrics', {});
|
|
1401
|
-
await chrome.debugger.sendCommand(debugTarget, 'Performance.disable', {});
|
|
1402
|
-
const cdpResult = /** @type {{ metrics?: Array<{ name: string, value: number }> }} */ (result);
|
|
1403
|
-
const metrics = (cdpResult.metrics || []).reduce((acc, m) => {
|
|
1404
|
-
acc[m.name] = m.value;
|
|
1405
|
-
return acc;
|
|
1406
|
-
}, /** @type {Record<string, number>} */ ({}));
|
|
1407
|
-
return createSuccess(request.id, { metrics }, { method: request.method });
|
|
1408
|
-
});
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
/**
|
|
1412
|
-
* Wait for the tab to reach the 'complete' load state.
|
|
1413
|
-
*
|
|
1414
|
-
* @param {BridgeRequest} request
|
|
1415
|
-
* @returns {Promise<BridgeResponse>}
|
|
1416
|
-
*/
|
|
1417
|
-
async function handleWaitForLoadState(request) {
|
|
1418
|
-
const target = await resolveRequestTarget(request);
|
|
1419
|
-
const params = normalizeWaitForLoadStateParams(request.params);
|
|
1420
|
-
const tab = params.waitForLoad
|
|
1421
|
-
? await waitForTabComplete(target.tabId, params.timeoutMs)
|
|
1422
|
-
: await chrome.tabs.get(target.tabId);
|
|
1423
|
-
return createSuccess(request.id, summarizeTabResult(tab, request.method), {
|
|
1424
|
-
method: request.method,
|
|
1425
|
-
});
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
/**
|
|
1429
|
-
* Inject the console interceptor into the page's main world if not already
|
|
1430
|
-
* present. The interceptor patches console methods and captures unhandled
|
|
1431
|
-
* errors into a bounded in-page buffer.
|
|
1432
|
-
*
|
|
1433
|
-
* @param {number} tabId
|
|
1434
|
-
* @returns {Promise<void>}
|
|
1435
|
-
*/
|
|
1436
|
-
async function ensureConsoleInterceptor(tabId) {
|
|
1437
|
-
await chrome.scripting.executeScript({
|
|
1438
|
-
target: { tabId },
|
|
1439
|
-
world: 'MAIN',
|
|
1440
|
-
func: () => {
|
|
1441
|
-
// @ts-ignore - intentional main-world global
|
|
1442
|
-
if (globalThis.__bb_console_installed) return;
|
|
1443
|
-
// @ts-ignore
|
|
1444
|
-
globalThis.__bb_console_installed = true;
|
|
1445
|
-
/** @type {Array<{level: string, args: string[], ts: number}>} */
|
|
1446
|
-
const buffer = [];
|
|
1447
|
-
// @ts-ignore
|
|
1448
|
-
globalThis.__bb_console_buffer = buffer;
|
|
1449
|
-
// @ts-ignore
|
|
1450
|
-
globalThis.__bb_console_dropped = 0;
|
|
1451
|
-
const MAX = 200;
|
|
1452
|
-
const orig = /** @type {Record<string, Function>} */ ({});
|
|
1453
|
-
const consoleMethods =
|
|
1454
|
-
/** @type {Record<string, (...args: unknown[]) => void>} */ (
|
|
1455
|
-
/** @type {unknown} */ (console)
|
|
1456
|
-
);
|
|
1457
|
-
for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
|
|
1458
|
-
orig[level] = consoleMethods[level];
|
|
1459
|
-
consoleMethods[level] = (...args) => {
|
|
1460
|
-
buffer.push({
|
|
1461
|
-
level,
|
|
1462
|
-
args: args.map((a) => {
|
|
1463
|
-
try {
|
|
1464
|
-
return typeof a === 'object'
|
|
1465
|
-
? JSON.stringify(a).slice(0, 500)
|
|
1466
|
-
: String(a).slice(0, 500);
|
|
1467
|
-
} catch {
|
|
1468
|
-
return String(a).slice(0, 500);
|
|
1469
|
-
}
|
|
1470
|
-
}),
|
|
1471
|
-
ts: Date.now(),
|
|
1472
|
-
});
|
|
1473
|
-
if (buffer.length > MAX) {
|
|
1474
|
-
const dropped =
|
|
1475
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped;
|
|
1476
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped =
|
|
1477
|
-
(typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
|
|
1478
|
-
buffer.splice(0, buffer.length - MAX);
|
|
1479
|
-
}
|
|
1480
|
-
orig[level].apply(console, args);
|
|
1481
|
-
};
|
|
1482
|
-
}
|
|
1483
|
-
globalThis.addEventListener('error', (e) => {
|
|
1484
|
-
buffer.push({
|
|
1485
|
-
level: 'exception',
|
|
1486
|
-
args: [
|
|
1487
|
-
e.message || 'Unknown error',
|
|
1488
|
-
e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : '',
|
|
1489
|
-
],
|
|
1490
|
-
ts: Date.now(),
|
|
1491
|
-
});
|
|
1492
|
-
if (buffer.length > MAX) {
|
|
1493
|
-
const dropped = /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped;
|
|
1494
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped =
|
|
1495
|
-
(typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
|
|
1496
|
-
buffer.splice(0, buffer.length - MAX);
|
|
1497
|
-
}
|
|
1498
|
-
});
|
|
1499
|
-
globalThis.addEventListener('unhandledrejection', (e) => {
|
|
1500
|
-
buffer.push({
|
|
1501
|
-
level: 'rejection',
|
|
1502
|
-
args: [String(e.reason).slice(0, 500)],
|
|
1503
|
-
ts: Date.now(),
|
|
1504
|
-
});
|
|
1505
|
-
if (buffer.length > MAX) {
|
|
1506
|
-
const dropped = /** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped;
|
|
1507
|
-
/** @type {Record<string, unknown>} */ (globalThis).__bb_console_dropped =
|
|
1508
|
-
(typeof dropped === 'number' ? dropped : 0) + (buffer.length - MAX);
|
|
1509
|
-
buffer.splice(0, buffer.length - MAX);
|
|
1510
|
-
}
|
|
1511
|
-
});
|
|
1512
|
-
},
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
/**
|
|
1517
|
-
* Best-effort console capture installation for enabled tabs. Some URLs cannot
|
|
1518
|
-
* be scripted; those failures should not block enablement or navigation.
|
|
1519
|
-
*
|
|
1520
|
-
* @param {number} tabId
|
|
1521
|
-
* @param {boolean} [resetBuffer=false]
|
|
1522
|
-
* @returns {Promise<void>}
|
|
1523
|
-
*/
|
|
1524
|
-
async function primeTabConsoleCapture(tabId, resetBuffer = false) {
|
|
1525
|
-
try {
|
|
1526
|
-
await ensureConsoleInterceptor(tabId);
|
|
1527
|
-
if (resetBuffer) {
|
|
1528
|
-
await readConsoleBuffer(tabId, true);
|
|
1529
|
-
}
|
|
1530
|
-
} catch (error) {
|
|
1531
|
-
if (isRecoverableInstrumentationError(error)) {
|
|
1532
|
-
return;
|
|
1533
|
-
}
|
|
1534
|
-
throw error;
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
/**
|
|
1539
|
-
* @param {unknown} error
|
|
1540
|
-
* @returns {boolean}
|
|
1541
|
-
*/
|
|
1542
|
-
function isRecoverableInstrumentationError(error) {
|
|
1543
|
-
const message = normalizeRuntimeErrorMessage(getErrorMessage(error));
|
|
1544
|
-
return (
|
|
1545
|
-
message === ERROR_CODES.TAB_MISMATCH ||
|
|
1546
|
-
/Cannot access contents of/i.test(message) ||
|
|
1547
|
-
/The extensions gallery cannot be scripted/i.test(message) ||
|
|
1548
|
-
/Cannot access a chrome:\/\//i.test(message) ||
|
|
1549
|
-
/Cannot script/i.test(message) ||
|
|
1550
|
-
/CONTENT_SCRIPT_UNAVAILABLE/i.test(message) ||
|
|
1551
|
-
/No tab with id/i.test(message) ||
|
|
1552
|
-
/Cannot attach to this target/i.test(message) ||
|
|
1553
|
-
/Another debugger is already attached/i.test(message)
|
|
1554
|
-
);
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
/**
|
|
1558
|
-
* Read and optionally clear the console buffer from the page's main world.
|
|
1559
|
-
*
|
|
1560
|
-
* @param {number} tabId
|
|
1561
|
-
* @param {boolean} clear
|
|
1562
|
-
* @returns {Promise<{ entries: Array<{level: string, args: string[], ts: number}>, dropped: number }>}
|
|
1563
|
-
*/
|
|
1564
|
-
async function readConsoleBuffer(tabId, clear) {
|
|
1565
|
-
const results = await chrome.scripting.executeScript({
|
|
1566
|
-
target: { tabId },
|
|
1567
|
-
world: 'MAIN',
|
|
1568
|
-
func: (shouldClear) => {
|
|
1569
|
-
// @ts-ignore
|
|
1570
|
-
const buf = globalThis.__bb_console_buffer || [];
|
|
1571
|
-
// @ts-ignore
|
|
1572
|
-
const dropped = globalThis.__bb_console_dropped || 0;
|
|
1573
|
-
const copy = [...buf];
|
|
1574
|
-
if (shouldClear) {
|
|
1575
|
-
// @ts-ignore
|
|
1576
|
-
globalThis.__bb_console_buffer = [];
|
|
1577
|
-
// @ts-ignore
|
|
1578
|
-
globalThis.__bb_console_dropped = 0;
|
|
1579
|
-
}
|
|
1580
|
-
return { entries: copy, dropped };
|
|
1581
|
-
},
|
|
1582
|
-
args: [clear],
|
|
1583
|
-
});
|
|
1584
|
-
return /** @type {any} */ (results?.[0]?.result) || { entries: [], dropped: 0 };
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
/**
|
|
1588
|
-
* Prime console capture for every tab in one window.
|
|
1589
|
-
*
|
|
1590
|
-
* @param {number} windowId
|
|
1591
|
-
* @param {boolean} [resetBuffer=false]
|
|
1592
|
-
* @returns {Promise<void>}
|
|
1593
|
-
*/
|
|
1594
|
-
async function primeWindowConsoleCapture(windowId, resetBuffer = false) {
|
|
1595
|
-
const tabs = await chrome.tabs.query({ windowId });
|
|
1596
|
-
const tabIds = tabs
|
|
1597
|
-
.map((tab) => (isNumber(tab.id) ? tab.id : null))
|
|
1598
|
-
.filter((tabId) => tabId !== null);
|
|
1599
|
-
await Promise.allSettled(tabIds.map((tabId) => primeTabConsoleCapture(tabId, resetBuffer)));
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
/**
|
|
1603
|
-
* Clear bridge buffers and roll back active patches for one tab.
|
|
1604
|
-
*
|
|
1605
|
-
* @param {number} tabId
|
|
1606
|
-
* @returns {Promise<void>}
|
|
1607
|
-
*/
|
|
1608
|
-
async function clearTabBridgeState(tabId) {
|
|
1609
|
-
try {
|
|
1610
|
-
await rollbackAllPatchesForTab(tabId);
|
|
1611
|
-
} catch (error) {
|
|
1612
|
-
if (!isRecoverableInstrumentationError(error)) {
|
|
1613
|
-
throw error;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
try {
|
|
1617
|
-
await readConsoleBuffer(tabId, true);
|
|
1618
|
-
} catch (error) {
|
|
1619
|
-
if (!isRecoverableInstrumentationError(error)) {
|
|
1620
|
-
throw error;
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
try {
|
|
1624
|
-
await readNetworkBuffer(tabId, true);
|
|
1625
|
-
} catch (error) {
|
|
1626
|
-
if (!isRecoverableInstrumentationError(error)) {
|
|
1627
|
-
throw error;
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
/**
|
|
1633
|
-
* Clear tab-local bridge state for all tabs in one window.
|
|
1634
|
-
*
|
|
1635
|
-
* @param {number} windowId
|
|
1636
|
-
* @returns {Promise<void>}
|
|
1637
|
-
*/
|
|
1638
|
-
async function clearWindowBridgeState(windowId) {
|
|
1639
|
-
const tabs = await chrome.tabs.query({ windowId });
|
|
1640
|
-
const tabIds = tabs
|
|
1641
|
-
.filter((tab) => tab.url && !isRestrictedAutomationUrl(tab.url))
|
|
1642
|
-
.map((tab) => (isNumber(tab.id) ? tab.id : null))
|
|
1643
|
-
.filter((tabId) => tabId !== null);
|
|
1644
|
-
await Promise.allSettled(tabIds.map((tabId) => clearTabBridgeState(tabId)));
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
/**
|
|
1648
|
-
* Roll back all reversible patches currently tracked in one tab.
|
|
1649
|
-
*
|
|
1650
|
-
* @param {number} tabId
|
|
1651
|
-
* @returns {Promise<void>}
|
|
1652
|
-
*/
|
|
1653
|
-
async function rollbackAllPatchesForTab(tabId) {
|
|
1654
|
-
try {
|
|
1655
|
-
await ensureContentScript(tabId);
|
|
1656
|
-
const listed = await sendTabMessage(
|
|
1657
|
-
tabId,
|
|
1658
|
-
{
|
|
1659
|
-
type: 'bridge.execute',
|
|
1660
|
-
method: 'patch.list',
|
|
1661
|
-
params: {},
|
|
1662
|
-
},
|
|
1663
|
-
CONTENT_SCRIPT_TIMEOUT_MS
|
|
1664
|
-
);
|
|
1665
|
-
const patches = Array.isArray(listed) ? listed : listed?.patches;
|
|
1666
|
-
if (!Array.isArray(patches)) {
|
|
1667
|
-
return;
|
|
1668
|
-
}
|
|
1669
|
-
for (const patch of patches) {
|
|
1670
|
-
const patchId =
|
|
1671
|
-
patch && typeof patch === 'object'
|
|
1672
|
-
? /** @type {Record<string, unknown>} */ (patch).patchId
|
|
1673
|
-
: null;
|
|
1674
|
-
if (typeof patchId !== 'string' || !patchId) {
|
|
1675
|
-
continue;
|
|
1676
|
-
}
|
|
1677
|
-
await sendTabMessage(
|
|
1678
|
-
tabId,
|
|
1679
|
-
{
|
|
1680
|
-
type: 'bridge.execute',
|
|
1681
|
-
method: 'patch.rollback',
|
|
1682
|
-
params: { patchId },
|
|
1683
|
-
},
|
|
1684
|
-
CONTENT_SCRIPT_TIMEOUT_MS
|
|
1685
|
-
);
|
|
1686
|
-
}
|
|
1687
|
-
} catch (error) {
|
|
1688
|
-
if (!isRecoverableInstrumentationError(error)) {
|
|
1689
|
-
throw error;
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
/**
|
|
1695
|
-
* @param {number} tabId
|
|
1696
|
-
* @returns {Promise<boolean>}
|
|
1697
|
-
*/
|
|
1698
|
-
async function isTabEnabled(tabId) {
|
|
1699
|
-
if (!state.enabledWindow) {
|
|
1700
|
-
return false;
|
|
1701
|
-
}
|
|
1702
|
-
try {
|
|
1703
|
-
const tab = await chrome.tabs.get(tabId);
|
|
1704
|
-
return tab.windowId === state.enabledWindow.windowId;
|
|
1705
|
-
} catch {
|
|
1706
|
-
return false;
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/**
|
|
1711
|
-
* Capture a targeted screenshot for the current target tab by asking the content
|
|
1712
|
-
* script for an element rect and then cropping the visible tab image.
|
|
1713
|
-
*
|
|
1714
|
-
* @param {ResolvedTabTarget} target
|
|
1715
|
-
* @param {string} method
|
|
1716
|
-
* @param {Record<string, unknown>} params
|
|
1717
|
-
* @returns {Promise<{ rect: unknown, image: string }>}
|
|
1718
|
-
*/
|
|
1719
|
-
async function handleScreenshot(target, method, params) {
|
|
1720
|
-
/** @type {{ x: number, y: number, width: number, height: number, scale: number }} */
|
|
1721
|
-
let clip;
|
|
1722
|
-
|
|
1723
|
-
if (method === 'screenshot.capture_element') {
|
|
1724
|
-
await ensureContentScript(target.tabId);
|
|
1725
|
-
try {
|
|
1726
|
-
clip = await sendTabMessage(
|
|
1727
|
-
target.tabId,
|
|
1728
|
-
{
|
|
1729
|
-
type: 'bridge.execute',
|
|
1730
|
-
method,
|
|
1731
|
-
params,
|
|
1732
|
-
},
|
|
1733
|
-
CONTENT_SCRIPT_TIMEOUT_MS
|
|
1734
|
-
);
|
|
1735
|
-
} catch (err) {
|
|
1736
|
-
// Retry once after a brief pause - the page may have been mid-render
|
|
1737
|
-
if (err instanceof Error && /stale/i.test(err.message)) {
|
|
1738
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
1739
|
-
clip = await sendTabMessage(
|
|
1740
|
-
target.tabId,
|
|
1741
|
-
{
|
|
1742
|
-
type: 'bridge.execute',
|
|
1743
|
-
method,
|
|
1744
|
-
params,
|
|
1745
|
-
},
|
|
1746
|
-
CONTENT_SCRIPT_TIMEOUT_MS
|
|
1747
|
-
);
|
|
1748
|
-
} else {
|
|
1749
|
-
throw err;
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
// Defensively coerce content-script values - NaN / undefined / negative
|
|
1753
|
-
// would slip past the < 1 guard and reach CDP as invalid values.
|
|
1754
|
-
clip = {
|
|
1755
|
-
x: Math.max(0, Number(clip.x) || 0),
|
|
1756
|
-
y: Math.max(0, Number(clip.y) || 0),
|
|
1757
|
-
width: Math.max(0, Number(clip.width) || 0),
|
|
1758
|
-
height: Math.max(0, Number(clip.height) || 0),
|
|
1759
|
-
scale: Number(clip.scale) || 1,
|
|
1760
|
-
};
|
|
1761
|
-
} else if (method === 'screenshot.capture_full_page') {
|
|
1762
|
-
await ensureContentScript(target.tabId);
|
|
1763
|
-
const dims =
|
|
1764
|
-
/** @type {{ scrollWidth: number, scrollHeight: number, devicePixelRatio: number }} */ (
|
|
1765
|
-
await sendTabMessage(
|
|
1766
|
-
target.tabId,
|
|
1767
|
-
{ type: 'bridge.execute', method, params },
|
|
1768
|
-
CONTENT_SCRIPT_TIMEOUT_MS
|
|
1769
|
-
)
|
|
1770
|
-
);
|
|
1771
|
-
clip = {
|
|
1772
|
-
x: 0,
|
|
1773
|
-
y: 0,
|
|
1774
|
-
width: Math.min(Math.max(1, Number(dims.scrollWidth) || 1), 16384),
|
|
1775
|
-
height: Math.min(Math.max(1, Number(dims.scrollHeight) || 1), 16384),
|
|
1776
|
-
scale: Number(dims.devicePixelRatio) || 1,
|
|
1777
|
-
};
|
|
1778
|
-
} else {
|
|
1779
|
-
// capture_region: params already carry viewport coordinates
|
|
1780
|
-
const scale = Number(params.scale) || 1;
|
|
1781
|
-
clip = {
|
|
1782
|
-
x: Number(params.x) || 0,
|
|
1783
|
-
y: Number(params.y) || 0,
|
|
1784
|
-
width: Math.max(1, Number(params.width) || 1),
|
|
1785
|
-
height: Math.max(1, Number(params.height) || 1),
|
|
1786
|
-
scale,
|
|
1787
|
-
};
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
if (clip.width < 1 || clip.height < 1) {
|
|
1791
|
-
throw new Error(
|
|
1792
|
-
`Capture target has no visible area (${clip.width}\u00d7${clip.height}px). ` +
|
|
1793
|
-
'It may be hidden, collapsed, or not yet rendered.'
|
|
1794
|
-
);
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
// Use CDP Page.captureScreenshot - works regardless of tab focus,
|
|
1798
|
-
// captures renderer output directly with built-in clip support.
|
|
1799
|
-
return tabDebugger.run(target.tabId, async (debugTarget) => {
|
|
1800
|
-
const dpr = clip.scale || 1;
|
|
1801
|
-
const cdpResult = /** @type {{ data?: string }} */ (
|
|
1802
|
-
await chrome.debugger.sendCommand(debugTarget, 'Page.captureScreenshot', {
|
|
1803
|
-
format: 'png',
|
|
1804
|
-
clip: {
|
|
1805
|
-
x: Math.max(0, clip.x),
|
|
1806
|
-
y: Math.max(0, clip.y),
|
|
1807
|
-
width: clip.width,
|
|
1808
|
-
height: clip.height,
|
|
1809
|
-
scale: dpr,
|
|
1810
|
-
},
|
|
1811
|
-
captureBeyondViewport: method === 'screenshot.capture_full_page',
|
|
1812
|
-
})
|
|
1813
|
-
);
|
|
1814
|
-
if (!cdpResult?.data) {
|
|
1815
|
-
throw new Error('CDP Page.captureScreenshot returned empty data.');
|
|
1816
|
-
}
|
|
1817
|
-
return {
|
|
1818
|
-
rect: clip,
|
|
1819
|
-
image: `data:image/png;base64,${cdpResult.data}`,
|
|
1820
|
-
};
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
/**
|
|
1825
|
-
* Send a message to the content script and fail fast if it does not respond.
|
|
1826
|
-
*
|
|
1827
|
-
* @param {number} tabId
|
|
1828
|
-
* @param {Record<string, unknown>} message
|
|
1829
|
-
* @param {number} timeoutMs
|
|
1830
|
-
* @returns {Promise<any>}
|
|
1831
|
-
*/
|
|
1832
|
-
async function sendTabMessage(tabId, message, timeoutMs) {
|
|
1833
|
-
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
1834
|
-
let timeoutId;
|
|
1835
|
-
const timeout = new Promise((_, reject) => {
|
|
1836
|
-
timeoutId = setTimeout(
|
|
1837
|
-
() =>
|
|
1838
|
-
reject(new Error(`Timed out waiting for content script response after ${timeoutMs}ms.`)),
|
|
1839
|
-
timeoutMs
|
|
1840
|
-
);
|
|
1841
|
-
});
|
|
1842
|
-
try {
|
|
1843
|
-
return await Promise.race([chrome.tabs.sendMessage(tabId, message), timeout]);
|
|
1844
|
-
} finally {
|
|
1845
|
-
clearTimeout(timeoutId);
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
/**
|
|
1850
|
-
* Proactively inject content scripts into all scriptable tabs in a window
|
|
1851
|
-
* when Bridge access is enabled. Errors on restricted pages are silently
|
|
1852
|
-
* ignored since ensureContentScript will handle them on demand.
|
|
1853
|
-
*
|
|
1854
|
-
* @param {number} windowId
|
|
1855
|
-
* @returns {Promise<void>}
|
|
1856
|
-
*/
|
|
1857
|
-
async function injectContentScriptsForWindow(windowId) {
|
|
1858
|
-
const tabs = await chrome.tabs.query({ windowId });
|
|
1859
|
-
await Promise.allSettled(
|
|
1860
|
-
tabs
|
|
1861
|
-
.map((tab) =>
|
|
1862
|
-
isNumber(tab.id) && tab.url && !isRestrictedAutomationUrl(tab.url) ? tab.id : null
|
|
1863
|
-
)
|
|
1864
|
-
.filter((tabId) => tabId !== null)
|
|
1865
|
-
.map((tabId) => ensureContentScript(tabId))
|
|
1866
|
-
);
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
/**
|
|
1870
|
-
* Detect Chrome scripting errors that indicate a restricted or unscriptable page.
|
|
1871
|
-
*
|
|
1872
|
-
* @param {string} message
|
|
1873
|
-
* @returns {boolean}
|
|
1874
|
-
*/
|
|
1875
|
-
function isRestrictedScriptingError(message) {
|
|
1876
|
-
return (
|
|
1877
|
-
/Cannot access contents of/i.test(message) ||
|
|
1878
|
-
/The extensions gallery cannot be scripted/i.test(message) ||
|
|
1879
|
-
/Cannot access a chrome:\/\//i.test(message) ||
|
|
1880
|
-
/Cannot script/i.test(message)
|
|
1881
|
-
);
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
|
-
/**
|
|
1885
|
-
* Ensure the content script is present on the target tab before issuing
|
|
1886
|
-
* content-script-backed requests. This makes page operations resilient after
|
|
1887
|
-
* extension reloads or on tabs that predate the current extension version.
|
|
1888
|
-
*
|
|
1889
|
-
* @param {number} tabId
|
|
1890
|
-
* @returns {Promise<void>}
|
|
1891
|
-
*/
|
|
1892
|
-
async function ensureContentScript(tabId) {
|
|
1893
|
-
try {
|
|
1894
|
-
await sendTabMessage(tabId, { type: 'bridge.ping' }, CONTENT_SCRIPT_TIMEOUT_MS);
|
|
1895
|
-
return;
|
|
1896
|
-
} catch {
|
|
1897
|
-
try {
|
|
1898
|
-
await chrome.scripting.executeScript({
|
|
1899
|
-
target: { tabId },
|
|
1900
|
-
files: [
|
|
1901
|
-
'packages/extension/src/content-script-helpers.js',
|
|
1902
|
-
'packages/extension/src/content-script.js',
|
|
1903
|
-
],
|
|
1904
|
-
});
|
|
1905
|
-
} catch (injectError) {
|
|
1906
|
-
const msg = injectError instanceof Error ? injectError.message : String(injectError);
|
|
1907
|
-
if (isRestrictedScriptingError(msg)) {
|
|
1908
|
-
throw new Error(
|
|
1909
|
-
'CONTENT_SCRIPT_UNAVAILABLE: Content script not available on this page (restricted or extension page).',
|
|
1910
|
-
{ cause: injectError }
|
|
1911
|
-
);
|
|
1912
|
-
}
|
|
1913
|
-
throw injectError;
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
/**
|
|
1919
|
-
* Execute a restricted Chrome DevTools Protocol request against the enabled
|
|
1920
|
-
* tab.
|
|
1921
|
-
*
|
|
1922
|
-
* @param {BridgeRequest} request
|
|
1923
|
-
* @returns {Promise<BridgeResponse>}
|
|
1924
|
-
*/
|
|
1925
|
-
async function handleCdpRequest(request) {
|
|
1926
|
-
const target = await resolveRequestTarget(request);
|
|
1927
|
-
return tabDebugger.run(target.tabId, async (debugTarget) => {
|
|
1928
|
-
let command;
|
|
1929
|
-
let params = {};
|
|
1930
|
-
if (request.method === 'cdp.get_document') {
|
|
1931
|
-
command = 'DOM.getDocument';
|
|
1932
|
-
params = { depth: 2, pierce: false };
|
|
1933
|
-
} else if (request.method === 'cdp.get_dom_snapshot') {
|
|
1934
|
-
command = 'DOMSnapshot.captureSnapshot';
|
|
1935
|
-
params = { computedStyles: request.params?.computedStyles ?? [] };
|
|
1936
|
-
} else if (request.method === 'cdp.get_box_model') {
|
|
1937
|
-
const nodeId = request.params?.nodeId;
|
|
1938
|
-
if (typeof nodeId !== 'number' || !Number.isFinite(nodeId)) {
|
|
1939
|
-
return createFailure(
|
|
1940
|
-
request.id,
|
|
1941
|
-
ERROR_CODES.INVALID_REQUEST,
|
|
1942
|
-
'nodeId must be a finite number.',
|
|
1943
|
-
null,
|
|
1944
|
-
{ method: request.method }
|
|
1945
|
-
);
|
|
1946
|
-
}
|
|
1947
|
-
command = 'DOM.getBoxModel';
|
|
1948
|
-
params = { nodeId };
|
|
1949
|
-
} else {
|
|
1950
|
-
const nodeId = request.params?.nodeId;
|
|
1951
|
-
if (typeof nodeId !== 'number' || !Number.isFinite(nodeId)) {
|
|
1952
|
-
return createFailure(
|
|
1953
|
-
request.id,
|
|
1954
|
-
ERROR_CODES.INVALID_REQUEST,
|
|
1955
|
-
'nodeId must be a finite number.',
|
|
1956
|
-
null,
|
|
1957
|
-
{ method: request.method }
|
|
1958
|
-
);
|
|
1959
|
-
}
|
|
1960
|
-
command = 'CSS.getComputedStyleForNode';
|
|
1961
|
-
params = { nodeId };
|
|
1962
|
-
}
|
|
1963
|
-
const result = await chrome.debugger.sendCommand(
|
|
1964
|
-
debugTarget,
|
|
1965
|
-
command,
|
|
1966
|
-
/** @type {Record<string, unknown>} */ (params)
|
|
1967
|
-
);
|
|
1968
|
-
return createSuccess(request.id, result, { method: request.method });
|
|
1969
|
-
});
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
/**
|
|
1973
|
-
* Wait for a tab to reach the `complete` status after a navigation-like action.
|
|
1974
|
-
*
|
|
1975
|
-
* @param {number} tabId
|
|
1976
|
-
* @param {number} timeoutMs
|
|
1977
|
-
* @returns {Promise<chrome.tabs.Tab>}
|
|
1978
|
-
*/
|
|
1979
|
-
async function waitForTabComplete(tabId, timeoutMs) {
|
|
1980
|
-
const initialTab = await chrome.tabs.get(tabId);
|
|
1981
|
-
if (initialTab.status === 'complete') {
|
|
1982
|
-
return initialTab;
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
return new Promise((resolve, reject) => {
|
|
1986
|
-
let finished = false;
|
|
1987
|
-
const timeoutId = setTimeout(() => {
|
|
1988
|
-
cleanup();
|
|
1989
|
-
reject(
|
|
1990
|
-
new Error(`Timed out waiting for tab ${tabId} to finish loading after ${timeoutMs}ms.`)
|
|
1991
|
-
);
|
|
1992
|
-
}, timeoutMs);
|
|
1993
|
-
|
|
1994
|
-
/**
|
|
1995
|
-
* @returns {void}
|
|
1996
|
-
*/
|
|
1997
|
-
function cleanup() {
|
|
1998
|
-
if (finished) {
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2001
|
-
finished = true;
|
|
2002
|
-
clearTimeout(timeoutId);
|
|
2003
|
-
chrome.tabs.onUpdated.removeListener(onUpdated);
|
|
2004
|
-
chrome.tabs.onRemoved.removeListener(onRemoved);
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
/**
|
|
2008
|
-
* @param {number} updatedTabId
|
|
2009
|
-
* @param {TabChangeInfo} changeInfo
|
|
2010
|
-
* @param {chrome.tabs.Tab} tab
|
|
2011
|
-
* @returns {void}
|
|
2012
|
-
*/
|
|
2013
|
-
function onUpdated(updatedTabId, changeInfo, tab) {
|
|
2014
|
-
if (updatedTabId !== tabId || changeInfo.status !== 'complete') {
|
|
2015
|
-
return;
|
|
2016
|
-
}
|
|
2017
|
-
cleanup();
|
|
2018
|
-
resolve(tab);
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
/**
|
|
2022
|
-
* @param {number} removedTabId
|
|
2023
|
-
* @returns {void}
|
|
2024
|
-
*/
|
|
2025
|
-
function onRemoved(removedTabId) {
|
|
2026
|
-
if (removedTabId !== tabId) {
|
|
2027
|
-
return;
|
|
2028
|
-
}
|
|
2029
|
-
cleanup();
|
|
2030
|
-
reject(new Error(ERROR_CODES.TAB_MISMATCH));
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
chrome.tabs.onUpdated.addListener(onUpdated);
|
|
2034
|
-
chrome.tabs.onRemoved.addListener(onRemoved);
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
/**
|
|
2039
|
-
* Resolve the tab a request should operate on. Requests may explicitly target
|
|
2040
|
-
* one tab via `tab_id`; otherwise they follow the active tab in the enabled
|
|
2041
|
-
* window.
|
|
2042
|
-
*
|
|
2043
|
-
* @param {BridgeRequest} request
|
|
2044
|
-
* @param {{ requireScriptable?: boolean }} [options]
|
|
2045
|
-
* @returns {Promise<ResolvedTabTarget>}
|
|
2046
|
-
*/
|
|
2047
|
-
async function resolveRequestTarget(request, options = {}) {
|
|
2048
|
-
const requireScriptable = options.requireScriptable !== false;
|
|
2049
|
-
if (!state.enabledWindow) {
|
|
2050
|
-
throw new Error(ERROR_CODES.ACCESS_DENIED);
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
try {
|
|
2054
|
-
await chrome.windows.get(state.enabledWindow.windowId);
|
|
2055
|
-
} catch {
|
|
2056
|
-
const cleared = await clearEnabledWindowIfGone();
|
|
2057
|
-
if (cleared) {
|
|
2058
|
-
throw new Error(ERROR_CODES.ACCESS_DENIED);
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
/** @type {chrome.tabs.Tab | null} */
|
|
2063
|
-
let explicitTab = null;
|
|
2064
|
-
if (typeof request.tab_id === 'number' && Number.isFinite(request.tab_id)) {
|
|
2065
|
-
explicitTab = await chrome.tabs.get(request.tab_id);
|
|
2066
|
-
}
|
|
2067
|
-
const [activeTab] = await chrome.tabs.query({
|
|
2068
|
-
active: true,
|
|
2069
|
-
windowId: state.enabledWindow.windowId,
|
|
2070
|
-
});
|
|
2071
|
-
const tab = selectRequestTabCandidate(request.tab_id, explicitTab, activeTab ?? null);
|
|
2072
|
-
|
|
2073
|
-
return resolveWindowScopedTab(tab, state.enabledWindow.windowId, {
|
|
2074
|
-
requireScriptable,
|
|
2075
|
-
});
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
/**
|
|
2079
|
-
* Resolve the current active tab in the last-focused window so the popup and
|
|
2080
|
-
* side panel can reflect and toggle its bridge enablement state.
|
|
2081
|
-
*
|
|
2082
|
-
* @returns {Promise<CurrentTabState | null>}
|
|
2083
|
-
*/
|
|
2084
|
-
async function getCurrentTabState() {
|
|
2085
|
-
const [activeTab] = await chrome.tabs.query({
|
|
2086
|
-
active: true,
|
|
2087
|
-
lastFocusedWindow: true,
|
|
2088
|
-
});
|
|
2089
|
-
if (!activeTab?.id || typeof activeTab.windowId !== 'number' || !activeTab.url) {
|
|
2090
|
-
return null;
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
return {
|
|
2094
|
-
tabId: activeTab.id,
|
|
2095
|
-
windowId: activeTab.windowId,
|
|
2096
|
-
title: activeTab.title ?? '',
|
|
2097
|
-
url: activeTab.url,
|
|
2098
|
-
enabled: isWindowEnabled(activeTab.windowId),
|
|
2099
|
-
accessRequested: isAccessRequestedWindow(activeTab.windowId),
|
|
2100
|
-
restricted: isRestrictedAutomationUrl(activeTab.url),
|
|
2101
|
-
};
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
/**
|
|
2105
|
-
* Resolve one specific tab into the UI shape used by the popup and side panel.
|
|
2106
|
-
*
|
|
2107
|
-
* @param {number | null} tabId
|
|
2108
|
-
* @returns {Promise<CurrentTabState | null>}
|
|
2109
|
-
*/
|
|
2110
|
-
async function getTabState(tabId) {
|
|
2111
|
-
if (!tabId) {
|
|
2112
|
-
return null;
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
try {
|
|
2116
|
-
const tab = await chrome.tabs.get(tabId);
|
|
2117
|
-
if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number' || !tab.url) {
|
|
2118
|
-
return null;
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
return {
|
|
2122
|
-
tabId: tab.id,
|
|
2123
|
-
windowId: tab.windowId,
|
|
2124
|
-
title: tab.title ?? '',
|
|
2125
|
-
url: tab.url,
|
|
2126
|
-
enabled: isWindowEnabled(tab.windowId),
|
|
2127
|
-
accessRequested: isAccessRequestedWindow(tab.windowId),
|
|
2128
|
-
restricted: isRestrictedAutomationUrl(tab.url),
|
|
2129
|
-
};
|
|
2130
|
-
} catch {
|
|
2131
|
-
return null;
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
/**
|
|
2136
|
-
* Enable or disable bridge communication for the current window.
|
|
2137
|
-
*
|
|
2138
|
-
* @param {boolean} enabled
|
|
2139
|
-
* @returns {Promise<void>}
|
|
2140
|
-
*/
|
|
2141
|
-
async function setCurrentWindowEnabled(enabled) {
|
|
2142
|
-
const currentTab = await getCurrentTabState();
|
|
2143
|
-
if (!currentTab?.url) {
|
|
2144
|
-
throw new Error(ERROR_CODES.TAB_MISMATCH);
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
await setWindowEnabled(currentTab.windowId, currentTab.title, enabled);
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
/**
|
|
2151
|
-
* Enable or disable bridge communication for one specific window.
|
|
2152
|
-
*
|
|
2153
|
-
* @param {number} windowId
|
|
2154
|
-
* @param {string} title
|
|
2155
|
-
* @param {boolean} enabled
|
|
2156
|
-
* @returns {Promise<void>}
|
|
2157
|
-
*/
|
|
2158
|
-
async function setWindowEnabled(windowId, title, enabled) {
|
|
2159
|
-
clearRequestedAccessWindow();
|
|
2160
|
-
const access = {
|
|
2161
|
-
windowId,
|
|
2162
|
-
title,
|
|
2163
|
-
enabledAt: Date.now(),
|
|
2164
|
-
};
|
|
2165
|
-
|
|
2166
|
-
if (enabled) {
|
|
2167
|
-
state.enabledWindow = access;
|
|
2168
|
-
await chrome.storage.session.set({
|
|
2169
|
-
[ENABLED_WINDOW_STORAGE_KEY]: access,
|
|
2170
|
-
});
|
|
2171
|
-
} else {
|
|
2172
|
-
if (state.enabledWindow && state.enabledWindow.windowId === windowId) {
|
|
2173
|
-
state.enabledWindow = null;
|
|
2174
|
-
await chrome.storage.session.remove(ENABLED_WINDOW_STORAGE_KEY);
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
try {
|
|
2179
|
-
await refreshActionIndicators();
|
|
2180
|
-
} catch {
|
|
2181
|
-
/* Badge updates can fail for closed or restricted tabs. */
|
|
2182
|
-
}
|
|
2183
|
-
await emitUiState();
|
|
2184
|
-
|
|
2185
|
-
if (enabled) {
|
|
2186
|
-
sendAccessUpdate(true);
|
|
2187
|
-
await chrome.alarms.create(KEEPALIVE_ALARM_NAME, { periodInMinutes: 0.4 });
|
|
2188
|
-
await Promise.allSettled([
|
|
2189
|
-
injectContentScriptsForWindow(access.windowId),
|
|
2190
|
-
primeWindowConsoleCapture(access.windowId, true),
|
|
2191
|
-
]);
|
|
2192
|
-
} else {
|
|
2193
|
-
sendAccessUpdate(false);
|
|
2194
|
-
try {
|
|
2195
|
-
await chrome.alarms.clear(KEEPALIVE_ALARM_NAME);
|
|
2196
|
-
await clearWindowBridgeState(windowId);
|
|
2197
|
-
} catch (error) {
|
|
2198
|
-
reportAsyncError(error);
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
/**
|
|
2204
|
-
* React to tab navigation/title changes so popup and side panel state stays in
|
|
2205
|
-
* sync with the tab the user is currently looking at.
|
|
2206
|
-
*
|
|
2207
|
-
* @param {number} tabId
|
|
2208
|
-
* @param {TabChangeInfo} changeInfo
|
|
2209
|
-
* @param {chrome.tabs.Tab} tab
|
|
2210
|
-
* @returns {Promise<void>}
|
|
2211
|
-
*/
|
|
2212
|
-
async function handleTabUpdated(tabId, changeInfo, tab) {
|
|
2213
|
-
if (
|
|
2214
|
-
typeof changeInfo.title === 'string' &&
|
|
2215
|
-
state.enabledWindow &&
|
|
2216
|
-
tab.windowId === state.enabledWindow.windowId
|
|
2217
|
-
) {
|
|
2218
|
-
state.enabledWindow = {
|
|
2219
|
-
...state.enabledWindow,
|
|
2220
|
-
title: changeInfo.title,
|
|
2221
|
-
};
|
|
2222
|
-
await chrome.storage.session.set({
|
|
2223
|
-
[ENABLED_WINDOW_STORAGE_KEY]: state.enabledWindow,
|
|
2224
|
-
});
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
if (
|
|
2228
|
-
typeof changeInfo.url === 'string' ||
|
|
2229
|
-
typeof changeInfo.title === 'string' ||
|
|
2230
|
-
changeInfo.status === 'complete'
|
|
2231
|
-
) {
|
|
2232
|
-
if (
|
|
2233
|
-
changeInfo.status === 'complete' &&
|
|
2234
|
-
state.enabledWindow &&
|
|
2235
|
-
tab.windowId === state.enabledWindow.windowId
|
|
2236
|
-
) {
|
|
2237
|
-
await primeTabConsoleCapture(tabId);
|
|
2238
|
-
}
|
|
2239
|
-
await updateActionIndicatorForTab(tabId);
|
|
2240
|
-
await emitUiState();
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
/**
|
|
2245
|
-
* Remove any persisted enablement when the enabled window closes.
|
|
2246
|
-
*
|
|
2247
|
-
* @param {number} tabId
|
|
2248
|
-
* @param {{ windowId: number, isWindowClosing: boolean }} removeInfo
|
|
2249
|
-
* @returns {Promise<void>}
|
|
2250
|
-
*/
|
|
2251
|
-
async function handleTabRemoved(tabId, removeInfo) {
|
|
2252
|
-
if (
|
|
2253
|
-
state.enabledWindow &&
|
|
2254
|
-
removeInfo.isWindowClosing &&
|
|
2255
|
-
removeInfo.windowId === state.enabledWindow.windowId
|
|
2256
|
-
) {
|
|
2257
|
-
state.enabledWindow = null;
|
|
2258
|
-
await chrome.storage.session.remove(ENABLED_WINDOW_STORAGE_KEY);
|
|
2259
|
-
sendAccessUpdate(false);
|
|
2260
|
-
}
|
|
2261
|
-
if (removeInfo.isWindowClosing && removeInfo.windowId === state.requestedAccessWindowId) {
|
|
2262
|
-
clearRequestedAccessWindow(removeInfo.windowId);
|
|
2263
|
-
}
|
|
2264
|
-
await updateActionIndicatorForTab(tabId);
|
|
2265
|
-
await emitUiState();
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
/**
|
|
2269
|
-
* Refresh the extension action badge and title across the currently open tabs.
|
|
2270
|
-
*
|
|
2271
|
-
* @returns {Promise<void>}
|
|
2272
|
-
*/
|
|
2273
|
-
async function refreshActionIndicators() {
|
|
2274
|
-
const query = state.enabledWindow ? { windowId: state.enabledWindow.windowId } : {};
|
|
2275
|
-
const tabs = await chrome.tabs.query(query);
|
|
2276
|
-
const tabIds = tabs
|
|
2277
|
-
.map((tab) => (isNumber(tab.id) ? tab.id : null))
|
|
2278
|
-
.filter((tabId) => tabId !== null);
|
|
2279
|
-
await Promise.allSettled(tabIds.map((tabId) => updateActionIndicatorForTab(tabId)));
|
|
2280
|
-
|
|
2281
|
-
// Some Chromium-based browsers (e.g. Edge) do not visually refresh the toolbar
|
|
2282
|
-
// badge after per-tab updates until the tab navigates. Setting the global badge
|
|
2283
|
-
// (without tabId) to match the active tab forces an immediate repaint.
|
|
2284
|
-
await syncGlobalBadgeToActiveTab();
|
|
2285
|
-
}
|
|
2286
|
-
|
|
2287
|
-
/**
|
|
2288
|
-
* Set the global badge (no tabId) to match the active tab in the last-focused
|
|
2289
|
-
* window. This forces browsers that batch per-tab badge updates (e.g. Edge) to
|
|
2290
|
-
* immediately repaint the toolbar icon.
|
|
2291
|
-
*
|
|
2292
|
-
* @returns {Promise<void>}
|
|
2293
|
-
*/
|
|
2294
|
-
async function syncGlobalBadgeToActiveTab() {
|
|
2295
|
-
try {
|
|
2296
|
-
const [activeTab] = await chrome.tabs.query({
|
|
2297
|
-
active: true,
|
|
2298
|
-
lastFocusedWindow: true,
|
|
2299
|
-
});
|
|
2300
|
-
if (!activeTab?.id) return;
|
|
2301
|
-
const enabled = await isTabEnabled(activeTab.id);
|
|
2302
|
-
const accessRequested = !enabled && (await isAccessRequestedTab(activeTab.id));
|
|
2303
|
-
const restricted = enabled && isRestrictedAutomationUrl(activeTab.url ?? '');
|
|
2304
|
-
const text = enabled
|
|
2305
|
-
? restricted
|
|
2306
|
-
? RESTRICTED_BADGE_TEXT
|
|
2307
|
-
: ENABLED_BADGE_TEXT
|
|
2308
|
-
: accessRequested
|
|
2309
|
-
? ACCESS_REQUEST_BADGE_TEXT
|
|
2310
|
-
: '';
|
|
2311
|
-
const bgColor = enabled
|
|
2312
|
-
? restricted
|
|
2313
|
-
? '#e07020'
|
|
2314
|
-
: '#787878'
|
|
2315
|
-
: accessRequested
|
|
2316
|
-
? '#f2cf2f'
|
|
2317
|
-
: '#464646';
|
|
2318
|
-
const textColor = enabled ? '#ffffff' : accessRequested ? '#000000' : '#ffffff';
|
|
2319
|
-
await chrome.action.setBadgeText({ text });
|
|
2320
|
-
try {
|
|
2321
|
-
await chrome.action.setBadgeBackgroundColor({ color: bgColor });
|
|
2322
|
-
} catch {
|
|
2323
|
-
/* unsupported */
|
|
2324
|
-
}
|
|
2325
|
-
try {
|
|
2326
|
-
await chrome.action.setBadgeTextColor({ color: textColor });
|
|
2327
|
-
} catch {
|
|
2328
|
-
/* unsupported */
|
|
2329
|
-
}
|
|
2330
|
-
} catch {
|
|
2331
|
-
/* non-critical */
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
/**
|
|
2336
|
-
* Update the action badge and title for one tab so enabled windows are visibly
|
|
2337
|
-
* marked from the Chrome toolbar.
|
|
2338
|
-
*
|
|
2339
|
-
* @param {number} tabId
|
|
2340
|
-
* @returns {Promise<void>}
|
|
2341
|
-
*/
|
|
2342
|
-
async function updateActionIndicatorForTab(tabId) {
|
|
2343
|
-
const enabled = await isTabEnabled(tabId);
|
|
2344
|
-
const accessRequested = !enabled && (await isAccessRequestedTab(tabId));
|
|
2345
|
-
let restricted = false;
|
|
2346
|
-
if (enabled) {
|
|
2347
|
-
try {
|
|
2348
|
-
const tab = await chrome.tabs.get(tabId);
|
|
2349
|
-
restricted = isRestrictedAutomationUrl(tab.url ?? '');
|
|
2350
|
-
} catch {
|
|
2351
|
-
/* ignore */
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
const badgeText = enabled
|
|
2355
|
-
? restricted
|
|
2356
|
-
? RESTRICTED_BADGE_TEXT
|
|
2357
|
-
: ENABLED_BADGE_TEXT
|
|
2358
|
-
: accessRequested
|
|
2359
|
-
? ACCESS_REQUEST_BADGE_TEXT
|
|
2360
|
-
: '';
|
|
2361
|
-
const bgColor = enabled
|
|
2362
|
-
? restricted
|
|
2363
|
-
? '#e07020'
|
|
2364
|
-
: '#787878'
|
|
2365
|
-
: accessRequested
|
|
2366
|
-
? '#f2cf2f'
|
|
2367
|
-
: '#464646';
|
|
2368
|
-
const textColor = enabled ? '#ffffff' : accessRequested ? '#000000' : '#ffffff';
|
|
2369
|
-
try {
|
|
2370
|
-
await chrome.action.setBadgeBackgroundColor({ tabId, color: bgColor });
|
|
2371
|
-
} catch {
|
|
2372
|
-
/* color APIs may be unsupported */
|
|
2373
|
-
}
|
|
2374
|
-
try {
|
|
2375
|
-
await chrome.action.setBadgeTextColor({ tabId, color: textColor });
|
|
2376
|
-
} catch {
|
|
2377
|
-
/* setBadgeTextColor not supported everywhere */
|
|
2378
|
-
}
|
|
2379
|
-
try {
|
|
2380
|
-
if (enabled && restricted) {
|
|
2381
|
-
await chrome.action.setTitle({
|
|
2382
|
-
tabId,
|
|
2383
|
-
title: 'Browser Bridge is enabled, but this page cannot be interacted with.',
|
|
2384
|
-
});
|
|
2385
|
-
} else if (enabled) {
|
|
2386
|
-
await chrome.action.setTitle({
|
|
2387
|
-
tabId,
|
|
2388
|
-
title: 'Browser Bridge is enabled for this window.',
|
|
2389
|
-
});
|
|
2390
|
-
} else if (accessRequested) {
|
|
2391
|
-
await chrome.action.setTitle({
|
|
2392
|
-
tabId,
|
|
2393
|
-
title:
|
|
2394
|
-
'Agent requested Browser Bridge access for this window. Click to open Browser Bridge, then click Enable.',
|
|
2395
|
-
});
|
|
2396
|
-
} else {
|
|
2397
|
-
await chrome.action.setTitle({ tabId, title: 'Browser Bridge' });
|
|
2398
|
-
}
|
|
2399
|
-
} catch {
|
|
2400
|
-
/* title can fail for closed tabs */
|
|
2401
|
-
}
|
|
2402
|
-
try {
|
|
2403
|
-
await chrome.action.setBadgeText({ tabId, text: badgeText });
|
|
2404
|
-
} catch (error) {
|
|
2405
|
-
if (normalizeRuntimeErrorMessage(getErrorMessage(error)) === ERROR_CODES.TAB_MISMATCH) {
|
|
2406
|
-
return;
|
|
2407
|
-
}
|
|
2408
|
-
throw error;
|
|
2409
|
-
}
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
/**
|
|
2413
|
-
* Check whether the user explicitly enabled bridge communication for a given
|
|
2414
|
-
* window.
|
|
2415
|
-
*
|
|
2416
|
-
* @param {number} windowId
|
|
2417
|
-
* @returns {boolean}
|
|
2418
|
-
*/
|
|
2419
|
-
function isWindowEnabled(windowId) {
|
|
2420
|
-
return state.enabledWindow?.windowId === windowId;
|
|
2421
|
-
}
|
|
2422
|
-
|
|
2423
|
-
/**
|
|
2424
|
-
* @param {number} windowId
|
|
2425
|
-
* @returns {boolean}
|
|
2426
|
-
*/
|
|
2427
|
-
function isAccessRequestedWindow(windowId) {
|
|
2428
|
-
return state.requestedAccessWindowId === windowId;
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
/**
|
|
2432
|
-
* @param {number} tabId
|
|
2433
|
-
* @returns {Promise<boolean>}
|
|
2434
|
-
*/
|
|
2435
|
-
async function isAccessRequestedTab(tabId) {
|
|
2436
|
-
try {
|
|
2437
|
-
const tab = await chrome.tabs.get(tabId);
|
|
2438
|
-
return typeof tab.windowId === 'number' && isAccessRequestedWindow(tab.windowId);
|
|
2439
|
-
} catch {
|
|
2440
|
-
return false;
|
|
2441
|
-
}
|
|
2442
|
-
}
|
|
2443
|
-
|
|
2444
|
-
/**
|
|
2445
|
-
* @param {number | null} [windowId=null]
|
|
2446
|
-
* @returns {void}
|
|
2447
|
-
*/
|
|
2448
|
-
function clearRequestedAccessWindow(windowId = null) {
|
|
2449
|
-
if (windowId == null || state.requestedAccessWindowId === windowId) {
|
|
2450
|
-
state.requestedAccessWindowId = null;
|
|
2451
|
-
}
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
/**
|
|
2455
|
-
* @param {number | null} [windowId=null]
|
|
2456
|
-
* @returns {void}
|
|
2457
|
-
*/
|
|
2458
|
-
function clearRequestedAccessPopupWindow(windowId = null) {
|
|
2459
|
-
if (windowId == null || state.requestedAccessPopupWindowId === windowId) {
|
|
2460
|
-
state.requestedAccessPopupWindowId = null;
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
/**
|
|
2465
|
-
* Surface an enable cue in the extension UI when an agent-side request fails
|
|
2466
|
-
* because Browser Bridge is off for the target window.
|
|
2467
|
-
*
|
|
2468
|
-
* @param {BridgeRequest} request
|
|
2469
|
-
* @returns {Promise<void>}
|
|
2470
|
-
*/
|
|
2471
|
-
async function requestEnableFromAgentSide(request) {
|
|
2472
|
-
const target = await resolveRequestedAccessTarget(request);
|
|
2473
|
-
if (!target) {
|
|
2474
|
-
return;
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2477
|
-
if (state.requestedAccessWindowId != null) {
|
|
2478
|
-
return;
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2481
|
-
state.requestedAccessWindowId = target.windowId;
|
|
2482
|
-
await refreshActionIndicators();
|
|
2483
|
-
await emitUiState();
|
|
2484
|
-
await openRequestedAccessUi(target);
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
/**
|
|
2488
|
-
* @param {ResolvedTabTarget} target
|
|
2489
|
-
* @returns {{ allowed: true } | { allowed: false, message: string }}
|
|
2490
|
-
*/
|
|
2491
|
-
function checkAccessRequestAvailability(target) {
|
|
2492
|
-
if (state.requestedAccessWindowId == null) {
|
|
2493
|
-
return { allowed: true };
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
if (state.requestedAccessWindowId === target.windowId) {
|
|
2497
|
-
return {
|
|
2498
|
-
allowed: false,
|
|
2499
|
-
message:
|
|
2500
|
-
'Browser Bridge access is already pending for this window. Ask the user to click Enable before requesting access again.',
|
|
2501
|
-
};
|
|
2502
|
-
}
|
|
2503
|
-
|
|
2504
|
-
return {
|
|
2505
|
-
allowed: false,
|
|
2506
|
-
message:
|
|
2507
|
-
'Browser Bridge access is already pending for another window. Ask the user to click Enable for that window before requesting access again.',
|
|
2508
|
-
};
|
|
2509
|
-
}
|
|
2510
|
-
|
|
2511
|
-
/**
|
|
2512
|
-
* Handle an explicit access.request call. Resolves the active tab in the
|
|
2513
|
-
* last-focused window, surfaces the Enable cue in the extension UI, and
|
|
2514
|
-
* returns the requested window/tab metadata.
|
|
2515
|
-
*
|
|
2516
|
-
* @param {BridgeRequest} request
|
|
2517
|
-
* @returns {Promise<BridgeResponse>}
|
|
2518
|
-
*/
|
|
2519
|
-
async function handleAccessRequest(request) {
|
|
2520
|
-
const target = await resolveRequestedAccessTarget(request);
|
|
2521
|
-
|
|
2522
|
-
if (state.enabledWindow) {
|
|
2523
|
-
const access = await getAccessStatus();
|
|
2524
|
-
return createSuccess(
|
|
2525
|
-
request.id,
|
|
2526
|
-
{
|
|
2527
|
-
enabled: true,
|
|
2528
|
-
access,
|
|
2529
|
-
},
|
|
2530
|
-
{ method: request.method }
|
|
2531
|
-
);
|
|
2532
|
-
}
|
|
2533
|
-
|
|
2534
|
-
if (!target) {
|
|
2535
|
-
return createFailure(
|
|
2536
|
-
request.id,
|
|
2537
|
-
ERROR_CODES.ACCESS_DENIED,
|
|
2538
|
-
'No scriptable tab found in the focused window.',
|
|
2539
|
-
null,
|
|
2540
|
-
{ method: request.method }
|
|
2541
|
-
);
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
const availability = checkAccessRequestAvailability(target);
|
|
2545
|
-
if (!availability.allowed) {
|
|
2546
|
-
return createFailure(
|
|
2547
|
-
request.id,
|
|
2548
|
-
ERROR_CODES.ACCESS_DENIED,
|
|
2549
|
-
availability.message,
|
|
2550
|
-
{
|
|
2551
|
-
requestedWindowId: state.requestedAccessWindowId,
|
|
2552
|
-
requestedTargetWindowId: target.windowId,
|
|
2553
|
-
requestedTargetTabId: target.tabId,
|
|
2554
|
-
},
|
|
2555
|
-
{ method: request.method }
|
|
2556
|
-
);
|
|
2557
|
-
}
|
|
2558
|
-
|
|
2559
|
-
state.requestedAccessWindowId = target.windowId;
|
|
2560
|
-
await refreshActionIndicators();
|
|
2561
|
-
await emitUiState();
|
|
2562
|
-
await openRequestedAccessUi(target);
|
|
2563
|
-
|
|
2564
|
-
return createSuccess(
|
|
2565
|
-
request.id,
|
|
2566
|
-
{
|
|
2567
|
-
enabled: false,
|
|
2568
|
-
requested: true,
|
|
2569
|
-
windowId: target.windowId,
|
|
2570
|
-
tabId: target.tabId,
|
|
2571
|
-
title: target.title,
|
|
2572
|
-
url: target.url,
|
|
2573
|
-
},
|
|
2574
|
-
{ method: request.method }
|
|
2575
|
-
);
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
|
-
/**
|
|
2579
|
-
* @param {BridgeRequest} request
|
|
2580
|
-
* @returns {Promise<ResolvedTabTarget | null>}
|
|
2581
|
-
*/
|
|
2582
|
-
async function resolveRequestedAccessTarget(request) {
|
|
2583
|
-
if (typeof request.tab_id === 'number' && request.tab_id > 0) {
|
|
2584
|
-
try {
|
|
2585
|
-
const tab = await chrome.tabs.get(request.tab_id);
|
|
2586
|
-
return normalizeRequestedAccessTab(tab);
|
|
2587
|
-
} catch {
|
|
2588
|
-
return null;
|
|
2589
|
-
}
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
|
-
const tabs = await chrome.tabs.query({
|
|
2593
|
-
active: true,
|
|
2594
|
-
lastFocusedWindow: true,
|
|
2595
|
-
});
|
|
2596
|
-
return normalizeRequestedAccessTab(tabs[0] ?? null);
|
|
2597
|
-
}
|
|
2598
|
-
|
|
2599
|
-
/**
|
|
2600
|
-
* Open Browser Bridge UI for an agent-side access request. If the side panel
|
|
2601
|
-
* is already open for that window, leave it in place so its existing attention
|
|
2602
|
-
* state continues to guide the user. Otherwise open one controlled popup
|
|
2603
|
-
* window so multiple browser windows cannot splash duplicate prompts.
|
|
2604
|
-
*
|
|
2605
|
-
* @param {ResolvedTabTarget} target
|
|
2606
|
-
* @returns {Promise<void>}
|
|
2607
|
-
*/
|
|
2608
|
-
async function openRequestedAccessUi(target) {
|
|
2609
|
-
if (await isSidePanelOpenForWindow(target.windowId)) {
|
|
2610
|
-
return;
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
try {
|
|
2614
|
-
await openRequestedAccessPopupWindow(target);
|
|
2615
|
-
} catch (error) {
|
|
2616
|
-
console.warn('Could not open Browser Bridge popup window for access request.', error);
|
|
2617
|
-
}
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
|
-
/**
|
|
2621
|
-
* Open the popup UI in its own small extension window, scoped to the requested
|
|
2622
|
-
* tab. Reuse the same popup window while access remains pending so only one
|
|
2623
|
-
* visible prompt exists across browser windows.
|
|
2624
|
-
*
|
|
2625
|
-
* @param {ResolvedTabTarget} target
|
|
2626
|
-
* @returns {Promise<void>}
|
|
2627
|
-
*/
|
|
2628
|
-
async function openRequestedAccessPopupWindow(target) {
|
|
2629
|
-
const popupUrl = chrome.runtime.getURL(
|
|
2630
|
-
`${POPUP_PATH}?tabId=${encodeURIComponent(String(target.tabId))}&windowed=1`
|
|
2631
|
-
);
|
|
2632
|
-
const popupWidth = 420;
|
|
2633
|
-
const popupHeight = 320;
|
|
2634
|
-
const popupPlacement = await getRequestedAccessPopupPlacement(target.windowId, popupWidth);
|
|
2635
|
-
|
|
2636
|
-
if (state.requestedAccessPopupWindowId != null) {
|
|
2637
|
-
try {
|
|
2638
|
-
const existingWindow = await chrome.windows.get(state.requestedAccessPopupWindowId, {
|
|
2639
|
-
populate: true,
|
|
2640
|
-
});
|
|
2641
|
-
const existingWindowId = typeof existingWindow.id === 'number' ? existingWindow.id : null;
|
|
2642
|
-
const popupTabId = existingWindow.tabs?.find((tab) => typeof tab.id === 'number')?.id ?? null;
|
|
2643
|
-
if (existingWindowId == null || popupTabId == null) {
|
|
2644
|
-
throw new Error('Requested access popup window is missing its tab.');
|
|
2645
|
-
}
|
|
2646
|
-
await chrome.tabs.update(popupTabId, { url: popupUrl });
|
|
2647
|
-
await chrome.windows.update(existingWindowId, {
|
|
2648
|
-
focused: true,
|
|
2649
|
-
...(popupPlacement ?? {}),
|
|
2650
|
-
});
|
|
2651
|
-
return;
|
|
2652
|
-
} catch {
|
|
2653
|
-
clearRequestedAccessPopupWindow();
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
|
|
2657
|
-
let createData = /** @type {chrome.windows.CreateData} */ ({
|
|
2658
|
-
url: popupUrl,
|
|
2659
|
-
type: 'popup',
|
|
2660
|
-
focused: true,
|
|
2661
|
-
width: popupWidth,
|
|
2662
|
-
height: popupHeight,
|
|
2663
|
-
});
|
|
2664
|
-
|
|
2665
|
-
if (popupPlacement) {
|
|
2666
|
-
createData = {
|
|
2667
|
-
...createData,
|
|
2668
|
-
...popupPlacement,
|
|
2669
|
-
};
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
|
-
const popupWindow = await chrome.windows.create(createData);
|
|
2673
|
-
state.requestedAccessPopupWindowId = typeof popupWindow?.id === 'number' ? popupWindow.id : null;
|
|
2674
|
-
}
|
|
2675
|
-
|
|
2676
|
-
/**
|
|
2677
|
-
* @param {number} targetWindowId
|
|
2678
|
-
* @param {number} popupWidth
|
|
2679
|
-
* @returns {Promise<Pick<chrome.windows.UpdateInfo, 'left' | 'top'> | null>}
|
|
2680
|
-
*/
|
|
2681
|
-
async function getRequestedAccessPopupPlacement(targetWindowId, popupWidth) {
|
|
2682
|
-
try {
|
|
2683
|
-
const browserWindow = await chrome.windows.get(targetWindowId);
|
|
2684
|
-
if (
|
|
2685
|
-
typeof browserWindow.left === 'number' &&
|
|
2686
|
-
typeof browserWindow.top === 'number' &&
|
|
2687
|
-
typeof browserWindow.width === 'number'
|
|
2688
|
-
) {
|
|
2689
|
-
return {
|
|
2690
|
-
left: browserWindow.left + Math.max(24, browserWindow.width - popupWidth - 40),
|
|
2691
|
-
top: browserWindow.top + 72,
|
|
2692
|
-
};
|
|
2693
|
-
}
|
|
2694
|
-
} catch {
|
|
2695
|
-
// Ignore window positioning failures and fall back to Chrome defaults.
|
|
2696
|
-
}
|
|
2697
|
-
|
|
2698
|
-
return null;
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
/**
|
|
2702
|
-
* @param {number} windowId
|
|
2703
|
-
* @returns {Promise<boolean>}
|
|
2704
|
-
*/
|
|
2705
|
-
async function isSidePanelOpenForWindow(windowId) {
|
|
2706
|
-
for (const portState of state.uiPorts.values()) {
|
|
2707
|
-
if (portState.surface !== 'sidepanel') {
|
|
2708
|
-
continue;
|
|
2709
|
-
}
|
|
2710
|
-
const currentTab = portState.scopeTabId
|
|
2711
|
-
? await getTabState(portState.scopeTabId)
|
|
2712
|
-
: await getCurrentTabState();
|
|
2713
|
-
if (currentTab?.windowId === windowId) {
|
|
2714
|
-
return true;
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
|
-
return false;
|
|
2718
|
-
}
|
|
2719
|
-
|
|
2720
|
-
/**
|
|
2721
|
-
* @param {string} portName
|
|
2722
|
-
* @returns {'popup' | 'sidepanel' | null}
|
|
2723
|
-
*/
|
|
2724
|
-
function getUiSurfaceFromPortName(portName) {
|
|
2725
|
-
if (portName === 'ui-popup') {
|
|
2726
|
-
return 'popup';
|
|
2727
|
-
}
|
|
2728
|
-
if (portName === 'ui-sidepanel') {
|
|
2729
|
-
return 'sidepanel';
|
|
2730
|
-
}
|
|
2731
|
-
if (portName === 'ui') {
|
|
2732
|
-
return 'popup';
|
|
2733
|
-
}
|
|
2734
|
-
return null;
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
|
-
/**
|
|
2738
|
-
* Resolve the scope context for a bridge request so the action log can show
|
|
2739
|
-
* where an operation happened even if that operation later revokes the session.
|
|
2740
|
-
*
|
|
2741
|
-
* @param {BridgeRequest} request
|
|
2742
|
-
* @returns {Promise<{ tabId: number | null, url: string } | null>}
|
|
2743
|
-
*/
|
|
2744
|
-
async function getActionContext(request) {
|
|
2745
|
-
try {
|
|
2746
|
-
if (request.method === 'tabs.close') {
|
|
2747
|
-
const params = normalizeTabCloseParams(request.params);
|
|
2748
|
-
const tab = await chrome.tabs.get(params.tabId);
|
|
2749
|
-
return {
|
|
2750
|
-
tabId: params.tabId,
|
|
2751
|
-
url: tab.url ?? '',
|
|
2752
|
-
};
|
|
2753
|
-
}
|
|
2754
|
-
if (!bridgeMethodNeedsTab(request.method)) {
|
|
2755
|
-
return null;
|
|
2756
|
-
}
|
|
2757
|
-
const tab = await resolveRequestTarget(request, {
|
|
2758
|
-
requireScriptable: request.method !== 'tabs.create',
|
|
2759
|
-
});
|
|
2760
|
-
return {
|
|
2761
|
-
tabId: tab.tabId,
|
|
2762
|
-
url: tab.url,
|
|
2763
|
-
};
|
|
2764
|
-
} catch {
|
|
2765
|
-
return null;
|
|
2766
|
-
}
|
|
2767
|
-
}
|
|
2768
|
-
|
|
2769
|
-
/**
|
|
2770
|
-
* Append one operator-facing action log entry and persist the bounded history.
|
|
2771
|
-
*
|
|
2772
|
-
* @param {BridgeRequest} request
|
|
2773
|
-
* @param {BridgeResponse} response
|
|
2774
|
-
* @param {{ tabId: number | null, url: string } | null} actionContext
|
|
2775
|
-
* @returns {Promise<void>}
|
|
2776
|
-
*/
|
|
2777
|
-
async function logBridgeAction(request, response, actionContext) {
|
|
2778
|
-
if (!shouldLogAction(request.method)) {
|
|
2779
|
-
return;
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
const diagnostics = getResponseDiagnostics(request.method, response);
|
|
2783
|
-
const summaryPayload = summarizeBridgeResponse(response, request.method);
|
|
2784
|
-
const summaryCost = estimateJsonPayloadCost(summaryPayload);
|
|
2785
|
-
|
|
2786
|
-
await appendActionLogEntry({
|
|
2787
|
-
method: request.method,
|
|
2788
|
-
source: normalizeActionLogSource(request.meta?.source),
|
|
2789
|
-
tabId: actionContext?.tabId ?? null,
|
|
2790
|
-
url: actionContext?.url ?? '',
|
|
2791
|
-
ok: response.ok,
|
|
2792
|
-
summary: summarizeActionResult(response),
|
|
2793
|
-
responseBytes: diagnostics.responseBytes,
|
|
2794
|
-
approxTokens: diagnostics.textApproxTokens,
|
|
2795
|
-
imageApproxTokens: diagnostics.imageApproxTokens,
|
|
2796
|
-
costClass: diagnostics.costClass,
|
|
2797
|
-
imageBytes: diagnostics.imageBytes,
|
|
2798
|
-
summaryBytes: summaryCost.bytes,
|
|
2799
|
-
summaryTokens: summaryCost.approxTokens,
|
|
2800
|
-
summaryCostClass: summaryCost.costClass,
|
|
2801
|
-
debuggerBacked: diagnostics.debuggerBacked,
|
|
2802
|
-
overBudget: response.meta?.budget_truncated === true,
|
|
2803
|
-
hasScreenshot: diagnostics.hasScreenshot,
|
|
2804
|
-
nodeCount: diagnostics.nodeCount,
|
|
2805
|
-
continuationHint:
|
|
2806
|
-
typeof response.meta?.continuation_hint === 'string' ? response.meta.continuation_hint : null,
|
|
2807
|
-
});
|
|
2808
|
-
await emitUiState();
|
|
2809
|
-
}
|
|
2810
|
-
|
|
2811
|
-
/**
|
|
2812
|
-
* Append one action log entry and persist the bounded history.
|
|
2813
|
-
*
|
|
2814
|
-
* @param {{
|
|
2815
|
-
* method: string,
|
|
2816
|
-
* source?: string,
|
|
2817
|
-
* tabId?: number | null,
|
|
2818
|
-
* url?: string,
|
|
2819
|
-
* ok: boolean,
|
|
2820
|
-
* summary: string,
|
|
2821
|
-
* responseBytes?: number,
|
|
2822
|
-
* approxTokens?: number,
|
|
2823
|
-
* imageApproxTokens?: number,
|
|
2824
|
-
* costClass?: 'cheap' | 'moderate' | 'heavy' | 'extreme',
|
|
2825
|
-
* imageBytes?: number,
|
|
2826
|
-
* summaryBytes?: number,
|
|
2827
|
-
* summaryTokens?: number,
|
|
2828
|
-
* summaryCostClass?: 'cheap' | 'moderate' | 'heavy' | 'extreme',
|
|
2829
|
-
* debuggerBacked?: boolean,
|
|
2830
|
-
* overBudget?: boolean,
|
|
2831
|
-
* hasScreenshot?: boolean,
|
|
2832
|
-
* nodeCount?: number | null,
|
|
2833
|
-
* continuationHint?: string | null
|
|
2834
|
-
* }} entry
|
|
2835
|
-
* @returns {Promise<void>}
|
|
2836
|
-
*/
|
|
2837
|
-
async function appendActionLogEntry(entry) {
|
|
2838
|
-
state.actionLog.push({
|
|
2839
|
-
id: crypto.randomUUID(),
|
|
2840
|
-
at: Date.now(),
|
|
2841
|
-
method: entry.method,
|
|
2842
|
-
source: normalizeActionLogSource(entry.source),
|
|
2843
|
-
tabId: entry.tabId ?? null,
|
|
2844
|
-
url: entry.url ?? '',
|
|
2845
|
-
ok: entry.ok,
|
|
2846
|
-
summary: entry.summary,
|
|
2847
|
-
responseBytes: entry.responseBytes ?? 0,
|
|
2848
|
-
approxTokens: entry.approxTokens ?? 0,
|
|
2849
|
-
imageApproxTokens: entry.imageApproxTokens ?? 0,
|
|
2850
|
-
costClass: entry.costClass ?? 'cheap',
|
|
2851
|
-
imageBytes: entry.imageBytes ?? 0,
|
|
2852
|
-
summaryBytes: entry.summaryBytes ?? 0,
|
|
2853
|
-
summaryTokens: entry.summaryTokens ?? 0,
|
|
2854
|
-
summaryCostClass: entry.summaryCostClass ?? 'cheap',
|
|
2855
|
-
debuggerBacked: entry.debuggerBacked === true,
|
|
2856
|
-
overBudget: entry.overBudget === true,
|
|
2857
|
-
hasScreenshot: entry.hasScreenshot ?? false,
|
|
2858
|
-
nodeCount: entry.nodeCount ?? null,
|
|
2859
|
-
continuationHint: entry.continuationHint ?? null,
|
|
2860
|
-
});
|
|
2861
|
-
while (state.actionLog.length > MAX_ACTION_LOG_ENTRIES) {
|
|
2862
|
-
state.actionLog.shift();
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
await chrome.storage.session.set({
|
|
2866
|
-
[ACTION_LOG_STORAGE_KEY]: state.actionLog,
|
|
2867
|
-
});
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
/**
|
|
2871
|
-
* @param {unknown} source
|
|
2872
|
-
* @returns {string}
|
|
2873
|
-
*/
|
|
2874
|
-
function normalizeActionLogSource(source) {
|
|
2875
|
-
return source === 'cli' || source === 'mcp' ? source : '';
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
/**
|
|
2879
|
-
* @param {unknown} entry
|
|
2880
|
-
* @returns {ActionLogEntry | null}
|
|
2881
|
-
*/
|
|
2882
|
-
function normalizeActionLogEntry(entry) {
|
|
2883
|
-
if (!entry || typeof entry !== 'object') {
|
|
2884
|
-
return null;
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
const candidate = /** @type {Record<string, unknown>} */ (entry);
|
|
2888
|
-
if (typeof candidate.id !== 'string' || typeof candidate.method !== 'string') {
|
|
2889
|
-
return null;
|
|
2890
|
-
}
|
|
2891
|
-
|
|
2892
|
-
return {
|
|
2893
|
-
id: candidate.id,
|
|
2894
|
-
at: Number(candidate.at) || 0,
|
|
2895
|
-
method: candidate.method,
|
|
2896
|
-
source: normalizeActionLogSource(candidate.source),
|
|
2897
|
-
tabId: typeof candidate.tabId === 'number' ? candidate.tabId : null,
|
|
2898
|
-
url: typeof candidate.url === 'string' ? candidate.url : '',
|
|
2899
|
-
ok: candidate.ok === true,
|
|
2900
|
-
summary: typeof candidate.summary === 'string' ? candidate.summary : '',
|
|
2901
|
-
responseBytes: Number(candidate.responseBytes) || 0,
|
|
2902
|
-
approxTokens: Number(candidate.approxTokens) || 0,
|
|
2903
|
-
imageApproxTokens: Number(candidate.imageApproxTokens) || 0,
|
|
2904
|
-
costClass:
|
|
2905
|
-
candidate.costClass === 'moderate' ||
|
|
2906
|
-
candidate.costClass === 'heavy' ||
|
|
2907
|
-
candidate.costClass === 'extreme'
|
|
2908
|
-
? candidate.costClass
|
|
2909
|
-
: 'cheap',
|
|
2910
|
-
imageBytes: Number(candidate.imageBytes) || 0,
|
|
2911
|
-
summaryBytes: Number(candidate.summaryBytes) || 0,
|
|
2912
|
-
summaryTokens: Number(candidate.summaryTokens) || 0,
|
|
2913
|
-
summaryCostClass:
|
|
2914
|
-
candidate.summaryCostClass === 'moderate' ||
|
|
2915
|
-
candidate.summaryCostClass === 'heavy' ||
|
|
2916
|
-
candidate.summaryCostClass === 'extreme'
|
|
2917
|
-
? candidate.summaryCostClass
|
|
2918
|
-
: 'cheap',
|
|
2919
|
-
debuggerBacked: candidate.debuggerBacked === true,
|
|
2920
|
-
overBudget: candidate.overBudget === true,
|
|
2921
|
-
hasScreenshot: candidate.hasScreenshot === true,
|
|
2922
|
-
nodeCount: typeof candidate.nodeCount === 'number' ? candidate.nodeCount : null,
|
|
2923
|
-
continuationHint:
|
|
2924
|
-
typeof candidate.continuationHint === 'string' ? candidate.continuationHint : null,
|
|
2925
|
-
};
|
|
2926
|
-
}
|
|
2927
|
-
|
|
2928
|
-
/**
|
|
2929
|
-
* Map thrown runtime errors to structured bridge failures.
|
|
2930
|
-
*
|
|
2931
|
-
* @param {BridgeRequest} request
|
|
2932
|
-
* @param {unknown} error
|
|
2933
|
-
* @returns {BridgeResponse}
|
|
2934
|
-
*/
|
|
2935
|
-
function toFailureResponse(request, error) {
|
|
2936
|
-
const message = normalizeRuntimeErrorMessage(getErrorMessage(error));
|
|
2937
|
-
const knownErrorCodes = /** @type {string[]} */ (Object.values(ERROR_CODES));
|
|
2938
|
-
/** @type {ErrorCode} */
|
|
2939
|
-
const code = knownErrorCodes.includes(message)
|
|
2940
|
-
? /** @type {ErrorCode} */ (message)
|
|
2941
|
-
: message === 'Element reference is stale.'
|
|
2942
|
-
? ERROR_CODES.ELEMENT_STALE
|
|
2943
|
-
: ERROR_CODES.INTERNAL_ERROR;
|
|
2944
|
-
|
|
2945
|
-
return createFailure(request.id, code, message, null, {
|
|
2946
|
-
method: request.method,
|
|
2947
|
-
});
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
/**
|
|
2951
|
-
* Apply token-budget truncation and attach cost/debugger metadata for the
|
|
2952
|
-
* response that will be sent back to the agent.
|
|
2953
|
-
*
|
|
2954
|
-
* @param {BridgeRequest} request
|
|
2955
|
-
* @param {BridgeResponse} response
|
|
2956
|
-
* @returns {BridgeResponse}
|
|
2957
|
-
*/
|
|
2958
|
-
function enrichBridgeResponse(request, response) {
|
|
2959
|
-
const budgetedResponse = enforceTokenBudget(request.method, response, request.meta?.token_budget);
|
|
2960
|
-
const diagnostics = getResponseDiagnostics(request.method, budgetedResponse);
|
|
2961
|
-
return {
|
|
2962
|
-
...budgetedResponse,
|
|
2963
|
-
meta: {
|
|
2964
|
-
...budgetedResponse.meta,
|
|
2965
|
-
transport_bytes: diagnostics.responseBytes,
|
|
2966
|
-
transport_approx_tokens: diagnostics.approxTokens,
|
|
2967
|
-
transport_cost_class: diagnostics.costClass,
|
|
2968
|
-
text_bytes: diagnostics.textBytes,
|
|
2969
|
-
text_approx_tokens: diagnostics.textApproxTokens,
|
|
2970
|
-
text_cost_class: diagnostics.textCostClass,
|
|
2971
|
-
image_approx_tokens: diagnostics.imageApproxTokens,
|
|
2972
|
-
image_bytes: diagnostics.imageBytes,
|
|
2973
|
-
response_bytes: diagnostics.responseBytes,
|
|
2974
|
-
approx_tokens: diagnostics.approxTokens,
|
|
2975
|
-
cost_class: diagnostics.costClass,
|
|
2976
|
-
debugger_backed: diagnostics.debuggerBacked,
|
|
2977
|
-
},
|
|
2978
|
-
};
|
|
2979
|
-
}
|
|
2980
|
-
|
|
2981
|
-
/**
|
|
2982
|
-
* Forward a response to the connected native host if it is present.
|
|
2983
|
-
*
|
|
2984
|
-
* @param {BridgeResponse} response
|
|
2985
|
-
* @returns {void}
|
|
2986
|
-
*/
|
|
2987
|
-
function reply(response) {
|
|
2988
|
-
state.nativePort?.postMessage(response);
|
|
2989
|
-
}
|
|
2990
|
-
|
|
2991
|
-
/**
|
|
2992
|
-
* Broadcast a UI event to all connected extension surfaces.
|
|
2993
|
-
*
|
|
2994
|
-
* @param {Record<string, unknown>} message
|
|
2995
|
-
* @returns {void}
|
|
2996
|
-
*/
|
|
2997
|
-
function broadcastUi(message) {
|
|
2998
|
-
for (const port of state.uiPorts.keys()) {
|
|
2999
|
-
postToUiPort(port, message);
|
|
3000
|
-
}
|
|
3001
|
-
}
|
|
3002
|
-
|
|
3003
|
-
/**
|
|
3004
|
-
* Post a message to a UI surface, pruning the port if Chrome has already
|
|
3005
|
-
* disconnected it.
|
|
3006
|
-
*
|
|
3007
|
-
* @param {chrome.runtime.Port} port
|
|
3008
|
-
* @param {Record<string, unknown>} message
|
|
3009
|
-
* @returns {boolean}
|
|
3010
|
-
*/
|
|
3011
|
-
function postToUiPort(port, message) {
|
|
3012
|
-
try {
|
|
3013
|
-
port.postMessage(message);
|
|
3014
|
-
return true;
|
|
3015
|
-
} catch {
|
|
3016
|
-
state.uiPorts.delete(port);
|
|
3017
|
-
return false;
|
|
3018
|
-
}
|
|
3019
|
-
}
|
|
3020
|
-
|
|
3021
|
-
/**
|
|
3022
|
-
* Publish the current connection/session snapshot to the popup and side panel.
|
|
3023
|
-
*
|
|
3024
|
-
* @returns {Promise<void>}
|
|
3025
|
-
*/
|
|
3026
|
-
async function emitUiState() {
|
|
3027
|
-
await Promise.all([...state.uiPorts.keys()].map((port) => emitUiStateForPort(port)));
|
|
3028
|
-
}
|
|
3029
|
-
|
|
3030
|
-
/**
|
|
3031
|
-
* Publish the current connection and tab snapshot to one UI surface.
|
|
3032
|
-
*
|
|
3033
|
-
* @param {chrome.runtime.Port} port
|
|
3034
|
-
* @returns {Promise<void>}
|
|
3035
|
-
*/
|
|
3036
|
-
async function emitUiStateForPort(port) {
|
|
3037
|
-
const portState = state.uiPorts.get(port);
|
|
3038
|
-
if (!portState) {
|
|
3039
|
-
return;
|
|
3040
|
-
}
|
|
3041
|
-
|
|
3042
|
-
refreshSetupStatus();
|
|
3043
|
-
|
|
3044
|
-
const currentTab = portState.scopeTabId
|
|
3045
|
-
? await getTabState(portState.scopeTabId)
|
|
3046
|
-
: await getCurrentTabState();
|
|
3047
|
-
const scopedTabId = currentTab?.tabId ?? portState.scopeTabId ?? null;
|
|
3048
|
-
|
|
3049
|
-
postToUiPort(port, {
|
|
3050
|
-
type: 'state.sync',
|
|
3051
|
-
state: {
|
|
3052
|
-
nativeConnected: Boolean(state.nativePort),
|
|
3053
|
-
currentTab,
|
|
3054
|
-
setupStatus: state.setupStatus,
|
|
3055
|
-
setupStatusPending: state.setupStatusPending,
|
|
3056
|
-
setupStatusError: state.setupStatusError,
|
|
3057
|
-
setupInstallPendingKey: state.setupInstallPendingKey,
|
|
3058
|
-
setupInstallError: state.setupInstallError,
|
|
3059
|
-
actionLog: [...state.actionLog]
|
|
3060
|
-
.filter((entry) => scopedTabId == null || entry.tabId === scopedTabId)
|
|
3061
|
-
.reverse(),
|
|
3062
|
-
},
|
|
3063
|
-
});
|
|
3064
|
-
}
|
|
3065
|
-
|
|
3066
|
-
/**
|
|
3067
|
-
* @param {unknown} message
|
|
3068
|
-
* @returns {boolean}
|
|
3069
|
-
*/
|
|
3070
|
-
function handleHostStatusMessage(message) {
|
|
3071
|
-
if (!message || typeof message !== 'object') {
|
|
3072
|
-
return false;
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
const candidate = /** @type {Record<string, unknown>} */ (message);
|
|
3076
|
-
if (candidate.type === 'host.bridge_response') {
|
|
3077
|
-
const response =
|
|
3078
|
-
candidate.response && typeof candidate.response === 'object'
|
|
3079
|
-
? /** @type {BridgeResponse} */ (candidate.response)
|
|
3080
|
-
: null;
|
|
3081
|
-
if (response?.id === state.setupInstallPendingRequestId) {
|
|
3082
|
-
const action = state.setupInstallPendingAction;
|
|
3083
|
-
state.setupInstallPendingRequestId = null;
|
|
3084
|
-
state.setupInstallPendingAction = null;
|
|
3085
|
-
if (response.ok) {
|
|
3086
|
-
state.setupInstallError = null;
|
|
3087
|
-
if (action) {
|
|
3088
|
-
void appendActionLogEntry({
|
|
3089
|
-
method: getSetupActionMethodLabel(action),
|
|
3090
|
-
ok: true,
|
|
3091
|
-
summary: getSetupActionSuccessSummary(action),
|
|
3092
|
-
}).catch(reportAsyncError);
|
|
3093
|
-
}
|
|
3094
|
-
refreshSetupStatus(true);
|
|
3095
|
-
} else {
|
|
3096
|
-
state.setupInstallError = response.error.message;
|
|
3097
|
-
if (action) {
|
|
3098
|
-
void appendActionLogEntry({
|
|
3099
|
-
method: getSetupActionMethodLabel(action),
|
|
3100
|
-
ok: false,
|
|
3101
|
-
summary: getSetupActionErrorSummary(action, response.error.message),
|
|
3102
|
-
}).catch(reportAsyncError);
|
|
3103
|
-
}
|
|
3104
|
-
state.setupInstallPendingKey = null;
|
|
3105
|
-
}
|
|
3106
|
-
void emitUiState().catch(reportAsyncError);
|
|
3107
|
-
return true;
|
|
3108
|
-
}
|
|
3109
|
-
if (response?.id === state.setupStatusPendingRequestId) {
|
|
3110
|
-
clearSetupStatusTimer();
|
|
3111
|
-
state.setupStatusPending = false;
|
|
3112
|
-
state.setupStatusPendingRequestId = null;
|
|
3113
|
-
state.setupInstallPendingKey = null;
|
|
3114
|
-
if (response.ok) {
|
|
3115
|
-
state.setupStatus = isSetupStatus(response.result) ? response.result : null;
|
|
3116
|
-
state.setupStatusUpdatedAt = Date.now();
|
|
3117
|
-
state.setupStatusError = null;
|
|
3118
|
-
} else {
|
|
3119
|
-
state.setupStatusError = response.error.message;
|
|
3120
|
-
}
|
|
3121
|
-
void emitUiState().catch(reportAsyncError);
|
|
3122
|
-
}
|
|
3123
|
-
return true;
|
|
3124
|
-
}
|
|
3125
|
-
|
|
3126
|
-
if (candidate.type === 'host.bridge_error') {
|
|
3127
|
-
if (candidate.requestId === state.setupInstallPendingRequestId) {
|
|
3128
|
-
const action = state.setupInstallPendingAction;
|
|
3129
|
-
state.setupInstallPendingRequestId = null;
|
|
3130
|
-
state.setupInstallPendingAction = null;
|
|
3131
|
-
state.setupInstallPendingKey = null;
|
|
3132
|
-
state.setupInstallError =
|
|
3133
|
-
typeof candidate.error === 'object' &&
|
|
3134
|
-
candidate.error &&
|
|
3135
|
-
typeof (/** @type {Record<string, unknown>} */ (candidate.error).message) === 'string'
|
|
3136
|
-
? /** @type {Record<string, string>} */ (candidate.error).message
|
|
3137
|
-
: 'Could not install host setup.';
|
|
3138
|
-
if (action) {
|
|
3139
|
-
void appendActionLogEntry({
|
|
3140
|
-
method: getSetupActionMethodLabel(action),
|
|
3141
|
-
ok: false,
|
|
3142
|
-
summary: getSetupActionErrorSummary(action, state.setupInstallError),
|
|
3143
|
-
}).catch(reportAsyncError);
|
|
3144
|
-
}
|
|
3145
|
-
void emitUiState().catch(reportAsyncError);
|
|
3146
|
-
return true;
|
|
3147
|
-
}
|
|
3148
|
-
if (candidate.requestId === state.setupStatusPendingRequestId) {
|
|
3149
|
-
clearSetupStatusTimer();
|
|
3150
|
-
state.setupStatusPending = false;
|
|
3151
|
-
state.setupStatusPendingRequestId = null;
|
|
3152
|
-
state.setupInstallPendingAction = null;
|
|
3153
|
-
state.setupInstallPendingKey = null;
|
|
3154
|
-
state.setupStatusError =
|
|
3155
|
-
typeof candidate.error === 'object' &&
|
|
3156
|
-
candidate.error &&
|
|
3157
|
-
typeof (/** @type {Record<string, unknown>} */ (candidate.error).message) === 'string'
|
|
3158
|
-
? /** @type {Record<string, string>} */ (candidate.error).message
|
|
3159
|
-
: 'Could not inspect host setup.';
|
|
3160
|
-
void emitUiState().catch(reportAsyncError);
|
|
3161
|
-
}
|
|
3162
|
-
return true;
|
|
3163
|
-
}
|
|
3164
|
-
|
|
3165
|
-
if (candidate.type === 'host.setup_status.response') {
|
|
3166
|
-
if (candidate.requestId === state.setupStatusPendingRequestId) {
|
|
3167
|
-
clearSetupStatusTimer();
|
|
3168
|
-
state.setupStatus = isSetupStatus(candidate.status) ? candidate.status : null;
|
|
3169
|
-
state.setupStatusPending = false;
|
|
3170
|
-
state.setupStatusPendingRequestId = null;
|
|
3171
|
-
state.setupInstallPendingAction = null;
|
|
3172
|
-
state.setupInstallPendingKey = null;
|
|
3173
|
-
state.setupStatusUpdatedAt = Date.now();
|
|
3174
|
-
state.setupStatusError = null;
|
|
3175
|
-
void emitUiState().catch(reportAsyncError);
|
|
3176
|
-
}
|
|
3177
|
-
return true;
|
|
3178
|
-
}
|
|
3179
|
-
|
|
3180
|
-
if (candidate.type === 'host.setup_status.error') {
|
|
3181
|
-
if (candidate.requestId === state.setupStatusPendingRequestId) {
|
|
3182
|
-
clearSetupStatusTimer();
|
|
3183
|
-
state.setupStatusPending = false;
|
|
3184
|
-
state.setupStatusPendingRequestId = null;
|
|
3185
|
-
state.setupInstallPendingAction = null;
|
|
3186
|
-
state.setupInstallPendingKey = null;
|
|
3187
|
-
state.setupStatusError =
|
|
3188
|
-
typeof candidate.error === 'object' &&
|
|
3189
|
-
candidate.error &&
|
|
3190
|
-
typeof (/** @type {Record<string, unknown>} */ (candidate.error).message) === 'string'
|
|
3191
|
-
? /** @type {Record<string, string>} */ (candidate.error).message
|
|
3192
|
-
: 'Could not inspect host setup.';
|
|
3193
|
-
void emitUiState().catch(reportAsyncError);
|
|
3194
|
-
}
|
|
3195
|
-
return true;
|
|
3196
|
-
}
|
|
3197
|
-
|
|
3198
|
-
return false;
|
|
3199
|
-
}
|
|
3200
|
-
|
|
3201
|
-
/**
|
|
3202
|
-
* @param {boolean} [force=false]
|
|
3203
|
-
* @returns {void}
|
|
3204
|
-
*/
|
|
3205
|
-
function refreshSetupStatus(force = false) {
|
|
3206
|
-
if (!state.nativePort) {
|
|
3207
|
-
clearSetupStatus();
|
|
3208
|
-
return;
|
|
3209
|
-
}
|
|
3210
|
-
|
|
3211
|
-
const isFresh =
|
|
3212
|
-
state.setupStatusUpdatedAt > 0 &&
|
|
3213
|
-
Date.now() - state.setupStatusUpdatedAt < SETUP_STATUS_STALE_MS;
|
|
3214
|
-
if (state.setupStatusPending || (!force && isFresh && !state.setupStatusError)) {
|
|
3215
|
-
return;
|
|
3216
|
-
}
|
|
3217
|
-
|
|
3218
|
-
const requestId = crypto.randomUUID();
|
|
3219
|
-
state.setupStatusPending = true;
|
|
3220
|
-
state.setupStatusPendingRequestId = requestId;
|
|
3221
|
-
state.setupStatusError = null;
|
|
3222
|
-
clearSetupStatusTimer();
|
|
3223
|
-
state.nativePort.postMessage({
|
|
3224
|
-
type: 'host.bridge_request',
|
|
3225
|
-
request: createRequest({
|
|
3226
|
-
id: requestId,
|
|
3227
|
-
method: 'setup.get_status',
|
|
3228
|
-
}),
|
|
3229
|
-
});
|
|
3230
|
-
state.setupStatusTimeoutId = setTimeout(() => {
|
|
3231
|
-
if (state.setupStatusPendingRequestId !== requestId) {
|
|
3232
|
-
return;
|
|
3233
|
-
}
|
|
3234
|
-
state.setupStatusPending = false;
|
|
3235
|
-
state.setupStatusPendingRequestId = null;
|
|
3236
|
-
state.setupInstallPendingAction = null;
|
|
3237
|
-
state.setupInstallPendingKey = null;
|
|
3238
|
-
state.setupStatusError = 'Host setup request timed out.';
|
|
3239
|
-
state.setupStatusTimeoutId = null;
|
|
3240
|
-
void emitUiState().catch(reportAsyncError);
|
|
3241
|
-
}, SETUP_STATUS_TIMEOUT_MS);
|
|
3242
|
-
}
|
|
3243
|
-
|
|
3244
|
-
/**
|
|
3245
|
-
* @param {string | null} [errorMessage=null]
|
|
3246
|
-
* @returns {void}
|
|
3247
|
-
*/
|
|
3248
|
-
function clearSetupStatus(errorMessage = null) {
|
|
3249
|
-
clearSetupStatusTimer();
|
|
3250
|
-
state.setupStatus = null;
|
|
3251
|
-
state.setupStatusPending = false;
|
|
3252
|
-
state.setupStatusPendingRequestId = null;
|
|
3253
|
-
state.setupStatusUpdatedAt = 0;
|
|
3254
|
-
state.setupStatusError = errorMessage;
|
|
3255
|
-
state.setupInstallPendingRequestId = null;
|
|
3256
|
-
state.setupInstallPendingAction = null;
|
|
3257
|
-
state.setupInstallPendingKey = null;
|
|
3258
|
-
state.setupInstallError = null;
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
|
-
/**
|
|
3262
|
-
* @returns {void}
|
|
3263
|
-
*/
|
|
3264
|
-
function clearSetupStatusTimer() {
|
|
3265
|
-
if (!state.setupStatusTimeoutId) {
|
|
3266
|
-
return;
|
|
3267
|
-
}
|
|
3268
|
-
clearTimeout(state.setupStatusTimeoutId);
|
|
3269
|
-
state.setupStatusTimeoutId = null;
|
|
3270
|
-
}
|
|
3271
|
-
|
|
3272
|
-
/**
|
|
3273
|
-
* @param {unknown} value
|
|
3274
|
-
* @returns {value is SetupStatus}
|
|
3275
|
-
*/
|
|
3276
|
-
function isSetupStatus(value) {
|
|
3277
|
-
if (!value || typeof value !== 'object') {
|
|
3278
|
-
return false;
|
|
3279
|
-
}
|
|
3280
|
-
const candidate = /** @type {Record<string, unknown>} */ (value);
|
|
3281
|
-
return Array.isArray(candidate.mcpClients) && Array.isArray(candidate.skillTargets);
|
|
3282
|
-
}
|
|
3283
|
-
|
|
3284
|
-
/**
|
|
3285
|
-
* Handle commands coming from the popup or side panel.
|
|
3286
|
-
*
|
|
3287
|
-
* @param {chrome.runtime.Port} port
|
|
3288
|
-
* @param {Record<string, any>} message
|
|
3289
|
-
* @returns {Promise<void>}
|
|
3290
|
-
*/
|
|
3291
|
-
async function handleUiMessage(port, message) {
|
|
3292
|
-
if (message?.type === 'state.request') {
|
|
3293
|
-
const scopeTabId = Number(message.scopeTabId);
|
|
3294
|
-
const currentPortState = state.uiPorts.get(port);
|
|
3295
|
-
if (!currentPortState) {
|
|
3296
|
-
return;
|
|
3297
|
-
}
|
|
3298
|
-
state.uiPorts.set(port, {
|
|
3299
|
-
surface: currentPortState.surface,
|
|
3300
|
-
scopeTabId: Number.isFinite(scopeTabId) && scopeTabId > 0 ? scopeTabId : null,
|
|
3301
|
-
});
|
|
3302
|
-
refreshSetupStatus();
|
|
3303
|
-
await emitUiStateForPort(port);
|
|
3304
|
-
return;
|
|
3305
|
-
}
|
|
3306
|
-
|
|
3307
|
-
if (message?.type === 'setup.status.refresh') {
|
|
3308
|
-
refreshSetupStatus(true);
|
|
3309
|
-
await emitUiStateForPort(port);
|
|
3310
|
-
return;
|
|
3311
|
-
}
|
|
3312
|
-
|
|
3313
|
-
if (message?.type === 'scope.set_enabled') {
|
|
3314
|
-
const requestedTabId = Number(message.tabId);
|
|
3315
|
-
try {
|
|
3316
|
-
// ── DEBUG: simulate slow/error toggles. Set to "delay", "error", or "" ──
|
|
3317
|
-
const _TOGGLE_SIM = /** @type {'delay' | 'error' | ''} */ ('');
|
|
3318
|
-
if (_TOGGLE_SIM === 'delay') {
|
|
3319
|
-
await new Promise((r) => setTimeout(r, 6000));
|
|
3320
|
-
} else if (_TOGGLE_SIM === 'error') {
|
|
3321
|
-
if (Math.random() > 0.3) {
|
|
3322
|
-
throw new Error('Something went wrong.');
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3325
|
-
// ── END DEBUG ──
|
|
3326
|
-
if (Number.isFinite(requestedTabId) && requestedTabId > 0) {
|
|
3327
|
-
const tabState = await getTabState(requestedTabId);
|
|
3328
|
-
if (!tabState) {
|
|
3329
|
-
throw new Error(ERROR_CODES.TAB_MISMATCH);
|
|
3330
|
-
}
|
|
3331
|
-
await setWindowEnabled(tabState.windowId, tabState.title, Boolean(message.enabled));
|
|
3332
|
-
} else {
|
|
3333
|
-
await setCurrentWindowEnabled(Boolean(message.enabled));
|
|
3334
|
-
}
|
|
3335
|
-
} catch (error) {
|
|
3336
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3337
|
-
try {
|
|
3338
|
-
port.postMessage({ type: 'toggle.error', error: errorMessage });
|
|
3339
|
-
} catch {
|
|
3340
|
-
/* port may have disconnected */
|
|
3341
|
-
}
|
|
3342
|
-
throw error;
|
|
3343
|
-
}
|
|
3344
|
-
return;
|
|
3345
|
-
}
|
|
3346
|
-
|
|
3347
|
-
if (message?.type === 'setup.install') {
|
|
3348
|
-
await handleSetupInstallAction(message);
|
|
3349
|
-
}
|
|
3350
|
-
}
|
|
3351
|
-
|
|
3352
|
-
/**
|
|
3353
|
-
* @param {Record<string, unknown>} message
|
|
3354
|
-
* @returns {Promise<void>}
|
|
3355
|
-
*/
|
|
3356
|
-
async function handleSetupInstallAction(message) {
|
|
3357
|
-
if (!state.nativePort) {
|
|
3358
|
-
state.setupInstallError = 'Native host is not connected.';
|
|
3359
|
-
await appendActionLogEntry({
|
|
3360
|
-
method: 'Host setup',
|
|
3361
|
-
ok: false,
|
|
3362
|
-
summary: 'Install failed: Native host is not connected.',
|
|
3363
|
-
});
|
|
3364
|
-
await emitUiState();
|
|
3365
|
-
return;
|
|
3366
|
-
}
|
|
3367
|
-
if (state.setupInstallPendingRequestId) {
|
|
3368
|
-
return;
|
|
3369
|
-
}
|
|
3370
|
-
const action = normalizeSetupInstallAction(message);
|
|
3371
|
-
const requestId = crypto.randomUUID();
|
|
3372
|
-
state.setupInstallPendingRequestId = requestId;
|
|
3373
|
-
state.setupInstallPendingAction = action;
|
|
3374
|
-
state.setupInstallPendingKey = getSetupInstallKey(action);
|
|
3375
|
-
state.setupInstallError = null;
|
|
3376
|
-
await appendActionLogEntry({
|
|
3377
|
-
method: getSetupActionMethodLabel(action),
|
|
3378
|
-
ok: true,
|
|
3379
|
-
summary: getSetupActionStartSummary(action),
|
|
3380
|
-
});
|
|
3381
|
-
state.nativePort.postMessage({
|
|
3382
|
-
type: 'host.bridge_request',
|
|
3383
|
-
request: createRequest({
|
|
3384
|
-
id: requestId,
|
|
3385
|
-
method: 'setup.install',
|
|
3386
|
-
params: action,
|
|
3387
|
-
}),
|
|
3388
|
-
});
|
|
3389
|
-
await emitUiState();
|
|
3390
|
-
}
|
|
3391
|
-
|
|
3392
|
-
/**
|
|
3393
|
-
* @param {Record<string, unknown>} message
|
|
3394
|
-
* @returns {SetupInstallAction}
|
|
3395
|
-
*/
|
|
3396
|
-
function normalizeSetupInstallAction(message) {
|
|
3397
|
-
const action = message.action === 'uninstall' ? 'uninstall' : 'install';
|
|
3398
|
-
const kind = message.kind === 'skill' ? 'skill' : message.kind === 'mcp' ? 'mcp' : null;
|
|
3399
|
-
const target = typeof message.target === 'string' ? message.target.trim().toLowerCase() : '';
|
|
3400
|
-
if (!kind || !target) {
|
|
3401
|
-
throw new Error(ERROR_CODES.INVALID_REQUEST);
|
|
3402
|
-
}
|
|
3403
|
-
return { action, kind, target };
|
|
3404
|
-
}
|
|
3405
|
-
|
|
3406
|
-
/**
|
|
3407
|
-
* @param {SetupInstallAction} action
|
|
3408
|
-
* @returns {string}
|
|
3409
|
-
*/
|
|
3410
|
-
function getSetupInstallKey(action) {
|
|
3411
|
-
return `${action.kind}:${action.target}`;
|
|
3412
|
-
}
|
|
3413
|
-
|
|
3414
|
-
/**
|
|
3415
|
-
* @param {SetupInstallAction} action
|
|
3416
|
-
* @returns {string}
|
|
3417
|
-
*/
|
|
3418
|
-
function getSetupActionMethodLabel(action) {
|
|
3419
|
-
return action.kind === 'mcp' ? 'Host setup: MCP' : 'Host setup: Skills';
|
|
3420
|
-
}
|
|
3421
|
-
|
|
3422
|
-
/**
|
|
3423
|
-
* @param {SetupInstallAction} action
|
|
3424
|
-
* @returns {string}
|
|
3425
|
-
*/
|
|
3426
|
-
function getSetupActionTargetLabel(action) {
|
|
3427
|
-
return action.target;
|
|
3428
|
-
}
|
|
3429
|
-
|
|
3430
|
-
/**
|
|
3431
|
-
* @param {SetupInstallAction} action
|
|
3432
|
-
* @returns {string}
|
|
3433
|
-
*/
|
|
3434
|
-
function getSetupActionStartSummary(action) {
|
|
3435
|
-
const verb = action.action === 'uninstall' ? 'Removing' : 'Installing';
|
|
3436
|
-
return `${verb} ${action.kind.toUpperCase()} for ${getSetupActionTargetLabel(action)}…`;
|
|
3437
|
-
}
|
|
3438
|
-
|
|
3439
|
-
/**
|
|
3440
|
-
* @param {SetupInstallAction} action
|
|
3441
|
-
* @returns {string}
|
|
3442
|
-
*/
|
|
3443
|
-
function getSetupActionSuccessSummary(action) {
|
|
3444
|
-
const verb = action.action === 'uninstall' ? 'Removed' : 'Installed';
|
|
3445
|
-
return `${verb} ${action.kind.toUpperCase()} for ${getSetupActionTargetLabel(action)}.`;
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3448
|
-
/**
|
|
3449
|
-
* @param {SetupInstallAction} action
|
|
3450
|
-
* @param {string} message
|
|
3451
|
-
* @returns {string}
|
|
3452
|
-
*/
|
|
3453
|
-
function getSetupActionErrorSummary(action, message) {
|
|
3454
|
-
const verb = action.action === 'uninstall' ? 'Removal' : 'Install';
|
|
3455
|
-
return `${verb} failed for ${action.kind.toUpperCase()} on ${getSetupActionTargetLabel(action)}: ${message}`;
|
|
3456
|
-
}
|
|
3457
|
-
|
|
3458
|
-
/**
|
|
3459
|
-
* Configure and open the side panel for a single tab so the panel is attached
|
|
3460
|
-
* to the current tab instead of acting like a window-global surface.
|
|
3461
|
-
*
|
|
3462
|
-
* @param {number} tabId
|
|
3463
|
-
* @param {number} windowId
|
|
3464
|
-
* @returns {Promise<void>}
|
|
3465
|
-
*/
|
|
3466
|
-
async function openSidePanelForTab(tabId, windowId) {
|
|
3467
|
-
await chrome.sidePanel.setOptions({
|
|
3468
|
-
tabId,
|
|
3469
|
-
path: `${SIDEPANEL_PATH}?tabId=${encodeURIComponent(String(tabId))}`,
|
|
3470
|
-
enabled: true,
|
|
3471
|
-
});
|
|
3472
|
-
await chrome.sidePanel.open({
|
|
3473
|
-
tabId,
|
|
3474
|
-
windowId,
|
|
3475
|
-
});
|
|
3476
|
-
}
|
|
3477
|
-
|
|
3478
|
-
/**
|
|
3479
|
-
* Keep fire-and-forget async listener failures out of the browser's uncaught
|
|
3480
|
-
* promise surface so extension errors stay actionable and structured.
|
|
3481
|
-
*
|
|
3482
|
-
* @param {unknown} error
|
|
3483
|
-
* @returns {void}
|
|
3484
|
-
*/
|
|
3485
|
-
function reportAsyncError(error) {
|
|
3486
|
-
if (normalizeRuntimeErrorMessage(getErrorMessage(error)) === ERROR_CODES.TAB_MISMATCH) {
|
|
3487
|
-
return;
|
|
3488
|
-
}
|
|
3489
|
-
console.error(error);
|
|
3490
|
-
}
|