dsh-builtin-browser 0.2.0 → 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/README.en.md +46 -1
- package/README.md +58 -7
- package/client.js +44 -8
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +82 -13
- package/lib/browser-electron/history-store.d.ts +5 -1
- package/lib/browser-electron/history-store.js +12 -1
- package/lib/browser-electron/provider.d.ts +42 -1
- package/lib/browser-electron/provider.js +96 -24
- package/lib/browser-electron/remote-host.d.ts +9 -1
- package/lib/browser-electron/remote-host.js +17 -3
- package/lib/browser-electron/settings-store.d.ts +5 -0
- package/lib/browser-electron/settings-store.js +24 -1
- package/lib/browser-electron/system-browser.d.ts +100 -0
- package/lib/browser-electron/system-browser.js +322 -0
- package/lib/browser-electron/virtual-cursor.d.ts +27 -2
- package/lib/browser-electron/virtual-cursor.js +77 -7
- package/lib/tool-browser/index.js +51 -15
- package/package.json +2 -1
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +84 -13
- package/src/browser-electron/history-store.ts +11 -2
- package/src/browser-electron/provider.ts +124 -25
- package/src/browser-electron/remote-host.ts +17 -2
- package/src/browser-electron/settings-store.ts +30 -1
- package/src/browser-electron/system-browser.ts +318 -0
- package/src/browser-electron/virtual-cursor.ts +76 -6
- package/src/tool-browser/index.ts +51 -15
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop-sidebar browser host: drive the page the desktop shell shows in its
|
|
3
|
+
* sidebar, instead of spawning a second, parallel Electron window.
|
|
4
|
+
*
|
|
5
|
+
* WHY
|
|
6
|
+
* The plugin runs inside the desktop's Node-mode host, where there is no Electron
|
|
7
|
+
* API — which is why it self-hosts a whole browser today. The shell, however, can
|
|
8
|
+
* own views, and its sidebar already displays real web pages. This host borrows
|
|
9
|
+
* that page over the shell's bridge (see apps/desktop/bridge/plugin-browser-bridge.js),
|
|
10
|
+
* so the agent and the human end up on ONE page instead of two.
|
|
11
|
+
*
|
|
12
|
+
* CONTRACT
|
|
13
|
+
* It implements the same `ElectronBrowserViewHost` seam the self-hosted host does,
|
|
14
|
+
* so the provider, the tools, browsing history, the synthetic cursor and the
|
|
15
|
+
* teardown rules are all unchanged: only the carrier differs. Everything is
|
|
16
|
+
* lazily materialized — a fresh shell has no sidebar guest until something asks
|
|
17
|
+
* for a page, and the bridge creates one on demand.
|
|
18
|
+
*
|
|
19
|
+
* FALLBACK
|
|
20
|
+
* `discover()` returns undefined whenever the shell offers no usable bridge
|
|
21
|
+
* (older desktop build, plain `dsh web`, bridge not started), and the entry point
|
|
22
|
+
* then keeps the self-hosted Electron it has always used.
|
|
23
|
+
* @module dsh-browser/browser-electron/desktop-bridge-host
|
|
24
|
+
*/
|
|
25
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
26
|
+
import type { BrowserUserAction } from './provider.js';
|
|
27
|
+
/** Absolute path of the endpoint file the shell writes. */
|
|
28
|
+
export declare function bridgeEndpointPath(): string;
|
|
29
|
+
/**
|
|
30
|
+
* The desktop sidebar, presented as a browser view host.
|
|
31
|
+
*
|
|
32
|
+
* One sidebar exists per shell window, so every view this host hands out refers
|
|
33
|
+
* to the same guest: the provider keeps its tab bookkeeping, and the tabs simply
|
|
34
|
+
* share the visible page. That is the intended behaviour for this carrier — the
|
|
35
|
+
* point is a single page both parties can see.
|
|
36
|
+
*/
|
|
37
|
+
export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
|
|
38
|
+
private readonly endpoint;
|
|
39
|
+
private readonly connection;
|
|
40
|
+
private readonly views;
|
|
41
|
+
/**
|
|
42
|
+
* Guests whose view is gone but whose page may still be open in the sidebar.
|
|
43
|
+
*
|
|
44
|
+
* The provider destroys its view handles before it asks for a release, so the
|
|
45
|
+
* mapping is dropped by then — keeping the guest ids here is what lets us close
|
|
46
|
+
* only our own tabs when several sessions are running (requirements §4: each
|
|
47
|
+
* session gets its own page).
|
|
48
|
+
*/
|
|
49
|
+
private readonly orphaned;
|
|
50
|
+
private userActionHandler;
|
|
51
|
+
/**
|
|
52
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
53
|
+
*/
|
|
54
|
+
private constructor();
|
|
55
|
+
/**
|
|
56
|
+
* Find a usable desktop bridge, if this surface has one.
|
|
57
|
+
*
|
|
58
|
+
* A stale endpoint (a shell that already exited, leaving its file behind) is
|
|
59
|
+
* rejected here rather than surfacing later as a mysterious ECONNREFUSED: the
|
|
60
|
+
* caller falls back to self-hosting, which always works.
|
|
61
|
+
* @returns the host, or undefined when no bridge is available.
|
|
62
|
+
*/
|
|
63
|
+
static discover(): Promise<DesktopBridgeViewHost | undefined>;
|
|
64
|
+
/** Whether this host can back views: the bridge already answered `list`. */
|
|
65
|
+
available(): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The guest id backing a view, materialized on first use.
|
|
68
|
+
*
|
|
69
|
+
* The sidebar browser is itself a multi-tab surface, so each view gets its own
|
|
70
|
+
* tab's guest: the provider's tab bookkeeping then maps onto real tabs the human
|
|
71
|
+
* can see and switch between. Two rules keep that honest:
|
|
72
|
+
* - a cached guest is used as-is: probing it first cost a round-trip on every
|
|
73
|
+
* command, so liveness is established by the command failing instead;
|
|
74
|
+
* - a fresh view takes an unclaimed guest, growing the tab strip only when
|
|
75
|
+
* every existing guest is already spoken for.
|
|
76
|
+
* @param viewId - the view whose guest is wanted.
|
|
77
|
+
* @param url - address to use when a sidebar browser has to be opened first.
|
|
78
|
+
*/
|
|
79
|
+
private guestFor;
|
|
80
|
+
/**
|
|
81
|
+
* Ask for at least `count` sidebar tabs and return their guest ids.
|
|
82
|
+
* @param count - minimum number of tabs.
|
|
83
|
+
*/
|
|
84
|
+
private guestIds;
|
|
85
|
+
createView(): ElectronViewHandle;
|
|
86
|
+
destroyView(handle: ElectronViewHandle): void;
|
|
87
|
+
/**
|
|
88
|
+
* Nothing to show: the sidebar is already on screen, and showing it again is
|
|
89
|
+
* the shell's business (it owns the tab strip and the visibility toggle).
|
|
90
|
+
*/
|
|
91
|
+
showView(): void;
|
|
92
|
+
/** Same as {@link showView}: the page is already presented by the shell. */
|
|
93
|
+
presentView(): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* The shell owns window grouping, and one sidebar serves every view, so there
|
|
96
|
+
* is nothing to record here. (It must not write a placeholder guest id either:
|
|
97
|
+
* a view whose guest is not yet materialized has to stay absent from the map,
|
|
98
|
+
* or the first real command would be sent to the placeholder.)
|
|
99
|
+
*/
|
|
100
|
+
groupView(): void;
|
|
101
|
+
/** Focus is the shell's to manage, and there is no separate window to raise. */
|
|
102
|
+
focus(): Promise<void>;
|
|
103
|
+
onUserAction(handler: (action: BrowserUserAction) => void): void;
|
|
104
|
+
/** The shell reports its own window lifecycle; nothing to subscribe to here. */
|
|
105
|
+
onViewClosed(): void;
|
|
106
|
+
/**
|
|
107
|
+
* Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
|
|
108
|
+
*
|
|
109
|
+
* Destroying our own view handles is not enough on this carrier: the sidebar
|
|
110
|
+
* belongs to the shell and would happily keep the page (and its renderer) alive.
|
|
111
|
+
* Closing the tabs is what actually ends the page — and only the page: cookies
|
|
112
|
+
* live in the partition, history on disk, so both survive.
|
|
113
|
+
* @returns a promise that settles once the shell has been asked.
|
|
114
|
+
*/
|
|
115
|
+
releasePage(): Promise<void>;
|
|
116
|
+
/**
|
|
117
|
+
* Fold the sidebar away without ending the page (`ui.autoExpandOnce` is off, or
|
|
118
|
+
* the caller wants the screen back while work continues).
|
|
119
|
+
* @returns a promise that settles once the shell has been asked.
|
|
120
|
+
*/
|
|
121
|
+
collapse(): Promise<void>;
|
|
122
|
+
/** No child process of our own to stop; close the shared connection instead. */
|
|
123
|
+
dispose(): void;
|
|
124
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop-sidebar browser host: drive the page the desktop shell shows in its
|
|
3
|
+
* sidebar, instead of spawning a second, parallel Electron window.
|
|
4
|
+
*
|
|
5
|
+
* WHY
|
|
6
|
+
* The plugin runs inside the desktop's Node-mode host, where there is no Electron
|
|
7
|
+
* API — which is why it self-hosts a whole browser today. The shell, however, can
|
|
8
|
+
* own views, and its sidebar already displays real web pages. This host borrows
|
|
9
|
+
* that page over the shell's bridge (see apps/desktop/bridge/plugin-browser-bridge.js),
|
|
10
|
+
* so the agent and the human end up on ONE page instead of two.
|
|
11
|
+
*
|
|
12
|
+
* CONTRACT
|
|
13
|
+
* It implements the same `ElectronBrowserViewHost` seam the self-hosted host does,
|
|
14
|
+
* so the provider, the tools, browsing history, the synthetic cursor and the
|
|
15
|
+
* teardown rules are all unchanged: only the carrier differs. Everything is
|
|
16
|
+
* lazily materialized — a fresh shell has no sidebar guest until something asks
|
|
17
|
+
* for a page, and the bridge creates one on demand.
|
|
18
|
+
*
|
|
19
|
+
* FALLBACK
|
|
20
|
+
* `discover()` returns undefined whenever the shell offers no usable bridge
|
|
21
|
+
* (older desktop build, plain `dsh web`, bridge not started), and the entry point
|
|
22
|
+
* then keeps the self-hosted Electron it has always used.
|
|
23
|
+
* @module dsh-browser/browser-electron/desktop-bridge-host
|
|
24
|
+
*/
|
|
25
|
+
import { BridgeConnection } from './bridge-connection.js';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { randomUUID } from 'node:crypto';
|
|
30
|
+
/** Absolute path of the endpoint file the shell writes. */
|
|
31
|
+
export function bridgeEndpointPath() {
|
|
32
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
33
|
+
return join(home, 'dsh-builtin-browser-bridge.json');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether a command failed because the page is no longer there (as opposed to
|
|
37
|
+
* failing on its own merits). Only these are worth retrying on a fresh guest.
|
|
38
|
+
* @param error - the failure to classify.
|
|
39
|
+
*/
|
|
40
|
+
function isGuestGone(error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
return /guest \d+ is not available|sidebar unavailable|did not create a browser guest/i.test(message);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The desktop sidebar, presented as a browser view host.
|
|
46
|
+
*
|
|
47
|
+
* One sidebar exists per shell window, so every view this host hands out refers
|
|
48
|
+
* to the same guest: the provider keeps its tab bookkeeping, and the tabs simply
|
|
49
|
+
* share the visible page. That is the intended behaviour for this carrier — the
|
|
50
|
+
* point is a single page both parties can see.
|
|
51
|
+
*/
|
|
52
|
+
export class DesktopBridgeViewHost {
|
|
53
|
+
endpoint;
|
|
54
|
+
connection;
|
|
55
|
+
views = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* Guests whose view is gone but whose page may still be open in the sidebar.
|
|
58
|
+
*
|
|
59
|
+
* The provider destroys its view handles before it asks for a release, so the
|
|
60
|
+
* mapping is dropped by then — keeping the guest ids here is what lets us close
|
|
61
|
+
* only our own tabs when several sessions are running (requirements §4: each
|
|
62
|
+
* session gets its own page).
|
|
63
|
+
*/
|
|
64
|
+
orphaned = new Set();
|
|
65
|
+
userActionHandler;
|
|
66
|
+
/**
|
|
67
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
68
|
+
*/
|
|
69
|
+
constructor(endpoint) {
|
|
70
|
+
this.endpoint = endpoint;
|
|
71
|
+
this.connection = new BridgeConnection(endpoint);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Find a usable desktop bridge, if this surface has one.
|
|
75
|
+
*
|
|
76
|
+
* A stale endpoint (a shell that already exited, leaving its file behind) is
|
|
77
|
+
* rejected here rather than surfacing later as a mysterious ECONNREFUSED: the
|
|
78
|
+
* caller falls back to self-hosting, which always works.
|
|
79
|
+
* @returns the host, or undefined when no bridge is available.
|
|
80
|
+
*/
|
|
81
|
+
static async discover() {
|
|
82
|
+
let endpoint;
|
|
83
|
+
try {
|
|
84
|
+
const raw = JSON.parse(readFileSync(bridgeEndpointPath(), 'utf8'));
|
|
85
|
+
if (typeof raw.port !== 'number' || typeof raw.token !== 'string' || typeof raw.pid !== 'number')
|
|
86
|
+
return undefined;
|
|
87
|
+
endpoint = { port: raw.port, token: raw.token, pid: raw.pid, ...raw.updatedAt !== undefined ? { updatedAt: raw.updatedAt } : {} };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
// A throwaway connection for discovery: the adopted host opens its own, and a
|
|
94
|
+
// rejected endpoint must not leave a socket behind.
|
|
95
|
+
const probe = new BridgeConnection(endpoint);
|
|
96
|
+
try {
|
|
97
|
+
const answer = await probe.call({ op: 'list' }, 5_000);
|
|
98
|
+
return answer.ok === true ? new DesktopBridgeViewHost(endpoint) : undefined;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
probe.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Dead endpoint or an older bridge: self-hosting remains the answer.
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Whether this host can back views: the bridge already answered `list`. */
|
|
110
|
+
available() {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The guest id backing a view, materialized on first use.
|
|
115
|
+
*
|
|
116
|
+
* The sidebar browser is itself a multi-tab surface, so each view gets its own
|
|
117
|
+
* tab's guest: the provider's tab bookkeeping then maps onto real tabs the human
|
|
118
|
+
* can see and switch between. Two rules keep that honest:
|
|
119
|
+
* - a cached guest is used as-is: probing it first cost a round-trip on every
|
|
120
|
+
* command, so liveness is established by the command failing instead;
|
|
121
|
+
* - a fresh view takes an unclaimed guest, growing the tab strip only when
|
|
122
|
+
* every existing guest is already spoken for.
|
|
123
|
+
* @param viewId - the view whose guest is wanted.
|
|
124
|
+
* @param url - address to use when a sidebar browser has to be opened first.
|
|
125
|
+
*/
|
|
126
|
+
async guestFor(viewId, url) {
|
|
127
|
+
const existing = this.views.get(viewId);
|
|
128
|
+
// No liveness check here: verifying the cached guest cost a round-trip on every
|
|
129
|
+
// command. A guest that has gone away is detected by the command itself failing,
|
|
130
|
+
// and `createView`'s sendCommand then discards the cache and calls back in here.
|
|
131
|
+
if (existing !== undefined)
|
|
132
|
+
return existing;
|
|
133
|
+
// The browser tab must exist before its strip can be extended: `ensureSidebar`
|
|
134
|
+
// opens the sidebar (and, on a fresh shell, materializes the first guest).
|
|
135
|
+
const sidebar = await this.connection.call({
|
|
136
|
+
op: 'ensureSidebar',
|
|
137
|
+
...url !== undefined ? { url } : {},
|
|
138
|
+
});
|
|
139
|
+
if (sidebar.ok !== true)
|
|
140
|
+
throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(sidebar.error)})`);
|
|
141
|
+
const wanted = this.views.size + 1;
|
|
142
|
+
let ids = await this.guestIds(Math.max(1, wanted));
|
|
143
|
+
const taken = new Set(this.views.values());
|
|
144
|
+
let free = ids.find(id => !taken.has(id));
|
|
145
|
+
if (free === undefined) {
|
|
146
|
+
// Every tab is already driving something: the strip has to grow.
|
|
147
|
+
ids = await this.guestIds(ids.length + 1);
|
|
148
|
+
free = ids.find(id => !taken.has(id));
|
|
149
|
+
}
|
|
150
|
+
if (free === undefined)
|
|
151
|
+
throw new Error('dsh-builtin-browser: the sidebar reported no free browser tab');
|
|
152
|
+
this.views.set(viewId, free);
|
|
153
|
+
return free;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Ask for at least `count` sidebar tabs and return their guest ids.
|
|
157
|
+
* @param count - minimum number of tabs.
|
|
158
|
+
*/
|
|
159
|
+
async guestIds(count) {
|
|
160
|
+
const answer = await this.connection.call({ op: 'ensureTabs', count });
|
|
161
|
+
if (answer.ok !== true)
|
|
162
|
+
throw new Error(`dsh-builtin-browser: could not open a sidebar tab (${String(answer.error)})`);
|
|
163
|
+
const ids = Array.isArray(answer.ids) ? answer.ids.map(Number).filter(Number.isFinite) : [];
|
|
164
|
+
if (ids.length === 0)
|
|
165
|
+
throw new Error('dsh-builtin-browser: the sidebar reported no browser tabs');
|
|
166
|
+
return ids;
|
|
167
|
+
}
|
|
168
|
+
createView() {
|
|
169
|
+
const viewId = randomUUID();
|
|
170
|
+
const navigateUrl = (method, params) => method === 'Page.navigate' ? String(params?.url ?? '') : undefined;
|
|
171
|
+
const run = async (guest, method, params) => {
|
|
172
|
+
const answer = await this.connection.call({ op: 'cdp', id: guest, method, params });
|
|
173
|
+
if (answer.ok !== true)
|
|
174
|
+
throw new Error(`dsh-builtin-browser: sidebar command failed: ${String(answer.error)}`);
|
|
175
|
+
// The bridge forwards whatever CDP answered; the seam expects an object.
|
|
176
|
+
return (answer.result ?? {});
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
id: viewId,
|
|
180
|
+
sendCommand: async (method, params) => {
|
|
181
|
+
// No liveness probe up front. Checking first cost a whole round-trip on
|
|
182
|
+
// every command; instead the command runs and its failure is what triggers
|
|
183
|
+
// recovery. Only a genuinely missing guest is retried, so a real error (a
|
|
184
|
+
// bad selector, a timeout) still surfaces unchanged.
|
|
185
|
+
const url = navigateUrl(method, params);
|
|
186
|
+
let guest = await this.guestFor(viewId, url);
|
|
187
|
+
try {
|
|
188
|
+
return await run(guest, method, params);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (!isGuestGone(error))
|
|
192
|
+
throw error;
|
|
193
|
+
// The human closed the tab: the page is gone, so take a fresh one and
|
|
194
|
+
// replay the command once. This is the "closing the interface ends the
|
|
195
|
+
// session" rule, now paid for only when it actually happens.
|
|
196
|
+
this.views.delete(viewId);
|
|
197
|
+
guest = await this.guestFor(viewId, url);
|
|
198
|
+
return await run(guest, method, params);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
destroyView(handle) {
|
|
204
|
+
const guest = this.views.get(handle.id);
|
|
205
|
+
if (guest !== undefined) {
|
|
206
|
+
// Remember it: the page outlives our handle, and a later release must be able
|
|
207
|
+
// to name exactly the tabs this plugin opened.
|
|
208
|
+
this.orphaned.add(guest);
|
|
209
|
+
this.views.delete(handle.id);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Nothing to show: the sidebar is already on screen, and showing it again is
|
|
214
|
+
* the shell's business (it owns the tab strip and the visibility toggle).
|
|
215
|
+
*/
|
|
216
|
+
showView() { }
|
|
217
|
+
/** Same as {@link showView}: the page is already presented by the shell. */
|
|
218
|
+
async presentView() { }
|
|
219
|
+
/**
|
|
220
|
+
* The shell owns window grouping, and one sidebar serves every view, so there
|
|
221
|
+
* is nothing to record here. (It must not write a placeholder guest id either:
|
|
222
|
+
* a view whose guest is not yet materialized has to stay absent from the map,
|
|
223
|
+
* or the first real command would be sent to the placeholder.)
|
|
224
|
+
*/
|
|
225
|
+
groupView() { }
|
|
226
|
+
/** Focus is the shell's to manage, and there is no separate window to raise. */
|
|
227
|
+
async focus() { }
|
|
228
|
+
onUserAction(handler) {
|
|
229
|
+
this.userActionHandler = handler;
|
|
230
|
+
}
|
|
231
|
+
/** The shell reports its own window lifecycle; nothing to subscribe to here. */
|
|
232
|
+
onViewClosed() { }
|
|
233
|
+
/**
|
|
234
|
+
* Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
|
|
235
|
+
*
|
|
236
|
+
* Destroying our own view handles is not enough on this carrier: the sidebar
|
|
237
|
+
* belongs to the shell and would happily keep the page (and its renderer) alive.
|
|
238
|
+
* Closing the tabs is what actually ends the page — and only the page: cookies
|
|
239
|
+
* live in the partition, history on disk, so both survive.
|
|
240
|
+
* @returns a promise that settles once the shell has been asked.
|
|
241
|
+
*/
|
|
242
|
+
async releasePage() {
|
|
243
|
+
// Only our own tabs: the ids come from the views we handed out (and their
|
|
244
|
+
// orphans), never from "every webview currently visible". With two sessions
|
|
245
|
+
// live, releasing one must leave the other's page alone.
|
|
246
|
+
const mine = [...this.views.values(), ...this.orphaned];
|
|
247
|
+
this.views.clear();
|
|
248
|
+
this.orphaned.clear();
|
|
249
|
+
// Nothing of ours is open: release nothing. Falling through to an unfiltered
|
|
250
|
+
// release here would close tabs a human opened, or another session's page.
|
|
251
|
+
if (mine.length === 0)
|
|
252
|
+
return;
|
|
253
|
+
let titles;
|
|
254
|
+
try {
|
|
255
|
+
const answer = await this.connection.call({ op: 'list' }, 5_000);
|
|
256
|
+
const sidebar = Array.isArray(answer.sidebar) ? answer.sidebar : [];
|
|
257
|
+
titles = sidebar
|
|
258
|
+
.filter(guest => mine.includes(Number(guest.id)))
|
|
259
|
+
.map(guest => String(guest.title ?? ''))
|
|
260
|
+
.filter(title => title !== '');
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Could not read the titles back; releasing nothing is safer than closing
|
|
264
|
+
// tabs that belong to somebody else.
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (titles.length === 0)
|
|
268
|
+
return;
|
|
269
|
+
try {
|
|
270
|
+
await this.connection.call({ op: 'closeSidebarBrowser', titles }, 10_000);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Best effort: the setting expresses a preference, and a shell that cannot
|
|
274
|
+
// be reached is no reason to fail the session teardown that called us.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Fold the sidebar away without ending the page (`ui.autoExpandOnce` is off, or
|
|
279
|
+
* the caller wants the screen back while work continues).
|
|
280
|
+
* @returns a promise that settles once the shell has been asked.
|
|
281
|
+
*/
|
|
282
|
+
async collapse() {
|
|
283
|
+
try {
|
|
284
|
+
await this.connection.call({ op: 'collapseSidebar' }, 10_000);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Presentation only; failing to fold is never worth an error.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** No child process of our own to stop; close the shared connection instead. */
|
|
291
|
+
dispose() {
|
|
292
|
+
this.connection.close();
|
|
293
|
+
this.views.clear();
|
|
294
|
+
this.orphaned.clear();
|
|
295
|
+
this.userActionHandler = undefined;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import z from '@deepseek-ai/schemastery';
|
|
13
13
|
import { ElectronBrowserProvider } from './provider.js';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
14
17
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
|
|
18
|
+
import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
|
|
19
|
+
import { detectBrowser, SystemBrowserViewHost } from './system-browser.js';
|
|
15
20
|
import { SettingsStore } from './settings-store.js';
|
|
16
21
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
|
|
17
22
|
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
|
|
@@ -29,30 +34,94 @@ export const Config = z.object({
|
|
|
29
34
|
});
|
|
30
35
|
/** Register the Electron browser provider with `ctx.browser`. */
|
|
31
36
|
export function apply(ctx, config) {
|
|
32
|
-
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
33
|
-
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
34
|
-
const host = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath());
|
|
35
37
|
// One settings document per plugin instance: the settings panel writes it, the
|
|
36
38
|
// provider reads it live, and both ends agree on the same file.
|
|
37
39
|
const settings = new SettingsStore();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// row would otherwise collide with the still-registered provider
|
|
41
|
-
// (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
|
|
42
|
-
const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, {
|
|
40
|
+
installSettingsRoute(ctx, settings);
|
|
41
|
+
const providerConfig = {
|
|
43
42
|
httpOnly: config.httpOnly,
|
|
44
43
|
downloadDir: config.downloadDir,
|
|
45
44
|
snapshotMaxElements: config.snapshotMaxElements,
|
|
46
45
|
contentMaxChars: config.contentMaxChars,
|
|
47
46
|
settings: () => settings.get(),
|
|
48
|
-
}
|
|
47
|
+
};
|
|
48
|
+
// A host supplied by the composition (the desktop shell's own seam) wins and is
|
|
49
|
+
// synchronous, so registration stays synchronous on that path.
|
|
50
|
+
if (config.viewHost !== undefined) {
|
|
51
|
+
const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost, providerConfig));
|
|
52
|
+
ctx.effect(() => () => { external(); });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Otherwise start self-hosted — a working browser from the first call, on every
|
|
56
|
+
// surface — and upgrade to the desktop sidebar if this machine has one. The
|
|
57
|
+
// upgrade is deliberately a swap of the whole provider: the two carriers own
|
|
58
|
+
// different lifetimes (a spawned child vs. the shell's view), and pretending
|
|
59
|
+
// otherwise would leave a stray window behind.
|
|
60
|
+
const selfHosted = new RemoteElectronViewHost(defaultHostMainPath());
|
|
61
|
+
let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig));
|
|
62
|
+
let upgraded = false;
|
|
49
63
|
ctx.effect(() => () => {
|
|
50
64
|
unregister();
|
|
51
|
-
|
|
52
|
-
host.dispose();
|
|
53
|
-
}
|
|
65
|
+
selfHosted.dispose();
|
|
54
66
|
});
|
|
55
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Swap the provider to another carrier, releasing the previous one.
|
|
69
|
+
* @param host - the carrier to adopt.
|
|
70
|
+
* @param note - a one-line description for the log.
|
|
71
|
+
*/
|
|
72
|
+
const adopt = (host, note) => {
|
|
73
|
+
if (upgraded) {
|
|
74
|
+
host.dispose?.();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
upgraded = true;
|
|
78
|
+
try {
|
|
79
|
+
unregister();
|
|
80
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig));
|
|
81
|
+
selfHosted.dispose();
|
|
82
|
+
ctx.logger?.info?.(`dsh-builtin-browser: ${note}`);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// Never leave the surface without a provider.
|
|
86
|
+
unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(new RemoteElectronViewHost(defaultHostMainPath()), providerConfig));
|
|
87
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// A browser the user explicitly asked for outranks every automatic choice: the
|
|
91
|
+
// whole point of the setting is that they know which browser they want. It is
|
|
92
|
+
// launched with a plugin-owned profile, so their own windows are untouched.
|
|
93
|
+
void (async () => {
|
|
94
|
+
const channel = settings.get().browser.channel;
|
|
95
|
+
if (channel === 'bundled')
|
|
96
|
+
return;
|
|
97
|
+
const detected = detectBrowser(channel);
|
|
98
|
+
if (detected === undefined) {
|
|
99
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const home = process.env.DSH_HOME ?? homedir();
|
|
103
|
+
// Login state lives in the browser profile, so the cookies setting decides where
|
|
104
|
+
// that profile goes: a stable directory keeps the user signed in across restarts
|
|
105
|
+
// (the point of using their own browser), while turning persistence off gets a
|
|
106
|
+
// throwaway directory that is removed when the browser is released.
|
|
107
|
+
const persist = settings.get().cookies.persist;
|
|
108
|
+
const profileDir = persist
|
|
109
|
+
? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
|
|
110
|
+
: join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`);
|
|
111
|
+
const host = await SystemBrowserViewHost.launch(detected, profileDir, [], persist ? undefined : profileDir);
|
|
112
|
+
if (host === undefined) {
|
|
113
|
+
ctx.logger?.warn?.(`dsh-builtin-browser: ${detected.kind} did not start; using the bundled browser`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
adopt(host, `driving the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
117
|
+
})().catch(() => { });
|
|
118
|
+
void DesktopBridgeViewHost.discover().then(sidebar => {
|
|
119
|
+
// No bridge (plain `dsh web`, an older desktop build, or a shell that already
|
|
120
|
+
// exited): keep self-hosting, which is what every surface has always done.
|
|
121
|
+
if (sidebar === undefined)
|
|
122
|
+
return;
|
|
123
|
+
adopt(sidebar, 'driving the desktop sidebar browser');
|
|
124
|
+
}).catch(() => { });
|
|
56
125
|
}
|
|
57
126
|
/** Route the settings panel reads and writes. */
|
|
58
127
|
const SETTINGS_ROUTE = '/dsh-builtin-browser/settings';
|
|
@@ -53,12 +53,16 @@ export declare class HistoryStore {
|
|
|
53
53
|
/**
|
|
54
54
|
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
55
55
|
* than the age limit are filtered out but left on disk until the next prune.
|
|
56
|
-
* @param options - optional result cap
|
|
56
|
+
* @param options - optional result cap plus three filters: `domain` (hostname),
|
|
57
|
+
* `query` (substring of the URL or title) and `session` (the task that visited
|
|
58
|
+
* it). Filters compose, so "the pages THIS task opened on that host" is one call.
|
|
57
59
|
* @returns the matching visits, newest first.
|
|
58
60
|
*/
|
|
59
61
|
list(options?: {
|
|
60
62
|
readonly limit?: number;
|
|
61
63
|
readonly domain?: string;
|
|
64
|
+
readonly query?: string;
|
|
65
|
+
readonly session?: string;
|
|
62
66
|
}): VisitedPage[];
|
|
63
67
|
/**
|
|
64
68
|
* Enforce retention: drop entries past the age limit, then the oldest beyond
|
|
@@ -70,16 +70,27 @@ export class HistoryStore {
|
|
|
70
70
|
/**
|
|
71
71
|
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
72
72
|
* than the age limit are filtered out but left on disk until the next prune.
|
|
73
|
-
* @param options - optional result cap
|
|
73
|
+
* @param options - optional result cap plus three filters: `domain` (hostname),
|
|
74
|
+
* `query` (substring of the URL or title) and `session` (the task that visited
|
|
75
|
+
* it). Filters compose, so "the pages THIS task opened on that host" is one call.
|
|
74
76
|
* @returns the matching visits, newest first.
|
|
75
77
|
*/
|
|
76
78
|
list(options = {}) {
|
|
77
79
|
const all = this.readAll();
|
|
78
80
|
const cutoff = Date.now() - this.maxAgeMs;
|
|
79
81
|
const domain = options.domain?.toLowerCase();
|
|
82
|
+
const query = options.query?.toLowerCase();
|
|
83
|
+
const session = options.session;
|
|
80
84
|
const matches = all.filter(page => {
|
|
81
85
|
if (page.at < cutoff)
|
|
82
86
|
return false;
|
|
87
|
+
if (session !== undefined && page.session !== session)
|
|
88
|
+
return false;
|
|
89
|
+
if (query !== undefined) {
|
|
90
|
+
const haystack = `${page.url}\n${page.title ?? ''}`.toLowerCase();
|
|
91
|
+
if (!haystack.includes(query))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
83
94
|
if (domain === undefined)
|
|
84
95
|
return true;
|
|
85
96
|
try {
|
|
@@ -87,6 +87,31 @@ export interface ElectronBrowserViewHost {
|
|
|
87
87
|
* @param handler - called with the group (session) key; must not throw.
|
|
88
88
|
*/
|
|
89
89
|
onViewClosed?(handler: (windowId: string) => void): void;
|
|
90
|
+
/**
|
|
91
|
+
* Release the service's page(s) without ending the plugin's own lifetime.
|
|
92
|
+
*
|
|
93
|
+
* Only carriers that outlive a browser session need this. A self-hosted window
|
|
94
|
+
* dies with its view, but the desktop shell's sidebar keeps running no matter
|
|
95
|
+
* what the plugin does — so "release the browser when the session ends"
|
|
96
|
+
* (settings: ui.closeWithSession) has to be asked for explicitly there.
|
|
97
|
+
* @returns a promise that settles once the release was attempted.
|
|
98
|
+
*/
|
|
99
|
+
releasePage?(): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Fold the carrier's presentation away while keeping the page alive.
|
|
102
|
+
*
|
|
103
|
+
* The desktop sidebar keeps running regardless of the plugin, so "do not take
|
|
104
|
+
* over the screen" (settings: ui.autoExpandOnce = false) can only be honoured by
|
|
105
|
+
* asking the shell to fold it. A self-hosted window has no such state and simply
|
|
106
|
+
* does not implement this.
|
|
107
|
+
* @returns a promise that settles once the fold was attempted.
|
|
108
|
+
*/
|
|
109
|
+
collapse?(): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Release everything the carrier owns (a spawned browser, a socket, a child).
|
|
112
|
+
* Called when the plugin stops using it, never while it is still in service.
|
|
113
|
+
*/
|
|
114
|
+
dispose?(): void;
|
|
90
115
|
}
|
|
91
116
|
/**
|
|
92
117
|
* A user-initiated browser action from the host's own UI (toolbar). The
|
|
@@ -501,6 +526,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
501
526
|
visited(options?: {
|
|
502
527
|
readonly limit?: number;
|
|
503
528
|
readonly domain?: string;
|
|
529
|
+
readonly query?: string;
|
|
530
|
+
readonly session?: string;
|
|
504
531
|
}): readonly VisitedPage[];
|
|
505
532
|
/**
|
|
506
533
|
* Show the synthetic pointer on a view, unless the user switched it off. The
|
|
@@ -511,6 +538,9 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
511
538
|
* @param x - viewport x in CSS pixels.
|
|
512
539
|
* @param y - viewport y in CSS pixels.
|
|
513
540
|
* @param action - click pulses a ripple; move only relocates.
|
|
541
|
+
* @param label - short description of the operation, shown beside the pointer.
|
|
542
|
+
* @param force - paint even when the pointer is already there (a click ripple must
|
|
543
|
+
* always play; a bare move need not repeat).
|
|
514
544
|
*/
|
|
515
545
|
private showCursor;
|
|
516
546
|
/**
|
|
@@ -552,7 +582,18 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
552
582
|
* is reported to the host (toolbar) when the host supports it, else logged.
|
|
553
583
|
*/
|
|
554
584
|
private handleUserAction;
|
|
555
|
-
/**
|
|
585
|
+
/**
|
|
586
|
+
* Report a failed user action to the host UI (toolbar), when supported.
|
|
587
|
+
*
|
|
588
|
+
* Two properties matter here, because this runs inside the catch of an async
|
|
589
|
+
* handler (issue #16):
|
|
590
|
+
* - the receiver must be preserved. Reading the method off the host and calling
|
|
591
|
+
* it unbound runs the host's implementation with `this === undefined`, so its
|
|
592
|
+
* very first statement (`void this.ready()`) throws, the throw escapes the
|
|
593
|
+
* async catch as an unhandled rejection, and the whole DSH host exits;
|
|
594
|
+
* - this method must be incapable of throwing. Reporting a failed action is
|
|
595
|
+
* diagnostics: it can never be allowed to become the failure.
|
|
596
|
+
*/
|
|
556
597
|
private notifyUserActionError;
|
|
557
598
|
/** Ask the host to show the active tab's view, carrying the session label. */
|
|
558
599
|
private showActive;
|