poi-plugin-mcp 0.2.15 → 0.2.21
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 +290 -201
- package/index.js +3 -0
- package/lib/bridge-controller.js +75 -1
- package/lib/fleet-metrics.js +249 -0
- package/lib/poi-action-events.js +405 -291
- package/lib/poi-api-responses.js +165 -0
- package/lib/poi-auto-interaction-recorder.js +399 -0
- package/lib/poi-data-query.js +254 -0
- package/lib/poi-http-bridge.js +1673 -1320
- package/lib/poi-input.js +366 -302
- package/lib/poi-interaction-recorder.js +1693 -0
- package/lib/poi-telemetry.js +515 -439
- package/lib/poi-webview-runtime.js +1181 -0
- package/lib/settings-view.js +112 -0
- package/lib/settings.js +8 -0
- package/mcp-server.js +542 -456
- package/package.json +3 -3
|
@@ -0,0 +1,1693 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const os = require('node:os')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
|
|
6
|
+
const { createPoiScreenshotProvider } = require('./poi-screenshot')
|
|
7
|
+
|
|
8
|
+
const CANONICAL_WIDTH = 1200
|
|
9
|
+
const CANONICAL_HEIGHT = 720
|
|
10
|
+
const DEFAULT_HOLD_THRESHOLD_MS = 200
|
|
11
|
+
const DEFAULT_DRAG_THRESHOLD_PX = 6
|
|
12
|
+
const DEFAULT_ATTACH_INTERVAL_MS = 1000
|
|
13
|
+
const DEFAULT_MAX_PATH_POINTS = 256
|
|
14
|
+
const DEFAULT_MAX_JSON_BYTES = 16 * 1024 * 1024
|
|
15
|
+
const DEFAULT_MAX_SESSION_BYTES = 8 * 1024 * 1024 * 1024
|
|
16
|
+
const DEFAULT_MAX_TIMELINE_EVENTS = 20000
|
|
17
|
+
const DEFAULT_MAX_SESSION_DURATION_MS = 4 * 60 * 60 * 1000
|
|
18
|
+
const DEFAULT_MAX_SCREENSHOT_BYTES = 16 * 1024 * 1024
|
|
19
|
+
const DEFAULT_MAX_STORED_SESSIONS = 200
|
|
20
|
+
const DEFAULT_MAX_TOTAL_RECORDING_BYTES = 64 * 1024 * 1024 * 1024
|
|
21
|
+
const DEFAULT_FINAL_MANIFEST_RESERVE_BYTES = 64 * 1024
|
|
22
|
+
const DEFAULT_CHECKPOINT_DELAYS_MS = Object.freeze([0, 250, 1000])
|
|
23
|
+
const DEFAULT_OUTPUT_ROOT = process.platform === 'win32'
|
|
24
|
+
? 'D:\\poi-mcp\\recordings'
|
|
25
|
+
: path.join(os.homedir(), '.poi-mcp', 'recordings')
|
|
26
|
+
const SENSITIVE_KEY = /(auth|authorization|cookie|credential|key|login(?:data)?|password|secret|session|sid|ticket|token)/iu
|
|
27
|
+
const SENSITIVE_VALUE = /\b(?:basic|bearer|api[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?id)\b/iu
|
|
28
|
+
const GAME_BUSINESS_SORT_KEYS = new Set([
|
|
29
|
+
'api_sort_key',
|
|
30
|
+
'shipSortKeyType',
|
|
31
|
+
'sort_key',
|
|
32
|
+
'sortKey',
|
|
33
|
+
])
|
|
34
|
+
const EQUIPMENT_UI_STORAGE_KEYS = new Set([
|
|
35
|
+
'listMode',
|
|
36
|
+
'slotItemFilter',
|
|
37
|
+
'slotItemFilterDetail',
|
|
38
|
+
'slotItemPage',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
function createPoiInteractionRecorder(options = {}) {
|
|
42
|
+
const getStore = options.getStore || defaultGetStore
|
|
43
|
+
const captureScreenshot = options.captureScreenshot ||
|
|
44
|
+
createPoiScreenshotProvider({ getStore })
|
|
45
|
+
const captureWebStorage = options.captureWebStorage || defaultCaptureWebStorage
|
|
46
|
+
const captureEquipmentUiState = options.captureEquipmentUiState ||
|
|
47
|
+
defaultCaptureEquipmentUiState
|
|
48
|
+
const eventTarget = options.eventTarget === undefined
|
|
49
|
+
? defaultEventTarget()
|
|
50
|
+
: options.eventTarget
|
|
51
|
+
const resolveWebContents = options.resolveWebContents || defaultResolveWebContents
|
|
52
|
+
const outputRoot = options.outputRoot || DEFAULT_OUTPUT_ROOT
|
|
53
|
+
const logger = options.logger || console
|
|
54
|
+
const now = options.now || (() => new Date())
|
|
55
|
+
const nowMs = options.nowMs || (() => Date.now())
|
|
56
|
+
const setIntervalFn = options.setInterval || setInterval
|
|
57
|
+
const clearIntervalFn = options.clearInterval || clearInterval
|
|
58
|
+
const setTimeoutFn = options.setTimeout || setTimeout
|
|
59
|
+
const clearTimeoutFn = options.clearTimeout || clearTimeout
|
|
60
|
+
const attachIntervalMs = positiveInteger(
|
|
61
|
+
options.attachIntervalMs,
|
|
62
|
+
DEFAULT_ATTACH_INTERVAL_MS,
|
|
63
|
+
'attachIntervalMs',
|
|
64
|
+
)
|
|
65
|
+
const holdThresholdMs = positiveInteger(
|
|
66
|
+
options.holdThresholdMs,
|
|
67
|
+
DEFAULT_HOLD_THRESHOLD_MS,
|
|
68
|
+
'holdThresholdMs',
|
|
69
|
+
)
|
|
70
|
+
const dragThresholdPx = positiveNumber(
|
|
71
|
+
options.dragThresholdPx,
|
|
72
|
+
DEFAULT_DRAG_THRESHOLD_PX,
|
|
73
|
+
'dragThresholdPx',
|
|
74
|
+
)
|
|
75
|
+
const maxPathPoints = positiveInteger(
|
|
76
|
+
options.maxPathPoints,
|
|
77
|
+
DEFAULT_MAX_PATH_POINTS,
|
|
78
|
+
'maxPathPoints',
|
|
79
|
+
)
|
|
80
|
+
const maxJsonBytes = positiveInteger(
|
|
81
|
+
options.maxJsonBytes,
|
|
82
|
+
DEFAULT_MAX_JSON_BYTES,
|
|
83
|
+
'maxJsonBytes',
|
|
84
|
+
)
|
|
85
|
+
const maxSessionBytes = positiveInteger(
|
|
86
|
+
options.maxSessionBytes,
|
|
87
|
+
DEFAULT_MAX_SESSION_BYTES,
|
|
88
|
+
'maxSessionBytes',
|
|
89
|
+
)
|
|
90
|
+
const maxTimelineEvents = positiveInteger(
|
|
91
|
+
options.maxTimelineEvents,
|
|
92
|
+
DEFAULT_MAX_TIMELINE_EVENTS,
|
|
93
|
+
'maxTimelineEvents',
|
|
94
|
+
)
|
|
95
|
+
const maxSessionDurationMs = positiveInteger(
|
|
96
|
+
options.maxSessionDurationMs,
|
|
97
|
+
DEFAULT_MAX_SESSION_DURATION_MS,
|
|
98
|
+
'maxSessionDurationMs',
|
|
99
|
+
)
|
|
100
|
+
const maxScreenshotBytes = positiveInteger(
|
|
101
|
+
options.maxScreenshotBytes,
|
|
102
|
+
DEFAULT_MAX_SCREENSHOT_BYTES,
|
|
103
|
+
'maxScreenshotBytes',
|
|
104
|
+
)
|
|
105
|
+
const maxStoredSessions = positiveInteger(
|
|
106
|
+
options.maxStoredSessions,
|
|
107
|
+
DEFAULT_MAX_STORED_SESSIONS,
|
|
108
|
+
'maxStoredSessions',
|
|
109
|
+
)
|
|
110
|
+
const maxTotalRecordingBytes = positiveInteger(
|
|
111
|
+
options.maxTotalRecordingBytes,
|
|
112
|
+
DEFAULT_MAX_TOTAL_RECORDING_BYTES,
|
|
113
|
+
'maxTotalRecordingBytes',
|
|
114
|
+
)
|
|
115
|
+
const checkpointDelaysMs = normalizeCheckpointDelays(
|
|
116
|
+
options.checkpointDelaysMs,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
let running = false
|
|
120
|
+
let attachedWebContents = null
|
|
121
|
+
let attachTimer = null
|
|
122
|
+
let sessionDeadlineTimer = null
|
|
123
|
+
let activeGesture = null
|
|
124
|
+
let gestureSequence = 0
|
|
125
|
+
let responseSequence = 0
|
|
126
|
+
let timelineSequence = 0
|
|
127
|
+
let sessionId = null
|
|
128
|
+
let sessionDir = null
|
|
129
|
+
let manifest = null
|
|
130
|
+
let pending = Promise.resolve()
|
|
131
|
+
let lastStorageEntries = new Map()
|
|
132
|
+
let acceptingEvents = false
|
|
133
|
+
let limitReached = null
|
|
134
|
+
let sessionBytes = 0
|
|
135
|
+
let activeSessionByteLimit = maxSessionBytes
|
|
136
|
+
let finalManifestReserveBytes = 0
|
|
137
|
+
let sessionStartedAtMs = 0
|
|
138
|
+
let storageHashKey = null
|
|
139
|
+
let sessionFileSizes = new Map()
|
|
140
|
+
let fileWritePending = Promise.resolve()
|
|
141
|
+
const checkpointTimers = new Map()
|
|
142
|
+
|
|
143
|
+
function enqueue(action) {
|
|
144
|
+
const result = pending.then(action, action)
|
|
145
|
+
pending = result.catch((error) => {
|
|
146
|
+
logger.error(`[poi-plugin-mcp] Recorder write failed: ${error.message}`)
|
|
147
|
+
})
|
|
148
|
+
return result
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function withinDurationBudget() {
|
|
152
|
+
if (!acceptingEvents) return false
|
|
153
|
+
if (nowMs() - sessionStartedAtMs <= maxSessionDurationMs) return true
|
|
154
|
+
reachLimit('session-duration-limit')
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function reachLimit(reason) {
|
|
159
|
+
if (limitReached) return
|
|
160
|
+
limitReached = {
|
|
161
|
+
reason,
|
|
162
|
+
reachedAt: timestamp(now()),
|
|
163
|
+
}
|
|
164
|
+
acceptingEvents = false
|
|
165
|
+
running = false
|
|
166
|
+
clearAttachTimer()
|
|
167
|
+
clearSessionDeadlineTimer()
|
|
168
|
+
cancelCheckpointTimers()
|
|
169
|
+
activeGesture = null
|
|
170
|
+
if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
|
|
171
|
+
eventTarget.removeEventListener('game.response', handleGameResponse)
|
|
172
|
+
}
|
|
173
|
+
detachWebContents()
|
|
174
|
+
logger.error(`[poi-plugin-mcp] Play recording paused: ${reason}`)
|
|
175
|
+
enqueue(finalizeSession)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writeSessionFile(filePath, data, options = {}) {
|
|
179
|
+
const operation = fileWritePending.then(
|
|
180
|
+
() => writeSessionFileNow(filePath, data, options),
|
|
181
|
+
() => writeSessionFileNow(filePath, data, options),
|
|
182
|
+
)
|
|
183
|
+
fileWritePending = operation.catch(() => {})
|
|
184
|
+
return operation
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writeSessionFileNow(filePath, data, options = {}) {
|
|
188
|
+
if (!acceptingEvents && !options.finalizing) return false
|
|
189
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8')
|
|
190
|
+
const fileKey = path.resolve(filePath)
|
|
191
|
+
const previousSize = sessionFileSizes.get(fileKey) || 0
|
|
192
|
+
const nextSize = options.append
|
|
193
|
+
? previousSize + buffer.length
|
|
194
|
+
: buffer.length
|
|
195
|
+
const byteDelta = nextSize - previousSize
|
|
196
|
+
const byteLimit = options.finalizing
|
|
197
|
+
? activeSessionByteLimit
|
|
198
|
+
: activeSessionByteLimit - finalManifestReserveBytes
|
|
199
|
+
if (
|
|
200
|
+
sessionBytes + byteDelta > byteLimit
|
|
201
|
+
) {
|
|
202
|
+
reachLimit('session-byte-limit')
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
|
|
207
|
+
if (options.append) {
|
|
208
|
+
await fs.promises.appendFile(filePath, buffer)
|
|
209
|
+
} else {
|
|
210
|
+
await fs.promises.writeFile(filePath, buffer)
|
|
211
|
+
}
|
|
212
|
+
sessionFileSizes.set(fileKey, nextSize)
|
|
213
|
+
sessionBytes += byteDelta
|
|
214
|
+
return true
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throw error
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function writeSessionJson(filePath, value, options = {}) {
|
|
221
|
+
const json = serializeBoundedJson(value, maxJsonBytes)
|
|
222
|
+
return writeSessionFile(filePath, json, options)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function currentLayout() {
|
|
226
|
+
const layout = getStore('layout.webview')
|
|
227
|
+
if (
|
|
228
|
+
!layout ||
|
|
229
|
+
!Number.isFinite(layout.width) ||
|
|
230
|
+
layout.width <= 0 ||
|
|
231
|
+
!Number.isFinite(layout.height) ||
|
|
232
|
+
layout.height <= 0
|
|
233
|
+
) {
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
return layout
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveCurrentWebContents() {
|
|
240
|
+
const layout = currentLayout()
|
|
241
|
+
if (!layout || !layout.ref) return null
|
|
242
|
+
if (typeof layout.ref.getWebContents === 'function') {
|
|
243
|
+
return layout.ref.getWebContents()
|
|
244
|
+
}
|
|
245
|
+
if (typeof layout.ref.getWebContentsId === 'function') {
|
|
246
|
+
const id = layout.ref.getWebContentsId()
|
|
247
|
+
if (Number.isInteger(id) && id > 0) return resolveWebContents(id)
|
|
248
|
+
}
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function tryAttach() {
|
|
253
|
+
if (!running || !acceptingEvents) return false
|
|
254
|
+
let nextWebContents = null
|
|
255
|
+
try {
|
|
256
|
+
nextWebContents = resolveCurrentWebContents()
|
|
257
|
+
} catch (error) {
|
|
258
|
+
logger.error(`[poi-plugin-mcp] Recorder WebView lookup failed: ${error.message}`)
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
!nextWebContents ||
|
|
263
|
+
typeof nextWebContents.on !== 'function'
|
|
264
|
+
) {
|
|
265
|
+
interruptActiveGesture()
|
|
266
|
+
detachWebContents()
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
if (nextWebContents === attachedWebContents) return true
|
|
270
|
+
|
|
271
|
+
interruptActiveGesture()
|
|
272
|
+
detachWebContents()
|
|
273
|
+
attachedWebContents = nextWebContents
|
|
274
|
+
attachedWebContents.on('before-mouse-event', handleMouseEvent)
|
|
275
|
+
attachedWebContents.on('did-start-navigation', handleWebContentsNavigation)
|
|
276
|
+
if (typeof attachedWebContents.once === 'function') {
|
|
277
|
+
attachedWebContents.once('destroyed', handleWebContentsDestroyed)
|
|
278
|
+
}
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function handleWebContentsDestroyed() {
|
|
283
|
+
interruptActiveGesture()
|
|
284
|
+
detachWebContents()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function handleWebContentsNavigation(
|
|
288
|
+
_event,
|
|
289
|
+
_url,
|
|
290
|
+
_isInPlace,
|
|
291
|
+
isMainFrame,
|
|
292
|
+
) {
|
|
293
|
+
if (isMainFrame === false) return
|
|
294
|
+
interruptActiveGesture()
|
|
295
|
+
detachWebContents()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function detachWebContents() {
|
|
299
|
+
if (!attachedWebContents) return
|
|
300
|
+
const remove = typeof attachedWebContents.off === 'function'
|
|
301
|
+
? attachedWebContents.off.bind(attachedWebContents)
|
|
302
|
+
: typeof attachedWebContents.removeListener === 'function'
|
|
303
|
+
? attachedWebContents.removeListener.bind(attachedWebContents)
|
|
304
|
+
: null
|
|
305
|
+
if (remove) {
|
|
306
|
+
remove('before-mouse-event', handleMouseEvent)
|
|
307
|
+
remove('did-start-navigation', handleWebContentsNavigation)
|
|
308
|
+
remove('destroyed', handleWebContentsDestroyed)
|
|
309
|
+
}
|
|
310
|
+
attachedWebContents = null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function interruptActiveGesture() {
|
|
314
|
+
if (!activeGesture) return
|
|
315
|
+
finishGesture({
|
|
316
|
+
x: activeGesture.last.webview.x,
|
|
317
|
+
y: activeGesture.last.webview.y,
|
|
318
|
+
button: activeGesture.button,
|
|
319
|
+
}, true)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function handleMouseEvent(_event, mouse) {
|
|
323
|
+
observeMouseEvent({
|
|
324
|
+
mouse,
|
|
325
|
+
observedAt: timestamp(now()),
|
|
326
|
+
observedAtMs: nowMs(),
|
|
327
|
+
screenshot: null,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function observeMouseEvent(observation) {
|
|
332
|
+
const mouse = observation && observation.mouse
|
|
333
|
+
if (
|
|
334
|
+
!running ||
|
|
335
|
+
!acceptingEvents ||
|
|
336
|
+
!mouse ||
|
|
337
|
+
typeof mouse !== 'object' ||
|
|
338
|
+
!Number.isFinite(mouse.x) ||
|
|
339
|
+
!Number.isFinite(mouse.y)
|
|
340
|
+
) {
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (!withinDurationBudget()) return
|
|
344
|
+
const timing = {
|
|
345
|
+
observedAt: normalizedObservedAt(observation.observedAt, now),
|
|
346
|
+
observedAtMs: Number.isFinite(observation.observedAtMs)
|
|
347
|
+
? observation.observedAtMs
|
|
348
|
+
: nowMs(),
|
|
349
|
+
screenshot: observation.screenshot || null,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (mouse.type === 'mouseDown') {
|
|
353
|
+
beginGesture(mouse, timing)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if (mouse.type === 'mouseMove' && activeGesture) {
|
|
357
|
+
if (!reportsHeldButton(mouse, activeGesture.button)) {
|
|
358
|
+
interruptActiveGesture()
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
updateGesture(mouse, timing)
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (mouse.type === 'mouseUp' && activeGesture) {
|
|
365
|
+
if (mouse.button === activeGesture.button) finishGesture(mouse, false, timing)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function beginGesture(mouse, timing) {
|
|
370
|
+
if (activeGesture) finishGesture(mouse, true)
|
|
371
|
+
const layout = currentLayout()
|
|
372
|
+
if (!layout) return
|
|
373
|
+
|
|
374
|
+
gestureSequence += 1
|
|
375
|
+
const id = `gesture-${String(gestureSequence).padStart(6, '0')}`
|
|
376
|
+
const startedAtMs = timing.observedAtMs
|
|
377
|
+
const start = pointerPoint(mouse, layout, 0)
|
|
378
|
+
const startRelativePath = `frames/${id}-start.png`
|
|
379
|
+
activeGesture = {
|
|
380
|
+
id,
|
|
381
|
+
button: supportedButton(mouse.button),
|
|
382
|
+
startedAt: timing.observedAt,
|
|
383
|
+
startedAtMs,
|
|
384
|
+
layout: { width: layout.width, height: layout.height },
|
|
385
|
+
start,
|
|
386
|
+
last: start,
|
|
387
|
+
maxDisplacementPx: 0,
|
|
388
|
+
observedMovement: false,
|
|
389
|
+
path: [pathPoint(start)],
|
|
390
|
+
startScreenshot: requestScreenshot(
|
|
391
|
+
startRelativePath,
|
|
392
|
+
'start',
|
|
393
|
+
timing.screenshot,
|
|
394
|
+
),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function updateGesture(mouse, timing) {
|
|
399
|
+
const gesture = activeGesture
|
|
400
|
+
gesture.observedMovement = true
|
|
401
|
+
const point = pointerPoint(
|
|
402
|
+
mouse,
|
|
403
|
+
gesture.layout,
|
|
404
|
+
timing.observedAtMs - gesture.startedAtMs,
|
|
405
|
+
)
|
|
406
|
+
gesture.last = point
|
|
407
|
+
gesture.maxDisplacementPx = Math.max(
|
|
408
|
+
gesture.maxDisplacementPx,
|
|
409
|
+
distance(gesture.start.webview, point.webview),
|
|
410
|
+
)
|
|
411
|
+
appendPathPoint(gesture.path, pathPoint(point), maxPathPoints)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function finishGesture(mouse, interrupted = false, timing = null) {
|
|
415
|
+
const gesture = activeGesture
|
|
416
|
+
if (!gesture) return
|
|
417
|
+
activeGesture = null
|
|
418
|
+
|
|
419
|
+
const endedAtMs = timing && Number.isFinite(timing.observedAtMs)
|
|
420
|
+
? timing.observedAtMs
|
|
421
|
+
: nowMs()
|
|
422
|
+
const endedAt = timing && typeof timing.observedAt === 'string'
|
|
423
|
+
? timing.observedAt
|
|
424
|
+
: timestamp(now())
|
|
425
|
+
const end = pointerPoint(
|
|
426
|
+
mouse,
|
|
427
|
+
gesture.layout,
|
|
428
|
+
Math.max(0, endedAtMs - gesture.startedAtMs),
|
|
429
|
+
)
|
|
430
|
+
appendPathPoint(gesture.path, pathPoint(end), maxPathPoints)
|
|
431
|
+
const displacementPx = distance(gesture.start.webview, end.webview)
|
|
432
|
+
gesture.maxDisplacementPx = Math.max(
|
|
433
|
+
gesture.maxDisplacementPx,
|
|
434
|
+
displacementPx,
|
|
435
|
+
)
|
|
436
|
+
const durationMs = Math.max(0, Math.round(endedAtMs - gesture.startedAtMs))
|
|
437
|
+
const gestureType = interrupted
|
|
438
|
+
? 'interrupted'
|
|
439
|
+
: gesture.maxDisplacementPx >= dragThresholdPx
|
|
440
|
+
? 'drag'
|
|
441
|
+
: durationMs > holdThresholdMs && gesture.observedMovement
|
|
442
|
+
? 'hold'
|
|
443
|
+
: 'click'
|
|
444
|
+
const screenshots = [gesture.startScreenshot]
|
|
445
|
+
if (gestureType !== 'click') {
|
|
446
|
+
screenshots.push(requestScreenshot(`frames/${gesture.id}-end.png`, 'end'))
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
enqueue(async () => {
|
|
450
|
+
const screenshotResults = await Promise.all(screenshots)
|
|
451
|
+
await appendTimeline({
|
|
452
|
+
kind: 'pointer',
|
|
453
|
+
gesture: gestureType,
|
|
454
|
+
gestureId: gesture.id,
|
|
455
|
+
occurredAt: gesture.startedAt,
|
|
456
|
+
endedAt,
|
|
457
|
+
button: gesture.button,
|
|
458
|
+
durationMs,
|
|
459
|
+
displacementPx: round3(displacementPx),
|
|
460
|
+
maxDisplacementPx: round3(gesture.maxDisplacementPx),
|
|
461
|
+
start: stripElapsed(gesture.start),
|
|
462
|
+
end: stripElapsed(end),
|
|
463
|
+
path: gesture.path,
|
|
464
|
+
screenshots: screenshotResults,
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
if (!interrupted) scheduleGestureCheckpoints(gesture.id)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function requestScreenshot(relativePath, phase, prefetchedScreenshot = null) {
|
|
471
|
+
let requested
|
|
472
|
+
if (prefetchedScreenshot) {
|
|
473
|
+
requested = Promise.resolve(prefetchedScreenshot)
|
|
474
|
+
} else {
|
|
475
|
+
try {
|
|
476
|
+
requested = Promise.resolve(captureScreenshot())
|
|
477
|
+
} catch (error) {
|
|
478
|
+
requested = Promise.reject(error)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return requested.then(async (capture) => {
|
|
482
|
+
if (
|
|
483
|
+
!capture ||
|
|
484
|
+
capture.mimeType !== 'image/png' ||
|
|
485
|
+
typeof capture.dataBase64 !== 'string' ||
|
|
486
|
+
capture.dataBase64.length === 0
|
|
487
|
+
) {
|
|
488
|
+
throw new Error('Recorder screenshot must be a PNG base64 payload')
|
|
489
|
+
}
|
|
490
|
+
const data = Buffer.from(capture.dataBase64, 'base64')
|
|
491
|
+
if (data.length > maxScreenshotBytes) {
|
|
492
|
+
throw new Error(`Recorder screenshot exceeds ${maxScreenshotBytes} bytes`)
|
|
493
|
+
}
|
|
494
|
+
const absolutePath = path.join(sessionDir, ...relativePath.split('/'))
|
|
495
|
+
const written = await writeSessionFile(absolutePath, data)
|
|
496
|
+
if (!written) throw new Error('Recorder session byte limit reached')
|
|
497
|
+
return {
|
|
498
|
+
phase,
|
|
499
|
+
file: relativePath,
|
|
500
|
+
capturedAt: capture.capturedAt || timestamp(now()),
|
|
501
|
+
}
|
|
502
|
+
}).catch((error) => {
|
|
503
|
+
logger.error(`[poi-plugin-mcp] Recorder screenshot failed: ${error.message}`)
|
|
504
|
+
return {
|
|
505
|
+
phase,
|
|
506
|
+
file: null,
|
|
507
|
+
error: error.message,
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function handleGameResponse(event) {
|
|
513
|
+
observeGameResponse({
|
|
514
|
+
detail: event && event.detail,
|
|
515
|
+
observedAt: timestamp(now()),
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function observeGameResponse(observation) {
|
|
520
|
+
if (!running || !acceptingEvents || !withinDurationBudget()) return
|
|
521
|
+
const detail = observation && observation.detail
|
|
522
|
+
if (!detail || typeof detail.path !== 'string') return
|
|
523
|
+
|
|
524
|
+
responseSequence += 1
|
|
525
|
+
const responseId = `response-${String(responseSequence).padStart(6, '0')}`
|
|
526
|
+
const apiPath = safeApiPath(detail.path)
|
|
527
|
+
const leaf = safeFilename(path.posix.basename(apiPath) || 'response')
|
|
528
|
+
const relativePath = `responses/${responseId}-${leaf}.json`
|
|
529
|
+
const capturedAt = normalizedObservedAt(observation.observedAt, now)
|
|
530
|
+
const sanitized = sanitizeDump(detail)
|
|
531
|
+
const responseDocument = {
|
|
532
|
+
schemaVersion: 1,
|
|
533
|
+
responseId,
|
|
534
|
+
capturedAt,
|
|
535
|
+
...sanitized,
|
|
536
|
+
path: apiPath,
|
|
537
|
+
reportedTimeMs: Number.isFinite(detail.time) ? detail.time : null,
|
|
538
|
+
localState: snapshotInteractionState(getStore()),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
enqueue(async () => {
|
|
542
|
+
await writeSessionJson(
|
|
543
|
+
path.join(sessionDir, ...relativePath.split('/')),
|
|
544
|
+
responseDocument,
|
|
545
|
+
)
|
|
546
|
+
await appendTimeline({
|
|
547
|
+
kind: 'game.response',
|
|
548
|
+
responseId,
|
|
549
|
+
occurredAt: capturedAt,
|
|
550
|
+
path: apiPath,
|
|
551
|
+
reportedTimeMs: responseDocument.reportedTimeMs,
|
|
552
|
+
responseFile: relativePath,
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function appendTimeline(event) {
|
|
558
|
+
if (!acceptingEvents) return false
|
|
559
|
+
if (timelineSequence >= maxTimelineEvents) {
|
|
560
|
+
reachLimit('timeline-event-limit')
|
|
561
|
+
return false
|
|
562
|
+
}
|
|
563
|
+
timelineSequence += 1
|
|
564
|
+
const line = JSON.stringify({
|
|
565
|
+
sequence: timelineSequence,
|
|
566
|
+
...event,
|
|
567
|
+
})
|
|
568
|
+
if (Buffer.byteLength(line) > maxJsonBytes) {
|
|
569
|
+
timelineSequence -= 1
|
|
570
|
+
reachLimit('timeline-line-limit')
|
|
571
|
+
return false
|
|
572
|
+
}
|
|
573
|
+
const written = await writeSessionFile(
|
|
574
|
+
path.join(sessionDir, 'events.jsonl'),
|
|
575
|
+
`${line}\n`,
|
|
576
|
+
{ append: true },
|
|
577
|
+
)
|
|
578
|
+
if (!written) timelineSequence -= 1
|
|
579
|
+
return written
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function writeStateSnapshot(filename, capturedAt) {
|
|
583
|
+
const store = getStore()
|
|
584
|
+
const snapshot = {
|
|
585
|
+
schemaVersion: 1,
|
|
586
|
+
capturedAt,
|
|
587
|
+
...sanitizeDump(selectStoreState(store)),
|
|
588
|
+
}
|
|
589
|
+
await writeSessionJson(
|
|
590
|
+
path.join(sessionDir, 'states', filename),
|
|
591
|
+
snapshot,
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function scheduleGestureCheckpoints(gestureId) {
|
|
596
|
+
for (const delayMs of checkpointDelaysMs) {
|
|
597
|
+
let resolveCompletion
|
|
598
|
+
const completion = new Promise((resolve) => {
|
|
599
|
+
resolveCompletion = resolve
|
|
600
|
+
})
|
|
601
|
+
let timerHandle = null
|
|
602
|
+
const callback = () => {
|
|
603
|
+
checkpointTimers.delete(timerHandle)
|
|
604
|
+
enqueue(() => writeGestureCheckpoint(gestureId, delayMs))
|
|
605
|
+
.finally(resolveCompletion)
|
|
606
|
+
}
|
|
607
|
+
timerHandle = setTimeoutFn(callback, delayMs)
|
|
608
|
+
checkpointTimers.set(timerHandle, { completion, resolveCompletion })
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function writeGestureCheckpoint(gestureId, delayMs) {
|
|
613
|
+
const delayLabel = String(delayMs).padStart(4, '0')
|
|
614
|
+
const basename = `${gestureId.replace('gesture-', 'gesture-')}-${delayLabel}ms.json`
|
|
615
|
+
const capturedAt = timestamp(now())
|
|
616
|
+
const stateRelativePath = `checkpoints/${basename}`
|
|
617
|
+
const storageRelativePath = `storage/${basename}`
|
|
618
|
+
const equipmentUi = await readEquipmentUiState()
|
|
619
|
+
await writeSessionJson(
|
|
620
|
+
path.join(sessionDir, ...stateRelativePath.split('/')),
|
|
621
|
+
{
|
|
622
|
+
schemaVersion: 1,
|
|
623
|
+
gestureId,
|
|
624
|
+
delayMs,
|
|
625
|
+
capturedAt,
|
|
626
|
+
...snapshotInteractionState(getStore()),
|
|
627
|
+
equipmentUi,
|
|
628
|
+
},
|
|
629
|
+
)
|
|
630
|
+
await writeStorageSnapshot(storageRelativePath, {
|
|
631
|
+
gestureId,
|
|
632
|
+
delayMs,
|
|
633
|
+
capturedAt,
|
|
634
|
+
})
|
|
635
|
+
await appendTimeline({
|
|
636
|
+
kind: 'checkpoint',
|
|
637
|
+
gestureId,
|
|
638
|
+
delayMs,
|
|
639
|
+
occurredAt: capturedAt,
|
|
640
|
+
stateFile: stateRelativePath,
|
|
641
|
+
storageFile: storageRelativePath,
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function readEquipmentUiState() {
|
|
646
|
+
const webContents = attachedWebContents || resolveCurrentWebContents()
|
|
647
|
+
try {
|
|
648
|
+
if (!webContents) throw new Error('Poi game WebView is not ready')
|
|
649
|
+
return {
|
|
650
|
+
available: true,
|
|
651
|
+
error: null,
|
|
652
|
+
frames: sanitizeDump(await captureEquipmentUiState(webContents)),
|
|
653
|
+
}
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return {
|
|
656
|
+
available: false,
|
|
657
|
+
error: error.message,
|
|
658
|
+
frames: [],
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function writeStorageSnapshot(relativePath, metadata = {}) {
|
|
664
|
+
let frames = []
|
|
665
|
+
let available = false
|
|
666
|
+
let error = null
|
|
667
|
+
const webContents = attachedWebContents || resolveCurrentWebContents()
|
|
668
|
+
try {
|
|
669
|
+
if (!webContents) throw new Error('Poi game WebView is not ready')
|
|
670
|
+
const rawFrames = await captureWebStorage(webContents)
|
|
671
|
+
frames = normalizeStorageFrames(rawFrames, storageHashKey)
|
|
672
|
+
available = true
|
|
673
|
+
} catch (captureError) {
|
|
674
|
+
error = captureError.message
|
|
675
|
+
}
|
|
676
|
+
const { entries, changes } = diffStorageFrames(frames, lastStorageEntries)
|
|
677
|
+
lastStorageEntries = entries
|
|
678
|
+
await writeSessionJson(
|
|
679
|
+
path.join(sessionDir, ...relativePath.split('/')),
|
|
680
|
+
{
|
|
681
|
+
schemaVersion: 1,
|
|
682
|
+
available,
|
|
683
|
+
error,
|
|
684
|
+
capturedAt: metadata.capturedAt || timestamp(now()),
|
|
685
|
+
...metadata,
|
|
686
|
+
frames,
|
|
687
|
+
changes,
|
|
688
|
+
},
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function cancelCheckpointTimers() {
|
|
693
|
+
for (const [timerHandle, item] of checkpointTimers) {
|
|
694
|
+
clearTimeoutFn(timerHandle)
|
|
695
|
+
item.resolveCompletion()
|
|
696
|
+
}
|
|
697
|
+
checkpointTimers.clear()
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function clearAttachTimer() {
|
|
701
|
+
if (attachTimer == null) return
|
|
702
|
+
clearIntervalFn(attachTimer)
|
|
703
|
+
attachTimer = null
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function clearSessionDeadlineTimer() {
|
|
707
|
+
if (sessionDeadlineTimer == null) return
|
|
708
|
+
clearTimeoutFn(sessionDeadlineTimer)
|
|
709
|
+
sessionDeadlineTimer = null
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function start() {
|
|
713
|
+
if (running) return getStatus()
|
|
714
|
+
try {
|
|
715
|
+
const recordingRoot = await inspectRecordingRoot(outputRoot)
|
|
716
|
+
if (recordingRoot.sessionCount >= maxStoredSessions) {
|
|
717
|
+
const overflow = recordingRoot.sessionCount - maxStoredSessions + 1
|
|
718
|
+
await pruneOldestSessions(recordingRoot.sessionDirectories, overflow, logger)
|
|
719
|
+
}
|
|
720
|
+
const afterPrune = await inspectRecordingRoot(outputRoot)
|
|
721
|
+
if (afterPrune.byteCount >= maxTotalRecordingBytes) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
`Recorder aggregate recording byte limit reached (${maxTotalRecordingBytes}); archive or remove old recordings before starting`,
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
activeSessionByteLimit = Math.min(
|
|
727
|
+
maxSessionBytes,
|
|
728
|
+
maxTotalRecordingBytes - afterPrune.byteCount,
|
|
729
|
+
)
|
|
730
|
+
finalManifestReserveBytes = Math.min(
|
|
731
|
+
DEFAULT_FINAL_MANIFEST_RESERVE_BYTES,
|
|
732
|
+
Math.floor(activeSessionByteLimit / 4),
|
|
733
|
+
)
|
|
734
|
+
const startedAt = timestamp(now())
|
|
735
|
+
sessionId = boundedSessionId(
|
|
736
|
+
typeof options.sessionId === 'string'
|
|
737
|
+
? options.sessionId
|
|
738
|
+
: crypto.randomUUID(),
|
|
739
|
+
)
|
|
740
|
+
const directoryName = `${compactTimestamp(startedAt)}-${sessionId}`
|
|
741
|
+
sessionDir = path.join(outputRoot, directoryName)
|
|
742
|
+
manifest = {
|
|
743
|
+
schemaVersion: 1,
|
|
744
|
+
sessionId,
|
|
745
|
+
startedAt,
|
|
746
|
+
endedAt: null,
|
|
747
|
+
holdThresholdMs,
|
|
748
|
+
dragThresholdPx,
|
|
749
|
+
canonicalSize: {
|
|
750
|
+
width: CANONICAL_WIDTH,
|
|
751
|
+
height: CANONICAL_HEIGHT,
|
|
752
|
+
},
|
|
753
|
+
privacy: {
|
|
754
|
+
sensitiveKeysRedacted: true,
|
|
755
|
+
localStorageCaptured: true,
|
|
756
|
+
sessionStorageCaptured: true,
|
|
757
|
+
cookiesCaptured: false,
|
|
758
|
+
cacheStorageCaptured: false,
|
|
759
|
+
indexedDbCaptured: false,
|
|
760
|
+
genericStorageValuesPlaintext: false,
|
|
761
|
+
genericStorageHashesSessionScoped: true,
|
|
762
|
+
},
|
|
763
|
+
budgets: {
|
|
764
|
+
maxSessionBytes,
|
|
765
|
+
maxTimelineEvents,
|
|
766
|
+
maxSessionDurationMs,
|
|
767
|
+
maxScreenshotBytes,
|
|
768
|
+
maxStoredSessions,
|
|
769
|
+
maxTotalRecordingBytes,
|
|
770
|
+
activeSessionByteLimit,
|
|
771
|
+
finalManifestReserveBytes,
|
|
772
|
+
},
|
|
773
|
+
}
|
|
774
|
+
gestureSequence = 0
|
|
775
|
+
responseSequence = 0
|
|
776
|
+
timelineSequence = 0
|
|
777
|
+
activeGesture = null
|
|
778
|
+
pending = Promise.resolve()
|
|
779
|
+
lastStorageEntries = new Map()
|
|
780
|
+
acceptingEvents = true
|
|
781
|
+
limitReached = null
|
|
782
|
+
sessionBytes = 0
|
|
783
|
+
sessionFileSizes = new Map()
|
|
784
|
+
fileWritePending = Promise.resolve()
|
|
785
|
+
sessionStartedAtMs = nowMs()
|
|
786
|
+
storageHashKey = crypto.randomBytes(32)
|
|
787
|
+
running = true
|
|
788
|
+
cancelCheckpointTimers()
|
|
789
|
+
|
|
790
|
+
await fs.promises.mkdir(path.join(sessionDir, 'frames'), { recursive: true })
|
|
791
|
+
await fs.promises.mkdir(path.join(sessionDir, 'responses'), { recursive: true })
|
|
792
|
+
await fs.promises.mkdir(path.join(sessionDir, 'states'), { recursive: true })
|
|
793
|
+
await fs.promises.mkdir(path.join(sessionDir, 'storage'), { recursive: true })
|
|
794
|
+
await fs.promises.mkdir(path.join(sessionDir, 'checkpoints'), { recursive: true })
|
|
795
|
+
await writeSessionJson(
|
|
796
|
+
path.join(sessionDir, 'manifest.json'),
|
|
797
|
+
manifest,
|
|
798
|
+
)
|
|
799
|
+
await writeStateSnapshot('session-start.json', startedAt)
|
|
800
|
+
|
|
801
|
+
await writeSessionJson(
|
|
802
|
+
path.join(sessionDir, 'checkpoints', 'session-start-ui.json'),
|
|
803
|
+
{
|
|
804
|
+
schemaVersion: 1,
|
|
805
|
+
capturedAt: startedAt,
|
|
806
|
+
equipmentUi: await readEquipmentUiState(),
|
|
807
|
+
},
|
|
808
|
+
)
|
|
809
|
+
await writeStorageSnapshot('storage/session-start.json', {
|
|
810
|
+
capturedAt: startedAt,
|
|
811
|
+
phase: 'session-start',
|
|
812
|
+
})
|
|
813
|
+
if (!acceptingEvents) {
|
|
814
|
+
await pending
|
|
815
|
+
return getStatus()
|
|
816
|
+
}
|
|
817
|
+
sessionDeadlineTimer = setTimeoutFn(() => {
|
|
818
|
+
sessionDeadlineTimer = null
|
|
819
|
+
reachLimit('session-duration-limit')
|
|
820
|
+
}, maxSessionDurationMs)
|
|
821
|
+
attachTimer = setIntervalFn(tryAttach, attachIntervalMs)
|
|
822
|
+
if (eventTarget && typeof eventTarget.addEventListener === 'function') {
|
|
823
|
+
eventTarget.addEventListener('game.response', handleGameResponse)
|
|
824
|
+
}
|
|
825
|
+
tryAttach()
|
|
826
|
+
logger.log(`[poi-plugin-mcp] Play recording started: ${sessionDir}`)
|
|
827
|
+
return getStatus()
|
|
828
|
+
} catch (error) {
|
|
829
|
+
running = false
|
|
830
|
+
acceptingEvents = false
|
|
831
|
+
clearAttachTimer()
|
|
832
|
+
clearSessionDeadlineTimer()
|
|
833
|
+
cancelCheckpointTimers()
|
|
834
|
+
activeGesture = null
|
|
835
|
+
if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
|
|
836
|
+
eventTarget.removeEventListener('game.response', handleGameResponse)
|
|
837
|
+
}
|
|
838
|
+
detachWebContents()
|
|
839
|
+
throw error
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function stop() {
|
|
844
|
+
if (!running) {
|
|
845
|
+
await pending
|
|
846
|
+
return getStatus()
|
|
847
|
+
}
|
|
848
|
+
running = false
|
|
849
|
+
clearAttachTimer()
|
|
850
|
+
clearSessionDeadlineTimer()
|
|
851
|
+
if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
|
|
852
|
+
eventTarget.removeEventListener('game.response', handleGameResponse)
|
|
853
|
+
}
|
|
854
|
+
cancelCheckpointTimers()
|
|
855
|
+
if (activeGesture) {
|
|
856
|
+
finishGesture({
|
|
857
|
+
x: activeGesture.last.webview.x,
|
|
858
|
+
y: activeGesture.last.webview.y,
|
|
859
|
+
button: activeGesture.button,
|
|
860
|
+
}, true)
|
|
861
|
+
}
|
|
862
|
+
await pending
|
|
863
|
+
|
|
864
|
+
const endedAt = timestamp(now())
|
|
865
|
+
if (!limitReached) {
|
|
866
|
+
await writeStorageSnapshot('storage/session-stop.json', {
|
|
867
|
+
capturedAt: endedAt,
|
|
868
|
+
phase: 'session-stop',
|
|
869
|
+
})
|
|
870
|
+
await writeStateSnapshot('session-stop.json', endedAt)
|
|
871
|
+
}
|
|
872
|
+
detachWebContents()
|
|
873
|
+
await finalizeSession()
|
|
874
|
+
return getStatus()
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function finalizeSession() {
|
|
878
|
+
if (!manifest || manifest.endedAt) return
|
|
879
|
+
running = false
|
|
880
|
+
acceptingEvents = false
|
|
881
|
+
clearAttachTimer()
|
|
882
|
+
clearSessionDeadlineTimer()
|
|
883
|
+
detachWebContents()
|
|
884
|
+
manifest = {
|
|
885
|
+
...manifest,
|
|
886
|
+
endedAt: timestamp(now()),
|
|
887
|
+
pointerGestureCount: gestureSequence,
|
|
888
|
+
gameResponseCount: responseSequence,
|
|
889
|
+
timelineEventCount: timelineSequence,
|
|
890
|
+
sessionBytes,
|
|
891
|
+
limitReached,
|
|
892
|
+
}
|
|
893
|
+
let manifestWritten = false
|
|
894
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
895
|
+
manifest.sessionBytes = sessionBytes
|
|
896
|
+
manifestWritten = await writeSessionJson(
|
|
897
|
+
path.join(sessionDir, 'manifest.json'),
|
|
898
|
+
manifest,
|
|
899
|
+
{ finalizing: true },
|
|
900
|
+
)
|
|
901
|
+
if (!manifestWritten || manifest.sessionBytes === sessionBytes) break
|
|
902
|
+
}
|
|
903
|
+
if (!manifestWritten) {
|
|
904
|
+
logger.error('[poi-plugin-mcp] Recorder could not write its final manifest within the session byte limit')
|
|
905
|
+
}
|
|
906
|
+
logger.log(`[poi-plugin-mcp] Play recording stopped: ${sessionDir}`)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function flush() {
|
|
910
|
+
const scheduled = [...checkpointTimers.values()]
|
|
911
|
+
.map((item) => item.completion)
|
|
912
|
+
if (scheduled.length > 0) await Promise.all(scheduled)
|
|
913
|
+
await pending
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function getStatus() {
|
|
917
|
+
return {
|
|
918
|
+
running: running && acceptingEvents,
|
|
919
|
+
acceptingEvents,
|
|
920
|
+
attached: running && acceptingEvents && attachedWebContents !== null,
|
|
921
|
+
sessionId,
|
|
922
|
+
sessionDir,
|
|
923
|
+
sessionBytes,
|
|
924
|
+
limitReached,
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return Object.freeze({
|
|
929
|
+
flush,
|
|
930
|
+
getStatus,
|
|
931
|
+
observeGameResponse,
|
|
932
|
+
observeMouseEvent,
|
|
933
|
+
start,
|
|
934
|
+
stop,
|
|
935
|
+
})
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function normalizedObservedAt(value, now) {
|
|
939
|
+
if (typeof value === 'string' && Number.isFinite(Date.parse(value))) {
|
|
940
|
+
return new Date(value).toISOString()
|
|
941
|
+
}
|
|
942
|
+
return timestamp(now())
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const SESSION_DIRECTORY_PATTERN = /^\d{8}-\d{9}Z-/u
|
|
946
|
+
|
|
947
|
+
async function inspectRecordingRoot(outputRoot) {
|
|
948
|
+
let entries
|
|
949
|
+
try {
|
|
950
|
+
entries = await fs.promises.readdir(outputRoot, { withFileTypes: true })
|
|
951
|
+
} catch (error) {
|
|
952
|
+
if (error && error.code === 'ENOENT') {
|
|
953
|
+
return { sessionCount: 0, byteCount: 0, sessionDirectories: [] }
|
|
954
|
+
}
|
|
955
|
+
throw error
|
|
956
|
+
}
|
|
957
|
+
let byteCount = 0
|
|
958
|
+
const directories = entries
|
|
959
|
+
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
960
|
+
.map((entry) => path.join(outputRoot, entry.name))
|
|
961
|
+
.sort()
|
|
962
|
+
const files = entries
|
|
963
|
+
.filter((entry) => entry.isFile() && !entry.isSymbolicLink())
|
|
964
|
+
.map((entry) => path.join(outputRoot, entry.name))
|
|
965
|
+
for (const filePath of files) {
|
|
966
|
+
try {
|
|
967
|
+
byteCount += (await fs.promises.stat(filePath)).size
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (!error || error.code !== 'ENOENT') throw error
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const stack = [...directories]
|
|
973
|
+
while (stack.length > 0) {
|
|
974
|
+
const directory = stack.pop()
|
|
975
|
+
let children
|
|
976
|
+
try {
|
|
977
|
+
children = await fs.promises.readdir(directory, { withFileTypes: true })
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error && error.code === 'ENOENT') continue
|
|
980
|
+
throw error
|
|
981
|
+
}
|
|
982
|
+
for (const child of children) {
|
|
983
|
+
if (child.isSymbolicLink()) continue
|
|
984
|
+
const childPath = path.join(directory, child.name)
|
|
985
|
+
if (child.isDirectory()) {
|
|
986
|
+
stack.push(childPath)
|
|
987
|
+
} else if (child.isFile()) {
|
|
988
|
+
try {
|
|
989
|
+
byteCount += (await fs.promises.stat(childPath)).size
|
|
990
|
+
} catch (error) {
|
|
991
|
+
if (!error || error.code !== 'ENOENT') throw error
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const sessionDirectories = directories.filter((directory) =>
|
|
997
|
+
SESSION_DIRECTORY_PATTERN.test(path.basename(directory)),
|
|
998
|
+
)
|
|
999
|
+
return {
|
|
1000
|
+
sessionCount: sessionDirectories.length,
|
|
1001
|
+
byteCount,
|
|
1002
|
+
sessionDirectories,
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function pruneOldestSessions(sessionDirectories, count, logger) {
|
|
1007
|
+
if (count <= 0) return
|
|
1008
|
+
for (const directory of sessionDirectories.slice(0, count)) {
|
|
1009
|
+
await fs.promises.rm(directory, { recursive: true, force: true })
|
|
1010
|
+
logger.log(
|
|
1011
|
+
`[poi-plugin-mcp] Pruned oldest recording session: ${path.basename(directory)}`,
|
|
1012
|
+
)
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function pointerPoint(mouse, layout, elapsedMs) {
|
|
1017
|
+
const webview = {
|
|
1018
|
+
x: round3(mouse.x),
|
|
1019
|
+
y: round3(mouse.y),
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
elapsedMs: Math.max(0, Math.round(elapsedMs)),
|
|
1023
|
+
webview,
|
|
1024
|
+
canonical: {
|
|
1025
|
+
x: round3((webview.x * CANONICAL_WIDTH) / layout.width),
|
|
1026
|
+
y: round3((webview.y * CANONICAL_HEIGHT) / layout.height),
|
|
1027
|
+
},
|
|
1028
|
+
normalized: {
|
|
1029
|
+
x: round6(webview.x / layout.width),
|
|
1030
|
+
y: round6(webview.y / layout.height),
|
|
1031
|
+
},
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function pathPoint(point) {
|
|
1036
|
+
return {
|
|
1037
|
+
elapsedMs: point.elapsedMs,
|
|
1038
|
+
webview: point.webview,
|
|
1039
|
+
canonical: point.canonical,
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function stripElapsed(point) {
|
|
1044
|
+
const { elapsedMs: _elapsedMs, ...rest } = point
|
|
1045
|
+
return rest
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function appendPathPoint(points, point, maximum) {
|
|
1049
|
+
const previous = points[points.length - 1]
|
|
1050
|
+
if (
|
|
1051
|
+
previous &&
|
|
1052
|
+
previous.webview.x === point.webview.x &&
|
|
1053
|
+
previous.webview.y === point.webview.y &&
|
|
1054
|
+
previous.elapsedMs === point.elapsedMs
|
|
1055
|
+
) {
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
if (points.length < maximum) {
|
|
1059
|
+
points.push(point)
|
|
1060
|
+
} else {
|
|
1061
|
+
points[points.length - 1] = point
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function distance(a, b) {
|
|
1066
|
+
return Math.hypot(b.x - a.x, b.y - a.y)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function selectStoreState(store) {
|
|
1070
|
+
if (!store || typeof store !== 'object') {
|
|
1071
|
+
return {
|
|
1072
|
+
info: null,
|
|
1073
|
+
sortie: null,
|
|
1074
|
+
equipmentSortReference: {},
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
info: store.info == null ? null : store.info,
|
|
1079
|
+
sortie: store.sortie == null ? null : store.sortie,
|
|
1080
|
+
equipmentSortReference: selectEquipmentSortReference(store),
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function selectEquipmentSortReference(store) {
|
|
1085
|
+
const masters = store && store.const && store.const.$equips
|
|
1086
|
+
if (!masters || typeof masters !== 'object') return {}
|
|
1087
|
+
const reference = {}
|
|
1088
|
+
for (const [key, item] of Object.entries(masters).slice(0, 5000)) {
|
|
1089
|
+
if (!item || typeof item !== 'object') continue
|
|
1090
|
+
const keyId = Number(key)
|
|
1091
|
+
const masterId = Number.isInteger(item.api_id)
|
|
1092
|
+
? item.api_id
|
|
1093
|
+
: Number.isInteger(keyId)
|
|
1094
|
+
? keyId
|
|
1095
|
+
: null
|
|
1096
|
+
if (masterId == null) continue
|
|
1097
|
+
reference[masterId] = {
|
|
1098
|
+
masterId,
|
|
1099
|
+
name: typeof item.api_name === 'string'
|
|
1100
|
+
? item.api_name.slice(0, 256)
|
|
1101
|
+
: '',
|
|
1102
|
+
type: Array.isArray(item.api_type)
|
|
1103
|
+
? item.api_type.slice(0, 16).filter(Number.isFinite)
|
|
1104
|
+
: [],
|
|
1105
|
+
sortNo: Number.isFinite(item.api_sortno) ? item.api_sortno : null,
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return reference
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function snapshotInteractionState(store) {
|
|
1112
|
+
const info = store && typeof store === 'object' && store.info
|
|
1113
|
+
? store.info
|
|
1114
|
+
: {}
|
|
1115
|
+
const equipment = info.equips && typeof info.equips === 'object'
|
|
1116
|
+
? info.equips
|
|
1117
|
+
: {}
|
|
1118
|
+
const ships = info.ships && typeof info.ships === 'object'
|
|
1119
|
+
? info.ships
|
|
1120
|
+
: {}
|
|
1121
|
+
const fleets = Array.isArray(info.fleets) ? info.fleets : []
|
|
1122
|
+
const shipSlotOrder = {}
|
|
1123
|
+
for (const [shipId, ship] of Object.entries(ships)) {
|
|
1124
|
+
if (ship && Array.isArray(ship.api_slot)) {
|
|
1125
|
+
shipSlotOrder[shipId] = ship.api_slot.slice()
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
equipmentMembershipIds: Object.keys(equipment),
|
|
1130
|
+
shipSlotOrder,
|
|
1131
|
+
fleetShipOrder: fleets.map((fleet) => (
|
|
1132
|
+
fleet && Array.isArray(fleet.api_ship) ? fleet.api_ship.slice() : []
|
|
1133
|
+
)),
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function normalizeStorageFrames(rawFrames, hashKey) {
|
|
1138
|
+
if (!Array.isArray(rawFrames)) return []
|
|
1139
|
+
return rawFrames.slice(0, 64).map((frame, frameIndex) => {
|
|
1140
|
+
const source = frame && typeof frame === 'object' ? frame : {}
|
|
1141
|
+
return {
|
|
1142
|
+
frameIndex,
|
|
1143
|
+
origin: boundedStorageText(source.origin, 2048),
|
|
1144
|
+
pathname: boundedStorageText(source.pathname, 4096),
|
|
1145
|
+
...(source.error ? { error: boundedStorageText(source.error, 4096) } : {}),
|
|
1146
|
+
localStorage: normalizeStorageEntries(source.localStorage, hashKey),
|
|
1147
|
+
sessionStorage: normalizeStorageEntries(source.sessionStorage, hashKey),
|
|
1148
|
+
}
|
|
1149
|
+
})
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function normalizeStorageEntries(rawEntries, hashKey) {
|
|
1153
|
+
if (!Array.isArray(rawEntries)) return []
|
|
1154
|
+
return rawEntries.slice(0, 10000).flatMap((item) => {
|
|
1155
|
+
const key = Array.isArray(item) ? item[0] : item && item.key
|
|
1156
|
+
const value = Array.isArray(item) ? item[1] : item && item.value
|
|
1157
|
+
if (typeof key !== 'string') return []
|
|
1158
|
+
const rawValue = value == null ? '' : String(value)
|
|
1159
|
+
if (isSensitiveKey(key)) {
|
|
1160
|
+
return [{
|
|
1161
|
+
key: '[REDACTED]',
|
|
1162
|
+
sensitive: true,
|
|
1163
|
+
}]
|
|
1164
|
+
}
|
|
1165
|
+
const boundedKey = key.slice(0, 4096)
|
|
1166
|
+
const sensitiveValue = storageValueContainsSensitiveData(rawValue)
|
|
1167
|
+
if (EQUIPMENT_UI_STORAGE_KEYS.has(key)) {
|
|
1168
|
+
if (sensitiveValue) {
|
|
1169
|
+
return [{
|
|
1170
|
+
key: '[REDACTED]',
|
|
1171
|
+
sensitive: true,
|
|
1172
|
+
}]
|
|
1173
|
+
}
|
|
1174
|
+
return [{
|
|
1175
|
+
key: boundedKey,
|
|
1176
|
+
hash: hashText(rawValue, hashKey),
|
|
1177
|
+
value: boundedStorageText(rawValue, 1024),
|
|
1178
|
+
}]
|
|
1179
|
+
}
|
|
1180
|
+
const selected = selectEquipmentUiStorageFields(rawValue)
|
|
1181
|
+
if (selected && Object.keys(selected).length > 0) {
|
|
1182
|
+
const selectedText = JSON.stringify(selected)
|
|
1183
|
+
return [{
|
|
1184
|
+
key: boundedKey,
|
|
1185
|
+
hash: hashText(selectedText, hashKey),
|
|
1186
|
+
selected,
|
|
1187
|
+
}]
|
|
1188
|
+
}
|
|
1189
|
+
if (sensitiveValue) {
|
|
1190
|
+
return [{
|
|
1191
|
+
key: '[REDACTED]',
|
|
1192
|
+
sensitive: true,
|
|
1193
|
+
}]
|
|
1194
|
+
}
|
|
1195
|
+
return [{
|
|
1196
|
+
key: boundedKey,
|
|
1197
|
+
hash: hashText(rawValue, hashKey),
|
|
1198
|
+
}]
|
|
1199
|
+
})
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function storageValueContainsSensitiveData(rawValue) {
|
|
1203
|
+
if (
|
|
1204
|
+
SENSITIVE_VALUE.test(rawValue) ||
|
|
1205
|
+
looksLikeSensitiveHeaderLine(rawValue) ||
|
|
1206
|
+
/(?:^|[?&;\s])(?:api[_-]?key|auth|authorization|cookie|credential|key|login(?:data)?|password|secret|session|sid|ticket|token)=[^&;\s]+/iu.test(rawValue) ||
|
|
1207
|
+
looksLikeOpaqueCredential(rawValue)
|
|
1208
|
+
) {
|
|
1209
|
+
return true
|
|
1210
|
+
}
|
|
1211
|
+
let parsed
|
|
1212
|
+
try {
|
|
1213
|
+
parsed = JSON.parse(rawValue)
|
|
1214
|
+
} catch (_) {
|
|
1215
|
+
return false
|
|
1216
|
+
}
|
|
1217
|
+
const queue = [{ value: parsed, depth: 0 }]
|
|
1218
|
+
let visited = 0
|
|
1219
|
+
while (queue.length > 0 && visited < 10000) {
|
|
1220
|
+
const { value, depth } = queue.shift()
|
|
1221
|
+
visited += 1
|
|
1222
|
+
if (typeof value === 'string') {
|
|
1223
|
+
if (
|
|
1224
|
+
SENSITIVE_VALUE.test(value) ||
|
|
1225
|
+
looksLikeSensitiveHeaderLine(value) ||
|
|
1226
|
+
looksLikeOpaqueCredential(value)
|
|
1227
|
+
) {
|
|
1228
|
+
return true
|
|
1229
|
+
}
|
|
1230
|
+
continue
|
|
1231
|
+
}
|
|
1232
|
+
if (!value || typeof value !== 'object' || depth >= 8) continue
|
|
1233
|
+
for (const [key, item] of Object.entries(value).slice(0, 1000)) {
|
|
1234
|
+
if (isSensitiveKey(key)) return true
|
|
1235
|
+
queue.push({ value: item, depth: depth + 1 })
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return false
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function diffStorageFrames(frames, previousEntries) {
|
|
1242
|
+
const entries = new Map()
|
|
1243
|
+
const changes = []
|
|
1244
|
+
for (const frame of frames) {
|
|
1245
|
+
for (const storageName of ['localStorage', 'sessionStorage']) {
|
|
1246
|
+
for (const entry of frame[storageName]) {
|
|
1247
|
+
if (entry.sensitive) continue
|
|
1248
|
+
const identity = [
|
|
1249
|
+
frame.frameIndex,
|
|
1250
|
+
frame.origin,
|
|
1251
|
+
frame.pathname,
|
|
1252
|
+
storageName,
|
|
1253
|
+
entry.key,
|
|
1254
|
+
].join('\u0000')
|
|
1255
|
+
const next = {
|
|
1256
|
+
origin: frame.origin,
|
|
1257
|
+
pathname: frame.pathname,
|
|
1258
|
+
frameIndex: frame.frameIndex,
|
|
1259
|
+
storage: storageName,
|
|
1260
|
+
...entry,
|
|
1261
|
+
}
|
|
1262
|
+
entries.set(identity, next)
|
|
1263
|
+
const previous = previousEntries.get(identity)
|
|
1264
|
+
if (!previous) {
|
|
1265
|
+
changes.push({
|
|
1266
|
+
type: 'added',
|
|
1267
|
+
...next,
|
|
1268
|
+
})
|
|
1269
|
+
} else if (previous.hash !== next.hash) {
|
|
1270
|
+
changes.push({
|
|
1271
|
+
type: 'updated',
|
|
1272
|
+
origin: next.origin,
|
|
1273
|
+
pathname: next.pathname,
|
|
1274
|
+
frameIndex: next.frameIndex,
|
|
1275
|
+
storage: next.storage,
|
|
1276
|
+
key: next.key,
|
|
1277
|
+
oldHash: previous.hash,
|
|
1278
|
+
newHash: next.hash,
|
|
1279
|
+
oldValue: previous.value,
|
|
1280
|
+
newValue: next.value,
|
|
1281
|
+
oldSelected: previous.selected,
|
|
1282
|
+
newSelected: next.selected,
|
|
1283
|
+
})
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
for (const [identity, previous] of previousEntries) {
|
|
1289
|
+
if (!entries.has(identity)) {
|
|
1290
|
+
changes.push({
|
|
1291
|
+
type: 'removed',
|
|
1292
|
+
...previous,
|
|
1293
|
+
})
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return { entries, changes }
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function selectEquipmentUiStorageFields(rawValue) {
|
|
1300
|
+
if (
|
|
1301
|
+
typeof rawValue !== 'string' ||
|
|
1302
|
+
rawValue.length === 0 ||
|
|
1303
|
+
rawValue.length > 1024 * 1024
|
|
1304
|
+
) {
|
|
1305
|
+
return null
|
|
1306
|
+
}
|
|
1307
|
+
let parsed
|
|
1308
|
+
try {
|
|
1309
|
+
parsed = JSON.parse(rawValue)
|
|
1310
|
+
} catch (_) {
|
|
1311
|
+
return null
|
|
1312
|
+
}
|
|
1313
|
+
const output = {}
|
|
1314
|
+
const queue = [{ value: parsed, depth: 0 }]
|
|
1315
|
+
let visited = 0
|
|
1316
|
+
while (queue.length > 0 && visited < 1000) {
|
|
1317
|
+
const { value, depth } = queue.shift()
|
|
1318
|
+
visited += 1
|
|
1319
|
+
if (!value || typeof value !== 'object' || depth > 6) continue
|
|
1320
|
+
for (const [key, item] of Object.entries(value).slice(0, 256)) {
|
|
1321
|
+
if (
|
|
1322
|
+
EQUIPMENT_UI_STORAGE_KEYS.has(key) &&
|
|
1323
|
+
(
|
|
1324
|
+
typeof item === 'string' ||
|
|
1325
|
+
typeof item === 'boolean' ||
|
|
1326
|
+
(typeof item === 'number' && Number.isFinite(item))
|
|
1327
|
+
)
|
|
1328
|
+
) {
|
|
1329
|
+
output[key] = item
|
|
1330
|
+
} else if (item && typeof item === 'object') {
|
|
1331
|
+
queue.push({ value: item, depth: depth + 1 })
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return output
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function hashText(value, hashKey) {
|
|
1339
|
+
if (!Buffer.isBuffer(hashKey) || hashKey.length < 16) {
|
|
1340
|
+
throw new Error('Recorder storage hash key is unavailable')
|
|
1341
|
+
}
|
|
1342
|
+
return crypto.createHmac('sha256', hashKey).update(value, 'utf8').digest('hex')
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function boundedStorageText(value, maximum) {
|
|
1346
|
+
if (typeof value !== 'string') return ''
|
|
1347
|
+
return value.length <= maximum
|
|
1348
|
+
? value
|
|
1349
|
+
: `${value.slice(0, maximum)}[TRUNCATED]`
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function sanitizeDump(value) {
|
|
1353
|
+
const state = {
|
|
1354
|
+
seen: new WeakSet(),
|
|
1355
|
+
nodes: 0,
|
|
1356
|
+
maxNodes: 200000,
|
|
1357
|
+
maxDepth: 12,
|
|
1358
|
+
maxArrayItems: 10000,
|
|
1359
|
+
maxObjectKeys: 10000,
|
|
1360
|
+
maxStringLength: 65536,
|
|
1361
|
+
}
|
|
1362
|
+
return sanitizeValue(value, state, 0)
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function sanitizeValue(value, state, depth) {
|
|
1366
|
+
state.nodes += 1
|
|
1367
|
+
if (state.nodes > state.maxNodes) return '[TRUNCATED: node limit]'
|
|
1368
|
+
if (value === null || typeof value === 'boolean') return value
|
|
1369
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : String(value)
|
|
1370
|
+
if (typeof value === 'string') {
|
|
1371
|
+
return sanitizeString(value, state.maxStringLength)
|
|
1372
|
+
}
|
|
1373
|
+
if (typeof value === 'bigint') return value.toString()
|
|
1374
|
+
if (typeof value !== 'object') return undefined
|
|
1375
|
+
if (depth >= state.maxDepth) return '[TRUNCATED: depth limit]'
|
|
1376
|
+
if (state.seen.has(value)) return '[CIRCULAR]'
|
|
1377
|
+
state.seen.add(value)
|
|
1378
|
+
|
|
1379
|
+
if (value instanceof Date) return timestamp(value)
|
|
1380
|
+
if (Buffer.isBuffer(value)) return `[BINARY: ${value.length} bytes]`
|
|
1381
|
+
if (Array.isArray(value)) {
|
|
1382
|
+
if (
|
|
1383
|
+
value.length === 2 &&
|
|
1384
|
+
typeof value[0] === 'string' &&
|
|
1385
|
+
isSensitiveKey(value[0])
|
|
1386
|
+
) {
|
|
1387
|
+
return [
|
|
1388
|
+
sanitizeString(value[0], state.maxStringLength),
|
|
1389
|
+
'[REDACTED]',
|
|
1390
|
+
]
|
|
1391
|
+
}
|
|
1392
|
+
const limited = value.slice(0, state.maxArrayItems)
|
|
1393
|
+
const result = []
|
|
1394
|
+
for (let index = 0; index < limited.length; index += 1) {
|
|
1395
|
+
const previous = limited[index - 1]
|
|
1396
|
+
const item = (
|
|
1397
|
+
index % 2 === 1 &&
|
|
1398
|
+
typeof previous === 'string' &&
|
|
1399
|
+
isSensitiveKey(previous)
|
|
1400
|
+
)
|
|
1401
|
+
? '[REDACTED]'
|
|
1402
|
+
: sanitizeValue(limited[index], state, depth + 1)
|
|
1403
|
+
if (item !== undefined) result.push(item)
|
|
1404
|
+
}
|
|
1405
|
+
if (value.length > state.maxArrayItems) {
|
|
1406
|
+
result.push(`[TRUNCATED: ${value.length - state.maxArrayItems} items]`)
|
|
1407
|
+
}
|
|
1408
|
+
return result
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const output = {}
|
|
1412
|
+
const entries = Object.entries(value)
|
|
1413
|
+
for (const [key, item] of entries.slice(0, state.maxObjectKeys)) {
|
|
1414
|
+
if (isSensitiveKey(key)) {
|
|
1415
|
+
output[key] = '[REDACTED]'
|
|
1416
|
+
continue
|
|
1417
|
+
}
|
|
1418
|
+
const sanitized = sanitizeValue(item, state, depth + 1)
|
|
1419
|
+
if (sanitized !== undefined) output[key] = sanitized
|
|
1420
|
+
}
|
|
1421
|
+
if (entries.length > state.maxObjectKeys) {
|
|
1422
|
+
output.__truncatedKeys = entries.length - state.maxObjectKeys
|
|
1423
|
+
}
|
|
1424
|
+
return output
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function sanitizeString(value, maximum) {
|
|
1428
|
+
if (looksLikeSensitiveHeaderLine(value)) return '[REDACTED]'
|
|
1429
|
+
const withoutQuery = stripUrlQuery(value)
|
|
1430
|
+
if (
|
|
1431
|
+
SENSITIVE_VALUE.test(withoutQuery) ||
|
|
1432
|
+
/(?:^|[?&;\s])(?:api[_-]?key|auth|authorization|cookie|credential|key|login(?:data)?|password|secret|session|sid|ticket|token)=[^&;\s]+/iu.test(withoutQuery) ||
|
|
1433
|
+
looksLikeOpaqueCredential(withoutQuery)
|
|
1434
|
+
) {
|
|
1435
|
+
return '[REDACTED]'
|
|
1436
|
+
}
|
|
1437
|
+
return withoutQuery.length <= maximum
|
|
1438
|
+
? withoutQuery
|
|
1439
|
+
: `${withoutQuery.slice(0, maximum)}[TRUNCATED]`
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function isSensitiveKey(value) {
|
|
1443
|
+
return (
|
|
1444
|
+
typeof value === 'string' &&
|
|
1445
|
+
!GAME_BUSINESS_SORT_KEYS.has(value) &&
|
|
1446
|
+
SENSITIVE_KEY.test(value)
|
|
1447
|
+
)
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function looksLikeSensitiveHeaderLine(value) {
|
|
1451
|
+
return /(?:^|[\r\n])\s*(?:authorization|proxy-authorization|cookie|set-cookie|credential|login(?:data)?|password|secret|session|sid|ticket|token|x-api-key)\s*:/iu.test(value)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function stripUrlQuery(value) {
|
|
1455
|
+
if (
|
|
1456
|
+
!/^(?:https?:\/\/|\/)/iu.test(value) ||
|
|
1457
|
+
(!value.includes('?') && !value.includes('#'))
|
|
1458
|
+
) {
|
|
1459
|
+
return value
|
|
1460
|
+
}
|
|
1461
|
+
const queryIndex = value.indexOf('?')
|
|
1462
|
+
const fragmentIndex = value.indexOf('#')
|
|
1463
|
+
const indexes = [queryIndex, fragmentIndex].filter((index) => index >= 0)
|
|
1464
|
+
return indexes.length === 0 ? value : value.slice(0, Math.min(...indexes))
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function looksLikeOpaqueCredential(value) {
|
|
1468
|
+
const trimmed = value.trim()
|
|
1469
|
+
if (
|
|
1470
|
+
trimmed.length < 32 ||
|
|
1471
|
+
trimmed.length > 4096 ||
|
|
1472
|
+
!/^[A-Za-z0-9+/_=-]+$/u.test(trimmed)
|
|
1473
|
+
) {
|
|
1474
|
+
return false
|
|
1475
|
+
}
|
|
1476
|
+
return /[A-Za-z]/u.test(trimmed) && /\d/u.test(trimmed)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function serializeBoundedJson(value, maxBytes) {
|
|
1480
|
+
const json = `${JSON.stringify(value, null, 2)}\n`
|
|
1481
|
+
const bytes = Buffer.byteLength(json)
|
|
1482
|
+
if (bytes > maxBytes) {
|
|
1483
|
+
const fallback = {
|
|
1484
|
+
schemaVersion: 1,
|
|
1485
|
+
truncated: true,
|
|
1486
|
+
originalBytes: bytes,
|
|
1487
|
+
maxBytes,
|
|
1488
|
+
path: value && value.path,
|
|
1489
|
+
capturedAt: value && value.capturedAt,
|
|
1490
|
+
}
|
|
1491
|
+
return `${JSON.stringify(fallback, null, 2)}\n`
|
|
1492
|
+
}
|
|
1493
|
+
return json
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function supportedButton(value) {
|
|
1497
|
+
return ['left', 'middle', 'right'].includes(value) ? value : 'left'
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function reportsHeldButton(mouse, button) {
|
|
1501
|
+
if (typeof mouse.button === 'string') return mouse.button === button
|
|
1502
|
+
if (!Array.isArray(mouse.modifiers)) return false
|
|
1503
|
+
const expected = `${button}buttondown`
|
|
1504
|
+
return mouse.modifiers.some((modifier) => (
|
|
1505
|
+
typeof modifier === 'string' && modifier.toLowerCase() === expected
|
|
1506
|
+
))
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function safeFilename(value) {
|
|
1510
|
+
const sanitized = String(value).replace(/[^A-Za-z0-9._-]+/gu, '-')
|
|
1511
|
+
return sanitized.slice(0, 80) || 'response'
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function safeApiPath(value) {
|
|
1515
|
+
const source = String(value)
|
|
1516
|
+
try {
|
|
1517
|
+
return new URL(source, 'http://127.0.0.1').pathname
|
|
1518
|
+
} catch (_) {
|
|
1519
|
+
return source.split(/[?#]/u, 1)[0]
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function compactTimestamp(value) {
|
|
1524
|
+
return value.replace(/[-:.]/gu, '').replace('T', '-').replace('Z', 'Z')
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function timestamp(value) {
|
|
1528
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
1529
|
+
if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
|
|
1530
|
+
return date.toISOString()
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function boundedSessionId(value) {
|
|
1534
|
+
if (
|
|
1535
|
+
typeof value !== 'string' ||
|
|
1536
|
+
value.length === 0 ||
|
|
1537
|
+
value.length > 128 ||
|
|
1538
|
+
!/^[A-Za-z0-9._-]+$/u.test(value)
|
|
1539
|
+
) {
|
|
1540
|
+
throw new Error('sessionId must contain only letters, numbers, dot, underscore, or dash')
|
|
1541
|
+
}
|
|
1542
|
+
return value
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function positiveInteger(value, fallback, name) {
|
|
1546
|
+
const selected = value == null ? fallback : value
|
|
1547
|
+
if (!Number.isInteger(selected) || selected <= 0) {
|
|
1548
|
+
throw new Error(`${name} must be a positive integer`)
|
|
1549
|
+
}
|
|
1550
|
+
return selected
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function positiveNumber(value, fallback, name) {
|
|
1554
|
+
const selected = value == null ? fallback : value
|
|
1555
|
+
if (!Number.isFinite(selected) || selected <= 0) {
|
|
1556
|
+
throw new Error(`${name} must be a positive finite number`)
|
|
1557
|
+
}
|
|
1558
|
+
return selected
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function normalizeCheckpointDelays(value) {
|
|
1562
|
+
const selected = value == null ? DEFAULT_CHECKPOINT_DELAYS_MS : value
|
|
1563
|
+
if (
|
|
1564
|
+
!Array.isArray(selected) ||
|
|
1565
|
+
selected.length === 0 ||
|
|
1566
|
+
selected.length > 10 ||
|
|
1567
|
+
selected.some((item) => (
|
|
1568
|
+
!Number.isInteger(item) ||
|
|
1569
|
+
item < 0 ||
|
|
1570
|
+
item > 60000
|
|
1571
|
+
))
|
|
1572
|
+
) {
|
|
1573
|
+
throw new Error('checkpointDelaysMs must contain 1 to 10 integers from 0 to 60000')
|
|
1574
|
+
}
|
|
1575
|
+
return [...new Set(selected)].sort((a, b) => a - b)
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function round3(value) {
|
|
1579
|
+
return Math.round(value * 1000) / 1000
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function round6(value) {
|
|
1583
|
+
return Math.round(value * 1000000) / 1000000
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function defaultGetStore(storePath) {
|
|
1587
|
+
if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
|
|
1588
|
+
return window.getStore(storePath)
|
|
1589
|
+
}
|
|
1590
|
+
return null
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function defaultEventTarget() {
|
|
1594
|
+
return typeof window !== 'undefined' ? window : null
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function defaultResolveWebContents(webContentsId) {
|
|
1598
|
+
const { webContents } = require('@electron/remote')
|
|
1599
|
+
return webContents.fromId(webContentsId)
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
async function defaultCaptureWebStorage(webContents) {
|
|
1603
|
+
const mainFrame = webContents && webContents.mainFrame
|
|
1604
|
+
const frames = mainFrame && Array.isArray(mainFrame.framesInSubtree)
|
|
1605
|
+
? mainFrame.framesInSubtree
|
|
1606
|
+
: mainFrame
|
|
1607
|
+
? [mainFrame]
|
|
1608
|
+
: []
|
|
1609
|
+
if (frames.length === 0) {
|
|
1610
|
+
throw new Error('Poi game WebView frames are not ready')
|
|
1611
|
+
}
|
|
1612
|
+
const script = `(() => {
|
|
1613
|
+
const readStorage = (storage) => {
|
|
1614
|
+
const entries = []
|
|
1615
|
+
const limit = Math.min(storage.length, 512)
|
|
1616
|
+
for (let index = 0; index < limit; index += 1) {
|
|
1617
|
+
const key = storage.key(index)
|
|
1618
|
+
if (typeof key === 'string') {
|
|
1619
|
+
const value = storage.getItem(key)
|
|
1620
|
+
entries.push([
|
|
1621
|
+
key.slice(0, 4096),
|
|
1622
|
+
typeof value === 'string' ? value.slice(0, 65536) : value,
|
|
1623
|
+
])
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return entries
|
|
1627
|
+
}
|
|
1628
|
+
try {
|
|
1629
|
+
return {
|
|
1630
|
+
origin: location.origin,
|
|
1631
|
+
pathname: location.pathname,
|
|
1632
|
+
localStorage: readStorage(window.localStorage),
|
|
1633
|
+
sessionStorage: readStorage(window.sessionStorage),
|
|
1634
|
+
}
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
return {
|
|
1637
|
+
origin: location.origin,
|
|
1638
|
+
pathname: location.pathname,
|
|
1639
|
+
localStorage: [],
|
|
1640
|
+
sessionStorage: [],
|
|
1641
|
+
error: error && error.message ? error.message : String(error),
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
})()`
|
|
1645
|
+
const captured = await Promise.all(frames.slice(0, 64).map(async (frame) => {
|
|
1646
|
+
try {
|
|
1647
|
+
return await frame.executeJavaScript(script)
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
return {
|
|
1650
|
+
origin: '',
|
|
1651
|
+
pathname: '',
|
|
1652
|
+
localStorage: [],
|
|
1653
|
+
sessionStorage: [],
|
|
1654
|
+
error: error.message,
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}))
|
|
1658
|
+
return captured.filter((frame) => isKanColleGameOrigin(frame && frame.origin))
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function isKanColleGameOrigin(value) {
|
|
1662
|
+
if (typeof value !== 'string' || value.length === 0) return false
|
|
1663
|
+
try {
|
|
1664
|
+
const url = new URL(value)
|
|
1665
|
+
return (
|
|
1666
|
+
url.protocol === 'https:' &&
|
|
1667
|
+
(
|
|
1668
|
+
url.hostname === 'kancolle-server.com' ||
|
|
1669
|
+
url.hostname.endsWith('.kancolle-server.com')
|
|
1670
|
+
)
|
|
1671
|
+
)
|
|
1672
|
+
} catch (_) {
|
|
1673
|
+
return false
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
async function defaultCaptureEquipmentUiState(_webContents) {
|
|
1678
|
+
throw new Error(
|
|
1679
|
+
'Equipment UI sort probe unavailable: a direct runtime target is not configured',
|
|
1680
|
+
)
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
module.exports = {
|
|
1684
|
+
CANONICAL_HEIGHT,
|
|
1685
|
+
CANONICAL_WIDTH,
|
|
1686
|
+
DEFAULT_MAX_SESSION_BYTES,
|
|
1687
|
+
DEFAULT_MAX_STORED_SESSIONS,
|
|
1688
|
+
DEFAULT_MAX_TOTAL_RECORDING_BYTES,
|
|
1689
|
+
DEFAULT_OUTPUT_ROOT,
|
|
1690
|
+
captureEquipmentUiStateFromWebContents: defaultCaptureEquipmentUiState,
|
|
1691
|
+
captureWebStorageFromWebContents: defaultCaptureWebStorage,
|
|
1692
|
+
createPoiInteractionRecorder,
|
|
1693
|
+
}
|