dsh-browser-plus 0.0.0-stage → 0.5.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/CHANGELOG.md +153 -0
- package/LICENSE +22 -0
- package/NOTICE.md +7 -0
- package/README.en.md +119 -0
- package/README.md +118 -2
- package/assets/dsh-browser-plus-256.png +0 -0
- package/assets/dsh-browser-plus-512.png +0 -0
- package/assets/dsh-browser-plus-small.svg +9 -0
- package/assets/dsh-browser-plus.ico +0 -0
- package/assets/dsh-browser-plus.svg +11 -0
- package/assets/readme-workspace.png +0 -0
- package/client/index.js +185 -0
- package/cordis.patch.yml +41 -0
- package/docs/MIGRATION.md +48 -0
- package/docs/README.md +22 -0
- package/docs/SOAK-CHECKLIST.md +98 -0
- package/docs/architecture.md +88 -0
- package/docs/tool-reference.md +126 -0
- package/docs/user-guide.md +137 -0
- package/docs/why-browser.md +45 -0
- package/lib/browser/runtime.d.ts +238 -0
- package/lib/browser/runtime.js +330 -0
- package/lib/browser/types.d.ts +758 -0
- package/lib/browser/types.js +18 -0
- package/lib/browser-electron/auth-cookies.d.ts +54 -0
- package/lib/browser-electron/auth-cookies.js +83 -0
- package/lib/browser-electron/chrome-state.d.ts +211 -0
- package/lib/browser-electron/chrome-state.js +12 -0
- package/lib/browser-electron/entry.d.ts +73 -0
- package/lib/browser-electron/entry.js +65 -0
- package/lib/browser-electron/fingerprint.d.ts +29 -0
- package/lib/browser-electron/fingerprint.js +42 -0
- package/lib/browser-electron/host-main.d.ts +19 -0
- package/lib/browser-electron/host-main.js +2691 -0
- package/lib/browser-electron/icon.d.ts +11 -0
- package/lib/browser-electron/icon.js +23 -0
- package/lib/browser-electron/page-chrome.d.ts +21 -0
- package/lib/browser-electron/page-chrome.js +2269 -0
- package/lib/browser-electron/provider.d.ts +767 -0
- package/lib/browser-electron/provider.js +2825 -0
- package/lib/browser-electron/remote-host.d.ts +145 -0
- package/lib/browser-electron/remote-host.js +993 -0
- package/lib/browser-electron/task-summary.d.ts +2 -0
- package/lib/browser-electron/task-summary.js +12 -0
- package/lib/browser-electron/task-thumbnail.d.ts +11 -0
- package/lib/browser-electron/task-thumbnail.js +9 -0
- package/lib/browser-electron/write-guard.d.ts +41 -0
- package/lib/browser-electron/write-guard.js +123 -0
- package/lib/client.js +185 -0
- package/lib/command-browser/index.d.ts +20 -0
- package/lib/command-browser/index.js +35 -0
- package/lib/http-browser/index.d.ts +28 -0
- package/lib/http-browser/index.js +110 -0
- package/lib/index.d.ts +27 -0
- package/lib/index.js +25 -0
- package/lib/task-todos/index.d.ts +25 -0
- package/lib/task-todos/index.js +100 -0
- package/lib/tool-browser/index.d.ts +31 -0
- package/lib/tool-browser/index.js +2026 -0
- package/package.json +120 -4
- package/screenshots.json +3 -0
- package/scripts/build-client.mjs +20 -0
- package/scripts/build-icons.mjs +80 -0
- package/scripts/capture-window.ps1 +79 -0
- package/scripts/crop-image.ps1 +20 -0
- package/scripts/smoke-browser-tools.mjs +2051 -0
- package/scripts/smoke-chrome-world.mjs +65 -0
- package/scripts/smoke-electron-host.mjs +50 -0
- package/scripts/test-orb-drag.mjs +83 -0
- package/src/browser/runtime.ts +506 -0
- package/src/browser/types.ts +741 -0
- package/src/browser-electron/auth-cookies.ts +125 -0
- package/src/browser-electron/chrome-state.ts +192 -0
- package/src/browser-electron/entry.ts +125 -0
- package/src/browser-electron/fingerprint.ts +45 -0
- package/src/browser-electron/host-main.ts +2526 -0
- package/src/browser-electron/icon.ts +26 -0
- package/src/browser-electron/page-chrome.ts +2281 -0
- package/src/browser-electron/provider.ts +3366 -0
- package/src/browser-electron/remote-host.ts +1051 -0
- package/src/browser-electron/task-summary.ts +10 -0
- package/src/browser-electron/task-thumbnail.ts +17 -0
- package/src/browser-electron/write-guard.ts +134 -0
- package/src/command-browser/index.ts +61 -0
- package/src/http-browser/index.ts +139 -0
- package/src/index.ts +65 -0
- package/src/task-todos/index.ts +114 -0
- package/src/tool-browser/index.ts +2071 -0
- package/src/types/electron-shim.d.ts +143 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Return a page-safe task location without path, query, or fragment data. */
|
|
2
|
+
export function taskSummaryUrl(raw) {
|
|
3
|
+
if (raw === '')
|
|
4
|
+
return '';
|
|
5
|
+
try {
|
|
6
|
+
const origin = new URL(raw).origin;
|
|
7
|
+
return origin === 'null' ? '' : origin;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const TASK_THUMBNAIL_WIDTH = 288;
|
|
2
|
+
export declare const TASK_THUMBNAIL_JPEG_QUALITY = 58;
|
|
3
|
+
export declare const MAX_TASK_THUMBNAIL_BYTES: number;
|
|
4
|
+
export interface ThumbnailImage {
|
|
5
|
+
resize(options: {
|
|
6
|
+
width: number;
|
|
7
|
+
}): {
|
|
8
|
+
toJPEG(quality: number): Buffer;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function taskThumbnailDataUrl(image: ThumbnailImage): string | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const TASK_THUMBNAIL_WIDTH = 288;
|
|
2
|
+
export const TASK_THUMBNAIL_JPEG_QUALITY = 58;
|
|
3
|
+
export const MAX_TASK_THUMBNAIL_BYTES = 180 * 1024;
|
|
4
|
+
export function taskThumbnailDataUrl(image) {
|
|
5
|
+
const jpeg = image.resize({ width: TASK_THUMBNAIL_WIDTH }).toJPEG(TASK_THUMBNAIL_JPEG_QUALITY);
|
|
6
|
+
if (jpeg.length === 0 || jpeg.length > MAX_TASK_THUMBNAIL_BYTES)
|
|
7
|
+
return undefined;
|
|
8
|
+
return `data:image/jpeg;base64,${jpeg.toString('base64')}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-path admission for browser-produced files (screenshots, downloads).
|
|
3
|
+
*
|
|
4
|
+
* The provider — not the host — owns this guard so one implementation covers
|
|
5
|
+
* the self-hosted Electron host and desktop-shell handles alike, and so it can
|
|
6
|
+
* be exercised without Electron. Roots are resolved through the deepest
|
|
7
|
+
* existing ancestor, which keeps a symlinked or `..`-laden target from
|
|
8
|
+
* escaping a root it only appears to sit inside.
|
|
9
|
+
* @module dsh-browser-plus/browser-electron/write-guard
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Roots a browser write may target when the config does not name any: the
|
|
13
|
+
* DSH workspace (the process working directory) and the OS temp directory.
|
|
14
|
+
* This mirrors the DSH file sandbox's "write inside the workspace" boundary.
|
|
15
|
+
*/
|
|
16
|
+
export declare function defaultWriteRoots(): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Whether a path would be admitted by {@link resolveWritePath}, without
|
|
19
|
+
* throwing. Exposed for focused tests and for callers that want to probe.
|
|
20
|
+
* @param candidate - the path to test.
|
|
21
|
+
* @param roots - the allowed roots.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isWithinRoots(candidate: string, roots: readonly string[]): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a write target and admit it only when it lands inside one of the
|
|
26
|
+
* allowed roots. Returns the absolute path to write.
|
|
27
|
+
* @param savePath - the caller-supplied path.
|
|
28
|
+
* @param roots - the allowed roots; an empty list denies every write.
|
|
29
|
+
* @throws BrowserError `BROWSER_WRITE_PATH_DENIED` when the path is unusable or outside every root.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveWritePath(savePath: string, roots: readonly string[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a file the browser is about to hand to a page and admit it only when
|
|
34
|
+
* it lands inside one of the allowed roots. Unlike a write target the file must
|
|
35
|
+
* already exist, so the path itself is realpath'd: a symlink to a permitted
|
|
36
|
+
* file is admitted, a symlink that leaves the roots is not.
|
|
37
|
+
* @param filePath - the caller-supplied path.
|
|
38
|
+
* @param roots - the allowed roots; an empty list denies every read.
|
|
39
|
+
* @throws BrowserError `BROWSER_READ_PATH_DENIED` when the path is unusable, missing, or outside every root.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveReadPath(filePath: string, roots: readonly string[]): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-path admission for browser-produced files (screenshots, downloads).
|
|
3
|
+
*
|
|
4
|
+
* The provider — not the host — owns this guard so one implementation covers
|
|
5
|
+
* the self-hosted Electron host and desktop-shell handles alike, and so it can
|
|
6
|
+
* be exercised without Electron. Roots are resolved through the deepest
|
|
7
|
+
* existing ancestor, which keeps a symlinked or `..`-laden target from
|
|
8
|
+
* escaping a root it only appears to sit inside.
|
|
9
|
+
* @module dsh-browser-plus/browser-electron/write-guard
|
|
10
|
+
*/
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
14
|
+
import { BrowserError } from "../browser/types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Roots a browser write may target when the config does not name any: the
|
|
17
|
+
* DSH workspace (the process working directory) and the OS temp directory.
|
|
18
|
+
* This mirrors the DSH file sandbox's "write inside the workspace" boundary.
|
|
19
|
+
*/
|
|
20
|
+
export function defaultWriteRoots() {
|
|
21
|
+
return [process.cwd(), tmpdir()];
|
|
22
|
+
}
|
|
23
|
+
/** Windows paths compare case-insensitively; POSIX paths do not. */
|
|
24
|
+
function comparable(value) {
|
|
25
|
+
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve symlinks on the deepest ancestor that exists, then re-append the
|
|
29
|
+
* segments that do not. A save target usually does not exist yet, so the
|
|
30
|
+
* target itself cannot be realpath'd — but its parent usually can.
|
|
31
|
+
*/
|
|
32
|
+
function realpathOfNearestAncestor(target) {
|
|
33
|
+
let current = target;
|
|
34
|
+
const tail = [];
|
|
35
|
+
for (;;) {
|
|
36
|
+
try {
|
|
37
|
+
const real = realpathSync.native(current);
|
|
38
|
+
return tail.length === 0 ? real : join(real, ...[...tail].reverse());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
const parent = dirname(current);
|
|
42
|
+
// Reached the filesystem root without finding anything real: fall back
|
|
43
|
+
// to the lexical path rather than denying a legitimate write.
|
|
44
|
+
if (parent === current)
|
|
45
|
+
return target;
|
|
46
|
+
tail.push(basename(current));
|
|
47
|
+
current = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** True when `candidate` is `root` itself or lives beneath it. */
|
|
52
|
+
function within(candidate, root) {
|
|
53
|
+
if (candidate === root)
|
|
54
|
+
return true;
|
|
55
|
+
return candidate.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
56
|
+
}
|
|
57
|
+
/** Real, comparable forms of both sides; roots are normalized exactly once per call. */
|
|
58
|
+
function resolvedPair(candidate, roots) {
|
|
59
|
+
return {
|
|
60
|
+
target: comparable(realpathOfNearestAncestor(resolve(candidate))),
|
|
61
|
+
roots: roots.map(root => comparable(realpathOfNearestAncestor(resolve(root)))),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether a path would be admitted by {@link resolveWritePath}, without
|
|
66
|
+
* throwing. Exposed for focused tests and for callers that want to probe.
|
|
67
|
+
* @param candidate - the path to test.
|
|
68
|
+
* @param roots - the allowed roots.
|
|
69
|
+
*/
|
|
70
|
+
export function isWithinRoots(candidate, roots) {
|
|
71
|
+
const { target, roots: resolved } = resolvedPair(candidate, roots);
|
|
72
|
+
return resolved.some(root => within(target, root));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a write target and admit it only when it lands inside one of the
|
|
76
|
+
* allowed roots. Returns the absolute path to write.
|
|
77
|
+
* @param savePath - the caller-supplied path.
|
|
78
|
+
* @param roots - the allowed roots; an empty list denies every write.
|
|
79
|
+
* @throws BrowserError `BROWSER_WRITE_PATH_DENIED` when the path is unusable or outside every root.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveWritePath(savePath, roots) {
|
|
82
|
+
if (typeof savePath !== 'string' || savePath.trim() === '') {
|
|
83
|
+
throw new BrowserError('browser: refusing to write without a save path', 'BROWSER_WRITE_PATH_DENIED');
|
|
84
|
+
}
|
|
85
|
+
const absolute = resolve(savePath);
|
|
86
|
+
if (!isWithinRoots(absolute, roots)) {
|
|
87
|
+
// This message reaches the model context and, through a task's error field,
|
|
88
|
+
// the page itself — so it never names an absolute path.
|
|
89
|
+
const hint = roots.length === 0 ? ' (none configured)' : '';
|
|
90
|
+
throw new BrowserError(`browser: refusing to write "${savePath}" outside the allowed roots${hint}; `
|
|
91
|
+
+ 'add the directory to the browser-electron "writeRoots" config to allow it', 'BROWSER_WRITE_PATH_DENIED');
|
|
92
|
+
}
|
|
93
|
+
return absolute;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a file the browser is about to hand to a page and admit it only when
|
|
97
|
+
* it lands inside one of the allowed roots. Unlike a write target the file must
|
|
98
|
+
* already exist, so the path itself is realpath'd: a symlink to a permitted
|
|
99
|
+
* file is admitted, a symlink that leaves the roots is not.
|
|
100
|
+
* @param filePath - the caller-supplied path.
|
|
101
|
+
* @param roots - the allowed roots; an empty list denies every read.
|
|
102
|
+
* @throws BrowserError `BROWSER_READ_PATH_DENIED` when the path is unusable, missing, or outside every root.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveReadPath(filePath, roots) {
|
|
105
|
+
if (typeof filePath !== 'string' || filePath.trim() === '') {
|
|
106
|
+
throw new BrowserError('browser: refusing to read without a file path', 'BROWSER_READ_PATH_DENIED');
|
|
107
|
+
}
|
|
108
|
+
const absolute = resolve(filePath);
|
|
109
|
+
let real;
|
|
110
|
+
try {
|
|
111
|
+
real = realpathSync.native(absolute);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new BrowserError(`browser: refusing to read "${filePath}": the file does not exist`, 'BROWSER_READ_PATH_DENIED');
|
|
115
|
+
}
|
|
116
|
+
const allowed = roots.map(root => comparable(realpathOfNearestAncestor(resolve(root))));
|
|
117
|
+
if (!allowed.some(root => within(comparable(real), root))) {
|
|
118
|
+
const hint = roots.length === 0 ? ' (none configured)' : '';
|
|
119
|
+
throw new BrowserError(`browser: refusing to read "${filePath}" outside the allowed roots${hint}; `
|
|
120
|
+
+ 'add the directory to the browser-electron "readRoots" config to allow it', 'BROWSER_READ_PATH_DENIED');
|
|
121
|
+
}
|
|
122
|
+
return absolute;
|
|
123
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-browser-plus — client half.
|
|
3
|
+
*
|
|
4
|
+
* One right-sidebar tab whose whole job is to put the shared browser window on
|
|
5
|
+
* screen: picking it from the rightbar's add list opens the window, and the
|
|
6
|
+
* panel keeps a button for raising it again. It is named after the plugin and
|
|
7
|
+
* carries the plugin's own mark, so it is not confused with the product's
|
|
8
|
+
* built-in browser tab. It talks to the plugin's own
|
|
9
|
+
* HTTP route (`src/http-browser/index.ts`), because a third-party client bundle
|
|
10
|
+
* has no generated Remote surface to call a server-side method through.
|
|
11
|
+
*
|
|
12
|
+
* This file is the browser bundle's source in the client-modules factory
|
|
13
|
+
* format: running it only registers the factory, and the module body runs when
|
|
14
|
+
* the plugin is first materialized. `scripts/build-client.mjs` copies it to
|
|
15
|
+
* `lib/client.js` — it is one file with no imports beyond the platform table,
|
|
16
|
+
* so no bundler is involved.
|
|
17
|
+
*/
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-browser-plus',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
var module = { exports: {} }
|
|
22
|
+
var exports = module.exports
|
|
23
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
24
|
+
const React = require('react')
|
|
25
|
+
|
|
26
|
+
/** Implementation identity; also the key its body registers under. */
|
|
27
|
+
const IMPLEMENTATION_ID = 'dsh-browser-plus'
|
|
28
|
+
/** Tab kind: a page type, opened by kind and recognizing no address. */
|
|
29
|
+
const KIND = 'dsh-browser-plus'
|
|
30
|
+
const OPEN_PATH = '/api/dsh-browser-plus/open'
|
|
31
|
+
const STATUS_PATH = '/api/dsh-browser-plus/status'
|
|
32
|
+
|
|
33
|
+
const COPY = {
|
|
34
|
+
zh: {
|
|
35
|
+
title: 'DSH-Browser-Plus',
|
|
36
|
+
description: '打开共享浏览器窗口',
|
|
37
|
+
open: '打开浏览器窗口',
|
|
38
|
+
busy: '正在打开…',
|
|
39
|
+
opened: '浏览器窗口已打开。',
|
|
40
|
+
failed: '打开失败:',
|
|
41
|
+
tasks: (count) => (count > 0 ? '当前有 ' + String(count) + ' 个浏览器任务' : '还没有浏览器任务'),
|
|
42
|
+
},
|
|
43
|
+
en: {
|
|
44
|
+
title: 'DSH-Browser-Plus',
|
|
45
|
+
description: 'Open the shared browser window',
|
|
46
|
+
open: 'Open browser window',
|
|
47
|
+
busy: 'Opening…',
|
|
48
|
+
opened: 'The browser window is open.',
|
|
49
|
+
failed: 'Could not open it: ',
|
|
50
|
+
tasks: (count) => (count > 0 ? String(count) + ' browser task(s) open' : 'no browser task yet'),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The panel's copy, read at render so a language switch needs no reload. */
|
|
55
|
+
function copy() {
|
|
56
|
+
try {
|
|
57
|
+
const declared = String(document.documentElement.getAttribute('lang') || navigator.language || '')
|
|
58
|
+
return declared.toLowerCase().indexOf('zh') === 0 ? COPY.zh : COPY.en
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return COPY.zh
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** POST the open endpoint; resolve to a message, never throw at the renderer. */
|
|
65
|
+
function requestOpen() {
|
|
66
|
+
return fetch(OPEN_PATH, { method: 'POST' }).then((response) => response.json().catch(() => null).then((body) => {
|
|
67
|
+
if (!response.ok || body === null || body.ok !== true) {
|
|
68
|
+
throw new Error(body !== null && typeof body.error === 'string' ? body.error : 'HTTP ' + String(response.status))
|
|
69
|
+
}
|
|
70
|
+
return true
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read the task count for the status line; a failure is not worth a notice. */
|
|
75
|
+
function requestStatus() {
|
|
76
|
+
return fetch(STATUS_PATH).then((response) => response.json()).then((body) => (body !== null && body.ok === true && typeof body.tasks === 'number' ? body.tasks : null)).catch(() => null)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The plugin's own mark, inlined from assets/dsh-browser-plus.svg so the
|
|
81
|
+
* bundle keeps requiring nothing but the platform table. The plate is part
|
|
82
|
+
* of the art: it is what keeps a white outline legible on a light surface.
|
|
83
|
+
*/
|
|
84
|
+
function BrowserIcon(props) {
|
|
85
|
+
const size = props !== null && props !== undefined && props.size !== undefined ? props.size : 36
|
|
86
|
+
return React.createElement('svg', {
|
|
87
|
+
width: size,
|
|
88
|
+
height: size,
|
|
89
|
+
className: props !== null && props !== undefined ? props.className : undefined,
|
|
90
|
+
'aria-hidden': 'true',
|
|
91
|
+
viewBox: '0 0 256 256',
|
|
92
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
93
|
+
},
|
|
94
|
+
React.createElement('rect', { x: 10, y: 10, width: 236, height: 236, rx: 54, fill: '#202124' }),
|
|
95
|
+
React.createElement('g', { transform: 'translate(20 20) scale(9)', fill: 'none', stroke: '#ffffff', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
96
|
+
React.createElement('rect', { x: 3.25, y: 4.75, width: 17.5, height: 14.5, rx: 3.25 }),
|
|
97
|
+
React.createElement('path', { d: 'M3.25 9.4h17.5' }),
|
|
98
|
+
React.createElement('circle', { cx: 6.15, cy: 7.05, r: 0.9, fill: '#ffffff', stroke: 'none' }),
|
|
99
|
+
React.createElement('circle', { cx: 8.75, cy: 7.05, r: 0.9, fill: '#ffffff', stroke: 'none' }),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const PANEL_STYLE = {
|
|
105
|
+
display: 'flex',
|
|
106
|
+
flexDirection: 'column',
|
|
107
|
+
gap: '10px',
|
|
108
|
+
padding: '16px',
|
|
109
|
+
color: 'inherit',
|
|
110
|
+
font: 'inherit',
|
|
111
|
+
}
|
|
112
|
+
const BUTTON_STYLE = {
|
|
113
|
+
alignSelf: 'flex-start',
|
|
114
|
+
padding: '6px 14px',
|
|
115
|
+
border: '1px solid currentColor',
|
|
116
|
+
borderRadius: '6px',
|
|
117
|
+
background: 'transparent',
|
|
118
|
+
color: 'inherit',
|
|
119
|
+
font: 'inherit',
|
|
120
|
+
cursor: 'pointer',
|
|
121
|
+
}
|
|
122
|
+
const NOTE_STYLE = { opacity: 0.7, fontSize: '12px' }
|
|
123
|
+
|
|
124
|
+
/** The panel body: it opens the window as soon as it is mounted. */
|
|
125
|
+
function BrowserPanel() {
|
|
126
|
+
const t = copy()
|
|
127
|
+
const [busy, setBusy] = React.useState(false)
|
|
128
|
+
const [note, setNote] = React.useState(null)
|
|
129
|
+
const [tasks, setTasks] = React.useState(null)
|
|
130
|
+
|
|
131
|
+
const open = React.useCallback(() => {
|
|
132
|
+
setBusy(true)
|
|
133
|
+
setNote(null)
|
|
134
|
+
requestOpen()
|
|
135
|
+
.then(() => {
|
|
136
|
+
setBusy(false)
|
|
137
|
+
setNote({ ok: true, text: t.opened })
|
|
138
|
+
return requestStatus().then((count) => setTasks(count))
|
|
139
|
+
})
|
|
140
|
+
.catch((error) => {
|
|
141
|
+
setBusy(false)
|
|
142
|
+
setNote({ ok: false, text: t.failed + String(error !== null && error !== undefined && error.message !== undefined ? error.message : error) })
|
|
143
|
+
})
|
|
144
|
+
}, [t])
|
|
145
|
+
|
|
146
|
+
// The tab itself is the affordance: opening it opens the window.
|
|
147
|
+
React.useEffect(() => {
|
|
148
|
+
open()
|
|
149
|
+
requestStatus().then((count) => setTasks(count))
|
|
150
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
151
|
+
}, [])
|
|
152
|
+
|
|
153
|
+
return React.createElement('div', { style: PANEL_STYLE },
|
|
154
|
+
React.createElement('button', { type: 'button', style: BUTTON_STYLE, disabled: busy, onClick: open }, busy ? t.busy : t.open),
|
|
155
|
+
note !== null ? React.createElement('div', { style: NOTE_STYLE, role: 'status' }, note.text) : null,
|
|
156
|
+
tasks !== null ? React.createElement('div', { style: NOTE_STYLE }, t.tasks(tasks)) : null,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
exports.inject = ['slots', 'sidebarRightTabs']
|
|
161
|
+
exports.apply = (ctx) => {
|
|
162
|
+
const slots = ctx.get('slots')
|
|
163
|
+
const tabs = ctx.get('sidebarRightTabs')
|
|
164
|
+
if (slots === undefined || tabs === undefined) return
|
|
165
|
+
ctx.effect(() => tabs.register({
|
|
166
|
+
id: IMPLEMENTATION_ID,
|
|
167
|
+
kind: KIND,
|
|
168
|
+
priority: 'extension',
|
|
169
|
+
title: () => copy().title,
|
|
170
|
+
guide: [{
|
|
171
|
+
id: 'open',
|
|
172
|
+
order: 40,
|
|
173
|
+
title: () => copy().title,
|
|
174
|
+
description: () => copy().description,
|
|
175
|
+
icon: BrowserIcon,
|
|
176
|
+
}],
|
|
177
|
+
}), 'dsh-browser-plus:type')
|
|
178
|
+
ctx.effect(() => slots.inject('sidebar.right.pane.tab', () => slots.register({
|
|
179
|
+
name: 'sidebar.right.pane.tab',
|
|
180
|
+
key: IMPLEMENTATION_ID,
|
|
181
|
+
}, BrowserPanel)), 'dsh-browser-plus:body')
|
|
182
|
+
}
|
|
183
|
+
return module.exports
|
|
184
|
+
},
|
|
185
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-facing `/browser` command: puts the shared browser window on screen
|
|
3
|
+
* without going through the model.
|
|
4
|
+
*
|
|
5
|
+
* The window is normally created as a side effect of the first browser tool
|
|
6
|
+
* call, so a human who wants to browse (or take over) before any agent call had
|
|
7
|
+
* no way to raise it. `/browser` is that way in: it spawns the host and shows
|
|
8
|
+
* the window when nothing is open, and raises the existing window otherwise.
|
|
9
|
+
*
|
|
10
|
+
* The command registry is declared structurally instead of imported from
|
|
11
|
+
* `@deepseek-ai/dsh-commands`: the registry is a peer service the profile
|
|
12
|
+
* provides, and depending on that package only for its types would make this
|
|
13
|
+
* row fail to load wherever the service exists but the dependency does not.
|
|
14
|
+
* @module dsh-browser-plus/command-browser
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
17
|
+
export declare const name = "browser-command";
|
|
18
|
+
export declare const inject: string[];
|
|
19
|
+
/** Register the command. */
|
|
20
|
+
export declare function apply(ctx: Context, _config?: unknown): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-facing `/browser` command: puts the shared browser window on screen
|
|
3
|
+
* without going through the model.
|
|
4
|
+
*
|
|
5
|
+
* The window is normally created as a side effect of the first browser tool
|
|
6
|
+
* call, so a human who wants to browse (or take over) before any agent call had
|
|
7
|
+
* no way to raise it. `/browser` is that way in: it spawns the host and shows
|
|
8
|
+
* the window when nothing is open, and raises the existing window otherwise.
|
|
9
|
+
*
|
|
10
|
+
* The command registry is declared structurally instead of imported from
|
|
11
|
+
* `@deepseek-ai/dsh-commands`: the registry is a peer service the profile
|
|
12
|
+
* provides, and depending on that package only for its types would make this
|
|
13
|
+
* row fail to load wherever the service exists but the dependency does not.
|
|
14
|
+
* @module dsh-browser-plus/command-browser
|
|
15
|
+
*/
|
|
16
|
+
export const name = 'browser-command';
|
|
17
|
+
export const inject = ['browser', 'commands'];
|
|
18
|
+
/** Register the command. */
|
|
19
|
+
export function apply(ctx, _config = {}) {
|
|
20
|
+
const commands = ctx.commands;
|
|
21
|
+
commands.register({
|
|
22
|
+
name: 'browser',
|
|
23
|
+
description: '打开并前置共享浏览器窗口',
|
|
24
|
+
handler: async () => {
|
|
25
|
+
try {
|
|
26
|
+
await ctx.browser.ensureWindowVisible();
|
|
27
|
+
return { kind: 'success', text: '浏览器窗口已打开。' };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
31
|
+
return { kind: 'error', text: `打开浏览器窗口失败:${detail}` };
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP bridge the browser panel talks to.
|
|
3
|
+
*
|
|
4
|
+
* The panel lives in the Web GUI, the window lives in the DSH process, and a
|
|
5
|
+
* client bundle has no way to call a server-side plugin method directly: the
|
|
6
|
+
* generated Remote/Typert surface exists for product packages, not for a
|
|
7
|
+
* third-party plugin built with plain tsc. The DSH web server, however, is a
|
|
8
|
+
* service this plugin can register routes on, and the GUI is served from that
|
|
9
|
+
* same origin — so one POST is the whole bridge.
|
|
10
|
+
*
|
|
11
|
+
* Routes (all JSON, no-store):
|
|
12
|
+
* POST /api/dsh-browser-plus/open -> { ok: true } once the window is up
|
|
13
|
+
* GET /api/dsh-browser-plus/status -> { ok: true, tasks: number }
|
|
14
|
+
*
|
|
15
|
+
* The `webServer` service is declared structurally rather than imported from
|
|
16
|
+
* `@deepseek-ai/dsh-host-webserver`: this row must load wherever the service
|
|
17
|
+
* exists, without taking a build-time dependency on the package that owns it.
|
|
18
|
+
* @module dsh-browser-plus/http-browser
|
|
19
|
+
*/
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
21
|
+
/** Absolute path of the "open the window" endpoint. */
|
|
22
|
+
export declare const OPEN_PATH = "/api/dsh-browser-plus/open";
|
|
23
|
+
/** Absolute path of the status endpoint. */
|
|
24
|
+
export declare const STATUS_PATH = "/api/dsh-browser-plus/status";
|
|
25
|
+
export declare const name = "browser-http";
|
|
26
|
+
export declare const inject: string[];
|
|
27
|
+
/** Register the browser-panel bridge. */
|
|
28
|
+
export declare function apply(ctx: Context, _config?: unknown): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP bridge the browser panel talks to.
|
|
3
|
+
*
|
|
4
|
+
* The panel lives in the Web GUI, the window lives in the DSH process, and a
|
|
5
|
+
* client bundle has no way to call a server-side plugin method directly: the
|
|
6
|
+
* generated Remote/Typert surface exists for product packages, not for a
|
|
7
|
+
* third-party plugin built with plain tsc. The DSH web server, however, is a
|
|
8
|
+
* service this plugin can register routes on, and the GUI is served from that
|
|
9
|
+
* same origin — so one POST is the whole bridge.
|
|
10
|
+
*
|
|
11
|
+
* Routes (all JSON, no-store):
|
|
12
|
+
* POST /api/dsh-browser-plus/open -> { ok: true } once the window is up
|
|
13
|
+
* GET /api/dsh-browser-plus/status -> { ok: true, tasks: number }
|
|
14
|
+
*
|
|
15
|
+
* The `webServer` service is declared structurally rather than imported from
|
|
16
|
+
* `@deepseek-ai/dsh-host-webserver`: this row must load wherever the service
|
|
17
|
+
* exists, without taking a build-time dependency on the package that owns it.
|
|
18
|
+
* @module dsh-browser-plus/http-browser
|
|
19
|
+
*/
|
|
20
|
+
/** Absolute path of the "open the window" endpoint. */
|
|
21
|
+
export const OPEN_PATH = '/api/dsh-browser-plus/open';
|
|
22
|
+
/** Absolute path of the status endpoint. */
|
|
23
|
+
export const STATUS_PATH = '/api/dsh-browser-plus/status';
|
|
24
|
+
export const name = 'browser-http';
|
|
25
|
+
export const inject = ['browser', 'webServer'];
|
|
26
|
+
/** Write one JSON response. */
|
|
27
|
+
function sendJson(res, status, payload) {
|
|
28
|
+
res.statusCode = status;
|
|
29
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
30
|
+
res.setHeader('cache-control', 'no-store');
|
|
31
|
+
res.end(JSON.stringify(payload));
|
|
32
|
+
}
|
|
33
|
+
/** Read one request header as a single string. */
|
|
34
|
+
function headerValue(req, name) {
|
|
35
|
+
const raw = req.headers?.[name];
|
|
36
|
+
return Array.isArray(raw) ? (raw[0] ?? '') : (raw ?? '');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Refuse a request that a page on another site made.
|
|
40
|
+
*
|
|
41
|
+
* These two endpoints are unauthenticated and only open a window / report a task
|
|
42
|
+
* count — but any web page can POST to localhost without a preflight, so without
|
|
43
|
+
* this check a random site could pop our browser window open. The panel's own
|
|
44
|
+
* fetch is same-origin, so its Origin always matches the Host we were reached on.
|
|
45
|
+
*/
|
|
46
|
+
function fromAnotherSite(req) {
|
|
47
|
+
const origin = headerValue(req, 'origin');
|
|
48
|
+
if (origin === '')
|
|
49
|
+
return false;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = new URL(origin);
|
|
52
|
+
const host = headerValue(req, 'host');
|
|
53
|
+
return host === '' || parsed.host !== host;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** One line for the panel to show; never a stack. */
|
|
60
|
+
function describe(error) {
|
|
61
|
+
return error instanceof Error ? error.message : String(error);
|
|
62
|
+
}
|
|
63
|
+
/** Register the browser-panel bridge. */
|
|
64
|
+
export function apply(ctx, _config = {}) {
|
|
65
|
+
const server = ctx.webServer;
|
|
66
|
+
if (server === undefined)
|
|
67
|
+
return;
|
|
68
|
+
ctx.effect(() => server.register({
|
|
69
|
+
kind: 'exact',
|
|
70
|
+
path: OPEN_PATH,
|
|
71
|
+
handler: async (req, res) => {
|
|
72
|
+
if (req.method !== 'POST') {
|
|
73
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (fromAnotherSite(req)) {
|
|
77
|
+
sendJson(res, 403, { ok: false, error: 'cross-site request refused' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await ctx.browser.ensureWindowVisible();
|
|
82
|
+
sendJson(res, 200, { ok: true });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
sendJson(res, 500, { ok: false, error: describe(error) });
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}), 'dsh-browser-plus:open route');
|
|
89
|
+
ctx.effect(() => server.register({
|
|
90
|
+
kind: 'exact',
|
|
91
|
+
path: STATUS_PATH,
|
|
92
|
+
handler: async (req, res) => {
|
|
93
|
+
if (req.method !== 'GET') {
|
|
94
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (fromAnotherSite(req)) {
|
|
98
|
+
sendJson(res, 403, { ok: false, error: 'cross-site request refused' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const tasks = await ctx.browser.listTasks();
|
|
103
|
+
sendJson(res, 200, { ok: true, tasks: tasks.length });
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
sendJson(res, 500, { ok: false, error: describe(error) });
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}), 'dsh-browser-plus:status route');
|
|
110
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-browser-plus plugin entry: aggregates the shared-browser capability
|
|
3
|
+
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
|
+
* - `dsh-browser-plus/browser` -> the ctx.browser seam (Service)
|
|
5
|
+
* - `dsh-browser-plus/browser-electron` -> the Electron CDP provider
|
|
6
|
+
* - `dsh-browser-plus/tool-browser` -> the model-facing browser_* tools
|
|
7
|
+
* This root entry only re-exports for programmatic use; the loader rows are
|
|
8
|
+
* the composition surface.
|
|
9
|
+
*
|
|
10
|
+
* It IS also mounted as a row of its own — an empty one — because the client
|
|
11
|
+
* module system only scans Loader rows whose specifier is an exact package root
|
|
12
|
+
* (`exactPackageSpecifier` in `@deepseek-ai/dsh-client-modules` returns
|
|
13
|
+
* undefined for a subpath). Without a root row the package's `dsh.client`
|
|
14
|
+
* declaration is never read and the browser panel never reaches the GUI, no
|
|
15
|
+
* matter how many subpath rows the bundle patch adds.
|
|
16
|
+
* @module dsh-browser-plus
|
|
17
|
+
*/
|
|
18
|
+
/** Plugin name for the root row. */
|
|
19
|
+
export declare const name = "browser-plus";
|
|
20
|
+
/** The root row has no behaviour of its own; it exists to carry the package identity. */
|
|
21
|
+
export declare function apply(): void;
|
|
22
|
+
export { BrowserError } from './browser/types.ts';
|
|
23
|
+
export type { BrowserChallenge, BrowserContentFormat, BrowserControlOwner, BrowserContentRequest, BrowserContentResult, BrowserDragRequest, BrowserDragResult, BrowserExecuteRequest, BrowserPointerResult, BrowserPointerTarget, BrowserExecuteResult, BrowserFillField, BrowserFillRequest, BrowserFillResult, BrowserHandoffState, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserRefRequest, BrowserScreenshotRequest, BrowserScrollIntoViewRequest, BrowserScrollRequest, BrowserScrollResult, BrowserScreenshotResult, BrowserSessionId, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTaskInfo, BrowserTaskStatus, BrowserTaskUpdate, BrowserTypeRequest, ExportedCookie, } from './browser/types.ts';
|
|
24
|
+
export { BrowserRuntime } from './browser/runtime.ts';
|
|
25
|
+
export { ElectronBrowserProvider } from './browser-electron/provider.ts';
|
|
26
|
+
export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts';
|
|
27
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from './browser-electron/remote-host.ts';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-browser-plus plugin entry: aggregates the shared-browser capability
|
|
3
|
+
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
|
+
* - `dsh-browser-plus/browser` -> the ctx.browser seam (Service)
|
|
5
|
+
* - `dsh-browser-plus/browser-electron` -> the Electron CDP provider
|
|
6
|
+
* - `dsh-browser-plus/tool-browser` -> the model-facing browser_* tools
|
|
7
|
+
* This root entry only re-exports for programmatic use; the loader rows are
|
|
8
|
+
* the composition surface.
|
|
9
|
+
*
|
|
10
|
+
* It IS also mounted as a row of its own — an empty one — because the client
|
|
11
|
+
* module system only scans Loader rows whose specifier is an exact package root
|
|
12
|
+
* (`exactPackageSpecifier` in `@deepseek-ai/dsh-client-modules` returns
|
|
13
|
+
* undefined for a subpath). Without a root row the package's `dsh.client`
|
|
14
|
+
* declaration is never read and the browser panel never reaches the GUI, no
|
|
15
|
+
* matter how many subpath rows the bundle patch adds.
|
|
16
|
+
* @module dsh-browser-plus
|
|
17
|
+
*/
|
|
18
|
+
/** Plugin name for the root row. */
|
|
19
|
+
export const name = 'browser-plus';
|
|
20
|
+
/** The root row has no behaviour of its own; it exists to carry the package identity. */
|
|
21
|
+
export function apply() { }
|
|
22
|
+
export { BrowserError } from "./browser/types.js";
|
|
23
|
+
export { BrowserRuntime } from "./browser/runtime.js";
|
|
24
|
+
export { ElectronBrowserProvider } from "./browser-electron/provider.js";
|
|
25
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from "./browser-electron/remote-host.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between the Agent's todo list and the shared browser window.
|
|
3
|
+
*
|
|
4
|
+
* DSH keeps each Agent's plan as a session projection (`todos`, written by
|
|
5
|
+
* `todo_write` and folded from `todo/write` events). The floating orb in the
|
|
6
|
+
* browser window renders that plan, but the browser host is a child process that
|
|
7
|
+
* can only see what the parent hands it — so this row subscribes to the
|
|
8
|
+
* projection change feed and mirrors the plan into the browser provider.
|
|
9
|
+
*
|
|
10
|
+
* Every dependency here is optional on purpose. A deployment without the
|
|
11
|
+
* projection registry, or without the todo tool mounted at all, must still get a
|
|
12
|
+
* working browser: the orb then simply has no plan to show and falls back to the
|
|
13
|
+
* task's own status and last browser action.
|
|
14
|
+
* @module dsh-browser-plus/task-todos
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
17
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
18
|
+
export declare const name = "browser-task-todos";
|
|
19
|
+
/** The browser seam; the projection registry is injected optionally below. */
|
|
20
|
+
export declare const inject: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Register the bridge.
|
|
23
|
+
* @param ctx - plugin context carrying the browser seam.
|
|
24
|
+
*/
|
|
25
|
+
export declare function apply(ctx: Context): void;
|