@flrande/bak-extension 0.2.5 → 0.3.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/.bak-e2e-build-stamp +1 -1
- package/dist/background.global.js +973 -147
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/public/manifest.json +1 -1
- package/src/background.ts +550 -153
- package/src/workspace.ts +626 -0
package/src/background.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { ConsoleEntry, Locator } from '@flrande/bak-protocol';
|
|
2
2
|
import { isSupportedAutomationUrl } from './url-policy.js';
|
|
3
3
|
import { computeReconnectDelayMs } from './reconnect.js';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_WORKSPACE_ID,
|
|
6
|
+
type WorkspaceBrowser,
|
|
7
|
+
type WorkspaceColor,
|
|
8
|
+
type WorkspaceRecord,
|
|
9
|
+
type WorkspaceTab,
|
|
10
|
+
type WorkspaceWindow,
|
|
11
|
+
WorkspaceManager
|
|
12
|
+
} from './workspace.js';
|
|
4
13
|
|
|
5
14
|
interface CliRequest {
|
|
6
15
|
id: string;
|
|
@@ -35,6 +44,7 @@ const DEFAULT_PORT = 17373;
|
|
|
35
44
|
const STORAGE_KEY_TOKEN = 'pairToken';
|
|
36
45
|
const STORAGE_KEY_PORT = 'cliPort';
|
|
37
46
|
const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
|
|
47
|
+
const STORAGE_KEY_WORKSPACE = 'agentWorkspace';
|
|
38
48
|
const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
|
|
39
49
|
|
|
40
50
|
let ws: WebSocket | null = null;
|
|
@@ -116,6 +126,184 @@ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
|
|
|
116
126
|
return toError('E_INTERNAL', message);
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
function toTabInfo(tab: chrome.tabs.Tab): WorkspaceTab {
|
|
130
|
+
if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
|
|
131
|
+
throw new Error('Tab is missing runtime identifiers');
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
id: tab.id,
|
|
135
|
+
title: tab.title ?? '',
|
|
136
|
+
url: tab.url ?? '',
|
|
137
|
+
active: Boolean(tab.active),
|
|
138
|
+
windowId: tab.windowId,
|
|
139
|
+
groupId: typeof tab.groupId === 'number' && tab.groupId >= 0 ? tab.groupId : null
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadWorkspaceState(): Promise<WorkspaceRecord | null> {
|
|
144
|
+
const stored = await chrome.storage.local.get(STORAGE_KEY_WORKSPACE);
|
|
145
|
+
const state = stored[STORAGE_KEY_WORKSPACE];
|
|
146
|
+
if (!state || typeof state !== 'object') {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return state as WorkspaceRecord;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function saveWorkspaceState(state: WorkspaceRecord | null): Promise<void> {
|
|
153
|
+
if (state === null) {
|
|
154
|
+
await chrome.storage.local.remove(STORAGE_KEY_WORKSPACE);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await chrome.storage.local.set({ [STORAGE_KEY_WORKSPACE]: state });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const workspaceBrowser: WorkspaceBrowser = {
|
|
161
|
+
async getTab(tabId) {
|
|
162
|
+
try {
|
|
163
|
+
return toTabInfo(await chrome.tabs.get(tabId));
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
async getActiveTab() {
|
|
169
|
+
const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
170
|
+
const tab = tabs[0];
|
|
171
|
+
if (!tab) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return toTabInfo(tab);
|
|
175
|
+
},
|
|
176
|
+
async listTabs(filter) {
|
|
177
|
+
const tabs = await chrome.tabs.query(filter?.windowId ? { windowId: filter.windowId } : {});
|
|
178
|
+
return tabs
|
|
179
|
+
.filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
|
|
180
|
+
.map((tab) => toTabInfo(tab));
|
|
181
|
+
},
|
|
182
|
+
async createTab(options) {
|
|
183
|
+
const createdTab = await chrome.tabs.create({
|
|
184
|
+
windowId: options.windowId,
|
|
185
|
+
url: options.url ?? 'about:blank',
|
|
186
|
+
active: options.active
|
|
187
|
+
});
|
|
188
|
+
if (!createdTab) {
|
|
189
|
+
throw new Error('Tab creation returned no tab');
|
|
190
|
+
}
|
|
191
|
+
return toTabInfo(createdTab);
|
|
192
|
+
},
|
|
193
|
+
async updateTab(tabId, options) {
|
|
194
|
+
const updatedTab = await chrome.tabs.update(tabId, {
|
|
195
|
+
active: options.active,
|
|
196
|
+
url: options.url
|
|
197
|
+
});
|
|
198
|
+
if (!updatedTab) {
|
|
199
|
+
throw new Error(`Tab update returned no tab for ${tabId}`);
|
|
200
|
+
}
|
|
201
|
+
return toTabInfo(updatedTab);
|
|
202
|
+
},
|
|
203
|
+
async closeTab(tabId) {
|
|
204
|
+
await chrome.tabs.remove(tabId);
|
|
205
|
+
},
|
|
206
|
+
async getWindow(windowId) {
|
|
207
|
+
try {
|
|
208
|
+
const window = await chrome.windows.get(windowId);
|
|
209
|
+
return {
|
|
210
|
+
id: window.id!,
|
|
211
|
+
focused: Boolean(window.focused)
|
|
212
|
+
} satisfies WorkspaceWindow;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
async createWindow(options) {
|
|
218
|
+
const previouslyFocusedWindow =
|
|
219
|
+
options.focused === true
|
|
220
|
+
? null
|
|
221
|
+
: (await chrome.windows.getAll()).find((window) => window.focused === true && typeof window.id === 'number') ?? null;
|
|
222
|
+
const previouslyFocusedTab =
|
|
223
|
+
previouslyFocusedWindow?.id !== undefined
|
|
224
|
+
? (await chrome.tabs.query({ windowId: previouslyFocusedWindow.id, active: true })).find((tab) => typeof tab.id === 'number') ?? null
|
|
225
|
+
: null;
|
|
226
|
+
const created = await chrome.windows.create({
|
|
227
|
+
url: options.url ?? 'about:blank',
|
|
228
|
+
focused: true
|
|
229
|
+
});
|
|
230
|
+
if (!created || typeof created.id !== 'number') {
|
|
231
|
+
throw new Error('Window missing id');
|
|
232
|
+
}
|
|
233
|
+
if (options.focused !== true && previouslyFocusedWindow?.id && previouslyFocusedWindow.id !== created.id) {
|
|
234
|
+
await chrome.windows.update(previouslyFocusedWindow.id, { focused: true });
|
|
235
|
+
if (typeof previouslyFocusedTab?.id === 'number') {
|
|
236
|
+
await chrome.tabs.update(previouslyFocusedTab.id, { active: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const finalWindow = await chrome.windows.get(created.id);
|
|
240
|
+
return {
|
|
241
|
+
id: finalWindow.id!,
|
|
242
|
+
focused: Boolean(finalWindow.focused)
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
async updateWindow(windowId, options) {
|
|
246
|
+
const updated = await chrome.windows.update(windowId, {
|
|
247
|
+
focused: options.focused
|
|
248
|
+
});
|
|
249
|
+
if (!updated || typeof updated.id !== 'number') {
|
|
250
|
+
throw new Error('Window missing id');
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
id: updated.id,
|
|
254
|
+
focused: Boolean(updated.focused)
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
async closeWindow(windowId) {
|
|
258
|
+
await chrome.windows.remove(windowId);
|
|
259
|
+
},
|
|
260
|
+
async getGroup(groupId) {
|
|
261
|
+
try {
|
|
262
|
+
const group = await chrome.tabGroups.get(groupId);
|
|
263
|
+
return {
|
|
264
|
+
id: group.id,
|
|
265
|
+
windowId: group.windowId,
|
|
266
|
+
title: group.title ?? '',
|
|
267
|
+
color: group.color as WorkspaceColor,
|
|
268
|
+
collapsed: Boolean(group.collapsed)
|
|
269
|
+
};
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
async groupTabs(tabIds, groupId) {
|
|
275
|
+
return await chrome.tabs.group({
|
|
276
|
+
tabIds: tabIds as [number, ...number[]],
|
|
277
|
+
groupId
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
async updateGroup(groupId, options) {
|
|
281
|
+
const updated = await chrome.tabGroups.update(groupId, {
|
|
282
|
+
title: options.title,
|
|
283
|
+
color: options.color,
|
|
284
|
+
collapsed: options.collapsed
|
|
285
|
+
});
|
|
286
|
+
if (!updated) {
|
|
287
|
+
throw new Error(`Tab group update returned no group for ${groupId}`);
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
id: updated.id,
|
|
291
|
+
windowId: updated.windowId,
|
|
292
|
+
title: updated.title ?? '',
|
|
293
|
+
color: updated.color as WorkspaceColor,
|
|
294
|
+
collapsed: Boolean(updated.collapsed)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const workspaceManager = new WorkspaceManager(
|
|
300
|
+
{
|
|
301
|
+
load: loadWorkspaceState,
|
|
302
|
+
save: saveWorkspaceState
|
|
303
|
+
},
|
|
304
|
+
workspaceBrowser
|
|
305
|
+
);
|
|
306
|
+
|
|
119
307
|
async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
|
|
120
308
|
try {
|
|
121
309
|
const current = await chrome.tabs.get(tabId);
|
|
@@ -183,33 +371,55 @@ async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TI
|
|
|
183
371
|
});
|
|
184
372
|
}
|
|
185
373
|
|
|
374
|
+
async function waitForTabUrl(tabId: number, expectedUrl: string, timeoutMs = 10_000): Promise<void> {
|
|
375
|
+
const deadline = Date.now() + timeoutMs;
|
|
376
|
+
while (Date.now() < deadline) {
|
|
377
|
+
try {
|
|
378
|
+
const tab = await chrome.tabs.get(tabId);
|
|
379
|
+
const currentUrl = tab.url ?? '';
|
|
380
|
+
const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
|
|
381
|
+
if (currentUrl === expectedUrl || pendingUrl === expectedUrl) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
// Ignore transient lookup failures while the tab is navigating.
|
|
386
|
+
}
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throw new Error(`tab url timeout: ${tabId} -> ${expectedUrl}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
186
393
|
interface WithTabOptions {
|
|
187
394
|
requireSupportedAutomationUrl?: boolean;
|
|
188
395
|
}
|
|
189
396
|
|
|
190
|
-
async function withTab(tabId?: number, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
|
|
397
|
+
async function withTab(target: { tabId?: number; workspaceId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
|
|
191
398
|
const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
|
|
192
399
|
const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
|
|
193
400
|
if (!tab.id) {
|
|
194
401
|
throw toError('E_NOT_FOUND', 'Tab missing id');
|
|
195
402
|
}
|
|
196
|
-
|
|
403
|
+
const pendingUrl = 'pendingUrl' in tab && typeof tab.pendingUrl === 'string' ? tab.pendingUrl : '';
|
|
404
|
+
if (requireSupportedAutomationUrl && !isSupportedAutomationUrl(tab.url) && !isSupportedAutomationUrl(pendingUrl)) {
|
|
197
405
|
throw toError('E_PERMISSION', 'Unsupported tab URL: only http/https pages can be automated', {
|
|
198
|
-
url: tab.url ?? ''
|
|
406
|
+
url: tab.url ?? pendingUrl ?? ''
|
|
199
407
|
});
|
|
200
408
|
}
|
|
201
409
|
return tab;
|
|
202
410
|
};
|
|
203
411
|
|
|
204
|
-
if (typeof tabId === 'number') {
|
|
205
|
-
const tab = await chrome.tabs.get(tabId);
|
|
412
|
+
if (typeof target.tabId === 'number') {
|
|
413
|
+
const tab = await chrome.tabs.get(target.tabId);
|
|
206
414
|
return validate(tab);
|
|
207
415
|
}
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
416
|
+
|
|
417
|
+
const resolved = await workspaceManager.resolveTarget({
|
|
418
|
+
tabId: target.tabId,
|
|
419
|
+
workspaceId: typeof target.workspaceId === 'string' ? target.workspaceId : undefined,
|
|
420
|
+
createIfMissing: false
|
|
421
|
+
});
|
|
422
|
+
const tab = await chrome.tabs.get(resolved.tab.id);
|
|
213
423
|
return validate(tab);
|
|
214
424
|
}
|
|
215
425
|
|
|
@@ -241,7 +451,7 @@ async function captureAlignedTabScreenshot(tab: chrome.tabs.Tab): Promise<string
|
|
|
241
451
|
}
|
|
242
452
|
|
|
243
453
|
async function sendToContent<TResponse>(tabId: number, message: Record<string, unknown>): Promise<TResponse> {
|
|
244
|
-
const maxAttempts =
|
|
454
|
+
const maxAttempts = 10;
|
|
245
455
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
246
456
|
try {
|
|
247
457
|
const response = await chrome.tabs.sendMessage(tabId, message);
|
|
@@ -260,13 +470,57 @@ async function sendToContent<TResponse>(tabId: number, message: Record<string, u
|
|
|
260
470
|
if (!retriable || attempt >= maxAttempts) {
|
|
261
471
|
throw toError('E_NOT_READY', 'Content script unavailable', { detail });
|
|
262
472
|
}
|
|
263
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
473
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
|
|
264
474
|
}
|
|
265
475
|
}
|
|
266
476
|
|
|
267
477
|
throw toError('E_NOT_READY', 'Content script unavailable');
|
|
268
478
|
}
|
|
269
479
|
|
|
480
|
+
interface FocusContext {
|
|
481
|
+
windowId: number | null;
|
|
482
|
+
tabId: number | null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function captureFocusContext(): Promise<FocusContext> {
|
|
486
|
+
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
487
|
+
const activeTab = activeTabs.find((tab) => typeof tab.id === 'number' && typeof tab.windowId === 'number') ?? null;
|
|
488
|
+
return {
|
|
489
|
+
windowId: activeTab?.windowId ?? null,
|
|
490
|
+
tabId: activeTab?.id ?? null
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function restoreFocusContext(context: FocusContext): Promise<void> {
|
|
495
|
+
if (context.windowId !== null) {
|
|
496
|
+
try {
|
|
497
|
+
await chrome.windows.update(context.windowId, { focused: true });
|
|
498
|
+
} catch {
|
|
499
|
+
// Ignore restore errors if the original window no longer exists.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (context.tabId !== null) {
|
|
503
|
+
try {
|
|
504
|
+
await chrome.tabs.update(context.tabId, { active: true });
|
|
505
|
+
} catch {
|
|
506
|
+
// Ignore restore errors if the original tab no longer exists.
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
|
|
512
|
+
if (!enabled) {
|
|
513
|
+
return action();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const focusContext = await captureFocusContext();
|
|
517
|
+
try {
|
|
518
|
+
return await action();
|
|
519
|
+
} finally {
|
|
520
|
+
await restoreFocusContext(focusContext);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
270
524
|
function requireRpcEnvelope(
|
|
271
525
|
method: string,
|
|
272
526
|
value: unknown
|
|
@@ -298,6 +552,10 @@ async function forwardContentRpc(
|
|
|
298
552
|
|
|
299
553
|
async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
300
554
|
const params = request.params ?? {};
|
|
555
|
+
const target = {
|
|
556
|
+
tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
|
|
557
|
+
workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined
|
|
558
|
+
};
|
|
301
559
|
|
|
302
560
|
const rpcForwardMethods = new Set([
|
|
303
561
|
'page.title',
|
|
@@ -345,13 +603,8 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
345
603
|
const tabs = await chrome.tabs.query({});
|
|
346
604
|
return {
|
|
347
605
|
tabs: tabs
|
|
348
|
-
.filter((tab) => typeof tab.id === 'number')
|
|
349
|
-
.map((tab) => (
|
|
350
|
-
id: tab.id as number,
|
|
351
|
-
title: tab.title ?? '',
|
|
352
|
-
url: tab.url ?? '',
|
|
353
|
-
active: tab.active
|
|
354
|
-
}))
|
|
606
|
+
.filter((tab): tab is chrome.tabs.Tab => typeof tab.id === 'number' && typeof tab.windowId === 'number')
|
|
607
|
+
.map((tab) => toTabInfo(tab))
|
|
355
608
|
};
|
|
356
609
|
}
|
|
357
610
|
case 'tabs.getActive': {
|
|
@@ -361,12 +614,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
361
614
|
return { tab: null };
|
|
362
615
|
}
|
|
363
616
|
return {
|
|
364
|
-
tab:
|
|
365
|
-
id: tab.id,
|
|
366
|
-
title: tab.title ?? '',
|
|
367
|
-
url: tab.url ?? '',
|
|
368
|
-
active: Boolean(tab.active)
|
|
369
|
-
}
|
|
617
|
+
tab: toTabInfo(tab)
|
|
370
618
|
};
|
|
371
619
|
}
|
|
372
620
|
case 'tabs.get': {
|
|
@@ -376,12 +624,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
376
624
|
throw toError('E_NOT_FOUND', 'Tab missing id');
|
|
377
625
|
}
|
|
378
626
|
return {
|
|
379
|
-
tab:
|
|
380
|
-
id: tab.id,
|
|
381
|
-
title: tab.title ?? '',
|
|
382
|
-
url: tab.url ?? '',
|
|
383
|
-
active: Boolean(tab.active)
|
|
384
|
-
}
|
|
627
|
+
tab: toTabInfo(tab)
|
|
385
628
|
};
|
|
386
629
|
}
|
|
387
630
|
case 'tabs.focus': {
|
|
@@ -390,170 +633,281 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
|
|
|
390
633
|
return { ok: true };
|
|
391
634
|
}
|
|
392
635
|
case 'tabs.new': {
|
|
393
|
-
|
|
394
|
-
|
|
636
|
+
if (typeof params.workspaceId === 'string' || params.windowId === undefined) {
|
|
637
|
+
const opened = await preserveHumanFocus(true, async () => {
|
|
638
|
+
return await workspaceManager.openTab({
|
|
639
|
+
workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : DEFAULT_WORKSPACE_ID,
|
|
640
|
+
url: (params.url as string | undefined) ?? 'about:blank',
|
|
641
|
+
active: params.active === true,
|
|
642
|
+
focus: false
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
return {
|
|
646
|
+
tabId: opened.tab.id,
|
|
647
|
+
windowId: opened.tab.windowId,
|
|
648
|
+
groupId: opened.workspace.groupId,
|
|
649
|
+
workspaceId: opened.workspace.id
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const tab = await chrome.tabs.create({
|
|
653
|
+
url: (params.url as string | undefined) ?? 'about:blank',
|
|
654
|
+
windowId: typeof params.windowId === 'number' ? params.windowId : undefined,
|
|
655
|
+
active: params.active === true
|
|
656
|
+
});
|
|
657
|
+
if (params.addToGroup === true && typeof tab.id === 'number') {
|
|
658
|
+
const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
|
659
|
+
return {
|
|
660
|
+
tabId: tab.id,
|
|
661
|
+
windowId: tab.windowId,
|
|
662
|
+
groupId
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
tabId: tab.id,
|
|
667
|
+
windowId: tab.windowId
|
|
668
|
+
};
|
|
395
669
|
}
|
|
396
670
|
case 'tabs.close': {
|
|
397
671
|
const tabId = Number(params.tabId);
|
|
398
672
|
await chrome.tabs.remove(tabId);
|
|
399
673
|
return { ok: true };
|
|
400
674
|
}
|
|
675
|
+
case 'workspace.ensure': {
|
|
676
|
+
return preserveHumanFocus(params.focus !== true, async () => {
|
|
677
|
+
return await workspaceManager.ensureWorkspace({
|
|
678
|
+
workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
|
|
679
|
+
focus: params.focus === true,
|
|
680
|
+
initialUrl: typeof params.url === 'string' ? params.url : undefined
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
case 'workspace.info': {
|
|
685
|
+
return {
|
|
686
|
+
workspace: await workspaceManager.getWorkspaceInfo(typeof params.workspaceId === 'string' ? params.workspaceId : undefined)
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
case 'workspace.openTab': {
|
|
690
|
+
return await preserveHumanFocus(params.focus !== true, async () => {
|
|
691
|
+
return await workspaceManager.openTab({
|
|
692
|
+
workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
|
|
693
|
+
url: typeof params.url === 'string' ? params.url : undefined,
|
|
694
|
+
active: params.active === true,
|
|
695
|
+
focus: params.focus === true
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
case 'workspace.listTabs': {
|
|
700
|
+
return await workspaceManager.listTabs(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
|
|
701
|
+
}
|
|
702
|
+
case 'workspace.getActiveTab': {
|
|
703
|
+
return await workspaceManager.getActiveTab(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
|
|
704
|
+
}
|
|
705
|
+
case 'workspace.setActiveTab': {
|
|
706
|
+
return await workspaceManager.setActiveTab(Number(params.tabId), typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
|
|
707
|
+
}
|
|
708
|
+
case 'workspace.focus': {
|
|
709
|
+
return await workspaceManager.focus(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
|
|
710
|
+
}
|
|
711
|
+
case 'workspace.reset': {
|
|
712
|
+
return await preserveHumanFocus(params.focus !== true, async () => {
|
|
713
|
+
return await workspaceManager.reset({
|
|
714
|
+
workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined,
|
|
715
|
+
focus: params.focus === true,
|
|
716
|
+
initialUrl: typeof params.url === 'string' ? params.url : undefined
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
case 'workspace.close': {
|
|
721
|
+
return await workspaceManager.close(typeof params.workspaceId === 'string' ? params.workspaceId : undefined);
|
|
722
|
+
}
|
|
401
723
|
case 'page.goto': {
|
|
402
|
-
|
|
403
|
-
|
|
724
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
725
|
+
const tab = await withTab(target, {
|
|
726
|
+
requireSupportedAutomationUrl: false
|
|
727
|
+
});
|
|
728
|
+
const url = String(params.url ?? 'about:blank');
|
|
729
|
+
await chrome.tabs.update(tab.id!, { url });
|
|
730
|
+
await waitForTabUrl(tab.id!, url);
|
|
731
|
+
await forwardContentRpc(tab.id!, 'page.url', { tabId: tab.id }).catch(() => undefined);
|
|
732
|
+
await waitForTabComplete(tab.id!, 5_000).catch(() => undefined);
|
|
733
|
+
return { ok: true };
|
|
404
734
|
});
|
|
405
|
-
await chrome.tabs.update(tab.id!, { url: String(params.url ?? 'about:blank') });
|
|
406
|
-
await waitForTabComplete(tab.id!);
|
|
407
|
-
return { ok: true };
|
|
408
735
|
}
|
|
409
736
|
case 'page.back': {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
737
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
738
|
+
const tab = await withTab(target);
|
|
739
|
+
await chrome.tabs.goBack(tab.id!);
|
|
740
|
+
await waitForTabComplete(tab.id!);
|
|
741
|
+
return { ok: true };
|
|
742
|
+
});
|
|
414
743
|
}
|
|
415
744
|
case 'page.forward': {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
745
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
746
|
+
const tab = await withTab(target);
|
|
747
|
+
await chrome.tabs.goForward(tab.id!);
|
|
748
|
+
await waitForTabComplete(tab.id!);
|
|
749
|
+
return { ok: true };
|
|
750
|
+
});
|
|
420
751
|
}
|
|
421
752
|
case 'page.reload': {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
753
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
754
|
+
const tab = await withTab(target);
|
|
755
|
+
await chrome.tabs.reload(tab.id!);
|
|
756
|
+
await waitForTabComplete(tab.id!);
|
|
757
|
+
return { ok: true };
|
|
758
|
+
});
|
|
426
759
|
}
|
|
427
760
|
case 'page.viewport': {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (typeof tab.windowId !== 'number') {
|
|
432
|
-
throw toError('E_NOT_FOUND', 'Tab window unavailable');
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
|
|
436
|
-
const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
|
|
437
|
-
if (width || height) {
|
|
438
|
-
await chrome.windows.update(tab.windowId, {
|
|
439
|
-
width,
|
|
440
|
-
height
|
|
761
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
762
|
+
const tab = await withTab(target, {
|
|
763
|
+
requireSupportedAutomationUrl: false
|
|
441
764
|
});
|
|
442
|
-
|
|
765
|
+
if (typeof tab.windowId !== 'number') {
|
|
766
|
+
throw toError('E_NOT_FOUND', 'Tab window unavailable');
|
|
767
|
+
}
|
|
443
768
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
height
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
769
|
+
const width = typeof params.width === 'number' ? Math.max(320, Math.floor(params.width)) : undefined;
|
|
770
|
+
const height = typeof params.height === 'number' ? Math.max(320, Math.floor(params.height)) : undefined;
|
|
771
|
+
if (width || height) {
|
|
772
|
+
await chrome.windows.update(tab.windowId, {
|
|
773
|
+
width,
|
|
774
|
+
height
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const viewport = (await forwardContentRpc(tab.id!, 'page.viewport', {})) as {
|
|
779
|
+
width: number;
|
|
780
|
+
height: number;
|
|
781
|
+
devicePixelRatio: number;
|
|
782
|
+
};
|
|
783
|
+
const viewWidth = typeof width === 'number' ? width : viewport.width ?? tab.width ?? 0;
|
|
784
|
+
const viewHeight = typeof height === 'number' ? height : viewport.height ?? tab.height ?? 0;
|
|
785
|
+
return {
|
|
786
|
+
width: viewWidth,
|
|
787
|
+
height: viewHeight,
|
|
788
|
+
devicePixelRatio: viewport.devicePixelRatio
|
|
789
|
+
};
|
|
790
|
+
});
|
|
456
791
|
}
|
|
457
792
|
case 'page.snapshot': {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
793
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
794
|
+
const tab = await withTab(target);
|
|
795
|
+
if (typeof tab.id !== 'number' || typeof tab.windowId !== 'number') {
|
|
796
|
+
throw toError('E_NOT_FOUND', 'Tab missing id');
|
|
797
|
+
}
|
|
798
|
+
const includeBase64 = params.includeBase64 !== false;
|
|
799
|
+
const config = await getConfig();
|
|
800
|
+
const elements = await sendToContent<{ elements: unknown[] }>(tab.id, {
|
|
801
|
+
type: 'bak.collectElements',
|
|
802
|
+
debugRichText: config.debugRichText
|
|
803
|
+
});
|
|
804
|
+
const imageData = await captureAlignedTabScreenshot(tab);
|
|
805
|
+
return {
|
|
806
|
+
imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
|
|
807
|
+
elements: elements.elements,
|
|
808
|
+
tabId: tab.id,
|
|
809
|
+
url: tab.url ?? ''
|
|
810
|
+
};
|
|
467
811
|
});
|
|
468
|
-
const imageData = await captureAlignedTabScreenshot(tab);
|
|
469
|
-
return {
|
|
470
|
-
imageBase64: includeBase64 ? imageData.replace(/^data:image\/png;base64,/, '') : '',
|
|
471
|
-
elements: elements.elements,
|
|
472
|
-
tabId: tab.id,
|
|
473
|
-
url: tab.url ?? ''
|
|
474
|
-
};
|
|
475
812
|
}
|
|
476
813
|
case 'element.click': {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
814
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
815
|
+
const tab = await withTab(target);
|
|
816
|
+
const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
|
|
817
|
+
type: 'bak.performAction',
|
|
818
|
+
action: 'click',
|
|
819
|
+
locator: params.locator as Locator,
|
|
820
|
+
requiresConfirm: params.requiresConfirm === true
|
|
821
|
+
});
|
|
822
|
+
if (!response.ok) {
|
|
823
|
+
throw response.error ?? toError('E_INTERNAL', 'element.click failed');
|
|
824
|
+
}
|
|
825
|
+
return { ok: true };
|
|
483
826
|
});
|
|
484
|
-
if (!response.ok) {
|
|
485
|
-
throw response.error ?? toError('E_INTERNAL', 'element.click failed');
|
|
486
|
-
}
|
|
487
|
-
return { ok: true };
|
|
488
827
|
}
|
|
489
828
|
case 'element.type': {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
829
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
830
|
+
const tab = await withTab(target);
|
|
831
|
+
const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
|
|
832
|
+
type: 'bak.performAction',
|
|
833
|
+
action: 'type',
|
|
834
|
+
locator: params.locator as Locator,
|
|
835
|
+
text: String(params.text ?? ''),
|
|
836
|
+
clear: Boolean(params.clear),
|
|
837
|
+
requiresConfirm: params.requiresConfirm === true
|
|
838
|
+
});
|
|
839
|
+
if (!response.ok) {
|
|
840
|
+
throw response.error ?? toError('E_INTERNAL', 'element.type failed');
|
|
841
|
+
}
|
|
842
|
+
return { ok: true };
|
|
498
843
|
});
|
|
499
|
-
if (!response.ok) {
|
|
500
|
-
throw response.error ?? toError('E_INTERNAL', 'element.type failed');
|
|
501
|
-
}
|
|
502
|
-
return { ok: true };
|
|
503
844
|
}
|
|
504
845
|
case 'element.scroll': {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
846
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
847
|
+
const tab = await withTab(target);
|
|
848
|
+
const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
|
|
849
|
+
type: 'bak.performAction',
|
|
850
|
+
action: 'scroll',
|
|
851
|
+
locator: params.locator as Locator,
|
|
852
|
+
dx: Number(params.dx ?? 0),
|
|
853
|
+
dy: Number(params.dy ?? 320)
|
|
854
|
+
});
|
|
855
|
+
if (!response.ok) {
|
|
856
|
+
throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
|
|
857
|
+
}
|
|
858
|
+
return { ok: true };
|
|
512
859
|
});
|
|
513
|
-
if (!response.ok) {
|
|
514
|
-
throw response.error ?? toError('E_INTERNAL', 'element.scroll failed');
|
|
515
|
-
}
|
|
516
|
-
return { ok: true };
|
|
517
860
|
}
|
|
518
861
|
case 'page.wait': {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
862
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
863
|
+
const tab = await withTab(target);
|
|
864
|
+
const response = await sendToContent<{ ok: boolean; error?: CliResponse['error'] }>(tab.id!, {
|
|
865
|
+
type: 'bak.waitFor',
|
|
866
|
+
mode: String(params.mode ?? 'selector'),
|
|
867
|
+
value: String(params.value ?? ''),
|
|
868
|
+
timeoutMs: Number(params.timeoutMs ?? 5000)
|
|
869
|
+
});
|
|
870
|
+
if (!response.ok) {
|
|
871
|
+
throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
|
|
872
|
+
}
|
|
873
|
+
return { ok: true };
|
|
525
874
|
});
|
|
526
|
-
if (!response.ok) {
|
|
527
|
-
throw response.error ?? toError('E_TIMEOUT', 'page.wait failed');
|
|
528
|
-
}
|
|
529
|
-
return { ok: true };
|
|
530
875
|
}
|
|
531
876
|
case 'debug.getConsole': {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
877
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
878
|
+
const tab = await withTab(target);
|
|
879
|
+
const response = await sendToContent<{ entries: ConsoleEntry[] }>(tab.id!, {
|
|
880
|
+
type: 'bak.getConsole',
|
|
881
|
+
limit: Number(params.limit ?? 50)
|
|
882
|
+
});
|
|
883
|
+
return { entries: response.entries };
|
|
536
884
|
});
|
|
537
|
-
return { entries: response.entries };
|
|
538
885
|
}
|
|
539
886
|
case 'ui.selectCandidate': {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
887
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
888
|
+
const tab = await withTab(target);
|
|
889
|
+
const response = await sendToContent<{ ok: boolean; selectedEid?: string; error?: CliResponse['error'] }>(
|
|
890
|
+
tab.id!,
|
|
891
|
+
{
|
|
892
|
+
type: 'bak.selectCandidate',
|
|
893
|
+
candidates: params.candidates
|
|
894
|
+
}
|
|
895
|
+
);
|
|
896
|
+
if (!response.ok || !response.selectedEid) {
|
|
897
|
+
throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
|
|
546
898
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
throw response.error ?? toError('E_NEED_USER_CONFIRM', 'User did not confirm candidate');
|
|
550
|
-
}
|
|
551
|
-
return { selectedEid: response.selectedEid };
|
|
899
|
+
return { selectedEid: response.selectedEid };
|
|
900
|
+
});
|
|
552
901
|
}
|
|
553
902
|
default:
|
|
554
903
|
if (rpcForwardMethods.has(request.method)) {
|
|
555
|
-
|
|
556
|
-
|
|
904
|
+
return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
|
|
905
|
+
const tab = await withTab(target);
|
|
906
|
+
return await forwardContentRpc(tab.id!, request.method, {
|
|
907
|
+
...params,
|
|
908
|
+
tabId: tab.id
|
|
909
|
+
});
|
|
910
|
+
});
|
|
557
911
|
}
|
|
558
912
|
throw toError('E_NOT_FOUND', `Unsupported method from CLI bridge: ${request.method}`);
|
|
559
913
|
}
|
|
@@ -647,6 +1001,49 @@ async function connectWebSocket(): Promise<void> {
|
|
|
647
1001
|
});
|
|
648
1002
|
}
|
|
649
1003
|
|
|
1004
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
1005
|
+
void loadWorkspaceState().then(async (state) => {
|
|
1006
|
+
if (!state || !state.tabIds.includes(tabId)) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const nextTabIds = state.tabIds.filter((id) => id !== tabId);
|
|
1010
|
+
await saveWorkspaceState({
|
|
1011
|
+
...state,
|
|
1012
|
+
tabIds: nextTabIds,
|
|
1013
|
+
activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
|
|
1014
|
+
primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
1020
|
+
void loadWorkspaceState().then(async (state) => {
|
|
1021
|
+
if (!state || state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
await saveWorkspaceState({
|
|
1025
|
+
...state,
|
|
1026
|
+
activeTabId: activeInfo.tabId
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
chrome.windows.onRemoved.addListener((windowId) => {
|
|
1032
|
+
void loadWorkspaceState().then(async (state) => {
|
|
1033
|
+
if (!state || state.windowId !== windowId) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
await saveWorkspaceState({
|
|
1037
|
+
...state,
|
|
1038
|
+
windowId: null,
|
|
1039
|
+
groupId: null,
|
|
1040
|
+
tabIds: [],
|
|
1041
|
+
activeTabId: null,
|
|
1042
|
+
primaryTabId: null
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
|
|
650
1047
|
chrome.runtime.onInstalled.addListener(() => {
|
|
651
1048
|
void setConfig({ port: DEFAULT_PORT, debugRichText: false });
|
|
652
1049
|
});
|