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/README.md +53 -18
- package/README.zh.md +26 -14
- package/client.js +262 -28
- package/docs/ARCHITECTURE.md +15 -12
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +568 -17
- package/docs/FLASH.md +84 -0
- package/docs/ROADMAP.md +107 -13
- package/docs/VERIFY.md +20 -10
- package/index.js +456 -45
- package/package.json +20 -3
- package/read.js +734 -29
- package/review.js +213 -0
- package/runtime/flash-app/main.js +232 -0
- package/runtime/flash-app/package.json +1 -0
- package/runtime/flash-setup.mjs +151 -0
- package/runtime/swf.mjs +233 -0
- package/scripts/check-flash-local.mjs +104 -0
- package/scripts/check-flash-mirror.mjs +73 -0
- package/scripts/check-flash-tools.mjs +74 -0
- package/scripts/check-flash-window.mjs +58 -0
- package/scripts/doctor.mjs +3 -1
- package/sidecar.js +1763 -106
- package/tools.js +1667 -191
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
|
+
}
|