dsh-builtin-browser 0.1.21 → 0.1.23

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.
@@ -83,6 +83,12 @@ declare function resolveElectronPathImpl(inputs: ElectronPathInputs): string;
83
83
  * resources dir at all) likewise cannot run a script argument.
84
84
  */
85
85
  declare function isBareElectron(exe: string): boolean;
86
+ /** Candidate executable layouts used by the Electron npm package on one platform. */
87
+ declare function electronDistCandidates(pkgRoot: string, platform?: NodeJS.Platform): string[];
88
+ /** From an electron package entry file, find the dist executable beside it. */
89
+ declare function electronExeBeside(entry: string, platform?: NodeJS.Platform): string | undefined;
90
+ /** From an electron package root, find its dist executable. */
91
+ declare function electronDistExe(pkgRoot: string, platform?: NodeJS.Platform): string | undefined;
86
92
  /**
87
93
  * Self-hosted view host: spawns the plugin's Electron child on first use and
88
94
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -93,14 +99,20 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
93
99
  /** Test seam: the executable to spawn instead of the resolved Electron
94
100
  * binary. Absent -> resolveElectronPath() (production behavior). */
95
101
  private readonly spawnExecutable?;
102
+ /** Test seam: the binary probe behind `available()`. Absent ->
103
+ * resolveElectronPath(). */
104
+ private readonly probe;
96
105
  private client;
97
106
  private server;
98
107
  private pendingSocket;
99
108
  private readonly views;
100
109
  private readyPromise;
101
110
  private disposed;
102
- /** Cached probe result so `available()` stays cheap after the first call. */
111
+ /** Last probe result. A success is kept for the host's lifetime; a failure
112
+ * is retried after `PROBE_RETRY_MS` (see `available()`). */
103
113
  private electronAvailable;
114
+ /** Earliest time the next probe may run after a negative one. */
115
+ private nextProbeAt;
104
116
  /** Window groups (windowId per view), re-sent on every materialization so
105
117
  * a restarted child still places views in the right windows. */
106
118
  private readonly groups;
@@ -109,13 +121,22 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
109
121
  constructor(hostMainPath: string,
110
122
  /** Test seam: the executable to spawn instead of the resolved Electron
111
123
  * binary. Absent -> resolveElectronPath() (production behavior). */
112
- spawnExecutable?: string | undefined);
124
+ spawnExecutable?: string | undefined,
125
+ /** Test seam: the binary probe behind `available()`. Absent ->
126
+ * resolveElectronPath(). */
127
+ probe?: () => void);
113
128
  /**
114
129
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
115
- * is filesystem-only (no network), per the seam's contract, and the result
116
- * is cached for the host's lifetime — a missing binary surfaces as
117
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
118
- * confusing spawn failure on first use.
130
+ * is filesystem-only (no network), per the seam's contract, so a missing
131
+ * binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
132
+ * instead of a confusing spawn failure on first use.
133
+ *
134
+ * A success is cached for the host's lifetime. A failure is NOT: an install
135
+ * (`npm i electron`, or the postinstall that fetches the binary) can land
136
+ * long after DSH started, and provider selection happens once per process —
137
+ * caching "no" permanently reported "no usable browser provider is
138
+ * registered" until DSH restarted, with no way back short of that. Retrying
139
+ * on a cooldown heals it by itself while keeping the scan off the hot path.
119
140
  */
120
141
  available(): boolean;
121
142
  /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
@@ -126,6 +147,22 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
126
147
  createView(): ElectronViewHandle;
127
148
  private ensureView;
128
149
  showView(handle: ElectronViewHandle, label?: string): void;
150
+ /**
151
+ * (Re)present a view and WAIT for the child to confirm it, so the provider
152
+ * can dispatch CDP `Input.*` knowing the view has a display surface.
153
+ *
154
+ * Two things this fixes, both silent before:
155
+ * - showing a view that does not exist yet is a no-op in the child, so the
156
+ * view must be materialized first (a fresh tab's createView races the
157
+ * provider's showActive);
158
+ * - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
159
+ * path, leaving the new renderer without a surface.
160
+ *
161
+ * The barrier ping is meaningful because the child handles messages strictly
162
+ * in order: its reply is queued behind the showView and so only arrives once
163
+ * the visibility change has been applied.
164
+ */
165
+ presentView(handle: ElectronViewHandle, label?: string): Promise<void>;
129
166
  /** Route a view into its session's own window (one window per session). */
130
167
  groupView(handle: ElectronViewHandle, windowId: string, label?: string): void;
131
168
  /** Register the provider's handler for user-initiated toolbar actions. */
