dsh-inner-browser-use 0.34.28 → 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 +161 -22
- package/docs/ARCHITECTURE.md +15 -12
- package/docs/ARTIFACTS.md +1 -1
- package/docs/BENCH.md +209 -34
- package/docs/FLASH.md +84 -0
- package/docs/ROADMAP.md +21 -15
- package/docs/VERIFY.md +20 -10
- package/index.js +144 -24
- package/package.json +17 -3
- package/read.js +633 -29
- 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 +1076 -96
- package/tools.js +1068 -144
|
@@ -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
|
+
}
|
package/runtime/swf.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a SWF far enough to talk about it: what it is, how long it plays, which
|
|
3
|
+
* ActionScript it needs, and what it is made of.
|
|
4
|
+
*
|
|
5
|
+
* This is the front half of the Flash-compatible runtime: the movie is a tag
|
|
6
|
+
* stream, and everything a renderer, an agent or a replay needs can be read off
|
|
7
|
+
* that stream without decoding a single shape. It does not draw.
|
|
8
|
+
*/
|
|
9
|
+
import { inflateSync } from 'node:zlib'
|
|
10
|
+
|
|
11
|
+
/** Every tag this reader names; anything else is reported by number. */
|
|
12
|
+
export const TAG_NAMES = {
|
|
13
|
+
0: 'End',
|
|
14
|
+
1: 'ShowFrame',
|
|
15
|
+
2: 'DefineShape',
|
|
16
|
+
4: 'PlaceObject',
|
|
17
|
+
5: 'RemoveObject',
|
|
18
|
+
6: 'DefineBits',
|
|
19
|
+
7: 'DefineButton',
|
|
20
|
+
8: 'JPEGTables',
|
|
21
|
+
9: 'SetBackgroundColor',
|
|
22
|
+
10: 'DefineFont',
|
|
23
|
+
11: 'DefineText',
|
|
24
|
+
12: 'DoAction',
|
|
25
|
+
13: 'DefineFontInfo',
|
|
26
|
+
14: 'DefineSound',
|
|
27
|
+
15: 'StartSound',
|
|
28
|
+
17: 'DefineButtonSound',
|
|
29
|
+
18: 'SoundStreamHead',
|
|
30
|
+
19: 'SoundStreamBlock',
|
|
31
|
+
20: 'DefineBitsLossless',
|
|
32
|
+
21: 'DefineBitsJPEG2',
|
|
33
|
+
22: 'DefineShape2',
|
|
34
|
+
24: 'Protect',
|
|
35
|
+
26: 'PlaceObject2',
|
|
36
|
+
28: 'RemoveObject2',
|
|
37
|
+
32: 'DefineShape3',
|
|
38
|
+
33: 'DefineText2',
|
|
39
|
+
34: 'DefineButton2',
|
|
40
|
+
35: 'DefineBitsJPEG3',
|
|
41
|
+
36: 'DefineBitsLossless2',
|
|
42
|
+
37: 'DefineEditText',
|
|
43
|
+
39: 'DefineSprite',
|
|
44
|
+
43: 'FrameLabel',
|
|
45
|
+
45: 'SoundStreamHead2',
|
|
46
|
+
46: 'DefineMorphShape',
|
|
47
|
+
48: 'DefineFont2',
|
|
48
|
+
56: 'ExportAssets',
|
|
49
|
+
57: 'ImportAssets',
|
|
50
|
+
58: 'EnableDebugger',
|
|
51
|
+
59: 'DoInitAction',
|
|
52
|
+
60: 'DefineVideoStream',
|
|
53
|
+
61: 'VideoFrame',
|
|
54
|
+
62: 'DefineFontInfo2',
|
|
55
|
+
65: 'ScriptLimits',
|
|
56
|
+
69: 'FileAttributes',
|
|
57
|
+
70: 'PlaceObject3',
|
|
58
|
+
73: 'DefineFontAlignZones',
|
|
59
|
+
74: 'CSMTextSettings',
|
|
60
|
+
75: 'DefineFont3',
|
|
61
|
+
76: 'SymbolClass',
|
|
62
|
+
77: 'Metadata',
|
|
63
|
+
78: 'DefineScalingGrid',
|
|
64
|
+
82: 'DoABC',
|
|
65
|
+
83: 'DefineShape4',
|
|
66
|
+
84: 'DefineMorphShape2',
|
|
67
|
+
86: 'DefineSceneAndFrameLabelData',
|
|
68
|
+
87: 'DefineBinaryData',
|
|
69
|
+
88: 'DefineFontName',
|
|
70
|
+
89: 'StartSound2',
|
|
71
|
+
90: 'DefineBitsJPEG4',
|
|
72
|
+
91: 'DefineFont4',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SHAPES = new Set([2, 22, 32, 83])
|
|
76
|
+
const BITMAPS = new Set([6, 20, 21, 35, 36, 90])
|
|
77
|
+
const FONTS = new Set([10, 48, 75])
|
|
78
|
+
const SOUNDS = new Set([14])
|
|
79
|
+
const SPRITES = new Set([39])
|
|
80
|
+
const AVM1 = new Set([12, 59, 26]) // DoAction, DoInitAction, PlaceObject2 actions
|
|
81
|
+
const AVM2 = new Set([76, 72, 82]) // SymbolClass, DoABC2, DoABC
|
|
82
|
+
|
|
83
|
+
/** Bit reader over the movie header, which is packed rather than byte aligned. */
|
|
84
|
+
class Bits {
|
|
85
|
+
/** @param {Buffer} body - the bytes after the 8 byte file header. */
|
|
86
|
+
constructor(body) {
|
|
87
|
+
this.body = body
|
|
88
|
+
this.bit = 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {number} n - how many bits, at most 32. @returns {number} unsigned value. */
|
|
92
|
+
read(n) {
|
|
93
|
+
let value = 0
|
|
94
|
+
for (let i = 0; i < n; i += 1) {
|
|
95
|
+
const byte = this.body[(this.bit / 8) | 0]
|
|
96
|
+
if (byte === undefined) throw new Error('the movie header ends in the middle of a rectangle')
|
|
97
|
+
value = value * 2 + ((byte >> (7 - (this.bit % 8))) & 1)
|
|
98
|
+
this.bit += 1
|
|
99
|
+
}
|
|
100
|
+
return value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {number} n - how many bits. @returns {number} signed value. */
|
|
104
|
+
readSigned(n) {
|
|
105
|
+
if (n === 0) return 0
|
|
106
|
+
const raw = this.read(n)
|
|
107
|
+
return raw >= 2 ** (n - 1) ? raw - 2 ** n : raw
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @returns {number} the byte offset after the bits just read. */
|
|
111
|
+
get offset() {
|
|
112
|
+
return Math.ceil(this.bit / 8)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read one tag stream.
|
|
118
|
+
* @param {Buffer} body - the movie, or the body of a sprite.
|
|
119
|
+
* @param {number} start - byte offset of the first tag.
|
|
120
|
+
* @param {() => number} budget - how many tags outside sprites to record.
|
|
121
|
+
* @returns {{tags: Array<{code: number, name: string, offset: number, length: number}>, counts: Record<string, number>}} the tags found.
|
|
122
|
+
*/
|
|
123
|
+
function readTags(body, start, budget) {
|
|
124
|
+
const tags = []
|
|
125
|
+
const histogram = {}
|
|
126
|
+
const counts = { shapes: 0, sprites: 0, bitmaps: 0, fonts: 0, sounds: 0, scripts: 0 }
|
|
127
|
+
let at = start
|
|
128
|
+
while (at + 2 <= body.length) {
|
|
129
|
+
const head = body.readUInt16LE(at)
|
|
130
|
+
let code = head >> 6
|
|
131
|
+
let length = head & 0x3f
|
|
132
|
+
let header = 2
|
|
133
|
+
if (length === 0x3f) {
|
|
134
|
+
if (at + 6 > body.length) break
|
|
135
|
+
length = body.readUInt32LE(at + 2)
|
|
136
|
+
header = 6
|
|
137
|
+
}
|
|
138
|
+
if (at + header + length > body.length) {
|
|
139
|
+
throw new Error(`tag ${code} at ${at} claims ${length} bytes past the end of the movie`)
|
|
140
|
+
}
|
|
141
|
+
if (code === 0) break
|
|
142
|
+
const payload = at + header
|
|
143
|
+
if (SHAPES.has(code)) counts.shapes += 1
|
|
144
|
+
if (SPRITES.has(code)) counts.sprites += 1
|
|
145
|
+
if (BITMAPS.has(code)) counts.bitmaps += 1
|
|
146
|
+
if (FONTS.has(code)) counts.fonts += 1
|
|
147
|
+
if (SOUNDS.has(code)) counts.sounds += 1
|
|
148
|
+
if (code === 82 || code === 72) counts.scripts += 1
|
|
149
|
+
const name = TAG_NAMES[code] ?? `tag ${code}`
|
|
150
|
+
histogram[name] = (histogram[name] ?? 0) + 1
|
|
151
|
+
if (budget() > 0) tags.push({ code, name, offset: at, length })
|
|
152
|
+
if (code === 39 && length >= 4) {
|
|
153
|
+
/* A sprite carries its own small tag stream; read it for the counts only. */
|
|
154
|
+
try {
|
|
155
|
+
const inner = readTags(body.subarray(payload + 4, payload + length), 0, () => 0)
|
|
156
|
+
for (const key of Object.keys(counts)) counts[key] += inner.counts[key] ?? 0
|
|
157
|
+
} catch {
|
|
158
|
+
/* a sprite this reader cannot walk still counts as a sprite */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
at = payload + length
|
|
162
|
+
}
|
|
163
|
+
return { tags, counts, histogram }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read a movie.
|
|
168
|
+
* @param {Buffer|Uint8Array} input - the file.
|
|
169
|
+
* @returns {object} header, whether it plays ActionScript 3, tag histogram and the first tags.
|
|
170
|
+
*/
|
|
171
|
+
export function parseSwf(input) {
|
|
172
|
+
const file = Buffer.isBuffer(input) ? input : Buffer.from(input)
|
|
173
|
+
if (file.length < 8) throw new Error('too short to be a SWF: 8 bytes of header are the minimum')
|
|
174
|
+
const signature = file.subarray(0, 3).toString('latin1')
|
|
175
|
+
if (signature !== 'FWS' && signature !== 'CWS' && signature !== 'ZWS') {
|
|
176
|
+
throw new Error(`not a SWF: it starts with ${JSON.stringify(signature)}`)
|
|
177
|
+
}
|
|
178
|
+
const version = file[3]
|
|
179
|
+
const declared = file.readUInt32LE(4)
|
|
180
|
+
let body
|
|
181
|
+
if (signature === 'FWS') body = file.subarray(8)
|
|
182
|
+
else if (signature === 'CWS') body = inflateSync(file.subarray(8))
|
|
183
|
+
else throw new Error('a ZWS movie is LZMA compressed, which this reader cannot open yet')
|
|
184
|
+
const bits = new Bits(body)
|
|
185
|
+
const nbits = bits.read(5)
|
|
186
|
+
const box = [bits.readSigned(nbits), bits.readSigned(nbits), bits.readSigned(nbits), bits.readSigned(nbits)]
|
|
187
|
+
const afterBox = bits.offset
|
|
188
|
+
const rate = body.readUInt16LE(afterBox)
|
|
189
|
+
const frames = body.readUInt16LE(afterBox + 2)
|
|
190
|
+
const frameRate = Number(((rate & 0xff) / 256 + (rate >>> 8)).toFixed(2))
|
|
191
|
+
let left = 40
|
|
192
|
+
const tags = readTags(body, afterBox + 4, () => (left-- > 0 ? 1 : 0))
|
|
193
|
+
const histogram = tags.histogram
|
|
194
|
+
const names = new Set(Object.keys(histogram))
|
|
195
|
+
const avm2ByTag = ['SymbolClass', 'DoABC', 'DoABC2'].some((name) => names.has(name))
|
|
196
|
+
const avm1ByTag = ['DoAction', 'DoInitAction', 'PlaceObject2'].some((name) => names.has(name))
|
|
197
|
+
const avm = avm2ByTag ? 'avm2' : avm1ByTag ? 'avm1' : 'none'
|
|
198
|
+
return {
|
|
199
|
+
signature,
|
|
200
|
+
version,
|
|
201
|
+
declaredBytes: declared,
|
|
202
|
+
bytes: file.length,
|
|
203
|
+
compressed: signature !== 'FWS',
|
|
204
|
+
width: Math.round((box[1] - box[0]) / 20),
|
|
205
|
+
height: Math.round((box[3] - box[2]) / 20),
|
|
206
|
+
frameRate,
|
|
207
|
+
frames,
|
|
208
|
+
avm,
|
|
209
|
+
counts: tags.counts,
|
|
210
|
+
histogram,
|
|
211
|
+
tags: tags.tags,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The report as lines a person or a model can read without the raw tag dump.
|
|
217
|
+
* @param {object} report - from parseSwf.
|
|
218
|
+
* @param {{tags?: number}} [options] - how many leading tags to list.
|
|
219
|
+
* @returns {string[]} lines.
|
|
220
|
+
*/
|
|
221
|
+
export function describeSwf(report, options = {}) {
|
|
222
|
+
const lines = [
|
|
223
|
+
`a ${report.signature} movie, version ${report.version}, ${report.bytes} bytes${report.compressed ? ` (${report.declaredBytes} uncompressed)` : ''}`,
|
|
224
|
+
`${report.width}x${report.height} at ${report.frameRate} frames a second, ${report.frames} frames (${(report.frames / Math.max(report.frameRate, 0.01)).toFixed(1)} s)`,
|
|
225
|
+
report.avm === 'none' ? 'no ActionScript' : `needs ${report.avm === 'avm2' ? 'ActionScript 3 (AVM2)' : 'ActionScript 1/2 (AVM1)'}`,
|
|
226
|
+
`made of ${report.counts.shapes} shape(s), ${report.counts.sprites} sprite(s), ${report.counts.bitmaps} bitmap(s), ${report.counts.fonts} font(s), ${report.counts.sounds} sound(s), ${report.counts.scripts} script block(s)`,
|
|
227
|
+
]
|
|
228
|
+
const listed = report.tags.slice(0, options.tags ?? 12)
|
|
229
|
+
if (listed.length > 0) {
|
|
230
|
+
lines.push(`first tags ${listed.map((tag) => `${tag.name}@${tag.offset}+${tag.length}`).join(' ')}`)
|
|
231
|
+
}
|
|
232
|
+
return lines
|
|
233
|
+
}
|