dsh-sidebar-browser-control 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +156 -2
- package/README.zh.md +141 -0
- package/client.js +783 -0
- package/cordis.patch.yml +12 -0
- package/icon.svg +7 -0
- package/index.js +561 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
package/client.js
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-sidebar-browser-control — Client half.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the DSH Web page (Desktop app or browser tab) and owns everything the
|
|
5
|
+
* Host cannot reach: `ctx.sidebarRight` (the right-hand Sidebar's navigation
|
|
6
|
+
* controller) and, on Desktop, the live `<webview>` of each Browser tab.
|
|
7
|
+
*
|
|
8
|
+
* It holds one long-poll open to the Host half, executes the commands that
|
|
9
|
+
* arrive through it, and reports each result back. Nothing here runs on a timer.
|
|
10
|
+
*
|
|
11
|
+
* Tab identity: a Sidebar layout tab id is unique per Session only, so every
|
|
12
|
+
* tab this plugin reports carries the Session that owns it. A `tab_id` that is
|
|
13
|
+
* ambiguous across Sessions is reported and accepted as `<sessionId>#<tabId>`,
|
|
14
|
+
* and every lookup is scoped by Session when one is known.
|
|
15
|
+
*
|
|
16
|
+
* Carrier note: the Browser tab is an Electron `<webview>` on Desktop and a
|
|
17
|
+
* sandboxed `<iframe>` in the browser. Layout, tab lifecycle and navigation are
|
|
18
|
+
* available on both; reading the page, evaluating JavaScript, clicking and
|
|
19
|
+
* typing need the webview, so they report an explicit limitation in a browser
|
|
20
|
+
* tab instead of failing silently.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately absent: screenshots. `webview.capturePage()` crashed the Electron
|
|
23
|
+
* renderer on this deployment (2026-10-11, app 0.2.0-rc.2 / Electron 44), so the
|
|
24
|
+
* capability is not offered at all rather than shipped as a landmine.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
id: 'dsh-sidebar-browser-control',
|
|
29
|
+
factory: () => {
|
|
30
|
+
/** Route prefix owned by the Host half. */
|
|
31
|
+
const BASE = '/sidebar-browser-control';
|
|
32
|
+
/** Retry delay after a transport failure. */
|
|
33
|
+
const RETRY_MS = 1500;
|
|
34
|
+
|
|
35
|
+
/** Bridge state, owned by one page. */
|
|
36
|
+
const state = {
|
|
37
|
+
clientId: undefined,
|
|
38
|
+
stopped: true,
|
|
39
|
+
refusals: 0
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Plugin context, captured on apply. */
|
|
43
|
+
let context;
|
|
44
|
+
|
|
45
|
+
/** @param ms - milliseconds to wait. @returns a promise resolving afterwards. */
|
|
46
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
|
|
48
|
+
/** @returns whether this page is the Desktop application. */
|
|
49
|
+
function isDesktop() {
|
|
50
|
+
const carrier = globalThis.dshDesktop;
|
|
51
|
+
return carrier !== undefined && carrier !== null && carrier.protocolVersion === 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @returns the platform token reported to the Host. */
|
|
55
|
+
function platformName() {
|
|
56
|
+
return isDesktop() ? 'desktop' : 'web';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @returns the capability list reported to the Host. */
|
|
60
|
+
function capabilities() {
|
|
61
|
+
return isDesktop() ? ['tabs', 'control', 'page'] : ['tabs', 'control'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @returns the sidebar navigation controller. */
|
|
65
|
+
function sidebar() {
|
|
66
|
+
const service = context?.get('sidebarRight');
|
|
67
|
+
if (service === undefined || service === null) {
|
|
68
|
+
throw new Error('DSH 的右侧栏服务(sidebarRight)当前不可用,因此无法控制内置浏览器。');
|
|
69
|
+
}
|
|
70
|
+
return service;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param text - candidate text. @param limit - maximum length. @returns truncated text. */
|
|
74
|
+
function clamp(text, limit) {
|
|
75
|
+
if (text.length <= limit) return text;
|
|
76
|
+
return `${text.slice(0, limit)}\n…(已截断,共 ${text.length} 字符)`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @returns every Browser tab DSH knows about, across Sessions. */
|
|
80
|
+
function browserEntries() {
|
|
81
|
+
const entries = (sidebar().openTabs?.getSnapshot?.() ?? []).filter((entry) => entry.kind === 'browser');
|
|
82
|
+
const counts = new Map();
|
|
83
|
+
for (const entry of entries) counts.set(entry.tabId, (counts.get(entry.tabId) ?? 0) + 1);
|
|
84
|
+
return entries.map((entry) => ({
|
|
85
|
+
...entry,
|
|
86
|
+
handle: counts.get(entry.tabId) > 1 ? `${entry.sessionId}#${entry.tabId}` : entry.tabId
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Split a tab handle into its Session and layout id.
|
|
92
|
+
* @param handle - `tab7`, or `sessionId#tab7` for an ambiguous id.
|
|
93
|
+
* @returns `{ tabId, sessionId }`.
|
|
94
|
+
*/
|
|
95
|
+
function parseHandle(handle) {
|
|
96
|
+
const text = String(handle ?? '').trim();
|
|
97
|
+
if (text.length === 0) throw new Error('标签页标识不能为空。');
|
|
98
|
+
const separator = text.indexOf('#');
|
|
99
|
+
if (separator < 0) return { tabId: text, sessionId: undefined };
|
|
100
|
+
return { tabId: text.slice(separator + 1), sessionId: text.slice(0, separator) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @returns the on-screen Session id, when a Session is on screen. */
|
|
104
|
+
function onScreenSession() {
|
|
105
|
+
try {
|
|
106
|
+
return sidebar().mounted?.getSnapshot?.();
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve one tab handle to the entry that owns it.
|
|
114
|
+
* @param handle - tab id or `sessionId#tabId`.
|
|
115
|
+
* @returns the matching inventory entry.
|
|
116
|
+
*/
|
|
117
|
+
function findEntry(handle) {
|
|
118
|
+
const { tabId, sessionId } = parseHandle(handle);
|
|
119
|
+
const entries = browserEntries();
|
|
120
|
+
const scoped = sessionId === undefined ? entries : entries.filter((entry) => entry.sessionId === sessionId);
|
|
121
|
+
const exact = scoped.filter((entry) => entry.tabId === tabId);
|
|
122
|
+
if (exact.length === 1) return exact[0];
|
|
123
|
+
if (exact.length === 0) {
|
|
124
|
+
throw new Error(sessionId === undefined
|
|
125
|
+
? `找不到内置浏览器标签页 ${tabId};可用 browser_tabs 查看当前打开的内置浏览器标签页。`
|
|
126
|
+
: `会话 ${sessionId} 里没有内置浏览器标签页 ${tabId}。`);
|
|
127
|
+
}
|
|
128
|
+
const screen = onScreenSession();
|
|
129
|
+
const preferred = exact.find((entry) => entry.sessionId === screen);
|
|
130
|
+
if (preferred !== undefined) return preferred;
|
|
131
|
+
throw new Error(`标签页 ${tabId} 在多个会话里都存在;请改用带会话前缀的标识:${exact.map((entry) => entry.handle).join(' 或 ')}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The tab a command without `tab_id` acts on: the active tab when it is a
|
|
136
|
+
* Browser tab, otherwise the newest Browser tab of the on-screen Session.
|
|
137
|
+
* @returns an inventory entry, or undefined when no Browser tab is open.
|
|
138
|
+
*/
|
|
139
|
+
function defaultEntry() {
|
|
140
|
+
const entries = browserEntries();
|
|
141
|
+
if (entries.length === 0) return undefined;
|
|
142
|
+
const screen = onScreenSession();
|
|
143
|
+
const active = activeTabEntry();
|
|
144
|
+
if (active !== undefined && entries.some((entry) => entry.handle === active.handle)) return entries.find((entry) => entry.handle === active.handle);
|
|
145
|
+
const sameSession = entries.filter((entry) => entry.sessionId === screen);
|
|
146
|
+
const pool = sameSession.length > 0 ? sameSession : entries;
|
|
147
|
+
return pool[pool.length - 1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @returns the active tab of the active pane as an inventory entry, if it is a Browser tab. */
|
|
151
|
+
function activeTabEntry() {
|
|
152
|
+
try {
|
|
153
|
+
const record = sidebar().active?.();
|
|
154
|
+
if (record === undefined || record.kind !== 'browser') return undefined;
|
|
155
|
+
const screen = onScreenSession();
|
|
156
|
+
if (screen === undefined) return undefined;
|
|
157
|
+
return browserEntries().find((entry) => entry.sessionId === screen && entry.tabId === record.id);
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Find one Session's Sidebar subtree in the document.
|
|
165
|
+
* @param sessionId - owning Session.
|
|
166
|
+
* @returns the subtree root, or undefined when it is not rendered.
|
|
167
|
+
*/
|
|
168
|
+
function sessionScope(sessionId) {
|
|
169
|
+
if (sessionId === undefined) return undefined;
|
|
170
|
+
for (const node of document.querySelectorAll('[data-sidebar-right-session]')) {
|
|
171
|
+
if (node.dataset.sidebarRightSession === sessionId) return node;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Find a tab's marker element, scoped by Session when it is known.
|
|
178
|
+
* @param tabId - layout tab id.
|
|
179
|
+
* @param sessionId - owning Session.
|
|
180
|
+
* @returns the marker, if rendered.
|
|
181
|
+
*/
|
|
182
|
+
function tabMarker(tabId, sessionId) {
|
|
183
|
+
const scopes = sessionScope(sessionId) === undefined ? [document] : [sessionScope(sessionId)];
|
|
184
|
+
for (const scope of scopes) {
|
|
185
|
+
for (const node of scope.querySelectorAll('[data-sidebar-right-tab]')) {
|
|
186
|
+
if (node.dataset.sidebarRightTab === tabId) return node;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Find a tab's rendered page carrier.
|
|
194
|
+
* @param tabId - layout tab id.
|
|
195
|
+
* @param sessionId - owning Session.
|
|
196
|
+
* @returns the webview or iframe element, if mounted.
|
|
197
|
+
*/
|
|
198
|
+
function frameFor(tabId, sessionId) {
|
|
199
|
+
const scopes = sessionScope(sessionId) === undefined ? [document] : [sessionScope(sessionId)];
|
|
200
|
+
for (const scope of scopes) {
|
|
201
|
+
for (const frame of scope.querySelectorAll('webview[data-sidebar-browser-frame], iframe[data-sidebar-browser-frame]')) {
|
|
202
|
+
const marker = frame.closest('[data-sidebar-right-tab]');
|
|
203
|
+
if (marker !== null && marker.dataset.sidebarRightTab === tabId) return frame;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @param frame - a carrier element. @returns whether it is the Electron webview. */
|
|
210
|
+
function isWebview(frame) {
|
|
211
|
+
return frame.tagName !== undefined && frame.tagName.toLowerCase() === 'webview';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @param frame - a carrier element. @returns its current address, when readable. */
|
|
215
|
+
function frameUrl(frame) {
|
|
216
|
+
if (frame === undefined) return undefined;
|
|
217
|
+
try {
|
|
218
|
+
if (isWebview(frame)) {
|
|
219
|
+
const url = frame.getURL();
|
|
220
|
+
return typeof url === 'string' && url.length > 0 && url !== 'about:blank' ? url : undefined;
|
|
221
|
+
}
|
|
222
|
+
const src = frame.getAttribute('src');
|
|
223
|
+
return src === null || src.startsWith('about:blank') ? undefined : new URL(src, location.href).href;
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param frame - a carrier element. @returns its document title, when readable. */
|
|
230
|
+
function frameTitle(frame) {
|
|
231
|
+
if (frame === undefined) return '';
|
|
232
|
+
try {
|
|
233
|
+
return isWebview(frame) ? String(frame.getTitle() ?? '') : '';
|
|
234
|
+
} catch {
|
|
235
|
+
return '';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @param entry - inventory entry. @returns the committed tab record. */
|
|
240
|
+
function recordFor(entry) {
|
|
241
|
+
try {
|
|
242
|
+
const service = sidebar();
|
|
243
|
+
if (typeof service.tabsIn !== 'function') return undefined;
|
|
244
|
+
return service.tabsIn(entry.sessionId).find((record) => record.id === entry.tabId);
|
|
245
|
+
} catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @param entry - inventory entry. @returns the pane id holding the tab, when rendered. */
|
|
251
|
+
function paneFor(entry) {
|
|
252
|
+
const marker = tabMarker(entry.tabId, entry.sessionId);
|
|
253
|
+
const pane = marker?.closest('[data-dockkit-pane], [data-dockkit-float]');
|
|
254
|
+
return pane?.getAttribute('data-dockkit-pane') ?? pane?.getAttribute('data-dockkit-float') ?? undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @returns every Browser tab, with the addresses its carrier can report. */
|
|
258
|
+
function listTabs() {
|
|
259
|
+
const service = sidebar();
|
|
260
|
+
const screen = onScreenSession();
|
|
261
|
+
const active = activeTabEntry();
|
|
262
|
+
const tabs = browserEntries().map((entry) => {
|
|
263
|
+
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
264
|
+
const record = recordFor(entry);
|
|
265
|
+
return {
|
|
266
|
+
tab_id: entry.handle,
|
|
267
|
+
title: frameTitle(frame) || record?.title || '',
|
|
268
|
+
url: frameUrl(frame),
|
|
269
|
+
session: entry.sessionId,
|
|
270
|
+
on_screen: entry.sessionId === screen,
|
|
271
|
+
active: active !== undefined && active.handle === entry.handle,
|
|
272
|
+
pane: paneFor(entry)
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
platform: platformName(),
|
|
277
|
+
page_control: isDesktop(),
|
|
278
|
+
panel_open: service.isExpanded?.() === true,
|
|
279
|
+
active_tab: active?.handle,
|
|
280
|
+
tabs
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the carrier and identity a command targets.
|
|
286
|
+
* @param args - command arguments.
|
|
287
|
+
* @returns `{ entry, frame, tabId, sessionId }`.
|
|
288
|
+
*/
|
|
289
|
+
function resolveTarget(args) {
|
|
290
|
+
const requested = typeof args?.tab_id === 'string' && args.tab_id.trim().length > 0 ? args.tab_id.trim() : undefined;
|
|
291
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
292
|
+
if (entry === undefined) {
|
|
293
|
+
throw new Error('内置浏览器当前没有标签页;请先用 browser_control 的 open 打开一个网址。');
|
|
294
|
+
}
|
|
295
|
+
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
296
|
+
if (frame === undefined) {
|
|
297
|
+
throw new Error(`标签页 ${entry.handle} 当前没有已挂载的页面(它可能在别的会话、已收起,或还未渲染)。可先用 browser_control 的 focus 把它带到屏幕上。`);
|
|
298
|
+
}
|
|
299
|
+
return { entry, frame, tabId: entry.tabId, sessionId: entry.sessionId };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Require the Desktop carrier for page-level operations.
|
|
304
|
+
* @param frame - resolved carrier.
|
|
305
|
+
* @returns the webview element.
|
|
306
|
+
*/
|
|
307
|
+
function requireWebview(frame) {
|
|
308
|
+
if (!isWebview(frame)) {
|
|
309
|
+
throw new Error('页面级操作(读取、eval、点击、输入、截图)只在 Desktop 应用的内置浏览器里可用:浏览器版的 iframe 受 sandbox 与同源策略限制,无法读取页面内容。');
|
|
310
|
+
}
|
|
311
|
+
return frame;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Complete a bare host name and validate an address exactly as the Browser's
|
|
316
|
+
* own address bar does.
|
|
317
|
+
* @param input - user or model supplied address.
|
|
318
|
+
* @returns the normalized absolute URL.
|
|
319
|
+
*/
|
|
320
|
+
function parseAddress(input) {
|
|
321
|
+
const raw = String(input ?? '').trim();
|
|
322
|
+
if (raw.length === 0) throw new Error('需要一个网址。');
|
|
323
|
+
if (/\s/u.test(raw)) throw new Error(`网址不能包含空格:${raw}`);
|
|
324
|
+
const candidate = /^[a-z][a-z0-9+.-]*:/iu.test(raw) ? raw : `https://${raw}`;
|
|
325
|
+
let url;
|
|
326
|
+
try {
|
|
327
|
+
url = new URL(candidate);
|
|
328
|
+
} catch {
|
|
329
|
+
throw new Error(`无法识别的网址:${raw}`);
|
|
330
|
+
}
|
|
331
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
332
|
+
throw new Error('内置浏览器只支持 http/https 网址(本地文件请用文档预览)。');
|
|
333
|
+
}
|
|
334
|
+
if (url.username.length > 0 || url.password.length > 0) throw new Error('网址不能包含用户名或密码。');
|
|
335
|
+
if (url.origin === location.origin) throw new Error('不能在嵌入式浏览器里打开 DSH 应用自身。');
|
|
336
|
+
return url.href;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Wait for a freshly opened Browser tab to appear in the inventory.
|
|
341
|
+
* @param known - handles that existed before the open.
|
|
342
|
+
* @param sessionId - the Session the tab was opened in.
|
|
343
|
+
* @param timeoutMs - how long to wait.
|
|
344
|
+
* @returns the new entry, or undefined when the layout never reported one.
|
|
345
|
+
*/
|
|
346
|
+
async function waitForNewEntry(known, sessionId, timeoutMs = 3000) {
|
|
347
|
+
const deadline = Date.now() + timeoutMs;
|
|
348
|
+
while (Date.now() < deadline) {
|
|
349
|
+
const fresh = browserEntries().find((entry) => !known.has(entry.handle) && (sessionId === undefined || entry.sessionId === sessionId));
|
|
350
|
+
if (fresh !== undefined) return fresh;
|
|
351
|
+
await sleep(50);
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Wait for a tab's carrier element to be mounted.
|
|
358
|
+
* @param tabId - layout tab id.
|
|
359
|
+
* @param sessionId - owning Session.
|
|
360
|
+
* @param timeoutMs - how long to wait.
|
|
361
|
+
* @returns the carrier, or undefined.
|
|
362
|
+
*/
|
|
363
|
+
async function waitForFrame(tabId, sessionId, timeoutMs = 5000) {
|
|
364
|
+
const deadline = Date.now() + timeoutMs;
|
|
365
|
+
while (Date.now() < deadline) {
|
|
366
|
+
const frame = frameFor(tabId, sessionId);
|
|
367
|
+
if (frame !== undefined) return frame;
|
|
368
|
+
await sleep(50);
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** @param value - page value. @param limit - maximum JSON length. @returns a JSON-safe value. */
|
|
374
|
+
function clampValue(value, limit) {
|
|
375
|
+
if (value === undefined) return null;
|
|
376
|
+
let text;
|
|
377
|
+
try {
|
|
378
|
+
text = JSON.stringify(value);
|
|
379
|
+
} catch {
|
|
380
|
+
return String(value).slice(0, limit);
|
|
381
|
+
}
|
|
382
|
+
if (text === undefined) return String(value).slice(0, limit);
|
|
383
|
+
if (text.length <= limit) return value;
|
|
384
|
+
return `${text.slice(0, limit)}…(已截断,共 ${text.length} 字符)`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build the in-page expression that resolves a click/wait target.
|
|
389
|
+
* @param selector - optional CSS selector.
|
|
390
|
+
* @param text - optional visible text.
|
|
391
|
+
* @returns JavaScript source evaluating to an element or null.
|
|
392
|
+
*/
|
|
393
|
+
function matchExpression(selector, text) {
|
|
394
|
+
return `(() => {
|
|
395
|
+
const selector = ${JSON.stringify(selector ?? null)};
|
|
396
|
+
const wanted = ${JSON.stringify(text ?? null)};
|
|
397
|
+
if (selector !== null && selector.length > 0) return document.querySelector(selector);
|
|
398
|
+
if (wanted !== null && wanted.length > 0) {
|
|
399
|
+
const nodes = [...document.querySelectorAll('a,button,input,textarea,select,summary,label,li,td,th,span,div,p,[role=button],[role=link],[tabindex]')];
|
|
400
|
+
const read = (node) => (node.innerText || node.value || node.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
401
|
+
return nodes.find((node) => read(node) === wanted) ?? nodes.find((node) => read(node).includes(wanted)) ?? null;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
})()`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Wait for a navigation the carrier started to finish, so a reported URL is
|
|
409
|
+
* the one the model will actually act on.
|
|
410
|
+
* @param view - carrier element.
|
|
411
|
+
* @param previousUrl - address observed before the command.
|
|
412
|
+
* @param timeoutMs - how long to wait.
|
|
413
|
+
* @returns the settled address.
|
|
414
|
+
*/
|
|
415
|
+
async function settle(view, previousUrl, timeoutMs = 8000) {
|
|
416
|
+
const started = Date.now();
|
|
417
|
+
await sleep(150);
|
|
418
|
+
const deadline = started + timeoutMs;
|
|
419
|
+
while (Date.now() < deadline) {
|
|
420
|
+
let loading = false;
|
|
421
|
+
try {
|
|
422
|
+
loading = view.isLoading() === true;
|
|
423
|
+
} catch {
|
|
424
|
+
loading = false;
|
|
425
|
+
}
|
|
426
|
+
const url = frameUrl(view);
|
|
427
|
+
if (!loading && (url !== previousUrl || Date.now() - started > 700)) return url;
|
|
428
|
+
await sleep(120);
|
|
429
|
+
}
|
|
430
|
+
return frameUrl(view);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Run one `control` command through the sidebar controller and the carrier.
|
|
435
|
+
* @param args - command arguments.
|
|
436
|
+
* @returns the operation result.
|
|
437
|
+
*/
|
|
438
|
+
async function control(args) {
|
|
439
|
+
const service = sidebar();
|
|
440
|
+
const action = String(args?.action ?? '').toLowerCase();
|
|
441
|
+
const requested = typeof args?.tab_id === 'string' && args.tab_id.trim().length > 0 ? args.tab_id.trim() : undefined;
|
|
442
|
+
const url = typeof args?.url === 'string' && args.url.length > 0 ? parseAddress(args.url) : undefined;
|
|
443
|
+
|
|
444
|
+
switch (action) {
|
|
445
|
+
case 'open': {
|
|
446
|
+
if (url === undefined) throw new Error('open 需要一个 url 参数。');
|
|
447
|
+
const target = requested === undefined ? undefined : findEntry(requested);
|
|
448
|
+
const known = new Set(browserEntries().map((entry) => entry.handle));
|
|
449
|
+
service.openTab('browser', {
|
|
450
|
+
params: { url },
|
|
451
|
+
...target !== undefined ? { replaceTab: target.tabId } : {},
|
|
452
|
+
revealIfOpened: false
|
|
453
|
+
});
|
|
454
|
+
const screen = onScreenSession();
|
|
455
|
+
const entry = (await waitForNewEntry(known, screen)) ?? (target !== undefined ? target : defaultEntry());
|
|
456
|
+
if (entry !== undefined) await waitForFrame(entry.tabId, entry.sessionId, 3000);
|
|
457
|
+
const frame = entry === undefined ? undefined : frameFor(entry.tabId, entry.sessionId);
|
|
458
|
+
return {
|
|
459
|
+
action,
|
|
460
|
+
tab_id: entry?.handle,
|
|
461
|
+
session: entry?.sessionId,
|
|
462
|
+
url,
|
|
463
|
+
title: frameTitle(frame) || (entry === undefined ? undefined : recordFor(entry)?.title)
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
case 'navigate': {
|
|
467
|
+
if (url === undefined) throw new Error('navigate 需要一个 url 参数。');
|
|
468
|
+
const target = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
469
|
+
if (target === undefined) return await control({ action: 'open', url: args.url });
|
|
470
|
+
const frame = frameFor(target.tabId, target.sessionId);
|
|
471
|
+
if (isWebview(frame)) {
|
|
472
|
+
await frame.loadURL(url);
|
|
473
|
+
return { action, tab_id: target.handle, url, title: frameTitle(frame) };
|
|
474
|
+
}
|
|
475
|
+
service.openTab('browser', { params: { url }, replaceTab: target.tabId, revealIfOpened: false });
|
|
476
|
+
return { action, tab_id: target.handle, url };
|
|
477
|
+
}
|
|
478
|
+
case 'back':
|
|
479
|
+
case 'forward':
|
|
480
|
+
case 'reload': {
|
|
481
|
+
const { frame, entry } = resolveTarget(args);
|
|
482
|
+
const view = requireWebview(frame);
|
|
483
|
+
const before = frameUrl(view);
|
|
484
|
+
if (action === 'reload') view.reload();
|
|
485
|
+
else if (action === 'back') view.goBack();
|
|
486
|
+
else view.goForward();
|
|
487
|
+
return { action, tab_id: entry.handle, url: await settle(view, before), title: frameTitle(view) };
|
|
488
|
+
}
|
|
489
|
+
case 'close': {
|
|
490
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
491
|
+
if (entry === undefined) throw new Error('没有可关闭的标签页。');
|
|
492
|
+
if (entry.sessionId !== onScreenSession()) {
|
|
493
|
+
throw new Error(`标签页 ${entry.handle} 属于另一个会话;请先切换到该会话,或改用带会话前缀的标识在屏幕上打开它。`);
|
|
494
|
+
}
|
|
495
|
+
service.close(entry.tabId);
|
|
496
|
+
return { action, tab_id: entry.handle };
|
|
497
|
+
}
|
|
498
|
+
case 'focus': {
|
|
499
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
500
|
+
if (entry === undefined) throw new Error('没有可聚焦的标签页。');
|
|
501
|
+
service.focus(entry.tabId);
|
|
502
|
+
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
503
|
+
return { action, tab_id: entry.handle, title: frameTitle(frame) || recordFor(entry)?.title };
|
|
504
|
+
}
|
|
505
|
+
case 'expand': {
|
|
506
|
+
const wasOpen = service.isExpanded() === true;
|
|
507
|
+
if (!wasOpen) service.toggleExpanded();
|
|
508
|
+
return { action, note: wasOpen ? '右侧栏本来就是展开的。' : undefined };
|
|
509
|
+
}
|
|
510
|
+
case 'collapse': {
|
|
511
|
+
const wasOpen = service.isExpanded() === true;
|
|
512
|
+
if (wasOpen) service.toggleExpanded();
|
|
513
|
+
return { action, note: wasOpen ? undefined : '右侧栏本来就是收起的。' };
|
|
514
|
+
}
|
|
515
|
+
case 'fullscreen': {
|
|
516
|
+
const target = service.commandTarget();
|
|
517
|
+
if (target === undefined) throw new Error('当前没有可以切换全屏的停靠分栏(需要屏幕上有会话,且右侧栏已展开)。');
|
|
518
|
+
service.toggleFullscreen(target);
|
|
519
|
+
return { action, tab_id: target.tabId, note: '已切换右侧栏的显示模式。' };
|
|
520
|
+
}
|
|
521
|
+
case 'split': {
|
|
522
|
+
const paneId = service.split();
|
|
523
|
+
return { action, pane: paneId, note: paneId === undefined ? '当前没有可分割的停靠分栏(或已到两格上限)。' : undefined };
|
|
524
|
+
}
|
|
525
|
+
case 'float': {
|
|
526
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
527
|
+
if (entry === undefined) throw new Error('没有可浮出的标签页。');
|
|
528
|
+
service.float(entry.tabId);
|
|
529
|
+
return { action, tab_id: entry.handle };
|
|
530
|
+
}
|
|
531
|
+
case 'dock': {
|
|
532
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
533
|
+
if (entry === undefined) throw new Error('没有可停靠的标签页。');
|
|
534
|
+
const marker = tabMarker(entry.tabId, entry.sessionId);
|
|
535
|
+
const floatPane = marker?.closest('[data-dockkit-float]');
|
|
536
|
+
const paneId = floatPane?.getAttribute('data-dockkit-float') ?? undefined;
|
|
537
|
+
if (paneId === undefined) throw new Error('该标签页当前不在浮窗里。');
|
|
538
|
+
service.dock(paneId);
|
|
539
|
+
return { action, tab_id: entry.handle, pane: paneId };
|
|
540
|
+
}
|
|
541
|
+
case 'external': {
|
|
542
|
+
const { frame, entry } = resolveTarget(args);
|
|
543
|
+
const href = frameUrl(frame);
|
|
544
|
+
if (href === undefined) throw new Error('读不到当前页面的网址,无法在系统浏览器中打开。');
|
|
545
|
+
window.open(href, '_blank', 'noopener,noreferrer');
|
|
546
|
+
return { action, tab_id: entry.handle, url: href };
|
|
547
|
+
}
|
|
548
|
+
default:
|
|
549
|
+
throw new Error(`未知的 control 动作:${action}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Run one `page` command against the tab's webview.
|
|
555
|
+
* @param args - command arguments.
|
|
556
|
+
* @returns the operation result.
|
|
557
|
+
*/
|
|
558
|
+
async function page(args) {
|
|
559
|
+
const action = String(args?.action ?? '').toLowerCase();
|
|
560
|
+
const { frame, entry } = resolveTarget(args);
|
|
561
|
+
const view = requireWebview(frame);
|
|
562
|
+
const limit = Number.isFinite(args?.max_chars) ? Math.max(200, Math.min(200_000, Math.trunc(args.max_chars))) : 8000;
|
|
563
|
+
const base = { action, tab_id: entry.handle, url: frameUrl(view), title: frameTitle(view) };
|
|
564
|
+
|
|
565
|
+
switch (action) {
|
|
566
|
+
case 'text': {
|
|
567
|
+
const text = await view.executeJavaScript('(() => { const body = document.body; return body === null ? "" : body.innerText; })()');
|
|
568
|
+
return { ...base, text: clamp(typeof text === 'string' ? text : JSON.stringify(text), limit) };
|
|
569
|
+
}
|
|
570
|
+
case 'links': {
|
|
571
|
+
const links = await view.executeJavaScript('(() => [...document.querySelectorAll("a[href]")].slice(0, 300).map((node) => ({ text: (node.innerText || node.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 160), href: node.href })).filter((link) => link.href.startsWith("http")))()');
|
|
572
|
+
const list = Array.isArray(links) ? links : [];
|
|
573
|
+
return { ...base, links: list.slice(0, 120), text: clamp(list.map((link) => `- ${link.text} ${link.href}`).join('\n'), limit) };
|
|
574
|
+
}
|
|
575
|
+
case 'elements': {
|
|
576
|
+
const elements = await view.executeJavaScript(`(() => {
|
|
577
|
+
const path = (el) => {
|
|
578
|
+
const parts = [];
|
|
579
|
+
let node = el;
|
|
580
|
+
while (node !== null && node.nodeType === 1 && parts.length < 6) {
|
|
581
|
+
let part = node.tagName.toLowerCase();
|
|
582
|
+
if (node.id) { parts.unshift(part + '#' + node.id); break; }
|
|
583
|
+
const parent = node.parentElement;
|
|
584
|
+
if (parent !== null) {
|
|
585
|
+
const same = [...parent.children].filter((child) => child.tagName === node.tagName);
|
|
586
|
+
if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
|
|
587
|
+
}
|
|
588
|
+
parts.unshift(part);
|
|
589
|
+
node = parent;
|
|
590
|
+
}
|
|
591
|
+
return parts.join(' > ');
|
|
592
|
+
};
|
|
593
|
+
return [...document.querySelectorAll('a[href],button,input,textarea,select,summary,[role=button],[role=link],[contenteditable=true]')]
|
|
594
|
+
.filter((el) => { const rect = el.getBoundingClientRect(); return rect.width > 1 && rect.height > 1; })
|
|
595
|
+
.slice(0, 80)
|
|
596
|
+
.map((el) => ({ tag: el.tagName.toLowerCase(), selector: path(el), text: (el.innerText || el.value || el.getAttribute('aria-label') || el.placeholder || '').replace(/\\s+/g, ' ').trim().slice(0, 120), href: el.getAttribute('href') || undefined }));
|
|
597
|
+
})()`);
|
|
598
|
+
const list = Array.isArray(elements) ? elements : [];
|
|
599
|
+
return { ...base, elements: list, text: clamp(list.map((element) => `- ${element.selector} — ${element.text}${element.href !== undefined ? ` (${element.href})` : ''}`).join('\n'), limit) };
|
|
600
|
+
}
|
|
601
|
+
case 'eval': {
|
|
602
|
+
if (typeof args?.code !== 'string' || args.code.trim().length === 0) throw new Error('eval 需要一个 code 参数。');
|
|
603
|
+
const value = await view.executeJavaScript(args.code, true);
|
|
604
|
+
return { ...base, value: clampValue(value, limit) };
|
|
605
|
+
}
|
|
606
|
+
case 'click': {
|
|
607
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
608
|
+
const wanted = typeof args?.text === 'string' && args.text.length > 0 ? args.text : undefined;
|
|
609
|
+
if (selector === undefined && wanted === undefined) throw new Error('click 需要 selector 或 text 参数。');
|
|
610
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
611
|
+
const el = ${matchExpression(selector, wanted)};
|
|
612
|
+
if (el === null) return { ok: false, reason: 'not-found' };
|
|
613
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
614
|
+
el.click();
|
|
615
|
+
return { ok: true, tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200) };
|
|
616
|
+
})()`, true);
|
|
617
|
+
if (outcome?.ok !== true) throw new Error(`没有找到可点击的目标(${selector ?? wanted})。可先用 browser_page 的 elements 查看可用元素。`);
|
|
618
|
+
return { ...base, value: outcome, url: frameUrl(view), note: '已派发点击。' };
|
|
619
|
+
}
|
|
620
|
+
case 'type': {
|
|
621
|
+
if (typeof args?.text !== 'string') throw new Error('type 需要一个 text 参数。');
|
|
622
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
623
|
+
const submit = args?.submit === true;
|
|
624
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
625
|
+
const el = ${selector !== undefined ? `document.querySelector(${JSON.stringify(selector)})` : 'document.activeElement'};
|
|
626
|
+
if (el === null || el === undefined || !('value' in el)) return { ok: false, reason: 'not-editable' };
|
|
627
|
+
el.focus();
|
|
628
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
629
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
630
|
+
if (setter !== undefined) setter.call(el, ${JSON.stringify(args.text)}); else el.value = ${JSON.stringify(args.text)};
|
|
631
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
632
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
633
|
+
if (${submit ? 'true' : 'false'}) {
|
|
634
|
+
const form = el.form;
|
|
635
|
+
if (form !== null && form !== undefined && typeof form.requestSubmit === 'function') form.requestSubmit();
|
|
636
|
+
else el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
|
637
|
+
}
|
|
638
|
+
return { ok: true, tag: el.tagName.toLowerCase(), value: el.value };
|
|
639
|
+
})()`, true);
|
|
640
|
+
if (outcome?.ok !== true) throw new Error('没有找到可输入的输入框(可用 selector 指定,或先 click 聚焦它)。');
|
|
641
|
+
return { ...base, value: { tag: outcome.tag, value: String(outcome.value ?? '').slice(0, 200) }, note: submit ? '已填写并提交。' : '已填写。' };
|
|
642
|
+
}
|
|
643
|
+
case 'scroll': {
|
|
644
|
+
const pixels = Number.isFinite(args?.ms) ? Math.trunc(args.ms) : 0;
|
|
645
|
+
await view.executeJavaScript(`(() => { window.scrollBy({ top: ${pixels} === 0 ? Math.round(window.innerHeight * 0.9) : ${pixels}, behavior: 'smooth' }); return true; })()`);
|
|
646
|
+
await sleep(450);
|
|
647
|
+
const position = await view.executeJavaScript('(() => ({ x: window.scrollX, y: window.scrollY, height: document.documentElement.scrollHeight, viewport: window.innerHeight }))()');
|
|
648
|
+
return { ...base, value: position };
|
|
649
|
+
}
|
|
650
|
+
case 'wait': {
|
|
651
|
+
const timeout = Number.isFinite(args?.ms) ? Math.max(200, Math.min(60_000, Math.trunc(args.ms))) : 10_000;
|
|
652
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
653
|
+
const wanted = typeof args?.text === 'string' && args.text.length > 0 ? args.text : undefined;
|
|
654
|
+
const condition = selector !== undefined || wanted !== undefined
|
|
655
|
+
? `(() => { const el = ${matchExpression(selector, wanted)}; return el !== null; })()`
|
|
656
|
+
: '(() => document.readyState === "complete")()';
|
|
657
|
+
const deadline = Date.now() + timeout;
|
|
658
|
+
while (Date.now() < deadline) {
|
|
659
|
+
const ready = await view.executeJavaScript(condition);
|
|
660
|
+
if (ready === true) return { ...base, note: '条件已满足。' };
|
|
661
|
+
await sleep(250);
|
|
662
|
+
}
|
|
663
|
+
throw new Error(`等待超时(${timeout}ms):${selector ?? wanted ?? '页面加载'} 未出现。`);
|
|
664
|
+
}
|
|
665
|
+
case 'screenshot':
|
|
666
|
+
throw new Error('本插件不提供截图:webview.capturePage() 会让 Electron 渲染进程原生崩溃(已实测),因此该能力被刻意移除。需要页面图像时,用 browser_control 的 external 在系统浏览器中打开。');
|
|
667
|
+
default:
|
|
668
|
+
throw new Error(`未知的 page 动作:${action}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Execute one command from the Host half.
|
|
674
|
+
* @param command - `{ id, op, args }`.
|
|
675
|
+
* @returns the operation result.
|
|
676
|
+
*/
|
|
677
|
+
async function execute(command) {
|
|
678
|
+
switch (command.op) {
|
|
679
|
+
case 'tabs':
|
|
680
|
+
return listTabs();
|
|
681
|
+
case 'control':
|
|
682
|
+
return await control(command.args ?? {});
|
|
683
|
+
case 'page':
|
|
684
|
+
return await page(command.args ?? {});
|
|
685
|
+
default:
|
|
686
|
+
throw new Error(`未知的内置浏览器操作:${command.op}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Report one settled command back to the Host half.
|
|
692
|
+
* @param id - command id.
|
|
693
|
+
* @param ok - whether the operation succeeded.
|
|
694
|
+
* @param value - result value.
|
|
695
|
+
* @param error - failure message.
|
|
696
|
+
*/
|
|
697
|
+
async function report(id, ok, value, error) {
|
|
698
|
+
try {
|
|
699
|
+
await fetch(`${BASE}/result`, {
|
|
700
|
+
method: 'POST',
|
|
701
|
+
headers: { 'content-type': 'application/json' },
|
|
702
|
+
body: JSON.stringify({ clientId: state.clientId, id, ok, value: ok ? value : undefined, error: ok ? undefined : error })
|
|
703
|
+
});
|
|
704
|
+
} catch (failure) {
|
|
705
|
+
console.error('[sidebar-browser-control] 汇报结果失败', failure);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Run one command and report its outcome.
|
|
711
|
+
* @param command - `{ id, op, args }`.
|
|
712
|
+
*/
|
|
713
|
+
async function run(command) {
|
|
714
|
+
try {
|
|
715
|
+
const value = await execute(command);
|
|
716
|
+
await report(command.id, true, value === undefined ? null : value, undefined);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
await report(command.id, false, undefined, String(error?.message ?? error));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Commands run one at a time, so two calls cannot interleave inside one page. */
|
|
723
|
+
let queue = Promise.resolve();
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Queue one command behind the commands already running.
|
|
727
|
+
* @param command - `{ id, op, args }`.
|
|
728
|
+
*/
|
|
729
|
+
function enqueue(command) {
|
|
730
|
+
queue = queue.then(() => run(command), () => run(command));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Announce this page to the Host half and keep one long-poll open. */
|
|
734
|
+
async function bridge() {
|
|
735
|
+
while (!state.stopped) {
|
|
736
|
+
try {
|
|
737
|
+
if (state.clientId === undefined) {
|
|
738
|
+
const answer = await fetch(`${BASE}/hello`, {
|
|
739
|
+
method: 'POST',
|
|
740
|
+
headers: { 'content-type': 'application/json' },
|
|
741
|
+
body: JSON.stringify({
|
|
742
|
+
platform: platformName(),
|
|
743
|
+
capabilities: capabilities(),
|
|
744
|
+
href: location.href
|
|
745
|
+
})
|
|
746
|
+
});
|
|
747
|
+
const body = await answer.json();
|
|
748
|
+
if (body?.ok !== true || typeof body.clientId !== 'string') throw new Error('握手被拒绝');
|
|
749
|
+
state.clientId = body.clientId;
|
|
750
|
+
state.refusals = 0;
|
|
751
|
+
}
|
|
752
|
+
const answer = await fetch(`${BASE}/poll?clientId=${encodeURIComponent(state.clientId)}&focus=${document.hasFocus() ? 1 : 0}`, { cache: 'no-store' });
|
|
753
|
+
if (answer.status === 410) {
|
|
754
|
+
state.clientId = undefined;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (!answer.ok) throw new Error(`poll ${answer.status}`);
|
|
758
|
+
const body = await answer.json();
|
|
759
|
+
const commands = Array.isArray(body?.commands) ? body.commands : [];
|
|
760
|
+
for (const command of commands) enqueue(command);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
state.refusals += 1;
|
|
763
|
+
if (state.refusals <= 2) console.error('[sidebar-browser-control] 与宿主的桥接中断,稍后重试', error);
|
|
764
|
+
state.clientId = undefined;
|
|
765
|
+
await sleep(Math.min(RETRY_MS * state.refusals, 8000));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
apply(pluginContext) {
|
|
772
|
+
context = pluginContext;
|
|
773
|
+
pluginContext.effect(() => {
|
|
774
|
+
state.stopped = false;
|
|
775
|
+
void bridge();
|
|
776
|
+
return () => {
|
|
777
|
+
state.stopped = true;
|
|
778
|
+
};
|
|
779
|
+
}, 'sidebar-browser-control.bridge');
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
});
|