dsh-builtin-browser 0.2.0 → 0.3.1
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 +50 -5
- package/README.md +62 -10
- 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 +81 -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 +132 -0
- package/lib/browser-electron/system-browser.js +380 -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 +371 -0
- package/src/browser-electron/virtual-cursor.ts +76 -6
- package/src/tool-browser/index.ts +51 -15
|
@@ -11,7 +11,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
13
13
|
import { HistoryStore } from './history-store.js';
|
|
14
|
-
import { paintCursor } from './virtual-cursor.js';
|
|
14
|
+
import { forgetCursor, paintCursor } from './virtual-cursor.js';
|
|
15
15
|
import { BrowserError } from '../browser/types.js';
|
|
16
16
|
/**
|
|
17
17
|
* Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
|
|
@@ -393,6 +393,11 @@ export class ElectronBrowserProvider {
|
|
|
393
393
|
async settleDocument(handle, before, signal) {
|
|
394
394
|
const started = Date.now();
|
|
395
395
|
const deadline = started + SETTLE_TIMEOUT_MS;
|
|
396
|
+
// The document is being replaced, so the synthetic pointer drawn into the old one
|
|
397
|
+
// is gone. Dropping the remembered position is what lets it be painted again:
|
|
398
|
+
// without this, a paint at coincidentally identical coordinates would be skipped
|
|
399
|
+
// and the pointer would never come back after a navigation.
|
|
400
|
+
forgetCursor(handle);
|
|
396
401
|
for (;;) {
|
|
397
402
|
if (signal?.aborted === true)
|
|
398
403
|
return;
|
|
@@ -870,21 +875,39 @@ export class ElectronBrowserProvider {
|
|
|
870
875
|
// iframes stay opaque — the browser forbids reading them).
|
|
871
876
|
let content = ''
|
|
872
877
|
if (fmt === 'txt') {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
878
|
+
// Prefer the browser's own rendered text.
|
|
879
|
+
//
|
|
880
|
+
// Walking text nodes and joining them is not equivalent: pages that wrap
|
|
881
|
+
// every character in its own element — one-character spans for a
|
|
882
|
+
// per-character animation, which is common — produce one text node per
|
|
883
|
+
// letter, and joining those with spaces turns every word into a column of
|
|
884
|
+
// letters. The rendered text reassembles them the way a reader sees them,
|
|
885
|
+
// and it also honours visibility, so hidden text does not leak out.
|
|
886
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
887
|
+
const rendered = typeof root.innerText === 'string' ? root.innerText : ''
|
|
888
|
+
if (rendered.trim() !== '') {
|
|
889
|
+
content = rendered
|
|
890
|
+
} else {
|
|
891
|
+
// Fallback for nodes without layout (a detached root, or a fragment):
|
|
892
|
+
// collect text directly, still piercing shadow roots and same-origin
|
|
893
|
+
// iframes, and join without inventing separators beyond what the markup
|
|
894
|
+
// already implies.
|
|
895
|
+
const parts = []
|
|
896
|
+
const textWalk = (node) => {
|
|
897
|
+
if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
|
|
898
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
899
|
+
const tag = node.tagName.toLowerCase()
|
|
900
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') return
|
|
901
|
+
if (tag === 'iframe') {
|
|
902
|
+
try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
|
|
906
|
+
for (const child of node.childNodes) textWalk(child)
|
|
882
907
|
}
|
|
883
|
-
|
|
884
|
-
|
|
908
|
+
textWalk(root)
|
|
909
|
+
content = parts.join(' ')
|
|
885
910
|
}
|
|
886
|
-
textWalk(root)
|
|
887
|
-
content = parts.join(' ')
|
|
888
911
|
}
|
|
889
912
|
else if (fmt === 'html') content = root.outerHTML || ''
|
|
890
913
|
else if (fmt === 'json') content = JSON.stringify(root)
|
|
@@ -952,6 +975,15 @@ export class ElectronBrowserProvider {
|
|
|
952
975
|
y = Number(out.y);
|
|
953
976
|
}
|
|
954
977
|
else {
|
|
978
|
+
// A coordinate only exists because somebody read a picture. Under the
|
|
979
|
+
// non-visual strategy the plugin must not accept one: a model that cannot
|
|
980
|
+
// see cannot have produced it, so the coordinates are either copied from an
|
|
981
|
+
// earlier vision pass or invented, and honouring them turns a silent
|
|
982
|
+
// mis-click into a mystery. Refusing here, with a message that says what to
|
|
983
|
+
// do instead, converts that into a usable instruction.
|
|
984
|
+
if (this.settingsSource !== undefined && this.settingsSource().vision.strategy === 'nonVisual') {
|
|
985
|
+
throw new BrowserError('browser: the configured non-visual strategy refuses coordinate clicks — pass a semantic target instead, e.g. target { by: "text", value: "Sign in" }, which locates the element from the DOM and clicks its centre', 'BROWSER_NON_VISUAL_COORDINATES');
|
|
986
|
+
}
|
|
955
987
|
x = request.x;
|
|
956
988
|
y = request.y;
|
|
957
989
|
}
|
|
@@ -975,7 +1007,9 @@ export class ElectronBrowserProvider {
|
|
|
975
1007
|
}
|
|
976
1008
|
// Paint before the press so the ripple is already on screen when the click
|
|
977
1009
|
// lands — the cursor exists to show the human WHERE the agent acts.
|
|
978
|
-
|
|
1010
|
+
// Name the operation in the overlay: the pointer is the "agent is driving this
|
|
1011
|
+
// tab" signal, so the bubble is what lets a human follow along without the log.
|
|
1012
|
+
this.showCursor(handle, x, y, 'click', 'click', true);
|
|
979
1013
|
const release = () => {
|
|
980
1014
|
void handle
|
|
981
1015
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
@@ -1026,7 +1060,7 @@ export class ElectronBrowserProvider {
|
|
|
1026
1060
|
const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type');
|
|
1027
1061
|
const cursorPoint = scriptPoint(out);
|
|
1028
1062
|
if (cursorPoint !== undefined)
|
|
1029
|
-
this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click');
|
|
1063
|
+
this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click', 'type', true);
|
|
1030
1064
|
}
|
|
1031
1065
|
const text = 'text' in request ? request.text : '';
|
|
1032
1066
|
const timeoutMs = 30_000;
|
|
@@ -1879,11 +1913,14 @@ export class ElectronBrowserProvider {
|
|
|
1879
1913
|
* @param x - viewport x in CSS pixels.
|
|
1880
1914
|
* @param y - viewport y in CSS pixels.
|
|
1881
1915
|
* @param action - click pulses a ripple; move only relocates.
|
|
1916
|
+
* @param label - short description of the operation, shown beside the pointer.
|
|
1917
|
+
* @param force - paint even when the pointer is already there (a click ripple must
|
|
1918
|
+
* always play; a bare move need not repeat).
|
|
1882
1919
|
*/
|
|
1883
|
-
showCursor(handle, x, y, action) {
|
|
1920
|
+
showCursor(handle, x, y, action, label, force = false) {
|
|
1884
1921
|
if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor)
|
|
1885
1922
|
return;
|
|
1886
|
-
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression));
|
|
1923
|
+
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression), label, force);
|
|
1887
1924
|
}
|
|
1888
1925
|
/**
|
|
1889
1926
|
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
@@ -1967,6 +2004,13 @@ export class ElectronBrowserProvider {
|
|
|
1967
2004
|
this.sessions.delete(session);
|
|
1968
2005
|
for (const tab of existing.tabs)
|
|
1969
2006
|
this.host.destroyView(tab.handle);
|
|
2007
|
+
// Releasing the browser at session end is a setting, not fixed behaviour
|
|
2008
|
+
// (requirements §3). A self-hosted carrier already tore its window down with
|
|
2009
|
+
// the views above; the desktop's sidebar does not, so it is asked explicitly
|
|
2010
|
+
// — and its cookies plus the browsing history outlive the page either way.
|
|
2011
|
+
if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
|
|
2012
|
+
void this.host.releasePage?.().catch(() => undefined);
|
|
2013
|
+
}
|
|
1970
2014
|
}
|
|
1971
2015
|
return Promise.resolve();
|
|
1972
2016
|
}
|
|
@@ -2076,19 +2120,47 @@ export class ElectronBrowserProvider {
|
|
|
2076
2120
|
this.notifyUserActionError(action, error);
|
|
2077
2121
|
}
|
|
2078
2122
|
}
|
|
2079
|
-
/**
|
|
2123
|
+
/**
|
|
2124
|
+
* Report a failed user action to the host UI (toolbar), when supported.
|
|
2125
|
+
*
|
|
2126
|
+
* Two properties matter here, because this runs inside the catch of an async
|
|
2127
|
+
* handler (issue #16):
|
|
2128
|
+
* - the receiver must be preserved. Reading the method off the host and calling
|
|
2129
|
+
* it unbound runs the host's implementation with `this === undefined`, so its
|
|
2130
|
+
* very first statement (`void this.ready()`) throws, the throw escapes the
|
|
2131
|
+
* async catch as an unhandled rejection, and the whole DSH host exits;
|
|
2132
|
+
* - this method must be incapable of throwing. Reporting a failed action is
|
|
2133
|
+
* diagnostics: it can never be allowed to become the failure.
|
|
2134
|
+
*/
|
|
2080
2135
|
notifyUserActionError(action, error) {
|
|
2081
|
-
const notify = this.host.notifyUserActionError;
|
|
2082
2136
|
const message = `action ${action.type} failed: ${String(error)}`;
|
|
2083
|
-
|
|
2084
|
-
|
|
2137
|
+
let reported = false;
|
|
2138
|
+
try {
|
|
2139
|
+
const host = this.host;
|
|
2140
|
+
const notify = host.notifyUserActionError;
|
|
2141
|
+
if (typeof notify === 'function') {
|
|
2142
|
+
// Called on its owner: an unbound call here kills the host process.
|
|
2143
|
+
notify.call(host, action.windowId, message);
|
|
2144
|
+
reported = true;
|
|
2145
|
+
}
|
|
2085
2146
|
}
|
|
2086
|
-
|
|
2087
|
-
|
|
2147
|
+
catch (notifyError) {
|
|
2148
|
+
// Contained on purpose; the log line below still carries the original cause.
|
|
2149
|
+
process.stderr.write(`[dsh-browser] host notify failed: ${String(notifyError)}\n`);
|
|
2088
2150
|
}
|
|
2151
|
+
if (!reported)
|
|
2152
|
+
process.stderr.write(`[dsh-browser] user action failed: ${message}\n`);
|
|
2089
2153
|
}
|
|
2090
2154
|
/** Ask the host to show the active tab's view, carrying the session label. */
|
|
2091
2155
|
showActive(s) {
|
|
2156
|
+
// §3: whether the agent's page takes over the screen is the user's choice, not
|
|
2157
|
+
// a fixed behaviour. With auto-expand off the page still exists and is still
|
|
2158
|
+
// driven — it is simply folded away, so the agent works without claiming the
|
|
2159
|
+
// sidebar. Carriers that own no presentation (self-hosted) ignore the call.
|
|
2160
|
+
if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
|
|
2161
|
+
void this.host.collapse?.().catch(() => undefined);
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2092
2164
|
this.host.showView?.(this.activeTab(s).handle, s.label);
|
|
2093
2165
|
}
|
|
2094
2166
|
/** Read the current URL of a view through CDP. */
|
|
@@ -145,7 +145,15 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
145
145
|
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
146
146
|
*/
|
|
147
147
|
available(): boolean;
|
|
148
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Ensure the child is up and ready (lazy on first use; restarts after a crash).
|
|
150
|
+
*
|
|
151
|
+
* Never throws synchronously. Callers are fire-and-forget
|
|
152
|
+
* (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
|
|
153
|
+
* `.catch` entirely — the shape that let a failed toolbar report take the whole
|
|
154
|
+
* DSH host down (issue #16). A disposed host is reported as a rejected promise
|
|
155
|
+
* instead, which those callers already handle.
|
|
156
|
+
*/
|
|
149
157
|
private ready;
|
|
150
158
|
private start;
|
|
151
159
|
/** The child died: tear down so the next use starts a fresh child. */
|
|
@@ -765,13 +765,27 @@ export class RemoteElectronViewHost {
|
|
|
765
765
|
}
|
|
766
766
|
return this.electronAvailable;
|
|
767
767
|
}
|
|
768
|
-
/**
|
|
768
|
+
/**
|
|
769
|
+
* Ensure the child is up and ready (lazy on first use; restarts after a crash).
|
|
770
|
+
*
|
|
771
|
+
* Never throws synchronously. Callers are fire-and-forget
|
|
772
|
+
* (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
|
|
773
|
+
* `.catch` entirely — the shape that let a failed toolbar report take the whole
|
|
774
|
+
* DSH host down (issue #16). A disposed host is reported as a rejected promise
|
|
775
|
+
* instead, which those callers already handle.
|
|
776
|
+
*/
|
|
769
777
|
ready() {
|
|
770
778
|
// Fail fast on a disposed host: a recovery path can re-enter ready() after
|
|
771
779
|
// dispose() cleared its state, and starting fresh here would spawn a child
|
|
772
780
|
// nobody will ever kill.
|
|
773
|
-
if (this.disposed)
|
|
774
|
-
|
|
781
|
+
if (this.disposed) {
|
|
782
|
+
const gone = Promise.reject(new HostGoneError('browser host disposed'));
|
|
783
|
+
// Keep the rejection observable to callers while marking it handled here:
|
|
784
|
+
// a fire-and-forget caller that forgot to attach a handler must not turn
|
|
785
|
+
// this into an unhandled rejection either.
|
|
786
|
+
gone.catch(() => undefined);
|
|
787
|
+
return gone;
|
|
788
|
+
}
|
|
775
789
|
if (this.readyPromise !== undefined)
|
|
776
790
|
return this.readyPromise;
|
|
777
791
|
const started = this.start();
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* defaults" rather than an error.
|
|
9
9
|
* @module dsh-browser/browser-electron/settings-store
|
|
10
10
|
*/
|
|
11
|
+
import type { BrowserChannel } from './system-browser.js';
|
|
11
12
|
/** Vision/operation strategy the provider prefers when the model can see. */
|
|
12
13
|
export type VisionStrategy = 'auto' | 'nonVisual';
|
|
13
14
|
/** The full settings document, with every field resolved. */
|
|
@@ -35,6 +36,10 @@ export interface BrowserSettings {
|
|
|
35
36
|
readonly vision: {
|
|
36
37
|
readonly strategy: VisionStrategy;
|
|
37
38
|
};
|
|
39
|
+
/** Which browser binary carries the agent's pages. */
|
|
40
|
+
readonly browser: {
|
|
41
|
+
readonly channel: BrowserChannel;
|
|
42
|
+
};
|
|
38
43
|
/** Whether the agent may read cookies / export login state. */
|
|
39
44
|
readonly credentials: {
|
|
40
45
|
readonly allowRead: boolean;
|
|
@@ -17,6 +17,10 @@ export const DEFAULT_SETTINGS = {
|
|
|
17
17
|
cookies: { persist: true },
|
|
18
18
|
ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
|
|
19
19
|
vision: { strategy: 'auto' },
|
|
20
|
+
// Bundled by default: it needs nothing installed, and on the desktop the carrier
|
|
21
|
+
// is the sidebar anyway. Choosing a system browser is an explicit opt-in because
|
|
22
|
+
// it starts a real Chrome/Edge with its own profile.
|
|
23
|
+
browser: { channel: 'bundled' },
|
|
20
24
|
credentials: { allowRead: true },
|
|
21
25
|
};
|
|
22
26
|
/**
|
|
@@ -32,6 +36,17 @@ export function settingsPath() {
|
|
|
32
36
|
function bool(value, fallback) {
|
|
33
37
|
return typeof value === 'boolean' ? value : fallback;
|
|
34
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Coerce the browser channel, keeping the default when it is not one of the
|
|
41
|
+
* known choices. A hand-edited typo must not silently select a different browser.
|
|
42
|
+
* @param value - the raw value from the settings document.
|
|
43
|
+
* @returns a valid channel.
|
|
44
|
+
*/
|
|
45
|
+
function resolveChannel(value) {
|
|
46
|
+
return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
|
|
47
|
+
? value
|
|
48
|
+
: DEFAULT_SETTINGS.browser.channel;
|
|
49
|
+
}
|
|
35
50
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
36
51
|
function count(value, fallback) {
|
|
37
52
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
@@ -55,6 +70,7 @@ export function resolveSettings(raw) {
|
|
|
55
70
|
const ui = section(source, 'ui');
|
|
56
71
|
const vision = section(source, 'vision');
|
|
57
72
|
const credentials = section(source, 'credentials');
|
|
73
|
+
const browser = section(source, 'browser');
|
|
58
74
|
return {
|
|
59
75
|
history: {
|
|
60
76
|
enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
@@ -68,6 +84,7 @@ export function resolveSettings(raw) {
|
|
|
68
84
|
virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
69
85
|
},
|
|
70
86
|
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
87
|
+
browser: { channel: resolveChannel(browser.channel) },
|
|
71
88
|
credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
72
89
|
};
|
|
73
90
|
}
|
|
@@ -113,7 +130,13 @@ export class SettingsStore {
|
|
|
113
130
|
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs)
|
|
114
131
|
return this.cached;
|
|
115
132
|
try {
|
|
116
|
-
|
|
133
|
+
// A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
|
|
134
|
+
// `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
|
|
135
|
+
// Without this strip, hand-editing the settings file silently discarded every
|
|
136
|
+
// value in it: the document looked correct, and the plugin quietly ran on
|
|
137
|
+
// defaults — including switches the user had just turned off.
|
|
138
|
+
const body = text?.replace(/^\uFEFF/, '');
|
|
139
|
+
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body));
|
|
117
140
|
}
|
|
118
141
|
catch {
|
|
119
142
|
// Malformed JSON: behave as defaults rather than fail startup.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive the browser the user already has (Chrome / Edge / any Chromium) instead of
|
|
3
|
+
* the Electron we ship — the same approach Codex Browser Use takes.
|
|
4
|
+
*
|
|
5
|
+
* WHY
|
|
6
|
+
* The bundled Electron is a private copy with its own profile, so it can never see
|
|
7
|
+
* the logins the user already has in Chrome, and on a managed machine the user may
|
|
8
|
+
* simply prefer their own browser. Launching their browser with a *separate*
|
|
9
|
+
* `--user-data-dir` gives us a real Chromium we can drive over CDP without touching
|
|
10
|
+
* their everyday profile, bookmarks or windows: nothing of theirs is opened, locked
|
|
11
|
+
* or modified, and closing us never closes them.
|
|
12
|
+
*
|
|
13
|
+
* HOW
|
|
14
|
+
* Chromium exposes CDP on `--remote-debugging-port=0`, and writes the port it chose
|
|
15
|
+
* into `<profile>/DevToolsActivePort`. That file is the discovery mechanism — no
|
|
16
|
+
* port guessing, no collisions. From there everything is CDP over one WebSocket:
|
|
17
|
+
* `Target.createTarget` makes a page, `Target.attachToTarget` (flattened) gives a
|
|
18
|
+
* session for it, and the plugin's commands ride that session. Node 22 ships a global
|
|
19
|
+
* `WebSocket`, so this needs no dependency at all.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT IMPLEMENTS
|
|
22
|
+
* The same `ElectronBrowserViewHost` seam as the self-hosted and sidebar hosts, so
|
|
23
|
+
* the provider, the tools, history, the cursor and teardown rules are unchanged —
|
|
24
|
+
* only the carrier differs.
|
|
25
|
+
* @module dsh-browser/browser-electron/system-browser
|
|
26
|
+
*/
|
|
27
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
|
|
28
|
+
/** Which browser the user asked for. */
|
|
29
|
+
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto';
|
|
30
|
+
/** A resolved browser installation. */
|
|
31
|
+
export interface DetectedBrowser {
|
|
32
|
+
/** Which product this is. */
|
|
33
|
+
readonly kind: 'chrome' | 'edge' | 'brave';
|
|
34
|
+
/** Absolute path to the executable. */
|
|
35
|
+
readonly path: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Find an installed Chromium browser.
|
|
39
|
+
*
|
|
40
|
+
* `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
|
|
41
|
+
* is to be the browser a user actually chose rather than one Windows shipped.
|
|
42
|
+
* @param channel - the configured choice; `bundled` never resolves to a system browser.
|
|
43
|
+
* @param env - environment lookup, injected so tests need no real machine.
|
|
44
|
+
* @returns the detected browser, or undefined when the choice is unavailable.
|
|
45
|
+
*/
|
|
46
|
+
export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv): DetectedBrowser | undefined;
|
|
47
|
+
/** A system browser driven over CDP, presented as a browser view host. */
|
|
48
|
+
export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
49
|
+
private readonly browser;
|
|
50
|
+
private readonly profileDir;
|
|
51
|
+
private readonly extraArgs;
|
|
52
|
+
private readonly ephemeralDir?;
|
|
53
|
+
private readonly views;
|
|
54
|
+
private disposed;
|
|
55
|
+
/**
|
|
56
|
+
* The browser is started on FIRST USE, never on construction.
|
|
57
|
+
*
|
|
58
|
+
* A plugin that launches Chrome the moment its host loads is a plugin that takes
|
|
59
|
+
* over the machine before anyone asked it to: simply starting DSH would spawn a
|
|
60
|
+
* browser window. Registration must therefore be inert, and the process is only
|
|
61
|
+
* spawned when a view is actually needed.
|
|
62
|
+
*/
|
|
63
|
+
private client;
|
|
64
|
+
private child;
|
|
65
|
+
private starting;
|
|
66
|
+
/**
|
|
67
|
+
* @param browser - the detected installation to launch on first use.
|
|
68
|
+
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
69
|
+
* @param extraArgs - additional Chromium switches.
|
|
70
|
+
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
71
|
+
* persistence off so no login state outlives the session.
|
|
72
|
+
*/
|
|
73
|
+
constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined);
|
|
74
|
+
/** Which product this host would drive (for diagnostics). */
|
|
75
|
+
get kind(): DetectedBrowser['kind'];
|
|
76
|
+
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
77
|
+
get started(): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* The CDP client, starting the browser on first use.
|
|
80
|
+
* @returns the connected client.
|
|
81
|
+
*/
|
|
82
|
+
private ensureClient;
|
|
83
|
+
/**
|
|
84
|
+
* Launch the browser with a private profile and connect over CDP.
|
|
85
|
+
*
|
|
86
|
+
* `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
|
|
87
|
+
* reliable way to learn the port: a fixed port collides with whatever else the
|
|
88
|
+
* machine is running, and parsing stderr is fragile across versions.
|
|
89
|
+
* @returns the connected client.
|
|
90
|
+
*/
|
|
91
|
+
private start;
|
|
92
|
+
/**
|
|
93
|
+
* Whether this host can back views.
|
|
94
|
+
*
|
|
95
|
+
* True while the host is usable — including before the browser has been started,
|
|
96
|
+
* since the first command is what starts it. Reporting "unavailable" here would
|
|
97
|
+
* make the plugin fall back for no reason.
|
|
98
|
+
*/
|
|
99
|
+
available(): boolean;
|
|
100
|
+
createView(): ElectronViewHandle;
|
|
101
|
+
destroyView(handle: ElectronViewHandle): void;
|
|
102
|
+
/** Nothing to show: the browser owns its own windows. */
|
|
103
|
+
showView(): void;
|
|
104
|
+
/** Same as {@link showView}. */
|
|
105
|
+
presentView(): Promise<void>;
|
|
106
|
+
/** The browser owns window grouping. */
|
|
107
|
+
groupView(): void;
|
|
108
|
+
/**
|
|
109
|
+
* Bring the browser's window forward.
|
|
110
|
+
*
|
|
111
|
+
* The browser is a separate application, so there is no view to raise; focusing
|
|
112
|
+
* the process is the closest honest equivalent, and failing to do so (a minimized
|
|
113
|
+
* window, a locked session) is never worth an error.
|
|
114
|
+
*/
|
|
115
|
+
focus(): Promise<void>;
|
|
116
|
+
/** The browser reports its own window lifecycle. */
|
|
117
|
+
onUserAction(): void;
|
|
118
|
+
/** The browser reports its own window lifecycle. */
|
|
119
|
+
onViewClosed(): void;
|
|
120
|
+
/**
|
|
121
|
+
* Close the browser we launched, if we ever launched one.
|
|
122
|
+
*
|
|
123
|
+
* The user's own windows belong to a different process and are never touched. A
|
|
124
|
+
* host whose browser was never started (the common case when nobody used the
|
|
125
|
+
* browser) simply tears down its bookkeeping.
|
|
126
|
+
*/
|
|
127
|
+
dispose(): void;
|
|
128
|
+
/** The flattened session for a view, creating its page on first use. */
|
|
129
|
+
private ensureSession;
|
|
130
|
+
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
|
131
|
+
private readonly sessions;
|
|
132
|
+
}
|