@flrande/bak-extension 0.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/dist/background.global.js +623 -0
- package/dist/content.global.js +2042 -0
- package/dist/manifest.json +20 -0
- package/dist/popup.global.js +52 -0
- package/dist/popup.html +106 -0
- package/package.json +18 -0
- package/public/manifest.json +20 -0
- package/public/popup.html +106 -0
- package/scripts/copy-assets.mjs +16 -0
- package/src/background.ts +705 -0
- package/src/content.ts +2267 -0
- package/src/limitations.ts +38 -0
- package/src/popup.ts +65 -0
- package/src/privacy.ts +135 -0
- package/src/reconnect.ts +25 -0
- package/src/url-policy.ts +12 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// src/url-policy.ts
|
|
4
|
+
function isSupportedAutomationUrl(url) {
|
|
5
|
+
if (typeof url !== "string" || !url) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const parsed = new URL(url);
|
|
10
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/reconnect.ts
|
|
17
|
+
var DEFAULT_BASE_DELAY_MS = 1500;
|
|
18
|
+
var DEFAULT_MAX_DELAY_MS = 15e3;
|
|
19
|
+
function clampNonNegative(value) {
|
|
20
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
return Math.floor(value);
|
|
24
|
+
}
|
|
25
|
+
function computeReconnectDelayMs(attempt, options = {}) {
|
|
26
|
+
const baseDelayMs = Math.max(100, clampNonNegative(options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS));
|
|
27
|
+
const maxDelayMs = Math.max(baseDelayMs, clampNonNegative(options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS));
|
|
28
|
+
const normalizedAttempt = Number.isFinite(attempt) ? Math.floor(attempt) : 0;
|
|
29
|
+
const safeAttempt = Math.max(0, Math.min(10, normalizedAttempt));
|
|
30
|
+
return Math.min(maxDelayMs, baseDelayMs * 2 ** safeAttempt);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/background.ts
|
|
34
|
+
var DEFAULT_PORT = 17373;
|
|
35
|
+
var STORAGE_KEY_TOKEN = "pairToken";
|
|
36
|
+
var STORAGE_KEY_PORT = "cliPort";
|
|
37
|
+
var STORAGE_KEY_DEBUG_RICH_TEXT = "debugRichText";
|
|
38
|
+
var DEFAULT_TAB_LOAD_TIMEOUT_MS = 4e4;
|
|
39
|
+
var ws = null;
|
|
40
|
+
var reconnectTimer = null;
|
|
41
|
+
var nextReconnectInMs = null;
|
|
42
|
+
var reconnectAttempt = 0;
|
|
43
|
+
var lastError = null;
|
|
44
|
+
var manualDisconnect = false;
|
|
45
|
+
async function getConfig() {
|
|
46
|
+
const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
|
|
47
|
+
return {
|
|
48
|
+
token: typeof stored[STORAGE_KEY_TOKEN] === "string" ? stored[STORAGE_KEY_TOKEN] : "",
|
|
49
|
+
port: typeof stored[STORAGE_KEY_PORT] === "number" ? stored[STORAGE_KEY_PORT] : DEFAULT_PORT,
|
|
50
|
+
debugRichText: stored[STORAGE_KEY_DEBUG_RICH_TEXT] === true
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function setConfig(config) {
|
|
54
|
+
const payload = {};
|
|
55
|
+
if (typeof config.token === "string") {
|
|
56
|
+
payload[STORAGE_KEY_TOKEN] = config.token;
|
|
57
|
+
}
|
|
58
|
+
if (typeof config.port === "number") {
|
|
59
|
+
payload[STORAGE_KEY_PORT] = config.port;
|
|
60
|
+
}
|
|
61
|
+
if (typeof config.debugRichText === "boolean") {
|
|
62
|
+
payload[STORAGE_KEY_DEBUG_RICH_TEXT] = config.debugRichText;
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(payload).length > 0) {
|
|
65
|
+
await chrome.storage.local.set(payload);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function setRuntimeError(message, context) {
|
|
69
|
+
lastError = {
|
|
70
|
+
message,
|
|
71
|
+
context,
|
|
72
|
+
at: Date.now()
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function clearReconnectTimer() {
|
|
76
|
+
if (reconnectTimer !== null) {
|
|
77
|
+
clearTimeout(reconnectTimer);
|
|
78
|
+
reconnectTimer = null;
|
|
79
|
+
}
|
|
80
|
+
nextReconnectInMs = null;
|
|
81
|
+
}
|
|
82
|
+
function sendResponse(payload) {
|
|
83
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
84
|
+
ws.send(JSON.stringify(payload));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function toError(code, message, data) {
|
|
88
|
+
return { code, message, data };
|
|
89
|
+
}
|
|
90
|
+
function normalizeUnhandledError(error) {
|
|
91
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
92
|
+
return error;
|
|
93
|
+
}
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
const lower = message.toLowerCase();
|
|
96
|
+
if (lower.includes("no tab with id") || lower.includes("no window with id")) {
|
|
97
|
+
return toError("E_NOT_FOUND", message);
|
|
98
|
+
}
|
|
99
|
+
if (lower.includes("invalid url") || lower.includes("url is invalid")) {
|
|
100
|
+
return toError("E_INVALID_PARAMS", message);
|
|
101
|
+
}
|
|
102
|
+
if (lower.includes("cannot access contents of url") || lower.includes("permission denied")) {
|
|
103
|
+
return toError("E_PERMISSION", message);
|
|
104
|
+
}
|
|
105
|
+
return toError("E_INTERNAL", message);
|
|
106
|
+
}
|
|
107
|
+
async function waitForTabComplete(tabId, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS) {
|
|
108
|
+
try {
|
|
109
|
+
const current = await chrome.tabs.get(tabId);
|
|
110
|
+
if (current.status === "complete") {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
let done = false;
|
|
118
|
+
const probeStatus = () => {
|
|
119
|
+
void chrome.tabs.get(tabId).then((tab) => {
|
|
120
|
+
if (tab.status === "complete") {
|
|
121
|
+
finish();
|
|
122
|
+
}
|
|
123
|
+
}).catch(() => {
|
|
124
|
+
finish(new Error(`tab removed before load complete: ${tabId}`));
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
const finish = (error) => {
|
|
128
|
+
if (done) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
done = true;
|
|
132
|
+
clearTimeout(timeoutTimer);
|
|
133
|
+
clearInterval(pollTimer);
|
|
134
|
+
chrome.tabs.onUpdated.removeListener(onUpdated);
|
|
135
|
+
chrome.tabs.onRemoved.removeListener(onRemoved);
|
|
136
|
+
if (error) {
|
|
137
|
+
reject(error);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
resolve();
|
|
141
|
+
};
|
|
142
|
+
const onUpdated = (updatedTabId, changeInfo) => {
|
|
143
|
+
if (updatedTabId !== tabId) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (changeInfo.status === "complete") {
|
|
147
|
+
finish();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const onRemoved = (removedTabId) => {
|
|
151
|
+
if (removedTabId === tabId) {
|
|
152
|
+
finish(new Error(`tab removed before load complete: ${tabId}`));
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const pollTimer = setInterval(probeStatus, 250);
|
|
156
|
+
const timeoutTimer = setTimeout(() => {
|
|
157
|
+
finish(new Error(`tab load timeout: ${tabId}`));
|
|
158
|
+
}, timeoutMs);
|
|
159
|
+
chrome.tabs.onUpdated.addListener(onUpdated);
|
|
160
|
+
chrome.tabs.onRemoved.addListener(onRemoved);
|
|
161
|
+
probeStatus();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async function withTab(tabId, options = {}) {
|
|
165
|
+
const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
|
|
166
|
+
const validate = (tab2) => {
|
|
167
|
+
if (!tab2.id) {
|
|
168
|
+
throw toError("E_NOT_FOUND", "Tab missing id");
|
|
169
|
+
}
|
|
170
|
+
if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab2.url)) {
|
|
171
|
+
throw toError("E_PERMISSION", "Unsupported tab URL: only http/https pages can be automated", {
|
|
172
|
+
url: tab2.url ?? ""
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return tab2;
|
|
176
|
+
};
|
|
177
|
+
if (typeof tabId === "number") {
|
|
178
|
+
const tab2 = await chrome.tabs.get(tabId);
|
|
179
|
+
return validate(tab2);
|
|
180
|
+
}
|
|
181
|
+
const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
182
|
+
const tab = tabs[0];
|
|
183
|
+
if (!tab) {
|
|
184
|
+
throw toError("E_NOT_FOUND", "No active tab");
|
|
185
|
+
}
|
|
186
|
+
return validate(tab);
|
|
187
|
+
}
|
|
188
|
+
async function captureAlignedTabScreenshot(tab) {
|
|
189
|
+
if (typeof tab.id !== "number" || typeof tab.windowId !== "number") {
|
|
190
|
+
throw toError("E_NOT_FOUND", "Tab screenshot requires tab id and window id");
|
|
191
|
+
}
|
|
192
|
+
const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true });
|
|
193
|
+
const activeTab = activeTabs[0];
|
|
194
|
+
const shouldSwitch = activeTab?.id !== tab.id;
|
|
195
|
+
if (shouldSwitch) {
|
|
196
|
+
await chrome.tabs.update(tab.id, { active: true });
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
|
|
201
|
+
} finally {
|
|
202
|
+
if (shouldSwitch && typeof activeTab?.id === "number") {
|
|
203
|
+
try {
|
|
204
|
+
await chrome.tabs.update(activeTab.id, { active: true });
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function sendToContent(tabId, message) {
|
|
211
|
+
const maxAttempts = 6;
|
|
212
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
213
|
+
try {
|
|
214
|
+
const response = await chrome.tabs.sendMessage(tabId, message);
|
|
215
|
+
if (typeof response === "undefined") {
|
|
216
|
+
throw new Error("Content script returned undefined response");
|
|
217
|
+
}
|
|
218
|
+
return response;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
221
|
+
const retriable = detail.includes("Receiving end does not exist") || detail.includes("Could not establish connection") || detail.includes("No tab with id") || detail.includes("message port closed before a response was received") || detail.includes("Content script returned undefined response");
|
|
222
|
+
if (!retriable || attempt >= maxAttempts) {
|
|
223
|
+
throw toError("E_NOT_READY", "Content script unavailable", { detail });
|
|
224
|
+
}
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 150 * attempt));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw toError("E_NOT_READY", "Content script unavailable");
|
|
229
|
+
}
|
|
230
|
+
function requireRpcEnvelope(method, value) {
|
|
231
|
+
if (typeof value !== "object" || value === null || typeof value.ok !== "boolean") {
|
|
232
|
+
throw toError("E_NOT_READY", `Content script returned malformed response for ${method}`);
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
async function forwardContentRpc(tabId, method, params) {
|
|
237
|
+
const raw = await sendToContent(tabId, {
|
|
238
|
+
type: "bak.rpc",
|
|
239
|
+
method,
|
|
240
|
+
params
|
|
241
|
+
});
|
|
242
|
+
const response = requireRpcEnvelope(method, raw);
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
throw response.error ?? toError("E_INTERNAL", `${method} failed`);
|
|
245
|
+
}
|
|
246
|
+
return response.result;
|
|
247
|
+
}
|
|
248
|
+
async function handleRequest(request) {
|
|
249
|
+
const params = request.params ?? {};
|
|
250
|
+
const rpcForwardMethods = /* @__PURE__ */ new Set([
|
|
251
|
+
"page.title",
|
|
252
|
+
"page.url",
|
|
253
|
+
"page.text",
|
|
254
|
+
"page.dom",
|
|
255
|
+
"page.accessibilityTree",
|
|
256
|
+
"page.scrollTo",
|
|
257
|
+
"page.metrics",
|
|
258
|
+
"element.hover",
|
|
259
|
+
"element.doubleClick",
|
|
260
|
+
"element.rightClick",
|
|
261
|
+
"element.dragDrop",
|
|
262
|
+
"element.select",
|
|
263
|
+
"element.check",
|
|
264
|
+
"element.uncheck",
|
|
265
|
+
"element.scrollIntoView",
|
|
266
|
+
"element.focus",
|
|
267
|
+
"element.blur",
|
|
268
|
+
"element.get",
|
|
269
|
+
"keyboard.press",
|
|
270
|
+
"keyboard.type",
|
|
271
|
+
"keyboard.hotkey",
|
|
272
|
+
"mouse.move",
|
|
273
|
+
"mouse.click",
|
|
274
|
+
"mouse.wheel",
|
|
275
|
+
"file.upload",
|
|
276
|
+
"context.enterFrame",
|
|
277
|
+
"context.exitFrame",
|
|
278
|
+
"context.enterShadow",
|
|
279
|
+
"context.exitShadow",
|
|
280
|
+
"context.reset",
|
|
281
|
+
"network.list",
|
|
282
|
+
"network.get",
|
|
283
|
+
"network.waitFor",
|
|
284
|
+
"network.clear",
|
|
285
|
+
"debug.dumpState"
|
|
286
|
+
]);
|
|
287
|
+
switch (request.method) {
|
|
288
|
+
case "session.ping": {
|
|
289
|
+
return { ok: true, ts: Date.now() };
|
|
290
|
+
}
|
|
291
|
+
case "tabs.list": {
|
|
292
|
+
const tabs = await chrome.tabs.query({});
|
|
293
|
+
return {
|
|
294
|
+
tabs: tabs.filter((tab) => typeof tab.id === "number").map((tab) => ({
|
|
295
|
+
id: tab.id,
|
|
296
|
+
title: tab.title ?? "",
|
|
297
|
+
url: tab.url ?? "",
|
|
298
|
+
active: tab.active
|
|
299
|
+
}))
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
case "tabs.getActive": {
|
|
303
|
+
const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
304
|
+
const tab = tabs[0];
|
|
305
|
+
if (!tab || typeof tab.id !== "number") {
|
|
306
|
+
return { tab: null };
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
tab: {
|
|
310
|
+
id: tab.id,
|
|
311
|
+
title: tab.title ?? "",
|
|
312
|
+
url: tab.url ?? "",
|
|
313
|
+
active: Boolean(tab.active)
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
case "tabs.get": {
|
|
318
|
+
const tabId = Number(params.tabId);
|
|
319
|
+
const tab = await chrome.tabs.get(tabId);
|
|
320
|
+
if (typeof tab.id !== "number") {
|
|
321
|
+
throw toError("E_NOT_FOUND", "Tab missing id");
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
tab: {
|
|
325
|
+
id: tab.id,
|
|
326
|
+
title: tab.title ?? "",
|
|
327
|
+
url: tab.url ?? "",
|
|
328
|
+
active: Boolean(tab.active)
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
case "tabs.focus": {
|
|
333
|
+
const tabId = Number(params.tabId);
|
|
334
|
+
await chrome.tabs.update(tabId, { active: true });
|
|
335
|
+
return { ok: true };
|
|
336
|
+
}
|
|
337
|
+
case "tabs.new": {
|
|
338
|
+
const tab = await chrome.tabs.create({ url: params.url ?? "about:blank" });
|
|
339
|
+
return { tabId: tab.id };
|
|
340
|
+
}
|
|
341
|
+
case "tabs.close": {
|
|
342
|
+
const tabId = Number(params.tabId);
|
|
343
|
+
await chrome.tabs.remove(tabId);
|
|
344
|
+
return { ok: true };
|
|
345
|
+
}
|
|
346
|
+
case "page.goto": {
|
|
347
|
+
const tab = await withTab(params.tabId, {
|
|
348
|
+
requireSupportedAutomationUrl: false
|
|
349
|
+
});
|
|
350
|
+
await chrome.tabs.update(tab.id, { url: String(params.url ?? "about:blank") });
|
|
351
|
+
await waitForTabComplete(tab.id);
|
|
352
|
+
return { ok: true };
|
|
353
|
+
}
|
|
354
|
+
case "page.back": {
|
|
355
|
+
const tab = await withTab(params.tabId);
|
|
356
|
+
await chrome.tabs.goBack(tab.id);
|
|
357
|
+
await waitForTabComplete(tab.id);
|
|
358
|
+
return { ok: true };
|
|
359
|
+
}
|
|
360
|
+
case "page.forward": {
|
|
361
|
+
const tab = await withTab(params.tabId);
|
|
362
|
+
await chrome.tabs.goForward(tab.id);
|
|
363
|
+
await waitForTabComplete(tab.id);
|
|
364
|
+
return { ok: true };
|
|
365
|
+
}
|
|
366
|
+
case "page.reload": {
|
|
367
|
+
const tab = await withTab(params.tabId);
|
|
368
|
+
await chrome.tabs.reload(tab.id);
|
|
369
|
+
await waitForTabComplete(tab.id);
|
|
370
|
+
return { ok: true };
|
|
371
|
+
}
|
|
372
|
+
case "page.viewport": {
|
|
373
|
+
const tab = await withTab(params.tabId, {
|
|
374
|
+
requireSupportedAutomationUrl: false
|
|
375
|
+
});
|
|
376
|
+
if (typeof tab.windowId !== "number") {
|
|
377
|
+
throw toError("E_NOT_FOUND", "Tab window unavailable");
|
|
378
|
+
}
|
|
379
|
+
const width = typeof params.width === "number" ? Math.max(320, Math.floor(params.width)) : void 0;
|
|
380
|
+
const height = typeof params.height === "number" ? Math.max(320, Math.floor(params.height)) : void 0;
|
|
381
|
+
if (width || height) {
|
|
382
|
+
await chrome.windows.update(tab.windowId, {
|
|
383
|
+
width,
|
|
384
|
+
height
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const viewport = await forwardContentRpc(tab.id, "page.viewport", {});
|
|
388
|
+
const viewWidth = typeof width === "number" ? width : viewport.width ?? tab.width ?? 0;
|
|
389
|
+
const viewHeight = typeof height === "number" ? height : viewport.height ?? tab.height ?? 0;
|
|
390
|
+
return {
|
|
391
|
+
width: viewWidth,
|
|
392
|
+
height: viewHeight,
|
|
393
|
+
devicePixelRatio: viewport.devicePixelRatio
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
case "page.snapshot": {
|
|
397
|
+
const tab = await withTab(params.tabId);
|
|
398
|
+
if (typeof tab.id !== "number" || typeof tab.windowId !== "number") {
|
|
399
|
+
throw toError("E_NOT_FOUND", "Tab missing id");
|
|
400
|
+
}
|
|
401
|
+
const includeBase64 = params.includeBase64 !== false;
|
|
402
|
+
const config = await getConfig();
|
|
403
|
+
const elements = await sendToContent(tab.id, {
|
|
404
|
+
type: "bak.collectElements",
|
|
405
|
+
debugRichText: config.debugRichText
|
|
406
|
+
});
|
|
407
|
+
const imageData = await captureAlignedTabScreenshot(tab);
|
|
408
|
+
return {
|
|
409
|
+
imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, "") : "",
|
|
410
|
+
elements: elements.elements,
|
|
411
|
+
tabId: tab.id,
|
|
412
|
+
url: tab.url ?? ""
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
case "element.click": {
|
|
416
|
+
const tab = await withTab(params.tabId);
|
|
417
|
+
const response = await sendToContent(tab.id, {
|
|
418
|
+
type: "bak.performAction",
|
|
419
|
+
action: "click",
|
|
420
|
+
locator: params.locator,
|
|
421
|
+
requiresConfirm: params.requiresConfirm === true
|
|
422
|
+
});
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
throw response.error ?? toError("E_INTERNAL", "element.click failed");
|
|
425
|
+
}
|
|
426
|
+
return { ok: true };
|
|
427
|
+
}
|
|
428
|
+
case "element.type": {
|
|
429
|
+
const tab = await withTab(params.tabId);
|
|
430
|
+
const response = await sendToContent(tab.id, {
|
|
431
|
+
type: "bak.performAction",
|
|
432
|
+
action: "type",
|
|
433
|
+
locator: params.locator,
|
|
434
|
+
text: String(params.text ?? ""),
|
|
435
|
+
clear: Boolean(params.clear),
|
|
436
|
+
requiresConfirm: params.requiresConfirm === true
|
|
437
|
+
});
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
throw response.error ?? toError("E_INTERNAL", "element.type failed");
|
|
440
|
+
}
|
|
441
|
+
return { ok: true };
|
|
442
|
+
}
|
|
443
|
+
case "element.scroll": {
|
|
444
|
+
const tab = await withTab(params.tabId);
|
|
445
|
+
const response = await sendToContent(tab.id, {
|
|
446
|
+
type: "bak.performAction",
|
|
447
|
+
action: "scroll",
|
|
448
|
+
locator: params.locator,
|
|
449
|
+
dx: Number(params.dx ?? 0),
|
|
450
|
+
dy: Number(params.dy ?? 320)
|
|
451
|
+
});
|
|
452
|
+
if (!response.ok) {
|
|
453
|
+
throw response.error ?? toError("E_INTERNAL", "element.scroll failed");
|
|
454
|
+
}
|
|
455
|
+
return { ok: true };
|
|
456
|
+
}
|
|
457
|
+
case "page.wait": {
|
|
458
|
+
const tab = await withTab(params.tabId);
|
|
459
|
+
const response = await sendToContent(tab.id, {
|
|
460
|
+
type: "bak.waitFor",
|
|
461
|
+
mode: String(params.mode ?? "selector"),
|
|
462
|
+
value: String(params.value ?? ""),
|
|
463
|
+
timeoutMs: Number(params.timeoutMs ?? 5e3)
|
|
464
|
+
});
|
|
465
|
+
if (!response.ok) {
|
|
466
|
+
throw response.error ?? toError("E_TIMEOUT", "page.wait failed");
|
|
467
|
+
}
|
|
468
|
+
return { ok: true };
|
|
469
|
+
}
|
|
470
|
+
case "debug.getConsole": {
|
|
471
|
+
const tab = await withTab(params.tabId);
|
|
472
|
+
const response = await sendToContent(tab.id, {
|
|
473
|
+
type: "bak.getConsole",
|
|
474
|
+
limit: Number(params.limit ?? 50)
|
|
475
|
+
});
|
|
476
|
+
return { entries: response.entries };
|
|
477
|
+
}
|
|
478
|
+
case "ui.selectCandidate": {
|
|
479
|
+
const tab = await withTab(params.tabId);
|
|
480
|
+
const response = await sendToContent(
|
|
481
|
+
tab.id,
|
|
482
|
+
{
|
|
483
|
+
type: "bak.selectCandidate",
|
|
484
|
+
candidates: params.candidates
|
|
485
|
+
}
|
|
486
|
+
);
|
|
487
|
+
if (!response.ok || !response.selectedEid) {
|
|
488
|
+
throw response.error ?? toError("E_NEED_USER_CONFIRM", "User did not confirm candidate");
|
|
489
|
+
}
|
|
490
|
+
return { selectedEid: response.selectedEid };
|
|
491
|
+
}
|
|
492
|
+
default:
|
|
493
|
+
if (rpcForwardMethods.has(request.method)) {
|
|
494
|
+
const tab = await withTab(params.tabId);
|
|
495
|
+
return forwardContentRpc(tab.id, request.method, params);
|
|
496
|
+
}
|
|
497
|
+
throw toError("E_NOT_FOUND", `Unsupported method from CLI bridge: ${request.method}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function scheduleReconnect(reason) {
|
|
501
|
+
if (manualDisconnect) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (reconnectTimer !== null) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const delayMs = computeReconnectDelayMs(reconnectAttempt);
|
|
508
|
+
reconnectAttempt += 1;
|
|
509
|
+
nextReconnectInMs = delayMs;
|
|
510
|
+
reconnectTimer = setTimeout(() => {
|
|
511
|
+
reconnectTimer = null;
|
|
512
|
+
nextReconnectInMs = null;
|
|
513
|
+
void connectWebSocket();
|
|
514
|
+
}, delayMs);
|
|
515
|
+
if (!lastError) {
|
|
516
|
+
setRuntimeError(`Reconnect scheduled: ${reason}`, "socket");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function connectWebSocket() {
|
|
520
|
+
clearReconnectTimer();
|
|
521
|
+
if (manualDisconnect) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const config = await getConfig();
|
|
528
|
+
if (!config.token) {
|
|
529
|
+
setRuntimeError("Pair token is empty", "config");
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const url = `ws://127.0.0.1:${config.port}/extension?token=${encodeURIComponent(config.token)}`;
|
|
533
|
+
ws = new WebSocket(url);
|
|
534
|
+
ws.addEventListener("open", () => {
|
|
535
|
+
manualDisconnect = false;
|
|
536
|
+
reconnectAttempt = 0;
|
|
537
|
+
lastError = null;
|
|
538
|
+
ws?.send(JSON.stringify({
|
|
539
|
+
type: "hello",
|
|
540
|
+
role: "extension",
|
|
541
|
+
version: "0.1.0",
|
|
542
|
+
ts: Date.now()
|
|
543
|
+
}));
|
|
544
|
+
});
|
|
545
|
+
ws.addEventListener("message", (event) => {
|
|
546
|
+
try {
|
|
547
|
+
const request = JSON.parse(String(event.data));
|
|
548
|
+
if (!request.id || !request.method) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
void handleRequest(request).then((result) => {
|
|
552
|
+
sendResponse({ id: request.id, ok: true, result });
|
|
553
|
+
}).catch((error) => {
|
|
554
|
+
const normalized = normalizeUnhandledError(error);
|
|
555
|
+
sendResponse({ id: request.id, ok: false, error: normalized });
|
|
556
|
+
});
|
|
557
|
+
} catch (error) {
|
|
558
|
+
setRuntimeError(error instanceof Error ? error.message : String(error), "parse");
|
|
559
|
+
sendResponse({
|
|
560
|
+
id: "parse-error",
|
|
561
|
+
ok: false,
|
|
562
|
+
error: toError("E_INTERNAL", error instanceof Error ? error.message : String(error))
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
ws.addEventListener("close", () => {
|
|
567
|
+
ws = null;
|
|
568
|
+
scheduleReconnect("socket-closed");
|
|
569
|
+
});
|
|
570
|
+
ws.addEventListener("error", () => {
|
|
571
|
+
setRuntimeError("Cannot connect to bak cli", "socket");
|
|
572
|
+
ws?.close();
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
576
|
+
void setConfig({ port: DEFAULT_PORT, debugRichText: false });
|
|
577
|
+
});
|
|
578
|
+
chrome.runtime.onStartup.addListener(() => {
|
|
579
|
+
void connectWebSocket();
|
|
580
|
+
});
|
|
581
|
+
void connectWebSocket();
|
|
582
|
+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse2) => {
|
|
583
|
+
if (message?.type === "bak.updateConfig") {
|
|
584
|
+
manualDisconnect = false;
|
|
585
|
+
void setConfig({
|
|
586
|
+
token: message.token,
|
|
587
|
+
port: Number(message.port ?? DEFAULT_PORT),
|
|
588
|
+
debugRichText: message.debugRichText === true
|
|
589
|
+
}).then(() => {
|
|
590
|
+
ws?.close();
|
|
591
|
+
void connectWebSocket().then(() => sendResponse2({ ok: true }));
|
|
592
|
+
});
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
if (message?.type === "bak.getState") {
|
|
596
|
+
void getConfig().then((config) => {
|
|
597
|
+
sendResponse2({
|
|
598
|
+
ok: true,
|
|
599
|
+
connected: ws?.readyState === WebSocket.OPEN,
|
|
600
|
+
hasToken: Boolean(config.token),
|
|
601
|
+
port: config.port,
|
|
602
|
+
debugRichText: config.debugRichText,
|
|
603
|
+
lastError: lastError?.message ?? null,
|
|
604
|
+
lastErrorAt: lastError?.at ?? null,
|
|
605
|
+
lastErrorContext: lastError?.context ?? null,
|
|
606
|
+
reconnectAttempt,
|
|
607
|
+
nextReconnectInMs
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
if (message?.type === "bak.disconnect") {
|
|
613
|
+
manualDisconnect = true;
|
|
614
|
+
clearReconnectTimer();
|
|
615
|
+
reconnectAttempt = 0;
|
|
616
|
+
ws?.close();
|
|
617
|
+
ws = null;
|
|
618
|
+
sendResponse2({ ok: true });
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
});
|
|
623
|
+
})();
|