dsh-inner-browser-use 0.27.0 → 0.34.95

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/review.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * A review round: hand a repository address to the chat in the column, take its
3
+ * review back, and remember which commit the review was about. The log is what
4
+ * makes the next round checkable — advice given about a commit that has since
5
+ * moved on is stale, and saying so costs one line instead of a reread.
6
+ */
7
+
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+
11
+ /** Where rounds are kept when the caller does not say. */
12
+ export const ROUND_DIR = join(process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh'), 'inner-browser-use', 'rounds')
13
+
14
+ /** How long an answer may be, so a chatty model cannot flood the log. */
15
+ const ANSWER_CAP = 6000
16
+
17
+ /**
18
+ * @param {string} text - one line of configuration.
19
+ * @returns {string} the value, or an empty string.
20
+ */
21
+ function clean(text) {
22
+ return String(text ?? '').replace(/\s+/g, ' ').trim()
23
+ }
24
+
25
+ /**
26
+ * The message to send: enough for a reviewer that has never seen this project,
27
+ * and no more. The address carries the code, so the message does not paste any.
28
+ * @param {{repo?: string, commit?: string, focus?: string}} [what] - what to review.
29
+ * @returns {string} the message.
30
+ */
31
+ export function brief(what = {}) {
32
+ const repo = clean(what.repo)
33
+ /* What the reviewer said last time and what the code did about it: a point
34
+ already answered is not raised again, so a round does not re-litigate. */
35
+ const handled = Array.isArray(what.already) ? what.already.filter((mark) => clean(mark?.status) !== 'open') : []
36
+ const commit = clean(what.commit)
37
+ const focus = clean(what.focus)
38
+ const lines = ['Please review the latest code and tell me what to change.']
39
+ if (repo !== '') lines.push(`Repository: ${repo}`)
40
+ if (commit !== '') lines.push(`Look at commit ${commit} (the newest one) and the files it touches.`)
41
+ else lines.push('Look at the newest commit and the files it touches.')
42
+ if (focus !== '') lines.push(`Focus on: ${focus}`)
43
+ /* The reviewer cannot open the repository, so the change travels with the
44
+ request. Capped: a whole diff would bury the question it is attached to. */
45
+ const diff = String(what.diff ?? '').trim()
46
+ if (diff !== '') {
47
+ const cap = 5000
48
+ lines.push('The change under review, inline (a diff):')
49
+ lines.push(diff.length <= cap ? diff : `${diff.slice(0, cap)}\n… (trimmed)`)
50
+ }
51
+ if (handled.length > 0) {
52
+ lines.push('Already handled from your previous review, do not raise these again:')
53
+ for (const mark of handled.slice(0, 12)) {
54
+ const note = clean(mark.evidence) === '' ? '' : ` (${clean(mark.evidence).slice(0, 120)})`
55
+ lines.push(`- ${clean(mark.text).slice(0, 200)} — ${clean(mark.status)}${note}`)
56
+ }
57
+ }
58
+ lines.push('Answer with a short list: what is wrong, and the smallest change that fixes it. Name the file for each point.')
59
+ return lines.join('\n')
60
+ }
61
+
62
+ /**
63
+ * The suggestions in an answer, one line each: reviewers are asked for a list,
64
+ * and the list is what the next round acts on.
65
+ * @param {string} answer - what the reviewer said.
66
+ * @returns {string[]} the points, in the order given.
67
+ */
68
+ export function points(answer) {
69
+ /* A page read flattens an answer into one line, so a list arrives as
70
+ "intro - first point - second point". Split on the markers a reviewer uses
71
+ for items, keep only pieces that carry a marker, and drop the echo of the
72
+ question when the page repeats it above the answer. */
73
+ const out = []
74
+ const seen = new Set()
75
+ for (const raw of String(answer ?? '').split(/\r?\n/)) {
76
+ const parts = raw.split(/\s+[-\u2022\u2013\u2014]\s+|\s+(?=\d+[.)]\s)/).map((part) => part.trim()).filter((part) => part !== '')
77
+ if (parts.length === 1 && !/^(?:[-*\u2022]|\d+[.)])\s+/.test(parts[0])) continue
78
+ for (let at = 0; at < parts.length; at += 1) {
79
+ let line = parts[at]
80
+ if (at === 0 && !/^(?:[-*\u2022]|\d+[.)])\s+/.test(line)) continue
81
+ line = line.replace(/^(?:Answer:\s*)/i, '').replace(/^(?:[-*\u2022]|\d+[.)])\s+/, '').trim()
82
+ if (/^(?:Please review|Repository:|Look at commit|Focus on:|Answer with)/i.test(line)) continue
83
+ if (line.length < 8 || line.length > 400) continue
84
+ const key = line.toLowerCase()
85
+ if (seen.has(key)) continue
86
+ seen.add(key)
87
+ out.push(line)
88
+ }
89
+ }
90
+ return out.slice(0, 40)
91
+ }
92
+
93
+ /**
94
+ * Write one round down. Append-only: a round is history, not state to rewrite.
95
+ * @param {{dir?: string, commit?: string, question?: string, answer?: string, tookMs?: number}} entry - the round.
96
+ * @returns {{file: string, round: any}} where it went and what was written.
97
+ */
98
+ export function recordRound(entry = {}) {
99
+ const dir = clean(entry.dir) === '' ? ROUND_DIR : clean(entry.dir)
100
+ mkdirSync(dir, { recursive: true })
101
+ const file = join(dir, 'rounds.ndjson')
102
+ const round = {
103
+ at: new Date().toISOString(),
104
+ commit: clean(entry.commit),
105
+ question: clean(entry.question).slice(0, 400),
106
+ answer: String(entry.answer ?? '').replace(/\s+/g, ' ').trim().slice(0, ANSWER_CAP),
107
+ points: points(entry.answer),
108
+ tookMs: Math.max(0, Math.trunc(Number(entry.tookMs ?? 0)) || 0),
109
+ }
110
+ appendFileSync(file, `${JSON.stringify(round)}\n`)
111
+ return { file, round }
112
+ }
113
+
114
+ /**
115
+ * The last round played, for a round that wants to know what it was told before.
116
+ * @param {{dir?: string}} [where] - which log.
117
+ * @returns {any|null} the last round, or null when none was played.
118
+ */
119
+ export function lastRound(where = {}) {
120
+ const dir = clean(where.dir) === '' ? ROUND_DIR : clean(where.dir)
121
+ const file = join(dir, 'rounds.ndjson')
122
+ if (!existsSync(file)) return null
123
+ const rows = readFileSync(file, 'utf8').split('\n').filter((row) => row.trim() !== '')
124
+ if (rows.length === 0) return null
125
+ /* Verdicts are separate rows, appended after the round they judge: find the
126
+ newest round, then fold in the marks written since. */
127
+ /** @type {any[]} */
128
+ const rows2 = []
129
+ for (const row of rows) {
130
+ try {
131
+ rows2.push(JSON.parse(row))
132
+ } catch {
133
+ /* a torn line costs its own row, not the log */
134
+ }
135
+ }
136
+ let at = -1
137
+ for (let i = rows2.length - 1; i >= 0; i -= 1) {
138
+ if (rows2[i]?.kind !== 'verdict') {
139
+ at = i
140
+ break
141
+ }
142
+ }
143
+ if (at < 0) return null
144
+ const round = rows2[at]
145
+ const latest = new Map()
146
+ for (const row of rows2.slice(at + 1)) {
147
+ if (row?.kind !== 'verdict' || clean(row.roundAt) !== clean(round.at)) continue
148
+ for (const mark of Array.isArray(row.marks) ? row.marks : []) if (Number.isInteger(mark?.point)) latest.set(mark.point, mark)
149
+ }
150
+ return { ...round, marks: [...latest.values()].sort((a, b) => a.point - b.point) }
151
+ }
152
+
153
+ /** What a point can be, once the code has looked at it. */
154
+ export const MARK_STATUS = ['fixed', 'verified', 'invalid', 'open']
155
+
156
+ /**
157
+ * Say what happened to the points of the last round. The verdict is history
158
+ * too: it is appended, and the next round reads it so a settled point is not
159
+ * raised twice.
160
+ * @param {{dir?: string, marks?: any[]}} entry - point numbers (1-based) and their outcome.
161
+ * @returns {{file: string, round: any, marks: any[]}|null} what was written, or null when there is no round.
162
+ */
163
+ export function markRound(entry = {}) {
164
+ const dir = clean(entry.dir) === '' ? ROUND_DIR : clean(entry.dir)
165
+ const round = lastRound({ dir })
166
+ if (round === null) return null
167
+ const said = Array.isArray(round.points) ? round.points : []
168
+ /** @type {any[]} */
169
+ const marks = []
170
+ for (const raw of Array.isArray(entry.marks) ? entry.marks : []) {
171
+ const point = Math.trunc(Number(raw?.point ?? 0))
172
+ if (point < 1 || point > said.length) continue
173
+ const status = MARK_STATUS.includes(clean(raw?.status)) ? clean(raw.status) : 'open'
174
+ marks.push({ point, text: said[point - 1], status, evidence: clean(raw?.evidence).slice(0, 200), at: new Date().toISOString() })
175
+ }
176
+ if (marks.length === 0) return null
177
+ const file = join(dir, 'rounds.ndjson')
178
+ mkdirSync(dir, { recursive: true })
179
+ appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), kind: 'verdict', roundAt: round.at, commit: round.commit, marks })}\n`)
180
+ return { file, round, marks }
181
+ }
182
+
183
+ /**
184
+ * One line for the model: what the reviewer said last, and whether the code has
185
+ * moved since. A review of an old commit is worth reading and not worth obeying.
186
+ * @param {{dir?: string, commit?: string}} [now] - the commit the code is at now.
187
+ * @returns {string} the note, or an empty string when no round was played.
188
+ */
189
+ export function lastRoundNote(now = {}) {
190
+ const last = lastRound(now)
191
+ if (last === null) return ''
192
+ const commit = clean(now.commit)
193
+ const same = commit === '' || last.commit === '' || last.commit === commit
194
+ const points = Array.isArray(last.points) ? last.points : []
195
+ const head = points.length === 0 ? 'the reviewer gave no list' : `${points.length} point${points.length === 1 ? '' : 's'}`
196
+ const when = same ? 'about this commit' : `about ${String(last.commit).slice(0, 12)}, and the code has moved since`
197
+ return `Last review (${last.at.slice(0, 16).replace('T', ' ')}): ${head}, ${when}.`
198
+ }
199
+
200
+ /**
201
+ * The reviewer's last points as a list to act on.
202
+ * @param {{dir?: string}} [where] - which log.
203
+ * @returns {string[]} the points.
204
+ */
205
+ export function lastPoints(where = {}) {
206
+ const last = lastRound(where)
207
+ return last !== null && Array.isArray(last.points) ? last.points : []
208
+ }
209
+
210
+ /** @returns {string} the directory the log lives in. */
211
+ export function roundDir() {
212
+ return dirname(join(ROUND_DIR, 'rounds.ndjson'))
213
+ }
@@ -0,0 +1,232 @@
1
+ /* The one job of this app: open a window Chromium will render, and let the
2
+ sidecar drive it over the debugging port. The Flash plugin arrives from the
3
+ sidecar's environment and is appended to the command line here, because
4
+ Electron only honours these switches when they are set before Chromium
5
+ starts. */
6
+ const { app, BrowserWindow, Menu } = require('electron')
7
+ const http = require('http')
8
+ const env = process.env
9
+
10
+ app.commandLine.appendSwitch('use-mock-keychain')
11
+ app.commandLine.appendSwitch('lang', 'zh-CN')
12
+ app.commandLine.appendSwitch('password-store', 'basic')
13
+ app.commandLine.appendSwitch('disable-background-timer-throttling')
14
+ app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
15
+ app.commandLine.appendSwitch('disable-renderer-backgrounding')
16
+ /* A plugin paints on its own surface, and a GPU-composited surface does not
17
+ reach the screencast; software compositing puts it back in the page bitmap. */
18
+ if (env.DSH_FLASH_SOFTWARE === '1') {
19
+ app.commandLine.appendSwitch('disable-gpu')
20
+ app.commandLine.appendSwitch('disable-gpu-compositing')
21
+ app.commandLine.appendSwitch('in-process-gpu')
22
+ }
23
+ if (env.DSH_FLASH_PORT) app.commandLine.appendSwitch('remote-debugging-port', env.DSH_FLASH_PORT)
24
+ if (env.DSH_FLASH_UDD) app.commandLine.appendSwitch('user-data-dir', env.DSH_FLASH_UDD)
25
+ if (env.DSH_FLASH_PLUGIN) {
26
+ app.commandLine.appendSwitch('ppapi-flash-path', env.DSH_FLASH_PLUGIN)
27
+ app.commandLine.appendSwitch('ppapi-flash-version', env.DSH_FLASH_VERSION || '32.0.0.371')
28
+ app.commandLine.appendSwitch('allow-outdated-plugins')
29
+ /* The plugin process is sandboxed in a way that cannot open a plugin living
30
+ outside the app bundle: it fails with "file system sandbox blocked open()"
31
+ and the page then reports no usable Flash. This app renders one page we
32
+ name ourselves, so it does not need that sandbox. */
33
+ app.commandLine.appendSwitch('no-sandbox')
34
+ }
35
+
36
+ app.on('window-all-closed', () => {
37
+ /* A closed window is the person closing the mirror, not the app's business. */
38
+ })
39
+
40
+ /* A plugin paints on a surface the debugging protocol's screencast does not
41
+ see, so the app hands out its own frames over a loopback port. */
42
+ let capture = null
43
+ let latest = null
44
+ /* A plugin surface cannot be screencast, so this app is the capture source.
45
+ Capturing inside the request would make the picture cost the poll interval
46
+ plus the capture itself; a pump keeps one JPEG ready and requests return it. */
47
+ let latestJpeg = null
48
+ let latestAt = 0
49
+ let pumping = false
50
+ let pumpTimer = 0
51
+ let painted = 0
52
+
53
+ function alive(win) {
54
+ try {
55
+ return win !== null && win !== undefined && win.isDestroyed() !== true
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ /* A timer is its own fault domain: a window that closed between ticks must end
62
+ the pump, not throw into the app's process. */
63
+ function pump() {
64
+ /* Offscreen rendering already hands every frame over in its paint callback;
65
+ a screenshot pump on top of it could only write a staler picture back. */
66
+ if (painted > 0) {
67
+ if (pumpTimer !== 0) clearInterval(pumpTimer)
68
+ pumpTimer = 0
69
+ return
70
+ }
71
+ if (pumping === true) return
72
+ if (alive(capture) !== true) {
73
+ if (pumpTimer !== 0) clearInterval(pumpTimer)
74
+ pumpTimer = 0
75
+ return
76
+ }
77
+ pumping = true
78
+ try {
79
+ capture.webContents.capturePage()
80
+ .then((image) => {
81
+ if (alive(capture) !== true) return
82
+ latest = image
83
+ latestJpeg = image.toJPEG(Number(env.DSH_FLASH_JPEG_QUALITY || 72))
84
+ latestAt = Date.now()
85
+ })
86
+ .catch(() => {})
87
+ .finally(() => { pumping = false })
88
+ } catch {
89
+ pumping = false
90
+ }
91
+ }
92
+
93
+ app.whenReady().then(() => {
94
+ const window = new BrowserWindow({
95
+ width: 1440,
96
+ height: 900,
97
+ x: -3000,
98
+ y: -3000,
99
+ /* Never a window on anybody's screen: the picture belongs in the column
100
+ that asked for it, and a game window parked off to the side is exactly
101
+ what this must not add to the desktop. */
102
+ show: false,
103
+ /* Without this macOS pulls a window placed past the edge of the screen back
104
+ onto it; the window is meant to stay where nobody can see it. */
105
+ enableLargerThanScreen: true,
106
+ frame: false,
107
+ skipTaskbar: true,
108
+ hasShadow: false,
109
+ focusable: false,
110
+ webPreferences: {
111
+ plugins: true,
112
+ backgroundThrottling: false,
113
+ contextIsolation: true,
114
+ /* macOS does not paint a window nobody can see, which used to force this
115
+ app to put a real game window on the desktop. Offscreen rendering
116
+ paints into a bitmap instead, so the page exists only in the column. */
117
+ offscreen: true,
118
+ useSharedTexture: false,
119
+ paintWhenInitiallyHidden: true,
120
+ },
121
+ })
122
+ capture = window
123
+ /* The Dock wanted to show this as a running app; the only thing it runs is
124
+ the picture in someone's column. */
125
+ if (app.dock !== undefined && app.dock !== null) app.dock.hide()
126
+ /* A running app with a menu puts its name in the menu bar the moment macOS
127
+ activates it, which is a second way for this to appear on someone's
128
+ desktop. No menu, and an accessory app: only the picture in the column. */
129
+ Menu.setApplicationMenu(null)
130
+ if (typeof app.setActivationPolicy === 'function') app.setActivationPolicy('accessory')
131
+ /* Offscreen frames arrive here; nothing is ever shown on a display. A paint
132
+ callback is its own fault domain: one failed frame must not end the app. */
133
+ try {
134
+ window.webContents.setFrameRate(Number(env.DSH_FLASH_FRAME_RATE || 30))
135
+ window.webContents.on('paint', (event, dirty, image) => {
136
+ try {
137
+ latest = image
138
+ latestJpeg = image.toJPEG(Number(env.DSH_FLASH_JPEG_QUALITY || 72))
139
+ latestAt = Date.now()
140
+ painted += 1
141
+ } catch {
142
+ /* a frame that cannot be encoded is skipped; the next one may work */
143
+ }
144
+ })
145
+ } catch {
146
+ /* an Electron without offscreen painting falls back to the capture pump */
147
+ }
148
+ window.loadURL(env.DSH_FLASH_URL || 'about:blank')
149
+ /* Faster than the sidecar asks: it then always finds a fresh picture. */
150
+ pumpTimer = setInterval(pump, Number(env.DSH_FLASH_PUMP_MS || 40))
151
+
152
+ const port = Number(env.DSH_FLASH_BRIDGE_PORT || 0)
153
+ if (port > 0) {
154
+ http.createServer(async (request, response) => {
155
+ if (capture === null || !request.url) {
156
+ response.writeHead(404).end()
157
+ return
158
+ }
159
+ if (request.url.startsWith('/info')) {
160
+ const size = latest === null ? null : latest.getSize()
161
+ response.writeHead(200, { 'content-type': 'application/json' })
162
+ response.end(JSON.stringify({ size, age: latestAt === 0 ? null : Date.now() - latestAt, visible: capture.isVisible(), opacity: capture.getOpacity(), painted, bounds: capture.getBounds(), url: capture.webContents.getURL(), title: capture.webContents.getTitle() }))
163
+ return
164
+ }
165
+ if (request.url.startsWith('/resize')) {
166
+ /* The column is a tall, narrow pane: a 16:10 window letterboxes the
167
+ game into a strip. Resizing the window to the pane makes the picture
168
+ fill it and the JPEG smaller, since capture is window-sized. */
169
+ try {
170
+ const size = new URL(request.url, 'http://x').searchParams
171
+ const width = Math.max(200, Math.min(2560, Number(size.get('w') || 0)))
172
+ const height = Math.max(200, Math.min(2560, Number(size.get('h') || 0)))
173
+ if (Number.isFinite(width) && Number.isFinite(height)) {
174
+ if (alive(capture) !== true) { response.writeHead(503); response.end(); return }
175
+ capture.setBounds({ x: -3000, y: -3000, width: Math.round(width), height: Math.round(height) })
176
+ }
177
+ const bounds = capture.getBounds()
178
+ response.writeHead(200, { 'content-type': 'application/json' })
179
+ response.end(JSON.stringify({ bounds }))
180
+ } catch {
181
+ response.writeHead(500).end()
182
+ }
183
+ return
184
+ }
185
+ if (request.url.startsWith('/input')) {
186
+ /* Input goes straight into the page instead of round-tripping through
187
+ the debugging protocol: a press arrives in a few milliseconds, and a
188
+ game that reads its mouse every frame is not left behind. */
189
+ try {
190
+ if (request.method !== 'POST') { response.writeHead(405).end(); return }
191
+ const chunks = []
192
+ let bytes = 0
193
+ for await (const chunk of request) {
194
+ bytes += chunk.length
195
+ if (bytes > 8192) { response.writeHead(413).end(); return }
196
+ chunks.push(chunk)
197
+ }
198
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
199
+ if (alive(capture) !== true) { response.writeHead(503).end(); return }
200
+ const event = {}
201
+ for (const field of ['type', 'x', 'y', 'button', 'clickCount', 'deltaX', 'deltaY', 'keyCode', 'modifiers']) {
202
+ if (body[field] !== undefined && body[field] !== null) event[field] = body[field]
203
+ }
204
+ if (typeof event.type !== 'string' || event.type === '') { response.writeHead(400).end(); return }
205
+ capture.webContents.sendInputEvent(event)
206
+ response.writeHead(200, { 'content-type': 'application/json' })
207
+ response.end('{"ok":true}')
208
+ } catch {
209
+ response.writeHead(500).end()
210
+ }
211
+ return
212
+ }
213
+ if (!request.url.startsWith('/frame')) {
214
+ response.writeHead(404).end()
215
+ return
216
+ }
217
+ try {
218
+ const asked = Number(new URL(request.url, 'http://x').searchParams.get('q') || 0) // ui-copy-ignore
219
+ let jpeg = latestJpeg
220
+ if (jpeg === null || (asked > 0 && asked !== Number(env.DSH_FLASH_JPEG_QUALITY || 72))) {
221
+ const image = latest !== null ? latest : alive(capture) === true ? await capture.webContents.capturePage() : null
222
+ if (image === null) { response.writeHead(503); response.end(); return }
223
+ jpeg = image.toJPEG(asked > 0 ? asked : Number(env.DSH_FLASH_JPEG_QUALITY || 72))
224
+ }
225
+ response.writeHead(200, { 'content-type': 'image/jpeg', 'content-length': jpeg.length }) // ui-copy-ignore
226
+ response.end(jpeg)
227
+ } catch {
228
+ response.writeHead(503).end()
229
+ }
230
+ }).listen(port, '127.0.0.1')
231
+ }
232
+ })
@@ -0,0 +1 @@
1
+ {"name":"dsh-flash-app","version":"1.0.0","private":true,"main":"main.js"}
@@ -0,0 +1,151 @@
1
+ /* A page from the Flash era needs two things this plugin cannot put in its own
2
+ tarball: a browser that still speaks PPAPI, and the plugin itself. The first
3
+ is a Chromium build (BSD, redistributable); the second is Adobe's, which may
4
+ not be shipped. So the switch fetches both once, into the person's own state
5
+ directory, and afterwards the plugin works offline. */
6
+ import { execFileSync } from 'node:child_process'
7
+ import { existsSync, mkdirSync, renameSync, rmSync, statSync, readdirSync, copyFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ /** Root of everything the Flash runtime keeps. */
12
+ export const FLASH_DIR = join(homedir(), '.dsh', 'inner-browser-use', 'flash')
13
+
14
+ /** Chromium 87 is the last build with PPAPI, and the last Electron 11. */
15
+ const ELECTRON_VERSION = process.env.DSH_FLASH_ELECTRON_VERSION ?? '11.3.0'
16
+
17
+ /** Launchers known to carry a Pepper Flash plugin build. */
18
+ const LAUNCHERS = ['百田游戏安全管家.app', '百田游戏管家.app', 'Flash Player.app']
19
+
20
+ /** Where a Pepper Flash plugin may already live on this machine. */
21
+ const PLUGIN_PATHS = LAUNCHERS.flatMap((name) => [
22
+ join('/Applications', name, 'Contents', 'Resources', 'libs', 'PepperFlashPlayer.plugin'),
23
+ join(homedir(), 'Applications', name, 'Contents', 'Resources', 'libs', 'PepperFlashPlayer.plugin'),
24
+ join('/Library/Internet Plug-Ins', 'PepperFlashPlayer.plugin'),
25
+ ])
26
+
27
+ /** @param {string} path - candidate. @returns {boolean} true when it is a plugin bundle. */
28
+ function isPlugin(path) {
29
+ return existsSync(join(path, 'Contents', 'Info.plist'))
30
+ }
31
+
32
+ /** @returns {string | undefined} the plugin already installed here. */
33
+ export function findPlugin() {
34
+ const noted = process.env.DSH_INNER_BROWSER_FLASH_PLUGIN
35
+ if (noted !== undefined && existsSync(noted)) return noted
36
+ const own = join(FLASH_DIR, 'plugin', 'PepperFlashPlayer.plugin')
37
+ if (isPlugin(own)) return own
38
+ return PLUGIN_PATHS.find((path) => isPlugin(path))
39
+ }
40
+
41
+ /** @param {string} bundle - a .plugin bundle. @returns {string} the loadable plugin file. */
42
+ export function pluginExecutable(bundle) {
43
+ const inner = join(bundle, 'Contents', 'MacOS', 'PepperFlashPlayer')
44
+ return existsSync(inner) ? inner : bundle
45
+ }
46
+
47
+ /** @returns {string} where the Chromium 87 binary would be. */
48
+ export function electronPath() {
49
+ return join(FLASH_DIR, 'electron', 'Electron.app', 'Contents', 'MacOS', 'Electron')
50
+ }
51
+
52
+ /** @returns {{ electron: string, plugin: string } | null} the runtime, when ready. */
53
+ export function readyFlash() {
54
+ const electron = electronPath()
55
+ const bundle = findPlugin()
56
+ if (!existsSync(electron) || bundle === undefined) return null
57
+ return { electron, plugin: pluginExecutable(bundle) }
58
+ }
59
+
60
+ /** @returns {string} the Chromium 87 build matching this machine. */
61
+ function electronUrl() {
62
+ const noted = process.env.DSH_FLASH_ELECTRON_URL
63
+ if (noted !== undefined) return noted
64
+ /* Flash exists as an x86_64 library only, so the browser that hosts it must
65
+ be x86_64 as well; on Apple silicon both run under Rosetta. */
66
+ return `https://npmmirror.com/mirrors/electron/${ELECTRON_VERSION}/electron-v${ELECTRON_VERSION}-darwin-x64.zip`
67
+ }
68
+
69
+ /**
70
+ * Fetch one URL to a file, saying how far along it is.
71
+ * @param {string} url - what to fetch.
72
+ * @param {string} file - where it lands.
73
+ * @param {(seen: number, total: number) => void} [progress] - byte counter.
74
+ * @returns {Promise<void>} resolves when the file is complete.
75
+ */
76
+ async function download(url, file, progress) {
77
+ const response = await fetch(url, { redirect: 'follow' })
78
+ if (!response.ok) throw new Error(`flash: ${url} answered ${response.status}`)
79
+ const total = Number(response.headers.get('content-length') ?? 0)
80
+ const chunks = []
81
+ let seen = 0
82
+ for await (const chunk of response.body) {
83
+ chunks.push(chunk)
84
+ seen += chunk.length
85
+ progress?.(seen, total)
86
+ }
87
+ const body = Buffer.concat(chunks)
88
+ if (total > 0 && body.length !== total) throw new Error(`flash: short download (${body.length}/${total})`)
89
+ mkdirSync(join(file, '..'), { recursive: true })
90
+ await import('node:fs/promises').then((fs) => fs.writeFile(file, body))
91
+ }
92
+
93
+ /**
94
+ * Make the Flash runtime usable on this machine, fetching what is missing.
95
+ * @param {{ progress?: (line: string) => void }} [options] - reporting hook.
96
+ * @returns {Promise<{ electron: string, plugin: string }>} the runtime.
97
+ */
98
+ export async function ensureFlash(options = {}) {
99
+ const say = options.progress ?? (() => {})
100
+ mkdirSync(FLASH_DIR, { recursive: true })
101
+ let plugin = findPlugin()
102
+
103
+ if (!existsSync(electronPath())) {
104
+ const zip = join(FLASH_DIR, 'cache', `electron-v${ELECTRON_VERSION}-darwin-x64.zip`)
105
+ if (!existsSync(zip) || statSync(zip).size < 40_000_000) {
106
+ const url = electronUrl()
107
+ say(`下载 Chromium 87(约 80 MB): ${url}`)
108
+ let last = 0
109
+ await download(url, zip, (seen, total) => {
110
+ if (seen - last > 8_000_000) {
111
+ last = seen
112
+ say(` ${Math.round(seen / 1e6)} MB${total === 0 ? '' : ` / ${Math.round(total / 1e6)} MB`}`)
113
+ }
114
+ })
115
+ }
116
+ const staging = join(FLASH_DIR, 'electron.tmp')
117
+ rmSync(staging, { recursive: true, force: true })
118
+ mkdirSync(staging, { recursive: true })
119
+ say('解压 Chromium 87')
120
+ execFileSync('/usr/bin/unzip', ['-q', '-o', zip, '-d', staging], { stdio: 'ignore' })
121
+ const app = join(staging, 'Electron.app')
122
+ if (!existsSync(app)) throw new Error('flash: the archive had no Electron.app')
123
+ rmSync(join(FLASH_DIR, 'electron'), { recursive: true, force: true })
124
+ renameSync(staging, join(FLASH_DIR, 'electron'))
125
+ say('Chromium 87 就绪')
126
+ }
127
+
128
+ if (plugin === undefined) {
129
+ throw new Error('flash: 这台机器上没有 Pepper Flash 插件;装一次任意带 Flash 的游戏客户端(例如百田游戏安全管家),或用 DSH_INNER_BROWSER_FLASH_PLUGIN 指向已有的 PepperFlashPlayer.plugin')
130
+ }
131
+
132
+ const electron = electronPath()
133
+ const pluginFile = pluginExecutable(plugin)
134
+ say(`Flash 运行时就绪: ${electron}`)
135
+ say(`插件: ${pluginFile}`)
136
+ return { electron, plugin: pluginFile }
137
+ }
138
+
139
+ /**
140
+ * Human-readable state, without fetching anything.
141
+ * @returns {{ ready: boolean, electron: boolean, plugin: string | null, dir: string }} the state.
142
+ */
143
+ export function flashStatus() {
144
+ const plugin = findPlugin() ?? null
145
+ return {
146
+ ready: existsSync(electronPath()) && plugin !== null,
147
+ electron: existsSync(electronPath()),
148
+ plugin: plugin === null ? null : pluginExecutable(plugin),
149
+ dir: FLASH_DIR,
150
+ }
151
+ }