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.
Files changed (76) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/LICENSE +22 -0
  3. package/NOTICE.md +7 -0
  4. package/README.en.md +100 -0
  5. package/README.md +99 -2
  6. package/assets/dsh-browser-plus-256.png +0 -0
  7. package/assets/dsh-browser-plus-512.png +0 -0
  8. package/assets/dsh-browser-plus-small.svg +9 -0
  9. package/assets/dsh-browser-plus.ico +0 -0
  10. package/assets/dsh-browser-plus.svg +11 -0
  11. package/assets/readme-workspace.png +0 -0
  12. package/cordis.patch.yml +17 -0
  13. package/docs/MIGRATION.md +48 -0
  14. package/docs/README.md +22 -0
  15. package/docs/SOAK-CHECKLIST.md +98 -0
  16. package/docs/architecture.md +88 -0
  17. package/docs/tool-reference.md +124 -0
  18. package/docs/user-guide.md +121 -0
  19. package/docs/why-browser.md +45 -0
  20. package/lib/browser/runtime.d.ts +225 -0
  21. package/lib/browser/runtime.js +302 -0
  22. package/lib/browser/types.d.ts +668 -0
  23. package/lib/browser/types.js +18 -0
  24. package/lib/browser-electron/auth-cookies.d.ts +54 -0
  25. package/lib/browser-electron/auth-cookies.js +83 -0
  26. package/lib/browser-electron/chrome-state.d.ts +187 -0
  27. package/lib/browser-electron/chrome-state.js +12 -0
  28. package/lib/browser-electron/entry.d.ts +66 -0
  29. package/lib/browser-electron/entry.js +62 -0
  30. package/lib/browser-electron/fingerprint.d.ts +29 -0
  31. package/lib/browser-electron/fingerprint.js +42 -0
  32. package/lib/browser-electron/host-main.d.ts +18 -0
  33. package/lib/browser-electron/host-main.js +2494 -0
  34. package/lib/browser-electron/icon.d.ts +11 -0
  35. package/lib/browser-electron/icon.js +23 -0
  36. package/lib/browser-electron/page-chrome.d.ts +21 -0
  37. package/lib/browser-electron/page-chrome.js +2034 -0
  38. package/lib/browser-electron/provider.d.ts +709 -0
  39. package/lib/browser-electron/provider.js +2575 -0
  40. package/lib/browser-electron/remote-host.d.ts +143 -0
  41. package/lib/browser-electron/remote-host.js +952 -0
  42. package/lib/browser-electron/task-summary.d.ts +2 -0
  43. package/lib/browser-electron/task-summary.js +12 -0
  44. package/lib/browser-electron/task-thumbnail.d.ts +11 -0
  45. package/lib/browser-electron/task-thumbnail.js +9 -0
  46. package/lib/browser-electron/write-guard.d.ts +41 -0
  47. package/lib/browser-electron/write-guard.js +123 -0
  48. package/lib/index.d.ts +16 -0
  49. package/lib/index.js +14 -0
  50. package/lib/tool-browser/index.d.ts +31 -0
  51. package/lib/tool-browser/index.js +1931 -0
  52. package/package.json +95 -4
  53. package/screenshots.json +3 -0
  54. package/scripts/build-icons.mjs +80 -0
  55. package/scripts/capture-window.ps1 +79 -0
  56. package/scripts/crop-image.ps1 +20 -0
  57. package/scripts/smoke-browser-tools.mjs +1968 -0
  58. package/scripts/smoke-chrome-world.mjs +63 -0
  59. package/scripts/smoke-electron-host.mjs +50 -0
  60. package/src/browser/runtime.ts +470 -0
  61. package/src/browser/types.ts +649 -0
  62. package/src/browser-electron/auth-cookies.ts +125 -0
  63. package/src/browser-electron/chrome-state.ts +174 -0
  64. package/src/browser-electron/entry.ts +115 -0
  65. package/src/browser-electron/fingerprint.ts +45 -0
  66. package/src/browser-electron/host-main.ts +2330 -0
  67. package/src/browser-electron/icon.ts +26 -0
  68. package/src/browser-electron/page-chrome.ts +2046 -0
  69. package/src/browser-electron/provider.ts +3088 -0
  70. package/src/browser-electron/remote-host.ts +1004 -0
  71. package/src/browser-electron/task-summary.ts +10 -0
  72. package/src/browser-electron/task-thumbnail.ts +17 -0
  73. package/src/browser-electron/write-guard.ts +134 -0
  74. package/src/index.ts +52 -0
  75. package/src/tool-browser/index.ts +1974 -0
  76. 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
+ }