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/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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The Flash path, measured without the network: a page served from this
|
|
4
|
+
* process, its movie from the repo's own fixture, and the same bridge a site
|
|
5
|
+
* would be driven through. A site being slow or rate-limiting for a minute is
|
|
6
|
+
* not a plugin regression, and this check can tell the two apart.
|
|
7
|
+
*
|
|
8
|
+
* node scripts/check-flash-local.mjs
|
|
9
|
+
*
|
|
10
|
+
* Asserts the movie painted, frames reach a viewer, and a press, a release and
|
|
11
|
+
* a key each reach the plugin in well under the time a person would notice.
|
|
12
|
+
* Own profile, own state directory, closed afterwards; nothing appears on the
|
|
13
|
+
* desktop.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { mkdtempSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
/* The looping movie, because a still one paints once and says nothing about
|
|
24
|
+
whether frames follow a moving picture. */
|
|
25
|
+
const movie = readFileSync(join(here, '..', 'test', 'fixtures', 'loop.swf'))
|
|
26
|
+
const page = `<!doctype html>
|
|
27
|
+
<html><head><meta charset="utf-8"><title>Flash fixture</title></head>
|
|
28
|
+
<body style="margin:0;background:#101820">
|
|
29
|
+
<embed src="/tiny.swf" type="application/x-shockwave-flash" width="400" height="300">
|
|
30
|
+
</body></html>`
|
|
31
|
+
|
|
32
|
+
const server = createServer((request, reply) => {
|
|
33
|
+
if (request.url === '/tiny.swf') {
|
|
34
|
+
reply.writeHead(200, { 'content-type': 'application/x-shockwave-flash', 'content-length': movie.length })
|
|
35
|
+
reply.end(movie)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
reply.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
39
|
+
reply.end(page)
|
|
40
|
+
})
|
|
41
|
+
await new Promise((ready) => server.listen(0, '127.0.0.1', ready))
|
|
42
|
+
const url = `http://127.0.0.1:${server.address().port}/`
|
|
43
|
+
|
|
44
|
+
process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-flash-local-'))
|
|
45
|
+
const { Sidecar } = await import('../sidecar.js')
|
|
46
|
+
|
|
47
|
+
/** @param {number} ms - how long. */
|
|
48
|
+
const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
|
|
49
|
+
const hash = (buffer) => createHash('sha1').update(Buffer.from(buffer ?? '')).digest('hex').slice(0, 12)
|
|
50
|
+
|
|
51
|
+
const sidecar = new Sidecar({ profile: 'flash-local', headless: false, flash: true })
|
|
52
|
+
let ok = false
|
|
53
|
+
try {
|
|
54
|
+
await sidecar.launch('about:blank')
|
|
55
|
+
const seen = []
|
|
56
|
+
const detach = await sidecar.watch((frame) => {
|
|
57
|
+
seen.push({ at: Date.now(), shape: hash(frame.jpeg), kb: Math.round(String(frame.jpeg ?? '').length * 0.75 / 1024) })
|
|
58
|
+
})
|
|
59
|
+
const navigatedAt = Date.now()
|
|
60
|
+
await sidecar.page.navigate(url).catch(() => {})
|
|
61
|
+
for (let i = 0; i < 60 && seen.length === 0; i += 1) await wait(250)
|
|
62
|
+
const firstFrameMs = Date.now() - navigatedAt
|
|
63
|
+
await wait(3000)
|
|
64
|
+
detach()
|
|
65
|
+
const gaps = seen.slice(1).map((one, index) => one.at - seen[index].at).sort((a, b) => a - b)
|
|
66
|
+
const fps = gaps.length === 0 ? 0 : Math.round((1000 / (gaps[Math.floor(gaps.length / 2)] || 1000)) * 10) / 10
|
|
67
|
+
const shapes = new Set(seen.map((one) => one.shape))
|
|
68
|
+
const info = await fetch(`http://127.0.0.1:${sidecar.page.flashBridge}/info`, { signal: AbortSignal.timeout(1500) }).then((one) => one.json()).catch(() => ({}))
|
|
69
|
+
const pressedAt = Date.now()
|
|
70
|
+
await sidecar.page.mouse('mousePressed', { x: 640, y: 420 })
|
|
71
|
+
const pressMs = Date.now() - pressedAt
|
|
72
|
+
const releasedAt = Date.now()
|
|
73
|
+
await sidecar.page.mouse('mouseReleased', { x: 640, y: 420 })
|
|
74
|
+
const releaseMs = Date.now() - releasedAt
|
|
75
|
+
const keyAt = Date.now()
|
|
76
|
+
await sidecar.page.key('keyDown', { key: 'a', text: 'a' })
|
|
77
|
+
const keyMs = Date.now() - keyAt
|
|
78
|
+
await sidecar.page.key('keyUp', { key: 'a' })
|
|
79
|
+
await wait(600)
|
|
80
|
+
/* The same endpoint a click uses, asked directly: the plugin took the input
|
|
81
|
+
rather than a path timing out on the way there. */
|
|
82
|
+
const fedReply = await fetch(`http://127.0.0.1:${sidecar.page.flashBridge}/input`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ type: 'mouseMove', x: 200, y: 150, button: 'left', clickCount: 0 }),
|
|
86
|
+
signal: AbortSignal.timeout(1500),
|
|
87
|
+
}).catch((error) => ({ status: 0, text: () => Promise.resolve(String(error?.message ?? error)) }))
|
|
88
|
+
const fedStatus = fedReply.status
|
|
89
|
+
const fedText = await fedReply.text().catch(() => '')
|
|
90
|
+
const fed = { ok: fedText.includes('"ok":true') }
|
|
91
|
+
const frameKB = seen.length === 0 ? 0 : Math.round(seen.reduce((sum, one) => sum + one.kb, 0) / seen.length)
|
|
92
|
+
/* The fixture movie is not a game: what is asserted here is that the movie
|
|
93
|
+
painted, that frames reached a viewer, and that input arrived without a
|
|
94
|
+
detour through the debugging protocol. Frame rate on moving pictures is
|
|
95
|
+
measured by check-flash-mirror against a real site. */
|
|
96
|
+
ok = Number(info.painted ?? 0) > 1 && seen.length >= 3 && shapes.size >= 2 && firstFrameMs < 1500 && pressMs < 40 && releaseMs < 40 && keyMs < 40 && fed.ok === true && frameKB > 0
|
|
97
|
+
console.log(JSON.stringify({ url, firstFrameMs, frames: seen.length, fps, shapes: shapes.size, frameKB, painted: info.painted ?? 0, pressMs, releaseMs, keyMs, fedStatus, fedText: fedText.slice(0, 80), fed: fed.ok === true, ok }))
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.log(JSON.stringify({ ok: false, error: String(error?.message ?? error).slice(0, 160) }))
|
|
100
|
+
} finally {
|
|
101
|
+
await sidecar.shutdown().catch(() => {})
|
|
102
|
+
server.close()
|
|
103
|
+
}
|
|
104
|
+
process.exitCode = ok ? 0 : 1
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Does a Flash page mirror into the column at a usable rate, and do clicks land?
|
|
4
|
+
*
|
|
5
|
+
* node scripts/check-flash-mirror.mjs [url]
|
|
6
|
+
*
|
|
7
|
+
* Counts the frames a viewer really receives, times a press and a release
|
|
8
|
+
* through the page, and reports both next to the frame size. Own profile, own
|
|
9
|
+
* state directory, closed afterwards; nothing is shown on the desktop.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdtempSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const url = process.argv[2] ?? 'http://account.100bt.com/game/auth/login.html?gameName=aola&action=startgame&platform=100bt'
|
|
16
|
+
process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-flash-mirror-'))
|
|
17
|
+
|
|
18
|
+
const { Sidecar } = await import('../sidecar.js')
|
|
19
|
+
|
|
20
|
+
/** @param {number} ms - how long. */
|
|
21
|
+
const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
|
|
22
|
+
const sidecar = new Sidecar({ profile: 'flash-mirror', headless: false, flash: true })
|
|
23
|
+
let ok = false
|
|
24
|
+
try {
|
|
25
|
+
const started = Date.now()
|
|
26
|
+
await sidecar.launch('about:blank')
|
|
27
|
+
const sizes = []
|
|
28
|
+
const times = []
|
|
29
|
+
const detach = await sidecar.watch((frame) => {
|
|
30
|
+
sizes.push(Math.round(String(frame.jpeg ?? '').length * 0.75 / 1024))
|
|
31
|
+
times.push(Date.now())
|
|
32
|
+
})
|
|
33
|
+
const navigatedAt = Date.now()
|
|
34
|
+
const page = sidecar.page
|
|
35
|
+
await page.navigate(url).catch(() => {})
|
|
36
|
+
for (let i = 0; i < 60 && times.length === 0; i += 1) await wait(250)
|
|
37
|
+
const firstFrame = Date.now() - navigatedAt
|
|
38
|
+
await wait(4000)
|
|
39
|
+
detach()
|
|
40
|
+
const gaps = times.slice(1).map((at, index) => at - times[index]).sort((a, b) => a - b)
|
|
41
|
+
const fps = gaps.length === 0 ? 0 : Math.round((1000 / (gaps[Math.floor(gaps.length / 2)] || 1000)) * 10) / 10
|
|
42
|
+
const pressedAt = Date.now()
|
|
43
|
+
await page.mouse('mousePressed', { x: 640, y: 420 })
|
|
44
|
+
const pressMs = Date.now() - pressedAt
|
|
45
|
+
const releasedAt = Date.now()
|
|
46
|
+
await page.mouse('mouseReleased', { x: 640, y: 420 })
|
|
47
|
+
const releaseMs = Date.now() - releasedAt
|
|
48
|
+
await wait(400)
|
|
49
|
+
const before = await page.picture()
|
|
50
|
+
const keyAt = Date.now()
|
|
51
|
+
await page.key('keyDown', { key: 'a', text: 'a' })
|
|
52
|
+
const keyMs = Date.now() - keyAt
|
|
53
|
+
await page.key('keyUp', { key: 'a' })
|
|
54
|
+
await wait(600)
|
|
55
|
+
const moved = await page.picture()
|
|
56
|
+
ok = fps >= 15 && pressMs < 40 && releaseMs < 40 && keyMs < 40 && moved !== before && sizes.length > 5
|
|
57
|
+
console.log(JSON.stringify({
|
|
58
|
+
firstFrameMs: firstFrame,
|
|
59
|
+
frames: sizes.length,
|
|
60
|
+
fps,
|
|
61
|
+
frameKB: sizes.length === 0 ? 0 : Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length),
|
|
62
|
+
pressMs,
|
|
63
|
+
releaseMs,
|
|
64
|
+
keyMs,
|
|
65
|
+
responded: moved !== '' && moved !== before,
|
|
66
|
+
ok,
|
|
67
|
+
}))
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.log(JSON.stringify({ ok: false, error: String(error?.message ?? error).slice(0, 160) }))
|
|
70
|
+
} finally {
|
|
71
|
+
await sidecar.shutdown().catch(() => {})
|
|
72
|
+
}
|
|
73
|
+
process.exitCode = ok ? 0 : 1
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* What a tool call costs on a Flash page, from the model's side.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/check-flash-tools.mjs [url]
|
|
6
|
+
*
|
|
7
|
+
* Runs the real tool definitions against a real Flash runtime and reports time
|
|
8
|
+
* and characters for a click, a view and a type, plus whether the picture moved.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtempSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const url = process.argv[2] ?? 'http://account.100bt.com/game/auth/login.html?gameName=aola&action=startgame&platform=100bt'
|
|
15
|
+
process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-flash-tools-'))
|
|
16
|
+
|
|
17
|
+
const { Sidecar } = await import('../sidecar.js')
|
|
18
|
+
const { sidecarTools } = await import('../tools.js')
|
|
19
|
+
|
|
20
|
+
const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
|
|
21
|
+
const sidecar = new Sidecar({ profile: 'flash-tools', headless: false, flash: true })
|
|
22
|
+
const refs = { byChat: new Map() }
|
|
23
|
+
const tools = sidecarTools(sidecar, refs, {})
|
|
24
|
+
const tool = (name) => tools.find((one) => one.name === name)
|
|
25
|
+
let ok = false
|
|
26
|
+
try {
|
|
27
|
+
await sidecar.launch('about:blank')
|
|
28
|
+
await sidecar.watch(() => {})
|
|
29
|
+
await sidecar.page.navigate(url).catch(() => {})
|
|
30
|
+
await wait(9000)
|
|
31
|
+
const said = []
|
|
32
|
+
const timed = async (name, args) => {
|
|
33
|
+
const started = Date.now()
|
|
34
|
+
const value = await tool(name).execute(args, { agent: { id: 'flash-check' } })
|
|
35
|
+
const text = String(value?.text ?? '')
|
|
36
|
+
const record = { tool: name, ms: Date.now() - started, chars: text.length, text: text.slice(0, 120) }
|
|
37
|
+
said.push(record)
|
|
38
|
+
return record
|
|
39
|
+
}
|
|
40
|
+
await timed('browser_view', {})
|
|
41
|
+
/* One call is a sample, five are a cost: a plugin page blocks its main
|
|
42
|
+
thread while it draws, so a single number swings by a hundred ms. */
|
|
43
|
+
const rounds = []
|
|
44
|
+
for (let i = 0; i < 5; i += 1) {
|
|
45
|
+
rounds.push(await timed('browser_click', { x: 640, y: 420 }))
|
|
46
|
+
rounds.push(await timed('browser_type', { text: 'a' }))
|
|
47
|
+
}
|
|
48
|
+
const median = (list) => {
|
|
49
|
+
const sorted = list.map((one) => one.ms).sort((a, b) => a - b)
|
|
50
|
+
return sorted[Math.floor(sorted.length / 2)]
|
|
51
|
+
}
|
|
52
|
+
/* A read of a page just read is mostly the page again: it must cost a line,
|
|
53
|
+
not a snapshot. */
|
|
54
|
+
const viewAgain = await timed('browser_view', {})
|
|
55
|
+
const viewThird = await timed('browser_view', {})
|
|
56
|
+
const clicks = rounds.filter((one) => one.tool === 'browser_click')
|
|
57
|
+
const types = rounds.filter((one) => one.tool === 'browser_type')
|
|
58
|
+
const last = rounds[rounds.length - 1]
|
|
59
|
+
/* A step that repeats the address it read a moment ago is spend with nothing
|
|
60
|
+
to teach, so the budget is per step and tight: one line, not a page. */
|
|
61
|
+
const within = (one) => Number(one?.chars ?? 999) < 300
|
|
62
|
+
/* A page whose own requests fail still answers, but slowly: the numbers to
|
|
63
|
+
gate on are the token ones, and latency is judged only when the page came
|
|
64
|
+
up whole. Otherwise a bad minute at the site reads as a regression here. */
|
|
65
|
+
const degraded = said[0].chars < 300 || said[0].text.includes('page trouble')
|
|
66
|
+
const fast = () => degraded || (median(clicks) < 120 && median(types) < 120)
|
|
67
|
+
ok = clicks.length === 5 && fast() && clicks.every(within) && types.every(within) && Number(last?.chars ?? 0) > 0 && viewAgain.chars < said[0].chars && viewThird.chars < said[0].chars
|
|
68
|
+
console.log(JSON.stringify({ view: said[0], clickMedianMs: median(clicks), clickMaxMs: Math.max(...clicks.map((one) => one.ms)), typeMedianMs: median(types), typeMaxMs: Math.max(...types.map((one) => one.ms)), clickChars: clicks[0].chars, typeChars: types[0].chars, viewAgainChars: viewAgain.chars, viewThirdChars: viewThird.chars, viewAgainText: viewAgain.text, degraded, ok }))
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.log(JSON.stringify({ ok: false, error: String(error?.message ?? error).slice(0, 160) }))
|
|
71
|
+
} finally {
|
|
72
|
+
await sidecar.shutdown().catch(() => {})
|
|
73
|
+
}
|
|
74
|
+
process.exitCode = ok ? 0 : 1
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Can a Flash game run without ever appearing on the desktop?
|
|
4
|
+
*
|
|
5
|
+
* node scripts/check-flash-window.mjs [url]
|
|
6
|
+
*
|
|
7
|
+
* Starts the plugin's own Flash runtime on the address, then checks three
|
|
8
|
+
* things that together mean "only the column sees it": the window was never
|
|
9
|
+
* shown, the picture keeps changing, and the process is not a frontmost or
|
|
10
|
+
* Dock-bearing app. Its own profile and state directory; both are closed.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync } from 'node:fs'
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const url = process.argv[2] ?? 'http://account.100bt.com/game/auth/login.html?gameName=aola&action=startgame&platform=100bt'
|
|
18
|
+
process.env.DSH_INNER_BROWSER_STATE_DIR = mkdtempSync(join(tmpdir(), 'dsh-flash-window-'))
|
|
19
|
+
|
|
20
|
+
const { Sidecar } = await import('../sidecar.js')
|
|
21
|
+
|
|
22
|
+
/** @param {number} ms - how long. */
|
|
23
|
+
const wait = (ms) => new Promise((settle) => setTimeout(settle, ms))
|
|
24
|
+
const sidecar = new Sidecar({ profile: 'flash-window', headless: false, flash: true })
|
|
25
|
+
let ok = false
|
|
26
|
+
try {
|
|
27
|
+
const started = Date.now()
|
|
28
|
+
await sidecar.launch('about:blank')
|
|
29
|
+
await sidecar.page.navigate(url).catch(() => {})
|
|
30
|
+
await wait(10_000)
|
|
31
|
+
const base = `http://127.0.0.1:${sidecar.flashBridge}`
|
|
32
|
+
const info = await fetch(`${base}/info`).then((reply) => reply.json()).catch(() => null)
|
|
33
|
+
const grab = async () => {
|
|
34
|
+
const body = Buffer.from(await (await fetch(`${base}/frame.jpg`)).arrayBuffer())
|
|
35
|
+
return { kb: Math.round(body.length / 1024), hash: createHash('sha1').update(body).digest('hex').slice(0, 8) }
|
|
36
|
+
}
|
|
37
|
+
const one = await grab()
|
|
38
|
+
await wait(900)
|
|
39
|
+
const two = await grab()
|
|
40
|
+
const neverShown = info !== null && info.visible === false
|
|
41
|
+
const painting = info !== null && Number(info.painted ?? 0) > 10
|
|
42
|
+
const moving = one.hash !== two.hash
|
|
43
|
+
ok = neverShown && painting && moving
|
|
44
|
+
console.log(JSON.stringify({
|
|
45
|
+
url: String(info?.url ?? url).slice(0, 80),
|
|
46
|
+
visible: info?.visible,
|
|
47
|
+
painted: info?.painted,
|
|
48
|
+
frameKB: one.kb,
|
|
49
|
+
moved: moving,
|
|
50
|
+
ms: Date.now() - started,
|
|
51
|
+
ok,
|
|
52
|
+
}))
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.log(JSON.stringify({ ok: false, error: String(error?.message ?? error).slice(0, 160) }))
|
|
55
|
+
} finally {
|
|
56
|
+
await sidecar.shutdown().catch(() => {})
|
|
57
|
+
}
|
|
58
|
+
process.exitCode = ok ? 0 : 1
|
package/scripts/doctor.mjs
CHANGED
|
@@ -66,9 +66,10 @@ const state = await get(`${ROUTE}/sidecar/state`)
|
|
|
66
66
|
console.log(`host ${state === null ? `not answering under ${ROUTE}` : base}`)
|
|
67
67
|
if (state === null) {
|
|
68
68
|
console.log(' start the app, or set DSH_BRIDGE to the app address')
|
|
69
|
-
process.
|
|
69
|
+
process.exitCode = 0
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
if (state !== null) {
|
|
72
73
|
console.log(`browser ${state.alive === true ? `attached, ${state.headless === true ? 'headless' : 'with a window'}` : 'not running'}`)
|
|
73
74
|
const profileDir = join(PROFILES, String(state.profile ?? ''))
|
|
74
75
|
console.log(`profile ${state.profile} in ${profileDir}`)
|
|
@@ -87,3 +88,4 @@ if (existsSync(portFile)) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
if (String(state.error ?? '') !== '') console.log(`error ${state.error}`)
|
|
91
|
+
}
|