dsh-browser-plus 0.0.0-stage → 0.5.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/CHANGELOG.md +166 -0
- package/LICENSE +22 -0
- package/NOTICE.md +7 -0
- package/README.en.md +100 -0
- package/README.md +99 -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/cordis.patch.yml +17 -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 +124 -0
- package/docs/user-guide.md +121 -0
- package/docs/why-browser.md +45 -0
- package/lib/browser/runtime.d.ts +225 -0
- package/lib/browser/runtime.js +302 -0
- package/lib/browser/types.d.ts +668 -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 +187 -0
- package/lib/browser-electron/chrome-state.js +12 -0
- package/lib/browser-electron/entry.d.ts +66 -0
- package/lib/browser-electron/entry.js +62 -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 +18 -0
- package/lib/browser-electron/host-main.js +2494 -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 +2034 -0
- package/lib/browser-electron/provider.d.ts +709 -0
- package/lib/browser-electron/provider.js +2575 -0
- package/lib/browser-electron/remote-host.d.ts +143 -0
- package/lib/browser-electron/remote-host.js +952 -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/index.d.ts +16 -0
- package/lib/index.js +14 -0
- package/lib/tool-browser/index.d.ts +31 -0
- package/lib/tool-browser/index.js +1931 -0
- package/package.json +95 -4
- package/screenshots.json +3 -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 +1968 -0
- package/scripts/smoke-chrome-world.mjs +63 -0
- package/scripts/smoke-electron-host.mjs +50 -0
- package/src/browser/runtime.ts +470 -0
- package/src/browser/types.ts +649 -0
- package/src/browser-electron/auth-cookies.ts +125 -0
- package/src/browser-electron/chrome-state.ts +174 -0
- package/src/browser-electron/entry.ts +115 -0
- package/src/browser-electron/fingerprint.ts +45 -0
- package/src/browser-electron/host-main.ts +2330 -0
- package/src/browser-electron/icon.ts +26 -0
- package/src/browser-electron/page-chrome.ts +2046 -0
- package/src/browser-electron/provider.ts +3088 -0
- package/src/browser-electron/remote-host.ts +1004 -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/index.ts +52 -0
- package/src/tool-browser/index.ts +1974 -0
- package/src/types/electron-shim.d.ts +143 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export interface AuthCookieLike {
|
|
2
|
+
readonly domain?: string
|
|
3
|
+
readonly path?: string
|
|
4
|
+
readonly name: string
|
|
5
|
+
readonly value: string
|
|
6
|
+
readonly secure?: boolean
|
|
7
|
+
readonly httpOnly?: boolean
|
|
8
|
+
readonly expirationDate?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ExportedAuthCookie {
|
|
12
|
+
readonly url: string
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly value: string
|
|
15
|
+
readonly domain: string
|
|
16
|
+
readonly path: string
|
|
17
|
+
readonly secure: boolean
|
|
18
|
+
readonly httpOnly: boolean
|
|
19
|
+
readonly expirationDate: number | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Convert Electron cookies into portable auth records, skipping invalid domains. */
|
|
23
|
+
export function exportCookiesForAuth(cookies: readonly AuthCookieLike[]): ExportedAuthCookie[] {
|
|
24
|
+
return cookies.flatMap(cookie => {
|
|
25
|
+
const domain = cookie.domain
|
|
26
|
+
if (typeof domain !== 'string' || domain === '') return []
|
|
27
|
+
const host = domain.startsWith('.') ? domain.slice(1) : domain
|
|
28
|
+
const hostPart = host.includes(':') && !host.startsWith('[') ? '[' + host + ']' : host
|
|
29
|
+
const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/'
|
|
30
|
+
const secure = cookie.secure === true
|
|
31
|
+
return [{
|
|
32
|
+
url: 'http' + (secure ? 's' : '') + '://' + hostPart + path,
|
|
33
|
+
name: cookie.name,
|
|
34
|
+
value: cookie.value,
|
|
35
|
+
domain,
|
|
36
|
+
path,
|
|
37
|
+
secure,
|
|
38
|
+
httpOnly: cookie.httpOnly === true,
|
|
39
|
+
expirationDate: cookie.expirationDate,
|
|
40
|
+
}]
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* One cookie selected for removal, plus the URL Electron needs to delete it.
|
|
45
|
+
*/
|
|
46
|
+
export interface CookieClearTarget {
|
|
47
|
+
readonly name: string
|
|
48
|
+
readonly domain: string
|
|
49
|
+
readonly path: string
|
|
50
|
+
readonly url: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Which cookies a clear request targets: a domain scope, one exact name within
|
|
55
|
+
* that scope, or an explicit whole-profile wipe.
|
|
56
|
+
*/
|
|
57
|
+
export interface CookieClearFilter {
|
|
58
|
+
/** Domain scope; matches the domain itself and every subdomain. */
|
|
59
|
+
readonly domain?: string
|
|
60
|
+
/** Exact cookie name to remove within the scope. */
|
|
61
|
+
readonly name?: string
|
|
62
|
+
/** Remove every addressable cookie; requires this explicit opt-in. */
|
|
63
|
+
readonly all?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Normalize a scope/domain for comparison: drop one leading dot, lowercase. */
|
|
67
|
+
function normalizeDomain(domain: string): string {
|
|
68
|
+
const trimmed = domain.trim().toLowerCase()
|
|
69
|
+
return trimmed.startsWith('.') ? trimmed.slice(1) : trimmed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether one cookie domain falls under a scope: the scope itself, or a subdomain. */
|
|
73
|
+
export function cookieMatchesDomain(cookieDomain: string, scope: string): boolean {
|
|
74
|
+
const cookie = normalizeDomain(cookieDomain)
|
|
75
|
+
const target = normalizeDomain(scope)
|
|
76
|
+
if (cookie === '' || target === '') return false
|
|
77
|
+
return cookie === target || cookie.endsWith('.' + target)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the URL required to address one cookie for removal. */
|
|
81
|
+
export function cookieRemovalUrl(cookie: AuthCookieLike): string | undefined {
|
|
82
|
+
const domain = cookie.domain
|
|
83
|
+
if (typeof domain !== 'string' || domain === '') return undefined
|
|
84
|
+
const host = domain.startsWith('.') ? domain.slice(1) : domain
|
|
85
|
+
if (host === '') return undefined
|
|
86
|
+
const hostPart = host.includes(':') && !host.startsWith('[') ? '[' + host + ']' : host
|
|
87
|
+
const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/'
|
|
88
|
+
return 'http' + (cookie.secure === true ? 's' : '') + '://' + hostPart + path
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Select the cookies a clear request targets. An unscoped request is refused
|
|
93
|
+
* unless "all" is explicitly set, so a missing filter can never wipe logins.
|
|
94
|
+
* @param cookies - the profile's cookies.
|
|
95
|
+
* @param filter - domain scope, exact name, or an explicit full wipe.
|
|
96
|
+
* @returns the removable cookies, each with its removal URL.
|
|
97
|
+
*/
|
|
98
|
+
export function selectCookiesForClear(
|
|
99
|
+
cookies: readonly AuthCookieLike[],
|
|
100
|
+
filter: CookieClearFilter,
|
|
101
|
+
): CookieClearTarget[] {
|
|
102
|
+
const domainScope = typeof filter.domain === 'string' && filter.domain.trim() !== '' ? filter.domain : undefined
|
|
103
|
+
const nameScope = typeof filter.name === 'string' && filter.name !== '' ? filter.name : undefined
|
|
104
|
+
if (domainScope === undefined && nameScope === undefined && filter.all !== true) {
|
|
105
|
+
throw new Error('cookie clear requires a domain or name filter; pass all: true to remove every cookie')
|
|
106
|
+
}
|
|
107
|
+
const targets: CookieClearTarget[] = []
|
|
108
|
+
for (const cookie of cookies) {
|
|
109
|
+
const domain = cookie.domain
|
|
110
|
+
if (typeof domain !== 'string' || domain === '') continue
|
|
111
|
+
if (domainScope !== undefined || nameScope !== undefined) {
|
|
112
|
+
if (domainScope !== undefined && !cookieMatchesDomain(domain, domainScope)) continue
|
|
113
|
+
if (nameScope !== undefined && cookie.name !== nameScope) continue
|
|
114
|
+
}
|
|
115
|
+
const url = cookieRemovalUrl(cookie)
|
|
116
|
+
if (url === undefined) continue
|
|
117
|
+
targets.push({
|
|
118
|
+
name: cookie.name,
|
|
119
|
+
domain,
|
|
120
|
+
path: typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/',
|
|
121
|
+
url,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
return targets
|
|
125
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned state exchanged between the self-hosted Electron main process and
|
|
3
|
+
* the injected page chrome. Keeping the payload declarative makes it possible
|
|
4
|
+
* to update one task or one trail entry without rebuilding the whole workspace.
|
|
5
|
+
* @module dsh-browser-plus/browser-electron/chrome-state
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ChromePanels {
|
|
9
|
+
readonly tasks: boolean
|
|
10
|
+
readonly trail: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ChromeTrailEntry {
|
|
14
|
+
readonly action: string
|
|
15
|
+
readonly params?: Record<string, unknown>
|
|
16
|
+
readonly ok?: boolean
|
|
17
|
+
readonly at: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChromeTaskLatest {
|
|
21
|
+
readonly action: string
|
|
22
|
+
readonly at: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ChromeTaskSummary {
|
|
26
|
+
readonly key: string
|
|
27
|
+
readonly label: string
|
|
28
|
+
readonly active: boolean
|
|
29
|
+
readonly background: boolean
|
|
30
|
+
readonly url: string
|
|
31
|
+
readonly tabs: number
|
|
32
|
+
readonly status: 'idle' | 'running' | 'waiting-user' | 'failed'
|
|
33
|
+
readonly control: 'agent' | 'human'
|
|
34
|
+
readonly updatedAt: number
|
|
35
|
+
readonly latest?: ChromeTaskLatest
|
|
36
|
+
readonly error?: string
|
|
37
|
+
/**
|
|
38
|
+
* Bumped when a new image arrives through the 'task.thumbnail' patch. The
|
|
39
|
+
* image itself is never part of a summary: summaries reach every page.
|
|
40
|
+
*/
|
|
41
|
+
readonly thumbnailVersion: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One tab of the selected task, as the tab strip renders it.
|
|
46
|
+
*
|
|
47
|
+
* A tab is a host view, so this is the view list for the task in creation
|
|
48
|
+
* order; `active` marks the one currently shown. Titles and URLs come from the
|
|
49
|
+
* view itself and are refreshed on navigation.
|
|
50
|
+
*/
|
|
51
|
+
export interface ChromeTabSummary {
|
|
52
|
+
readonly id: string
|
|
53
|
+
readonly title: string
|
|
54
|
+
readonly url: string
|
|
55
|
+
readonly active: boolean
|
|
56
|
+
/**
|
|
57
|
+
* Whether this tab's page is bookmarked.
|
|
58
|
+
*
|
|
59
|
+
* The chrome only receives the redacted origin (the page copy lives in the page,
|
|
60
|
+
* so a full URL would hand it the query string), which means the toolbar cannot
|
|
61
|
+
* decide this for itself — the host computes it from the real URL.
|
|
62
|
+
*/
|
|
63
|
+
readonly starred?: boolean
|
|
64
|
+
/**
|
|
65
|
+
* Whether the tab has somewhere to go back / forward to.
|
|
66
|
+
*
|
|
67
|
+
* Like `starred`, only the host can answer this: the page copy of the chrome
|
|
68
|
+
* shares the page's own document, but the frame copy does not, and the two
|
|
69
|
+
* copies must agree. Drives the toolbar's back/forward buttons dimming.
|
|
70
|
+
*/
|
|
71
|
+
readonly canGoBack?: boolean
|
|
72
|
+
readonly canGoForward?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* True while this tab's document is still loading.
|
|
75
|
+
*
|
|
76
|
+
* Drives the two Chrome affordances the strip mirrors: a spinner in place of
|
|
77
|
+
* the favicon, and the toolbar's reload button turning into stop.
|
|
78
|
+
*/
|
|
79
|
+
readonly loading?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* The page's own favicon, re-encoded by the host as a small data: URL.
|
|
82
|
+
*
|
|
83
|
+
* The host fetches it through the view's own session and admits only a few
|
|
84
|
+
* raster types under a byte cap, so this never becomes a general-purpose
|
|
85
|
+
* network read. Like the title, it is visible to the page the chrome is
|
|
86
|
+
* injected into; a favicon is public artwork for a site the page could fetch
|
|
87
|
+
* itself, and the URL beside it is already reduced to an origin.
|
|
88
|
+
*/
|
|
89
|
+
readonly favicon?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One saved page.
|
|
94
|
+
*
|
|
95
|
+
* Bookmarks belong to the profile, not to a page: they are stored by the host
|
|
96
|
+
* and pushed to the chrome, never kept in the page's own storage. localStorage
|
|
97
|
+
* is per origin, so a bookmark saved on one site was invisible on every other
|
|
98
|
+
* (measured: saved on iana.org, absent on example.com).
|
|
99
|
+
*/
|
|
100
|
+
export interface ChromeBookmark {
|
|
101
|
+
readonly url: string
|
|
102
|
+
readonly title: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ChromeWorkspaceState {
|
|
106
|
+
readonly epoch: number
|
|
107
|
+
readonly revision: number
|
|
108
|
+
readonly selectedTaskKey?: string
|
|
109
|
+
readonly panels: ChromePanels
|
|
110
|
+
readonly tasks: readonly ChromeTaskSummary[]
|
|
111
|
+
/** Tabs of the selected task, in creation order. */
|
|
112
|
+
readonly tabs: readonly ChromeTabSummary[]
|
|
113
|
+
readonly trail: readonly ChromeTrailEntry[]
|
|
114
|
+
readonly bookmarks: readonly ChromeBookmark[]
|
|
115
|
+
/** Chrome's bookmark bar, off until the user turns it on from the ⋮ menu. */
|
|
116
|
+
readonly bookmarkBar: boolean
|
|
117
|
+
/** Empty unless the chrome frame view failed to come up; then, why. */
|
|
118
|
+
readonly frameError?: string
|
|
119
|
+
/**
|
|
120
|
+
* What the host's own window reports: visible/minimized/content size/frame size.
|
|
121
|
+
*
|
|
122
|
+
* Kept because the window's real state and what a screenshot tool *thinks* it is
|
|
123
|
+
* are not the same thing — a capture that grabbed a helper window once looked
|
|
124
|
+
* exactly like the browser window having come up 158x26, and that cost two
|
|
125
|
+
* rounds before this made it visible.
|
|
126
|
+
*/
|
|
127
|
+
readonly windowProbe?: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ChromeBootstrapMessage extends ChromeWorkspaceState {
|
|
131
|
+
readonly kind: 'bootstrap'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type ChromePatchOperation =
|
|
135
|
+
| { readonly op: 'task.upsert'; readonly task: ChromeTaskSummary }
|
|
136
|
+
| { readonly op: 'task.remove'; readonly key: string }
|
|
137
|
+
| { readonly op: 'task.active'; readonly key?: string }
|
|
138
|
+
| { readonly op: 'task.thumbnail'; readonly key: string; readonly version: number; readonly dataUrl?: string }
|
|
139
|
+
| { readonly op: 'trail.append'; readonly taskKey: string; readonly entry: ChromeTrailEntry }
|
|
140
|
+
| { readonly op: 'trail.replace'; readonly taskKey?: string; readonly entries: readonly ChromeTrailEntry[] }
|
|
141
|
+
| { readonly op: 'panels.set'; readonly panels: ChromePanels }
|
|
142
|
+
| { readonly op: 'tabs.set'; readonly tabs: readonly ChromeTabSummary[] }
|
|
143
|
+
| { readonly op: 'bookmarks.set'; readonly bookmarks: readonly ChromeBookmark[] }
|
|
144
|
+
| { readonly op: 'bookmarkbar.set'; readonly visible: boolean }
|
|
145
|
+
// 切任务/切标签时让**新露出来的那个页面**从表面色淡进来(只在这一刻播,导航不播)。
|
|
146
|
+
| { readonly op: 'reveal' }
|
|
147
|
+
/**
|
|
148
|
+
* A popup the chrome frame view asked the page's copy to show, anchored where the
|
|
149
|
+
* frame's own button is (the frame is 84px tall — a menu drawn there is clipped).
|
|
150
|
+
*/
|
|
151
|
+
| { readonly op: 'panel.state'; readonly id: string; readonly open: boolean; readonly left?: number; readonly width?: number }
|
|
152
|
+
/** One-shot feedback for the user (e.g. the result of importing a cookie file). */
|
|
153
|
+
| { readonly op: 'notice'; readonly text: string; readonly level?: 'warn' }
|
|
154
|
+
|
|
155
|
+
export interface ChromePatchMessage {
|
|
156
|
+
readonly kind: 'patch'
|
|
157
|
+
readonly epoch: number
|
|
158
|
+
readonly revision: number
|
|
159
|
+
readonly operations: readonly ChromePatchOperation[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type ChromeMessage = ChromeBootstrapMessage | ChromePatchMessage
|
|
163
|
+
|
|
164
|
+
export function createBootstrap(state: ChromeWorkspaceState): ChromeBootstrapMessage {
|
|
165
|
+
return { kind: 'bootstrap', ...state }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createPatch(
|
|
169
|
+
epoch: number,
|
|
170
|
+
revision: number,
|
|
171
|
+
operations: readonly ChromePatchOperation[],
|
|
172
|
+
): ChromePatchMessage {
|
|
173
|
+
return { kind: 'patch', epoch, revision, operations }
|
|
174
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron browser provider plugin entry: registers the Electron-backed
|
|
3
|
+
* `BrowserProvider` with `ctx.browser`. The provider needs a view host (real
|
|
4
|
+
* Electron `WebContentsView` objects). When a desktop shell supplies
|
|
5
|
+
* `ctx.electronViewHost`, that host is used (embedded, human-machine shared
|
|
6
|
+
* view). Otherwise the plugin self-hosts: it spawns its own Electron child
|
|
7
|
+
* (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
|
|
8
|
+
* installing the plugin is enough for `browser_*` tools to work on any
|
|
9
|
+
* surface.
|
|
10
|
+
* @module dsh-browser-plus/browser-electron
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import z from '@deepseek-ai/schemastery'
|
|
15
|
+
import type { BrowserRuntime } from '../browser/runtime.ts'
|
|
16
|
+
import { ElectronBrowserProvider } from './provider.ts'
|
|
17
|
+
import type { ElectronBrowserViewHost } from './provider.ts'
|
|
18
|
+
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.ts'
|
|
19
|
+
import { defaultWriteRoots } from './write-guard.ts'
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
ELECTRON_BROWSER_PROVIDER_ID,
|
|
23
|
+
ElectronBrowserProvider,
|
|
24
|
+
} from './provider.ts'
|
|
25
|
+
export type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts'
|
|
26
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.ts'
|
|
27
|
+
|
|
28
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
29
|
+
export const name = 'browser-electron'
|
|
30
|
+
|
|
31
|
+
/** The browser seam this provider registers into. */
|
|
32
|
+
export const inject = ['browser']
|
|
33
|
+
|
|
34
|
+
/** Plugin config: an optional externally-supplied view host. */
|
|
35
|
+
export interface Config {
|
|
36
|
+
/** View host supplied by a desktop shell; absent -> self-host. */
|
|
37
|
+
readonly viewHost?: ElectronBrowserViewHost
|
|
38
|
+
/** Allow navigation only to HTTP(S) URLs. Default true. */
|
|
39
|
+
readonly httpOnly?: boolean
|
|
40
|
+
/**
|
|
41
|
+
* Absolute directories `browser_screenshot` and `browser_download` may
|
|
42
|
+
* write into. Absent -> the workspace and the OS temp directory. An explicit
|
|
43
|
+
* empty list denies every write.
|
|
44
|
+
*/
|
|
45
|
+
readonly writeRoots?: string[]
|
|
46
|
+
/**
|
|
47
|
+
* Absolute directories `browser_upload_file` may read from. Absent -> the
|
|
48
|
+
* same default as writeRoots (the workspace and the OS temp directory).
|
|
49
|
+
*/
|
|
50
|
+
readonly readRoots?: string[]
|
|
51
|
+
/**
|
|
52
|
+
* Which JavaScript world the injected page chrome lives in. `main` (default)
|
|
53
|
+
* is the proven path; `isolated` keeps the chrome's task state and its
|
|
54
|
+
* binding token out of the page's own context, at the cost of an extra CDP
|
|
55
|
+
* context per document. Opt in only after confirming the toolbar in a real
|
|
56
|
+
* window (see docs/SOAK-CHECKLIST.md).
|
|
57
|
+
*/
|
|
58
|
+
readonly chromeWorld?: 'main' | 'isolated'
|
|
59
|
+
/**
|
|
60
|
+
* Replace the engine's User-Agent verbatim. When absent, Electron's
|
|
61
|
+
* `Electron/42.9.3` token is stripped and the matching client-hint headers
|
|
62
|
+
* are added, so the request fingerprint says Chrome instead of "this is a
|
|
63
|
+
* scripted Electron".
|
|
64
|
+
*/
|
|
65
|
+
readonly userAgent?: string
|
|
66
|
+
/**
|
|
67
|
+
* Keep the automation fingerprint masked. Default true: Electron advertises
|
|
68
|
+
* itself in the User-Agent and sends no client hints, which is the loudest
|
|
69
|
+
* thing a bot check can read. Set false to send the engine's own fingerprint.
|
|
70
|
+
*/
|
|
71
|
+
readonly maskAutomation?: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const Config: z<Config> = z.object({
|
|
75
|
+
// Absent on surfaces without a desktop shell; the plugin self-hosts then.
|
|
76
|
+
viewHost: z.any(),
|
|
77
|
+
httpOnly: z.boolean().default(true),
|
|
78
|
+
// The defaults are materialized HERE, not left to the provider's
|
|
79
|
+
// `?? defaultWriteRoots()`: schemastery turns an absent array key into [],
|
|
80
|
+
// and an empty array is not nullish, so the provider would see an empty
|
|
81
|
+
// allow-list and refuse every write. Defaulting in the schema keeps absence
|
|
82
|
+
// meaning "the documented defaults" while an explicit [] still denies all.
|
|
83
|
+
writeRoots: z.array(z.string()).default(defaultWriteRoots()),
|
|
84
|
+
readRoots: z.array(z.string()).default(defaultWriteRoots()),
|
|
85
|
+
chromeWorld: z.union(['main', 'isolated'] as const).default('main'),
|
|
86
|
+
userAgent: z.string(),
|
|
87
|
+
maskAutomation: z.boolean().default(true),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** Register the Electron browser provider with `ctx.browser`. */
|
|
91
|
+
export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config): void {
|
|
92
|
+
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
93
|
+
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
94
|
+
const host: ElectronBrowserViewHost = config.viewHost
|
|
95
|
+
?? new RemoteElectronViewHost(defaultHostMainPath(), {
|
|
96
|
+
chromeWorld: config.chromeWorld,
|
|
97
|
+
maskAutomation: config.maskAutomation,
|
|
98
|
+
...config.userAgent === undefined ? {} : { userAgent: config.userAgent },
|
|
99
|
+
})
|
|
100
|
+
// Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
|
|
101
|
+
// is bound to the seam's own fiber (the browser row), so a reload of this
|
|
102
|
+
// row would otherwise collide with the still-registered provider
|
|
103
|
+
// (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
|
|
104
|
+
const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, {
|
|
105
|
+
httpOnly: config.httpOnly,
|
|
106
|
+
...config.writeRoots !== undefined ? { writeRoots: config.writeRoots } : {},
|
|
107
|
+
...config.readRoots !== undefined ? { readRoots: config.readRoots } : {},
|
|
108
|
+
}))
|
|
109
|
+
ctx.effect(() => () => {
|
|
110
|
+
unregister()
|
|
111
|
+
if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
|
|
112
|
+
host.dispose()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-fingerprint helpers.
|
|
3
|
+
*
|
|
4
|
+
* Electron advertises itself in the User-Agent (`Electron/42.9.3`) and sends no
|
|
5
|
+
* client hints at all, even though its own `navigator.userAgentData` reports
|
|
6
|
+
* Chromium — so a request claiming Chrome arrived with none of the sec-ch-ua
|
|
7
|
+
* headers Chrome always sends. These turn the engine's own values into the shape
|
|
8
|
+
* a real Chrome produces, without inventing a fingerprint the engine cannot
|
|
9
|
+
* back up: the brands here are the ones the engine itself reports.
|
|
10
|
+
* @module dsh-browser-plus/browser-electron/fingerprint
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Strip Electron's self-advertisement from a User-Agent. */
|
|
14
|
+
export function stripElectronToken(userAgent: string): string {
|
|
15
|
+
return userAgent.replace(/\sElectron\/[\d.]+/, '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The engine's Chrome major version, used to mirror its client-hint brands. */
|
|
19
|
+
export function chromeMajor(userAgent: string): string | undefined {
|
|
20
|
+
return /Chrome\/(\d+)/.exec(userAgent)?.[1]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Serialize a brand list the way Chromium does in the sec-ch-ua header. */
|
|
24
|
+
export function secChUa(brands: readonly { brand: string; version: string }[]): string {
|
|
25
|
+
return brands.map(brand => `"${brand.brand}";v="${brand.version}"`).join(', ')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** sec-ch-ua-platform, spelled the way Chromium spells it per OS. */
|
|
29
|
+
export function clientHintPlatform(platform: NodeJS.Platform = process.platform): string {
|
|
30
|
+
if (platform === 'win32') return '"Windows"'
|
|
31
|
+
if (platform === 'darwin') return '"macOS"'
|
|
32
|
+
return '"Linux"'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The language list to hand Chromium for a locale. Passing it through
|
|
37
|
+
* `setUserAgent` (rather than rewriting the header) lets Chromium apply its own
|
|
38
|
+
* q-weights, which is exactly the shape real Chrome produces; Electron otherwise
|
|
39
|
+
* sends the bare locale alone.
|
|
40
|
+
*/
|
|
41
|
+
export function acceptLanguagesFor(locale: string): string {
|
|
42
|
+
const base = locale.split('-')[0]
|
|
43
|
+
// Dedupe so an "en-US" locale does not produce "en-US,en,en".
|
|
44
|
+
return [...new Set([locale, base, 'en'])].join(',')
|
|
45
|
+
}
|