@@ -144,6 +181,9 @@ export declare function defaultHostMainPath(): string;
144
181
  * steps; `resolveElectronPath` is the full resolution order.
145
182
  */
146
183
  export declare const internals: {
184
+ electronDistCandidates: typeof electronDistCandidates;
185
+ electronDistExe: typeof electronDistExe;
186
+ electronExeBeside: typeof electronExeBeside;
147
187
  isBareElectron: typeof isBareElectron;
148
188
  resolveElectronPath: typeof resolveElectronPath;
149
189
  resolveElectronPathImpl: typeof resolveElectronPathImpl;
@@ -25,6 +25,7 @@
25
25
  * @module dsh-browser/browser-electron/remote-host
26
26
  */
27
27
  import { execFileSync, spawn } from 'node:child_process';
28
+ import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
28
29
  import { randomBytes } from 'node:crypto';
29
30
  import { createRequire } from 'node:module';
30
31
  import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
@@ -33,6 +34,8 @@ import { createServer } from 'node:net';
33
34
  import { fileURLToPath } from 'node:url';
34
35
  /** How long to wait for the child to signal readiness before failing. */
35
36
  const READY_TIMEOUT_MS = 20_000;
37
+ /** Budget for the present/barrier round-trips (see presentView). */
38
+ const PRESENT_TIMEOUT_MS = 10_000;
36
39
  /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
37
40
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
38
41
  function resolveElectronPath() {
@@ -316,23 +319,31 @@ function compareVersions(a, b) {
316
319
  }
317
320
  return 0;
318
321
  }
319
- /** From an electron package entry file, find the dist executable beside it. */
320
- function electronExeBeside(entry) {
321
- const candidates = [
322
- join(dirname(entry), 'dist', 'electron.exe'),
323
- join(dirname(entry), 'dist', 'electron'),
324
- join(dirname(entry), '..', 'dist', 'electron.exe'),
325
- join(dirname(entry), '..', 'dist', 'electron'),
322
+ /** Candidate executable layouts used by the Electron npm package on one platform. */
323
+ function electronDistCandidates(pkgRoot, platform = process.platform) {
324
+ // macOS keeps the runnable binary inside the Electron.app bundle; unlike
325
+ // Windows/Linux there is no dist/electron(.exe) sibling to probe.
326
+ if (platform === 'darwin') {
327
+ return [join(pkgRoot, 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron')];
328
+ }
329
+ return [
330
+ join(pkgRoot, 'dist', 'electron.exe'),
331
+ join(pkgRoot, 'dist', 'electron'),
326
332
  ];
327
- for (const candidate of candidates) {
328
- if (existsSync(candidate))
333
+ }
334
+ /** From an electron package entry file, find the dist executable beside it. */
335
+ function electronExeBeside(entry, platform = process.platform) {
336
+ const packageRoots = [dirname(entry), join(dirname(entry), '..')];
337
+ for (const packageRoot of packageRoots) {
338
+ const candidate = electronDistExe(packageRoot, platform);
339
+ if (candidate !== undefined)
329
340
  return candidate;
330
341
  }
331
342
  return undefined;
332
343
  }
333
344
  /** From an electron package root, find its dist executable. */
334
- function electronDistExe(pkgRoot) {
335
- for (const candidate of [join(pkgRoot, 'dist', 'electron.exe'), join(pkgRoot, 'dist', 'electron')]) {
345
+ function electronDistExe(pkgRoot, platform = process.platform) {
346
+ for (const candidate of electronDistCandidates(pkgRoot, platform)) {
336
347
  if (existsSync(candidate))
337
348
  return candidate;
338
349
  }
@@ -411,8 +422,13 @@ class ElectronChildClient {
411
422
  });
412
423
  this.child.stderr.setEncoding('utf8');
413
424
  this.child.stderr.on('data', chunk => {
414
- // Diagnostics only; never parse stderr as protocol.
415
- process.stderr.write(`[dsh-browser host] ${String(chunk)}`);
425
+ // Diagnostics only; never parse stderr as protocol. Mirror to a log file
426
+ // as well: under a plain `dsh web` self-host these lines only ever reach
427
+ // the DSH process's stderr, so a host that crashes in a restart loop left
428
+ // the user with a string of timeouts and no stack to act on.
429
+ const text = String(chunk);
430
+ process.stderr.write(`[dsh-browser host] ${text}`);
431
+ appendHostLog(text);
416
432
  });
417
433
  // Send the spawn token over stdin (first line). The child reads it from
418
434
  // stdin instead of argv so the token is never visible in the process
@@ -423,11 +439,17 @@ class ElectronChildClient {
423
439
  // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
424
440
  // that would crash the whole DSH process.
425
441
  this.child.on('error', error => {
426
- process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`);
442
+ const line = `spawn error: ${String(error)}`;
443
+ process.stderr.write(`[dsh-browser host] ${line}\n`);
444
+ appendHostLog(`${line}\n`);
427
445
  this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
428
446
  });
429
447
  this.child.on('exit', (code, signal) => {
430
- this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`));
448
+ // The exit code/signal is the other half of a crash report; without it a
449
+ // restart loop is indistinguishable from a clean stop.
450
+ const line = `browser host exited (code=${String(code)} signal=${String(signal)})`;
451
+ appendHostLog(`${line}\n`);
452
+ this.fail(new Error(`dsh-builtin-browser: ${line}`));
431
453
  });
432
454
  }
433
455
  /** Reject everything in flight, mark the client dead, and notify the host. */
@@ -602,6 +624,10 @@ class RemoteView {
602
624
  params: params ?? {},
603
625
  });
604
626
  }
627
+ /** Give this view web focus in the child, so keyboard input reaches the page. */
628
+ focus() {
629
+ return this.client.call('focus', { viewId: this.id });
630
+ }
605
631
  /** Ask the child to download a URL to a local file (keeps cookies/login). */
606
632
  async download(url, savePath) {
607
633
  // The child writes the file itself (temp + rename); only a small
@@ -622,6 +648,14 @@ class RemoteView {
622
648
  return this.client.call('restoreAuth', { viewId: this.id, cookies }).then(r => r.restored);
623
649
  }
624
650
  }
651
+ /**
652
+ * How long a FAILED Electron-binary probe is remembered before the binary
653
+ * search runs again (`available()`). A success is kept for the host's lifetime.
654
+ */
655
+ const PROBE_RETRY_MS = (() => {
656
+ const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS);
657
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30000;
658
+ })();
625
659
  /**
626
660
  * Self-hosted view host: spawns the plugin's Electron child on first use and
627
661
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -630,14 +664,18 @@ class RemoteView {
630
664
  export class RemoteElectronViewHost {
631
665
  hostMainPath;
632
666
  spawnExecutable;
667
+ probe;
633
668
  client;
634
669
  server;
635
670
  pendingSocket;
636
671
  views = new Map();
637
672
  readyPromise;
638
673
  disposed = false;
639
- /** Cached probe result so `available()` stays cheap after the first call. */
674
+ /** Last probe result. A success is kept for the host's lifetime; a failure
675
+ * is retried after `PROBE_RETRY_MS` (see `available()`). */
640
676
  electronAvailable;
677
+ /** Earliest time the next probe may run after a negative one. */
678
+ nextProbeAt = 0;
641
679
  /** Window groups (windowId per view), re-sent on every materialization so
642
680
  * a restarted child still places views in the right windows. */
643
681
  groups = new Map();
@@ -646,30 +684,43 @@ export class RemoteElectronViewHost {
646
684
  constructor(hostMainPath,
647
685
  /** Test seam: the executable to spawn instead of the resolved Electron
648
686
  * binary. Absent -> resolveElectronPath() (production behavior). */
649
- spawnExecutable) {
687
+ spawnExecutable,
688
+ /** Test seam: the binary probe behind `available()`. Absent ->
689
+ * resolveElectronPath(). */
690
+ probe = resolveElectronPath) {
650
691
  this.hostMainPath = hostMainPath;
651
692
  this.spawnExecutable = spawnExecutable;
693
+ this.probe = probe;
652
694
  }
653
695
  /**
654
696
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
655
- * is filesystem-only (no network), per the seam's contract, and the result
656
- * is cached for the host's lifetime — a missing binary surfaces as
657
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
658
- * confusing spawn failure on first use.
697
+ * is filesystem-only (no network), per the seam's contract, so a missing
698
+ * binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
699
+ * instead of a confusing spawn failure on first use.
700
+ *
701
+ * A success is cached for the host's lifetime. A failure is NOT: an install
702
+ * (`npm i electron`, or the postinstall that fetches the binary) can land
703
+ * long after DSH started, and provider selection happens once per process —
704
+ * caching "no" permanently reported "no usable browser provider is
705
+ * registered" until DSH restarted, with no way back short of that. Retrying
706
+ * on a cooldown heals it by itself while keeping the scan off the hot path.
659
707
  */
660
708
  available() {
661
- if (this.electronAvailable === undefined) {
662
- try {
663
- resolveElectronPath();
664
- this.electronAvailable = true;
665
- }
666
- catch (error) {
667
- this.electronAvailable = false;
668
- // The provider will be reported unavailable, so the detailed
669
- // resolution error (install-electron / ELECTRON_PATH guidance) would
670
- // otherwise never reach the user — surface it on stderr.
671
- process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
672
- }
709
+ if (this.electronAvailable === true)
710
+ return true;
711
+ if (Date.now() < this.nextProbeAt)
712
+ return false;
713
+ this.nextProbeAt = Date.now() + PROBE_RETRY_MS;
714
+ try {
715
+ this.probe();
716
+ this.electronAvailable = true;
717
+ }
718
+ catch (error) {
719
+ this.electronAvailable = false;
720
+ // The provider will be reported unavailable, so the detailed
721
+ // resolution error (install-electron / ELECTRON_PATH guidance) would
722
+ // otherwise never reach the user — surface it on stderr.
723
+ process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
673
724
  }
674
725
  return this.electronAvailable;
675
726
  }
@@ -823,6 +874,35 @@ export class RemoteElectronViewHost {
823
874
  .then(() => this.client?.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }))
824
875
  .catch(() => { });
825
876
  }
877
+ /**
878
+ * (Re)present a view and WAIT for the child to confirm it, so the provider
879
+ * can dispatch CDP `Input.*` knowing the view has a display surface.
880
+ *
881
+ * Two things this fixes, both silent before:
882
+ * - showing a view that does not exist yet is a no-op in the child, so the
883
+ * view must be materialized first (a fresh tab's createView races the
884
+ * provider's showActive);
885
+ * - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
886
+ * path, leaving the new renderer without a surface.
887
+ *
888
+ * The barrier ping is meaningful because the child handles messages strictly
889
+ * in order: its reply is queued behind the showView and so only arrives once
890
+ * the visibility change has been applied.
891
+ */
892
+ async presentView(handle, label) {
893
+ await this.ready();
894
+ const client = this.client;
895
+ if (client === undefined)
896
+ throw new HostGoneError('browser host unavailable');
897
+ // A view created after the last createView does not exist in the child yet.
898
+ // ensureMaterialized also rebuilds it against a fresh child after a host
899
+ // death, which is the path that used to leave sessions timing out forever.
900
+ if (handle instanceof DeferredRemoteView) {
901
+ await withTimeout(handle.ensureMaterialized(), PRESENT_TIMEOUT_MS, 'browser host did not materialize the view');
902
+ }
903
+ await withTimeout(client.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }), PRESENT_TIMEOUT_MS, 'browser host did not confirm the view presentation');
904
+ await withTimeout(client.call('ping'), PRESENT_TIMEOUT_MS, 'browser host stopped answering');
905
+ }
826
906
  /** Route a view into its session's own window (one window per session). */
827
907
  groupView(handle, windowId, label) {
828
908
  this.groups.set(handle.id, { windowId, ...label !== undefined ? { label } : {} });
@@ -927,12 +1007,43 @@ class DeferredRemoteView {
927
1007
  async capture(opts) {
928
1008
  return this.withRecovery(view => view.capture(opts));
929
1009
  }
1010
+ async focus() {
1011
+ return this.withRecovery(view => view.focus());
1012
+ }
930
1013
  async flushAuth() {
931
1014
  return this.withRecovery(view => view.flushAuth());
932
1015
  }
933
1016
  async restoreAuth(cookies) {
934
1017
  return this.withRecovery(view => view.restoreAuth(cookies));
935
1018
  }
1019
+ /** Materialize this view in the child (no-op when already there). */
1020
+ async ensureMaterialized() {
1021
+ await this.withRecovery(async () => undefined);
1022
+ }
1023
+ }
1024
+ /**
1025
+ * Append one host-diagnostics chunk to `$DSH_HOME/logs/dsh-builtin-browser-host.log`.
1026
+ * Best-effort and self-limiting: diagnostics must never break the host or grow
1027
+ * without bound, so failures are swallowed and the file is truncated when it
1028
+ * gets large (a crash loop writes quickly).
1029
+ */
1030
+ const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024;
1031
+ function appendHostLog(text) {
1032
+ const home = process.env.DSH_HOME;
1033
+ if (home === undefined || home === '')
1034
+ return;
1035
+ try {
1036
+ const dir = join(home, 'logs');
1037
+ const file = join(dir, 'dsh-builtin-browser-host.log');
1038
+ try {
1039
+ if (statSync(file).size > HOST_LOG_MAX_BYTES)
1040
+ writeFileSync(file, '');
1041
+ }
1042
+ catch { /* first write, or unreadable: just append */ }
1043
+ mkdirSync(dir, { recursive: true });
1044
+ appendFileSync(file, text);
1045
+ }
1046
+ catch { /* diagnostics are best-effort only */ }
936
1047
  }
937
1048
  /** Reject a promise if it does not settle within the budget. */
938
1049
  function withTimeout(promise, ms, message) {
@@ -951,6 +1062,9 @@ export function defaultHostMainPath() {
951
1062
  * steps; `resolveElectronPath` is the full resolution order.
952
1063
  */
953
1064
  export const internals = {
1065
+ electronDistCandidates,
1066
+ electronDistExe,
1067
+ electronExeBeside,
954
1068
  isBareElectron,
955
1069
  resolveElectronPath,
956
1070
  resolveElectronPathImpl,
@@ -1042,7 +1042,7 @@ export function apply(ctx, config = {}) {
1042
1042
  description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). Use for visual confirmation of layout, charts, designs, or CAPTCHAs, or to feed a vision tool (read_image) that locates elements visually. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
1043
1043
  parameters: {
1044
1044
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1045
- savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location).' },
1045
+ savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
1046
1046
  format: { type: 'string', enum: ['png', 'jpeg'], description: 'Image format (default png; jpeg is self-hosted native path only).' },
1047
1047
  quality: { type: 'number', description: 'JPEG quality 1-100 (default 80); ignored for PNG.' },
1048
1048
  maxWidth: { type: 'number', description: 'Downscale to fit within this width (aspect preserved).' },
@@ -1312,7 +1312,7 @@ export function apply(ctx, config = {}) {
1312
1312
  description: 'Download a URL to a local file, keeping the browser session\'s cookies and login state. Use for fetching files behind authentication or from the current page context. Available on the self-hosted browser; the desktop shell delegates downloads to the real browser UI.',
1313
1313
  parameters: {
1314
1314
  url: { type: 'string', required: true, description: 'The URL to download.' },
1315
- savePath: { type: 'string', required: true, description: 'Absolute path of the file to write.' },
1315
+ savePath: { type: 'string', required: true, description: 'Absolute path of the file to write. Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
1316
1316
  },
1317
1317
  output: {
1318
1318
  schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true } } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -65,9 +65,9 @@
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@deepseek-ai/cordis": "^4.0.1",
68
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
69
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
70
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.3.0",
69
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.3.0",
70
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.1-rc.2 <0.3.0",
71
71
  "@deepseek-ai/schemastery": "^3.18.1"
72
72
  },
73
73
  "devDependencies": {
@@ -79,7 +79,7 @@
79
79
  "patch": "./cordis.patch.yml"
80
80
  },
81
81
  "compatibility": {
82
- "dsh": ">=0.1.1-rc.1 <0.2.0",
82
+ "dsh": ">=0.1.1-rc.1 <0.3.0",
83
83
  "profiles": [
84
84
  "web",
85
85
  "desktop"
@@ -87,7 +87,9 @@
87
87
  "dshReleases": {
88
88
  "0.1.0-rc.8": "unknown",
89
89
  "0.1.1-rc.1": "compatible",
90
- "0.1.1-rc.2": "compatible"
90
+ "0.1.1-rc.2": "compatible",
91
+ "0.2.0-rc.1": "compatible",
92
+ "0.2.0-rc.2": "compatible"
91
93
  }
92
94
  }
93
95
  }
@@ -177,7 +177,21 @@ export class BrowserRuntime extends Service {
177
177
  const usable = [...providers.values()].filter(provider => provider.available())
178
178
  const [single] = usable
179
179
  if (single === undefined) {
180
- throw new BrowserError('no usable browser provider is registered', 'BROWSER_PROVIDER_UNAVAILABLE')
180
+ // Two very different causes used to read identically here — nothing is
181
+ // registered, or something is registered but cannot run — and only the
182
+ // second one has a fix the user can apply without reinstalling.
183
+ const registered = [...providers.values()].map(provider => provider.id)
184
+ if (registered.length === 0) {
185
+ throw new BrowserError(
186
+ 'no browser provider is registered — install a browser plugin (e.g. dsh-builtin-browser) and enable it in this profile',
187
+ 'BROWSER_PROVIDER_UNAVAILABLE',
188
+ )
189
+ }
190
+ throw new BrowserError(
191
+ `browser provider(s) ${registered.join(', ')} are registered but report themselves unavailable — `
192
+ + 'for dsh-builtin-browser this means no spawnable Electron binary was found; run `npx install-electron` in the active profile or set ELECTRON_PATH',
193
+ 'BROWSER_PROVIDER_UNAVAILABLE',
194
+ )
181
195
  }
182
196
  if (usable.length > 1) {
183
197
  const ids = usable.map(provider => provider.id).join(', ')