btcp-browser-agent 0.1.0 → 0.1.2
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/package.json +8 -9
- package/packages/core/dist/actions.d.ts +97 -0
- package/packages/core/dist/actions.js +940 -0
- package/packages/core/dist/errors.d.ts +138 -0
- package/packages/core/dist/errors.js +157 -0
- package/packages/core/dist/index.d.ts +120 -0
- package/packages/core/dist/index.js +134 -0
- package/packages/core/dist/ref-map.d.ts +16 -0
- package/packages/core/dist/ref-map.js +91 -0
- package/packages/core/dist/snapshot.d.ts +37 -0
- package/packages/core/dist/snapshot.js +751 -0
- package/packages/core/dist/types.d.ts +396 -0
- package/packages/core/dist/types.js +7 -0
- package/packages/extension/dist/background.d.ts +227 -0
- package/packages/extension/dist/background.js +737 -0
- package/packages/extension/dist/content.d.ts +18 -0
- package/packages/extension/dist/content.js +149 -0
- package/packages/extension/dist/index.d.ts +228 -0
- package/packages/extension/dist/index.js +350 -0
- package/packages/extension/dist/session-manager.d.ts +87 -0
- package/packages/extension/dist/session-manager.js +322 -0
- package/packages/extension/{src/session-types.ts → dist/session-types.d.ts} +113 -144
- package/packages/extension/dist/session-types.js +5 -0
- package/packages/extension/dist/types.d.ts +88 -0
- package/packages/extension/dist/types.js +7 -0
- package/CLAUDE.md +0 -230
- package/SKILL.md +0 -143
- package/SNAPSHOT_IMPROVEMENTS.md +0 -302
- package/USAGE.md +0 -146
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/docs/browser-cli-design.md +0 -500
- package/examples/chrome-extension/CHANGELOG.md +0 -210
- package/examples/chrome-extension/DEBUG.md +0 -231
- package/examples/chrome-extension/ERROR_FIXED.md +0 -147
- package/examples/chrome-extension/QUICK_TEST.md +0 -189
- package/examples/chrome-extension/README.md +0 -149
- package/examples/chrome-extension/SESSION_ONLY_MODE.md +0 -305
- package/examples/chrome-extension/TEST_WITH_YOUR_TABS.md +0 -97
- package/examples/chrome-extension/build.js +0 -43
- package/examples/chrome-extension/manifest.json +0 -37
- package/examples/chrome-extension/package-lock.json +0 -1063
- package/examples/chrome-extension/package.json +0 -21
- package/examples/chrome-extension/popup.html +0 -195
- package/examples/chrome-extension/src/background.ts +0 -12
- package/examples/chrome-extension/src/content.ts +0 -7
- package/examples/chrome-extension/src/popup.ts +0 -303
- package/examples/chrome-extension/src/scenario-google-github.ts +0 -389
- package/examples/chrome-extension/test-page.html +0 -127
- package/examples/chrome-extension/tests/README.md +0 -206
- package/examples/chrome-extension/tests/scenario-google-to-github-star.ts +0 -380
- package/examples/chrome-extension/tsconfig.json +0 -14
- package/examples/snapshots/README.md +0 -207
- package/examples/snapshots/amazon-com-detail.html +0 -9528
- package/examples/snapshots/amazon-com-detail.snapshot.txt +0 -997
- package/examples/snapshots/convert-snapshots.ts +0 -97
- package/examples/snapshots/edition-cnn-com.html +0 -13292
- package/examples/snapshots/edition-cnn-com.snapshot.txt +0 -562
- package/examples/snapshots/github-com-microsoft-vscode.html +0 -2916
- package/examples/snapshots/github-com-microsoft-vscode.snapshot.txt +0 -455
- package/examples/snapshots/google-search.html +0 -20012
- package/examples/snapshots/google-search.snapshot.txt +0 -195
- package/examples/snapshots/metadata.json +0 -86
- package/examples/snapshots/npr-org-templates.html +0 -2031
- package/examples/snapshots/npr-org-templates.snapshot.txt +0 -224
- package/examples/snapshots/stackoverflow-com.html +0 -5216
- package/examples/snapshots/stackoverflow-com.snapshot.txt +0 -2404
- package/examples/snapshots/test-all-mode.html +0 -46
- package/examples/snapshots/test-all-mode.snapshot.txt +0 -5
- package/examples/snapshots/validate.test.ts +0 -296
- package/packages/cli/package.json +0 -42
- package/packages/cli/src/__tests__/cli.test.ts +0 -434
- package/packages/cli/src/__tests__/errors.test.ts +0 -226
- package/packages/cli/src/__tests__/executor.test.ts +0 -275
- package/packages/cli/src/__tests__/formatter.test.ts +0 -260
- package/packages/cli/src/__tests__/parser.test.ts +0 -288
- package/packages/cli/src/__tests__/suggestions.test.ts +0 -255
- package/packages/cli/src/commands/back.ts +0 -22
- package/packages/cli/src/commands/check.ts +0 -33
- package/packages/cli/src/commands/clear.ts +0 -33
- package/packages/cli/src/commands/click.ts +0 -32
- package/packages/cli/src/commands/closetab.ts +0 -31
- package/packages/cli/src/commands/eval.ts +0 -41
- package/packages/cli/src/commands/fill.ts +0 -30
- package/packages/cli/src/commands/focus.ts +0 -33
- package/packages/cli/src/commands/forward.ts +0 -22
- package/packages/cli/src/commands/goto.ts +0 -34
- package/packages/cli/src/commands/help.ts +0 -162
- package/packages/cli/src/commands/hover.ts +0 -34
- package/packages/cli/src/commands/index.ts +0 -129
- package/packages/cli/src/commands/newtab.ts +0 -35
- package/packages/cli/src/commands/press.ts +0 -40
- package/packages/cli/src/commands/reload.ts +0 -25
- package/packages/cli/src/commands/screenshot.ts +0 -27
- package/packages/cli/src/commands/scroll.ts +0 -64
- package/packages/cli/src/commands/select.ts +0 -35
- package/packages/cli/src/commands/snapshot.ts +0 -21
- package/packages/cli/src/commands/tab.ts +0 -32
- package/packages/cli/src/commands/tabs.ts +0 -26
- package/packages/cli/src/commands/text.ts +0 -27
- package/packages/cli/src/commands/title.ts +0 -17
- package/packages/cli/src/commands/type.ts +0 -38
- package/packages/cli/src/commands/uncheck.ts +0 -33
- package/packages/cli/src/commands/url.ts +0 -17
- package/packages/cli/src/commands/wait.ts +0 -54
- package/packages/cli/src/errors.ts +0 -164
- package/packages/cli/src/executor.ts +0 -68
- package/packages/cli/src/formatter.ts +0 -215
- package/packages/cli/src/index.ts +0 -257
- package/packages/cli/src/parser.ts +0 -195
- package/packages/cli/src/suggestions.ts +0 -207
- package/packages/cli/src/terminal/Terminal.ts +0 -365
- package/packages/cli/src/terminal/index.ts +0 -5
- package/packages/cli/src/types.ts +0 -155
- package/packages/cli/tsconfig.json +0 -20
- package/packages/core/package.json +0 -35
- package/packages/core/src/actions.ts +0 -1210
- package/packages/core/src/errors.ts +0 -296
- package/packages/core/src/index.test.ts +0 -638
- package/packages/core/src/index.ts +0 -220
- package/packages/core/src/ref-map.ts +0 -107
- package/packages/core/src/snapshot.ts +0 -873
- package/packages/core/src/types.ts +0 -536
- package/packages/core/tsconfig.json +0 -23
- package/packages/extension/README.md +0 -129
- package/packages/extension/package.json +0 -43
- package/packages/extension/src/background.ts +0 -888
- package/packages/extension/src/content.ts +0 -172
- package/packages/extension/src/index.ts +0 -579
- package/packages/extension/src/session-manager.ts +0 -385
- package/packages/extension/src/types.ts +0 -162
- package/packages/extension/tsconfig.json +0 -28
- package/src/index.ts +0 -64
- package/tsconfig.build.json +0 -12
- package/tsconfig.json +0 -26
- package/vitest.config.ts +0 -13
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @btcp/extension - Background Script
|
|
3
|
+
*
|
|
4
|
+
* Contains BrowserAgent - the high-level orchestrator that runs in the
|
|
5
|
+
* extension's background/service worker context.
|
|
6
|
+
*
|
|
7
|
+
* BrowserAgent manages:
|
|
8
|
+
* - Tab lifecycle (create, close, switch, list)
|
|
9
|
+
* - Navigation (goto, back, forward, reload)
|
|
10
|
+
* - Screenshots (chrome.tabs.captureVisibleTab)
|
|
11
|
+
* - Session state
|
|
12
|
+
* - Routing DOM commands to ContentAgents in target tabs
|
|
13
|
+
*/
|
|
14
|
+
import { SessionManager } from './session-manager.js';
|
|
15
|
+
/**
|
|
16
|
+
* BackgroundAgent - High-level browser automation orchestrator
|
|
17
|
+
*
|
|
18
|
+
* Runs in the extension's background script/service worker.
|
|
19
|
+
* Manages browser-level operations and routes DOM commands to
|
|
20
|
+
* ContentAgent instances running in content scripts.
|
|
21
|
+
*
|
|
22
|
+
* @example Single tab (default - uses activeTabId)
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const agent = new BackgroundAgent();
|
|
25
|
+
* await agent.navigate('https://example.com');
|
|
26
|
+
* await agent.execute({ id: '1', action: 'click', selector: '#submit' });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example Multi-tab with explicit tabId
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const agent = new BackgroundAgent();
|
|
32
|
+
*
|
|
33
|
+
* // Open two tabs
|
|
34
|
+
* const tab1 = await agent.newTab({ url: 'https://google.com' });
|
|
35
|
+
* const tab2 = await agent.newTab({ url: 'https://github.com', active: false });
|
|
36
|
+
*
|
|
37
|
+
* // Interact with specific tabs without switching
|
|
38
|
+
* await agent.tab(tab1.id).click('#search');
|
|
39
|
+
* await agent.tab(tab2.id).snapshot();
|
|
40
|
+
*
|
|
41
|
+
* // Or specify tabId in command
|
|
42
|
+
* await agent.execute({ id: '1', action: 'snapshot' }, { tabId: tab2.id });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class BackgroundAgent {
|
|
46
|
+
activeTabId = null;
|
|
47
|
+
sessionManager;
|
|
48
|
+
heartbeatInterval = null;
|
|
49
|
+
HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
|
50
|
+
constructor() {
|
|
51
|
+
this.sessionManager = new SessionManager();
|
|
52
|
+
// Initialize active tab on creation
|
|
53
|
+
this.initActiveTab();
|
|
54
|
+
// Start heartbeat to keep session tabs alive
|
|
55
|
+
this.startHeartbeat();
|
|
56
|
+
}
|
|
57
|
+
async initActiveTab() {
|
|
58
|
+
const tab = await this.getActiveTab();
|
|
59
|
+
this.activeTabId = tab?.id ?? null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Get the current active tab ID
|
|
63
|
+
*/
|
|
64
|
+
getActiveTabId() {
|
|
65
|
+
return this.activeTabId;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Set the active tab ID (for manual control)
|
|
69
|
+
*/
|
|
70
|
+
setActiveTabId(tabId) {
|
|
71
|
+
this.activeTabId = tabId;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get a handle for interacting with a specific tab
|
|
75
|
+
*
|
|
76
|
+
* This allows you to send commands to any tab without switching the active tab.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* const tab2Handle = browser.tab(tab2.id);
|
|
81
|
+
* await tab2Handle.snapshot();
|
|
82
|
+
* await tab2Handle.click('@ref:5');
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
tab(tabId) {
|
|
86
|
+
const agent = this;
|
|
87
|
+
let cmdCounter = 0;
|
|
88
|
+
const genId = () => `tab_${tabId}_${Date.now()}_${cmdCounter++}`;
|
|
89
|
+
return {
|
|
90
|
+
tabId,
|
|
91
|
+
execute(command) {
|
|
92
|
+
return agent.sendToContentAgent(command, tabId);
|
|
93
|
+
},
|
|
94
|
+
snapshot(options) {
|
|
95
|
+
return agent.sendToContentAgent({
|
|
96
|
+
id: genId(),
|
|
97
|
+
action: 'snapshot',
|
|
98
|
+
...options,
|
|
99
|
+
}, tabId);
|
|
100
|
+
},
|
|
101
|
+
click(selector) {
|
|
102
|
+
return agent.sendToContentAgent({
|
|
103
|
+
id: genId(),
|
|
104
|
+
action: 'click',
|
|
105
|
+
selector,
|
|
106
|
+
}, tabId);
|
|
107
|
+
},
|
|
108
|
+
fill(selector, value) {
|
|
109
|
+
return agent.sendToContentAgent({
|
|
110
|
+
id: genId(),
|
|
111
|
+
action: 'fill',
|
|
112
|
+
selector,
|
|
113
|
+
value,
|
|
114
|
+
}, tabId);
|
|
115
|
+
},
|
|
116
|
+
type(selector, text, options) {
|
|
117
|
+
return agent.sendToContentAgent({
|
|
118
|
+
id: genId(),
|
|
119
|
+
action: 'type',
|
|
120
|
+
selector,
|
|
121
|
+
text,
|
|
122
|
+
...options,
|
|
123
|
+
}, tabId);
|
|
124
|
+
},
|
|
125
|
+
getText(selector) {
|
|
126
|
+
return agent.sendToContentAgent({
|
|
127
|
+
id: genId(),
|
|
128
|
+
action: 'getText',
|
|
129
|
+
selector,
|
|
130
|
+
}, tabId);
|
|
131
|
+
},
|
|
132
|
+
isVisible(selector) {
|
|
133
|
+
return agent.sendToContentAgent({
|
|
134
|
+
id: genId(),
|
|
135
|
+
action: 'isVisible',
|
|
136
|
+
selector,
|
|
137
|
+
}, tabId);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// TAB MANAGEMENT
|
|
143
|
+
// ============================================================================
|
|
144
|
+
/**
|
|
145
|
+
* Get the currently active tab (only if in session)
|
|
146
|
+
*/
|
|
147
|
+
async getActiveTab() {
|
|
148
|
+
const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
|
|
149
|
+
// If no session, return null
|
|
150
|
+
if (sessionGroupId === null) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
// Get active tab and verify it's in the session
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
|
156
|
+
const activeTab = tabs[0];
|
|
157
|
+
// Only return if it's in the session group
|
|
158
|
+
if (activeTab && activeTab.groupId === sessionGroupId) {
|
|
159
|
+
resolve(activeTab);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
resolve(null);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* List all tabs (only in session group)
|
|
169
|
+
*/
|
|
170
|
+
async listTabs() {
|
|
171
|
+
// Get active session group ID
|
|
172
|
+
const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
|
|
173
|
+
// Session is required
|
|
174
|
+
if (sessionGroupId === null) {
|
|
175
|
+
throw new Error('No active session. Create a session first to manage tabs.');
|
|
176
|
+
}
|
|
177
|
+
// Only return tabs in the session group
|
|
178
|
+
const tabs = await new Promise((resolve) => {
|
|
179
|
+
chrome.tabs.query({ groupId: sessionGroupId }, (t) => resolve(t));
|
|
180
|
+
});
|
|
181
|
+
return tabs.map((t) => ({
|
|
182
|
+
id: t.id,
|
|
183
|
+
url: t.url,
|
|
184
|
+
title: t.title,
|
|
185
|
+
active: t.active,
|
|
186
|
+
index: t.index,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Create a new tab (only in session group)
|
|
191
|
+
*/
|
|
192
|
+
async newTab(options) {
|
|
193
|
+
// Require active session
|
|
194
|
+
const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
|
|
195
|
+
if (sessionGroupId === null) {
|
|
196
|
+
throw new Error('No active session. Create a session first to manage tabs.');
|
|
197
|
+
}
|
|
198
|
+
const tab = await new Promise((resolve) => {
|
|
199
|
+
chrome.tabs.create({ url: options?.url, active: options?.active ?? true }, (t) => resolve(t));
|
|
200
|
+
});
|
|
201
|
+
if (options?.url) {
|
|
202
|
+
await this.waitForTabLoad(tab.id);
|
|
203
|
+
}
|
|
204
|
+
if (options?.active !== false) {
|
|
205
|
+
this.activeTabId = tab.id;
|
|
206
|
+
}
|
|
207
|
+
// Add to active session
|
|
208
|
+
if (tab.id) {
|
|
209
|
+
const added = await this.sessionManager.addTabToActiveSession(tab.id);
|
|
210
|
+
if (!added) {
|
|
211
|
+
// If we couldn't add to session, close the tab
|
|
212
|
+
await chrome.tabs.remove(tab.id);
|
|
213
|
+
throw new Error('Failed to add new tab to session');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
id: tab.id,
|
|
218
|
+
url: tab.url,
|
|
219
|
+
title: tab.title,
|
|
220
|
+
active: tab.active,
|
|
221
|
+
index: tab.index,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Check if a tab is in the active session
|
|
226
|
+
*/
|
|
227
|
+
async isTabInSession(tabId) {
|
|
228
|
+
const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
|
|
229
|
+
// Session is required - no operations allowed without it
|
|
230
|
+
if (sessionGroupId === null) {
|
|
231
|
+
throw new Error('No active session. Create a session first to manage tabs.');
|
|
232
|
+
}
|
|
233
|
+
// Check if tab is in the session group
|
|
234
|
+
const tab = await chrome.tabs.get(tabId);
|
|
235
|
+
return tab.groupId === sessionGroupId;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Close a tab (only if in session)
|
|
239
|
+
*/
|
|
240
|
+
async closeTab(tabId) {
|
|
241
|
+
const targetId = tabId ?? this.activeTabId;
|
|
242
|
+
if (!targetId)
|
|
243
|
+
throw new Error('No tab to close');
|
|
244
|
+
// Validate tab is in session
|
|
245
|
+
const inSession = await this.isTabInSession(targetId);
|
|
246
|
+
if (!inSession) {
|
|
247
|
+
throw new Error('Cannot close tab: tab is not in the active session');
|
|
248
|
+
}
|
|
249
|
+
await new Promise((resolve) => {
|
|
250
|
+
chrome.tabs.remove(targetId, () => resolve());
|
|
251
|
+
});
|
|
252
|
+
// Update active tab if we closed the current one
|
|
253
|
+
if (targetId === this.activeTabId) {
|
|
254
|
+
const tab = await this.getActiveTab();
|
|
255
|
+
this.activeTabId = tab?.id ?? null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Switch to a tab (only if in session)
|
|
260
|
+
*/
|
|
261
|
+
async switchTab(tabId) {
|
|
262
|
+
// Validate tab is in session
|
|
263
|
+
const inSession = await this.isTabInSession(tabId);
|
|
264
|
+
if (!inSession) {
|
|
265
|
+
throw new Error('Cannot switch to tab: tab is not in the active session');
|
|
266
|
+
}
|
|
267
|
+
await new Promise((resolve) => {
|
|
268
|
+
chrome.tabs.update(tabId, { active: true }, () => resolve());
|
|
269
|
+
});
|
|
270
|
+
this.activeTabId = tabId;
|
|
271
|
+
}
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// NAVIGATION
|
|
274
|
+
// ============================================================================
|
|
275
|
+
/**
|
|
276
|
+
* Navigate to a URL (only in session tabs)
|
|
277
|
+
*/
|
|
278
|
+
async navigate(url, options) {
|
|
279
|
+
const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
|
|
280
|
+
if (!tabId)
|
|
281
|
+
throw new Error('No active tab');
|
|
282
|
+
// Validate tab is in session
|
|
283
|
+
const inSession = await this.isTabInSession(tabId);
|
|
284
|
+
if (!inSession) {
|
|
285
|
+
throw new Error('Cannot navigate: tab is not in the active session');
|
|
286
|
+
}
|
|
287
|
+
await new Promise((resolve) => {
|
|
288
|
+
chrome.tabs.update(tabId, { url }, () => resolve());
|
|
289
|
+
});
|
|
290
|
+
if (options?.waitUntil) {
|
|
291
|
+
await this.waitForTabLoad(tabId);
|
|
292
|
+
// Clear refs and highlights after navigation completes
|
|
293
|
+
try {
|
|
294
|
+
await this.sendToContentAgent({
|
|
295
|
+
id: `nav_clear_${Date.now()}`,
|
|
296
|
+
action: 'clearHighlight'
|
|
297
|
+
}, tabId);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
// Ignore errors - content script might not be ready yet
|
|
301
|
+
console.log('[BackgroundAgent] Failed to clear highlights after navigation:', error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Go back in history
|
|
307
|
+
*/
|
|
308
|
+
async back() {
|
|
309
|
+
const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
|
|
310
|
+
if (!tabId)
|
|
311
|
+
throw new Error('No active tab');
|
|
312
|
+
await new Promise((resolve) => {
|
|
313
|
+
chrome.tabs.goBack(tabId, () => resolve());
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Go forward in history
|
|
318
|
+
*/
|
|
319
|
+
async forward() {
|
|
320
|
+
const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
|
|
321
|
+
if (!tabId)
|
|
322
|
+
throw new Error('No active tab');
|
|
323
|
+
await new Promise((resolve) => {
|
|
324
|
+
chrome.tabs.goForward(tabId, () => resolve());
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Reload the current page
|
|
329
|
+
*/
|
|
330
|
+
async reload(options) {
|
|
331
|
+
const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
|
|
332
|
+
if (!tabId)
|
|
333
|
+
throw new Error('No active tab');
|
|
334
|
+
await new Promise((resolve) => {
|
|
335
|
+
chrome.tabs.reload(tabId, { bypassCache: options?.bypassCache }, () => resolve());
|
|
336
|
+
});
|
|
337
|
+
await this.waitForTabLoad(tabId);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Get the current URL
|
|
341
|
+
*/
|
|
342
|
+
async getUrl() {
|
|
343
|
+
const tab = await this.getActiveTab();
|
|
344
|
+
return tab?.url || '';
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Get the page title
|
|
348
|
+
*/
|
|
349
|
+
async getTitle() {
|
|
350
|
+
const tab = await this.getActiveTab();
|
|
351
|
+
return tab?.title || '';
|
|
352
|
+
}
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// SCREENSHOTS
|
|
355
|
+
// ============================================================================
|
|
356
|
+
/**
|
|
357
|
+
* Capture a screenshot of the visible tab
|
|
358
|
+
*/
|
|
359
|
+
async screenshot(options) {
|
|
360
|
+
const format = options?.format || 'png';
|
|
361
|
+
const quality = options?.quality;
|
|
362
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
363
|
+
chrome.tabs.captureVisibleTab({ format, quality }, (url) => {
|
|
364
|
+
if (chrome.runtime.lastError) {
|
|
365
|
+
reject(new Error(chrome.runtime.lastError.message));
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
resolve(url);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
// Extract base64 data from data URL
|
|
373
|
+
return dataUrl.split(',')[1];
|
|
374
|
+
}
|
|
375
|
+
// ============================================================================
|
|
376
|
+
// COMMAND EXECUTION
|
|
377
|
+
// ============================================================================
|
|
378
|
+
/**
|
|
379
|
+
* Execute a command - routes to appropriate handler
|
|
380
|
+
*
|
|
381
|
+
* Browser-level commands (navigate, screenshot, tabs) are handled here.
|
|
382
|
+
* DOM-level commands are forwarded to the ContentAgent in the target tab.
|
|
383
|
+
*
|
|
384
|
+
* @param command - The command to execute
|
|
385
|
+
* @param options - Optional settings including target tabId
|
|
386
|
+
*
|
|
387
|
+
* @example Default (active tab)
|
|
388
|
+
* ```typescript
|
|
389
|
+
* await browser.execute({ id: '1', action: 'snapshot' });
|
|
390
|
+
* ```
|
|
391
|
+
*
|
|
392
|
+
* @example Specific tab
|
|
393
|
+
* ```typescript
|
|
394
|
+
* await browser.execute({ id: '1', action: 'snapshot' }, { tabId: 123 });
|
|
395
|
+
* ```
|
|
396
|
+
*/
|
|
397
|
+
async execute(command, options) {
|
|
398
|
+
try {
|
|
399
|
+
// Extension commands are handled directly by BrowserAgent
|
|
400
|
+
if (this.isExtensionCommand(command)) {
|
|
401
|
+
return this.executeExtensionCommand(command);
|
|
402
|
+
}
|
|
403
|
+
// DOM commands are forwarded to ContentAgent in the target tab
|
|
404
|
+
return this.sendToContentAgent(command, options?.tabId);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
return {
|
|
408
|
+
id: command.id,
|
|
409
|
+
success: false,
|
|
410
|
+
error: error instanceof Error ? error.message : String(error),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Send a command to the ContentAgent in a specific tab
|
|
416
|
+
*/
|
|
417
|
+
async sendToContentAgent(command, tabId) {
|
|
418
|
+
const targetTabId = tabId ?? this.activeTabId ?? (await this.getActiveTab())?.id;
|
|
419
|
+
if (!targetTabId) {
|
|
420
|
+
return {
|
|
421
|
+
id: command.id,
|
|
422
|
+
success: false,
|
|
423
|
+
error: 'No active tab for DOM command',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
// Try sending with automatic retry and recovery
|
|
427
|
+
return this.sendMessageWithRetry(targetTabId, command);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Send message with automatic content script re-injection on failure
|
|
431
|
+
*/
|
|
432
|
+
async sendMessageWithRetry(tabId, command, retries = 1) {
|
|
433
|
+
return new Promise((resolve) => {
|
|
434
|
+
chrome.tabs.sendMessage(tabId, { type: 'btcp:command', command }, { frameId: 0 }, // Target only the main frame, not iframes
|
|
435
|
+
async (response) => {
|
|
436
|
+
if (chrome.runtime.lastError) {
|
|
437
|
+
// Content script not responding - try re-injection
|
|
438
|
+
if (retries > 0) {
|
|
439
|
+
console.log(`[Recovery] Re-injecting content script into tab ${tabId}`);
|
|
440
|
+
const success = await this.reinjectContentScript(tabId);
|
|
441
|
+
if (success) {
|
|
442
|
+
// Wait briefly for content script to initialize
|
|
443
|
+
await new Promise(r => setTimeout(r, 500));
|
|
444
|
+
// Retry the command
|
|
445
|
+
resolve(this.sendMessageWithRetry(tabId, command, retries - 1));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
resolve({
|
|
450
|
+
id: command.id,
|
|
451
|
+
success: false,
|
|
452
|
+
error: chrome.runtime.lastError.message || 'Failed to send message to tab',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
const resp = response;
|
|
457
|
+
if (resp.type === 'btcp:response') {
|
|
458
|
+
resolve(resp.response);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
resolve({
|
|
462
|
+
id: command.id,
|
|
463
|
+
success: false,
|
|
464
|
+
error: 'Invalid response type',
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Re-inject content script into a tab (for recovery from frozen state)
|
|
473
|
+
*/
|
|
474
|
+
async reinjectContentScript(tabId) {
|
|
475
|
+
try {
|
|
476
|
+
// Check if tab is ready for injection
|
|
477
|
+
const tab = await chrome.tabs.get(tabId);
|
|
478
|
+
if (!tab.url || tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
|
|
479
|
+
return false; // Can't inject into chrome:// or extension pages
|
|
480
|
+
}
|
|
481
|
+
// Execute content script
|
|
482
|
+
await chrome.scripting.executeScript({
|
|
483
|
+
target: { tabId },
|
|
484
|
+
files: ['content.js'],
|
|
485
|
+
});
|
|
486
|
+
console.log(`[Recovery] Successfully re-injected content script into tab ${tabId}`);
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
console.error(`[Recovery] Failed to re-inject content script:`, error);
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Start heartbeat to monitor session tabs
|
|
496
|
+
*/
|
|
497
|
+
startHeartbeat() {
|
|
498
|
+
this.heartbeatInterval = setInterval(() => {
|
|
499
|
+
this.pingSessionTabs();
|
|
500
|
+
}, this.HEARTBEAT_INTERVAL);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Stop heartbeat (for cleanup)
|
|
504
|
+
* Note: Currently not called as service workers are terminated by Chrome
|
|
505
|
+
* Could be used if explicit cleanup is needed in the future
|
|
506
|
+
*/
|
|
507
|
+
// @ts-expect-error - Unused but kept for potential future use
|
|
508
|
+
_stopHeartbeat() {
|
|
509
|
+
if (this.heartbeatInterval) {
|
|
510
|
+
clearInterval(this.heartbeatInterval);
|
|
511
|
+
this.heartbeatInterval = null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Ping all session tabs to check health
|
|
516
|
+
*/
|
|
517
|
+
async pingSessionTabs() {
|
|
518
|
+
try {
|
|
519
|
+
const tabs = await this.listTabs().catch(() => []);
|
|
520
|
+
for (const tab of tabs) {
|
|
521
|
+
chrome.tabs.sendMessage(tab.id, { type: 'btcp:ping' }, { frameId: 0 }, // Target only the main frame, not iframes
|
|
522
|
+
(response) => {
|
|
523
|
+
if (chrome.runtime.lastError) {
|
|
524
|
+
console.log(`[Heartbeat] Tab ${tab.id} unresponsive, will re-inject on next command`);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
const resp = response;
|
|
528
|
+
if (resp.type === 'btcp:pong' && !resp.ready) {
|
|
529
|
+
console.log(`[Heartbeat] Tab ${tab.id} content script not ready`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
// Silently ignore errors during heartbeat (e.g., no active session)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// ============================================================================
|
|
540
|
+
// PRIVATE HELPERS
|
|
541
|
+
// ============================================================================
|
|
542
|
+
isExtensionCommand(command) {
|
|
543
|
+
const extensionActions = [
|
|
544
|
+
'navigate', 'back', 'forward', 'reload',
|
|
545
|
+
'getUrl', 'getTitle', 'screenshot',
|
|
546
|
+
'tabNew', 'tabClose', 'tabSwitch', 'tabList',
|
|
547
|
+
'groupCreate', 'groupUpdate', 'groupDelete', 'groupList',
|
|
548
|
+
'groupAddTabs', 'groupRemoveTabs', 'groupGet',
|
|
549
|
+
'sessionGetCurrent', 'popupInitialize',
|
|
550
|
+
];
|
|
551
|
+
return extensionActions.includes(command.action);
|
|
552
|
+
}
|
|
553
|
+
async executeExtensionCommand(command) {
|
|
554
|
+
switch (command.action) {
|
|
555
|
+
case 'navigate':
|
|
556
|
+
await this.navigate(command.url, { waitUntil: command.waitUntil });
|
|
557
|
+
return { id: command.id, success: true, data: { url: command.url } };
|
|
558
|
+
case 'back':
|
|
559
|
+
await this.back();
|
|
560
|
+
return { id: command.id, success: true, data: { navigated: 'back' } };
|
|
561
|
+
case 'forward':
|
|
562
|
+
await this.forward();
|
|
563
|
+
return { id: command.id, success: true, data: { navigated: 'forward' } };
|
|
564
|
+
case 'reload':
|
|
565
|
+
await this.reload({ bypassCache: command.bypassCache });
|
|
566
|
+
return { id: command.id, success: true, data: { reloaded: true } };
|
|
567
|
+
case 'getUrl': {
|
|
568
|
+
const url = await this.getUrl();
|
|
569
|
+
return { id: command.id, success: true, data: { url } };
|
|
570
|
+
}
|
|
571
|
+
case 'getTitle': {
|
|
572
|
+
const title = await this.getTitle();
|
|
573
|
+
return { id: command.id, success: true, data: { title } };
|
|
574
|
+
}
|
|
575
|
+
case 'screenshot': {
|
|
576
|
+
const screenshot = await this.screenshot({
|
|
577
|
+
format: command.format,
|
|
578
|
+
quality: command.quality,
|
|
579
|
+
});
|
|
580
|
+
return { id: command.id, success: true, data: { screenshot, format: command.format || 'png' } };
|
|
581
|
+
}
|
|
582
|
+
case 'tabNew': {
|
|
583
|
+
const tab = await this.newTab({ url: command.url, active: command.active });
|
|
584
|
+
return { id: command.id, success: true, data: { tabId: tab.id, url: tab.url } };
|
|
585
|
+
}
|
|
586
|
+
case 'tabClose':
|
|
587
|
+
await this.closeTab(command.tabId);
|
|
588
|
+
return { id: command.id, success: true, data: { closed: command.tabId ?? this.activeTabId } };
|
|
589
|
+
case 'tabSwitch':
|
|
590
|
+
await this.switchTab(command.tabId);
|
|
591
|
+
return { id: command.id, success: true, data: { switched: command.tabId } };
|
|
592
|
+
case 'tabList': {
|
|
593
|
+
const tabs = await this.listTabs();
|
|
594
|
+
return { id: command.id, success: true, data: { tabs } };
|
|
595
|
+
}
|
|
596
|
+
case 'groupCreate': {
|
|
597
|
+
const group = await this.sessionManager.createGroup({
|
|
598
|
+
tabIds: command.tabIds,
|
|
599
|
+
title: command.title,
|
|
600
|
+
color: command.color,
|
|
601
|
+
collapsed: command.collapsed,
|
|
602
|
+
});
|
|
603
|
+
return { id: command.id, success: true, data: { group } };
|
|
604
|
+
}
|
|
605
|
+
case 'groupUpdate': {
|
|
606
|
+
const group = await this.sessionManager.updateGroup(command.groupId, {
|
|
607
|
+
title: command.title,
|
|
608
|
+
color: command.color,
|
|
609
|
+
collapsed: command.collapsed,
|
|
610
|
+
});
|
|
611
|
+
return { id: command.id, success: true, data: { group } };
|
|
612
|
+
}
|
|
613
|
+
case 'groupDelete':
|
|
614
|
+
await this.sessionManager.deleteGroup(command.groupId);
|
|
615
|
+
return { id: command.id, success: true, data: { deleted: command.groupId } };
|
|
616
|
+
case 'groupList': {
|
|
617
|
+
const groups = await this.sessionManager.listGroups();
|
|
618
|
+
return { id: command.id, success: true, data: { groups } };
|
|
619
|
+
}
|
|
620
|
+
case 'groupAddTabs':
|
|
621
|
+
await this.sessionManager.addTabsToGroup(command.groupId, command.tabIds);
|
|
622
|
+
return { id: command.id, success: true, data: { addedTabs: command.tabIds } };
|
|
623
|
+
case 'groupRemoveTabs':
|
|
624
|
+
await this.sessionManager.removeTabsFromGroup(command.tabIds);
|
|
625
|
+
return { id: command.id, success: true, data: { removedTabs: command.tabIds } };
|
|
626
|
+
case 'groupGet': {
|
|
627
|
+
const group = await this.sessionManager.getGroup(command.groupId);
|
|
628
|
+
return { id: command.id, success: true, data: { group } };
|
|
629
|
+
}
|
|
630
|
+
case 'sessionGetCurrent': {
|
|
631
|
+
const session = await this.sessionManager.getCurrentSession();
|
|
632
|
+
return { id: command.id, success: true, data: { session } };
|
|
633
|
+
}
|
|
634
|
+
case 'popupInitialize': {
|
|
635
|
+
console.log('[BackgroundAgent] Popup initializing, checking for session reconnection...');
|
|
636
|
+
// Check if we have a stored session but no active connection
|
|
637
|
+
const sessionGroupId = this.sessionManager.getActiveSessionGroupId();
|
|
638
|
+
if (sessionGroupId === null) {
|
|
639
|
+
// Try to reconnect from storage
|
|
640
|
+
const result = await chrome.storage.session.get('btcp_active_session');
|
|
641
|
+
const stored = result['btcp_active_session'];
|
|
642
|
+
if (stored?.groupId) {
|
|
643
|
+
console.log('[BackgroundAgent] Found stored session, attempting reconnection...');
|
|
644
|
+
const reconnected = await this.sessionManager.reconnectSession(stored.groupId);
|
|
645
|
+
return {
|
|
646
|
+
id: command.id,
|
|
647
|
+
success: true,
|
|
648
|
+
data: { initialized: true, reconnected },
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { id: command.id, success: true, data: { initialized: true, reconnected: false } };
|
|
653
|
+
}
|
|
654
|
+
default:
|
|
655
|
+
throw new Error(`Unknown extension action: ${command.action}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
waitForTabLoad(tabId, timeout = 30000) {
|
|
659
|
+
return new Promise((resolve, reject) => {
|
|
660
|
+
const startTime = Date.now();
|
|
661
|
+
const checkTab = () => {
|
|
662
|
+
chrome.tabs.get(tabId, (tab) => {
|
|
663
|
+
if (chrome.runtime.lastError) {
|
|
664
|
+
reject(new Error(chrome.runtime.lastError.message));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (tab.status === 'complete') {
|
|
668
|
+
resolve();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (Date.now() - startTime > timeout) {
|
|
672
|
+
reject(new Error('Tab load timeout'));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
setTimeout(checkTab, 100);
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
checkTab();
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// ============================================================================
|
|
683
|
+
// MESSAGE LISTENER SETUP
|
|
684
|
+
// ============================================================================
|
|
685
|
+
// Singleton instance for message handling
|
|
686
|
+
let backgroundAgent = null;
|
|
687
|
+
/**
|
|
688
|
+
* Get or create the BackgroundAgent singleton
|
|
689
|
+
*/
|
|
690
|
+
export function getBackgroundAgent() {
|
|
691
|
+
if (!backgroundAgent) {
|
|
692
|
+
backgroundAgent = new BackgroundAgent();
|
|
693
|
+
}
|
|
694
|
+
return backgroundAgent;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* @deprecated Use getBackgroundAgent instead
|
|
698
|
+
*/
|
|
699
|
+
export const getBrowserAgent = getBackgroundAgent;
|
|
700
|
+
/**
|
|
701
|
+
* Set up the message listener for the background script
|
|
702
|
+
* Call this once in your background.ts to enable command routing
|
|
703
|
+
*/
|
|
704
|
+
export function setupMessageListener() {
|
|
705
|
+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
706
|
+
const msg = message;
|
|
707
|
+
if (msg.type !== 'btcp:command') {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
const agent = getBackgroundAgent();
|
|
711
|
+
// Execute the command (BackgroundAgent handles routing to correct tab)
|
|
712
|
+
agent.execute(msg.command)
|
|
713
|
+
.then((response) => {
|
|
714
|
+
sendResponse({ type: 'btcp:response', response });
|
|
715
|
+
})
|
|
716
|
+
.catch((error) => {
|
|
717
|
+
sendResponse({
|
|
718
|
+
type: 'btcp:response',
|
|
719
|
+
response: {
|
|
720
|
+
id: msg.command.id,
|
|
721
|
+
success: false,
|
|
722
|
+
error: error instanceof Error ? error.message : String(error),
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
return true; // Keep channel open for async response
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
// Legacy exports for backwards compatibility
|
|
730
|
+
export const handleCommand = (command, _tabId) => getBackgroundAgent().execute(command);
|
|
731
|
+
export const executeExtensionCommand = (command) => getBackgroundAgent().execute(command);
|
|
732
|
+
export const sendToContentScript = (_tabId, command) => getBackgroundAgent().sendToContentAgent(command, _tabId);
|
|
733
|
+
/**
|
|
734
|
+
* @deprecated Use BackgroundAgent instead
|
|
735
|
+
*/
|
|
736
|
+
export const BrowserAgent = BackgroundAgent;
|
|
737
|
+
//# sourceMappingURL=background.js.map
|