dsh-inner-browser-use 0.34.28 → 0.34.96

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.
@@ -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
+ }
@@ -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
+ }