poi-plugin-mcp 0.2.16 → 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 -292
- 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,1181 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
|
|
3
|
+
const MAX_DEBUG_SCRIPT_LENGTH = 32 * 1024
|
|
4
|
+
const MAX_RESULT_BYTES = 2 * 1024 * 1024
|
|
5
|
+
const MAX_PATH_SEGMENTS = 64
|
|
6
|
+
const MAX_TEXTURE_RECTANGLE_VALUE = 1_000_000
|
|
7
|
+
const MAX_TEXTURE_URL_LENGTH = 2048
|
|
8
|
+
const SENSITIVE_KEY = /^(?:api_token|authorization|cookies?|credentials?|loginData|password|secret|ticket|accessToken|refreshToken)$/iu
|
|
9
|
+
const WEBVIEW_ROOTS = Object.freeze(['globalThis', 'pixi.last-rendered'])
|
|
10
|
+
const WEBVIEW_FIND_PROJECTIONS = Object.freeze(['pixi.interactive'])
|
|
11
|
+
|
|
12
|
+
function createPoiWebviewRuntime(options = {}) {
|
|
13
|
+
const getStore = options.getStore || defaultGetStore
|
|
14
|
+
const resolveWebContents = options.resolveWebContents || defaultResolveWebContents
|
|
15
|
+
const logger = options.logger || console
|
|
16
|
+
const now = options.now || (() => new Date())
|
|
17
|
+
|
|
18
|
+
function currentWebContents() {
|
|
19
|
+
const layout = getStore('layout.webview')
|
|
20
|
+
if (!layout || !layout.ref) {
|
|
21
|
+
throw new Error('Poi game WebView is not ready')
|
|
22
|
+
}
|
|
23
|
+
if (typeof layout.ref.getWebContents === 'function') {
|
|
24
|
+
const webContents = layout.ref.getWebContents()
|
|
25
|
+
if (webContents) return webContents
|
|
26
|
+
}
|
|
27
|
+
if (typeof layout.ref.getWebContentsId === 'function') {
|
|
28
|
+
const id = layout.ref.getWebContentsId()
|
|
29
|
+
if (Number.isInteger(id) && id > 0) {
|
|
30
|
+
const webContents = resolveWebContents(id)
|
|
31
|
+
if (webContents) return webContents
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Poi game WebView webContents is unavailable')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function frames() {
|
|
38
|
+
const webContents = currentWebContents()
|
|
39
|
+
const mainFrame = webContents.mainFrame
|
|
40
|
+
if (!mainFrame) {
|
|
41
|
+
return [{
|
|
42
|
+
id: 'main',
|
|
43
|
+
name: '',
|
|
44
|
+
url: typeof webContents.getURL === 'function' ? webContents.getURL() : '',
|
|
45
|
+
frame: null,
|
|
46
|
+
webContents,
|
|
47
|
+
}]
|
|
48
|
+
}
|
|
49
|
+
const candidates = Array.isArray(mainFrame.framesInSubtree)
|
|
50
|
+
? mainFrame.framesInSubtree
|
|
51
|
+
: [mainFrame]
|
|
52
|
+
const unique = candidates.includes(mainFrame)
|
|
53
|
+
? candidates
|
|
54
|
+
: [mainFrame, ...candidates]
|
|
55
|
+
return unique
|
|
56
|
+
.map((frame, index) => ({
|
|
57
|
+
id: frameId(frame, index),
|
|
58
|
+
name: typeof frame.name === 'string' ? frame.name : '',
|
|
59
|
+
url: typeof frame.url === 'string' ? frame.url : '',
|
|
60
|
+
frame,
|
|
61
|
+
webContents,
|
|
62
|
+
}))
|
|
63
|
+
.filter((candidate) => isKanColleGameUrl(candidate.url))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function selectFrame(requestedId) {
|
|
67
|
+
const available = frames()
|
|
68
|
+
if (available.length === 0) {
|
|
69
|
+
throw new Error('KanColle game frame is not ready')
|
|
70
|
+
}
|
|
71
|
+
if (requestedId == null || requestedId === '') {
|
|
72
|
+
throw new Error('frameId is required for WebView inspection')
|
|
73
|
+
}
|
|
74
|
+
const selected = available.find((candidate) => candidate.id === requestedId)
|
|
75
|
+
if (!selected) throw new Error(`Unknown WebView frame: ${requestedId}`)
|
|
76
|
+
return selected
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read-only inspection scripts are synchronous IIFEs; a hang can only come
|
|
80
|
+
// from executeJavaScript's IPC never settling (e.g. after frame navigation).
|
|
81
|
+
// Bound it so the bridge fails fast instead of holding the request forever.
|
|
82
|
+
const EXECUTE_INSPECTION_TIMEOUT_MS = 10000
|
|
83
|
+
|
|
84
|
+
async function execute(frameInfo, expression) {
|
|
85
|
+
const run = () => {
|
|
86
|
+
if (frameInfo.frame && typeof frameInfo.frame.executeJavaScript === 'function') {
|
|
87
|
+
return frameInfo.frame.executeJavaScript(expression, false)
|
|
88
|
+
}
|
|
89
|
+
if (typeof frameInfo.webContents.executeJavaScript === 'function') {
|
|
90
|
+
return frameInfo.webContents.executeJavaScript(expression, false)
|
|
91
|
+
}
|
|
92
|
+
return Promise.reject(new Error('Poi game WebView does not support JavaScript inspection'))
|
|
93
|
+
}
|
|
94
|
+
let timer
|
|
95
|
+
try {
|
|
96
|
+
return await Promise.race([
|
|
97
|
+
run(),
|
|
98
|
+
new Promise((_, reject) => {
|
|
99
|
+
timer = setTimeout(
|
|
100
|
+
() => reject(new Error('WebView inspection timed out')),
|
|
101
|
+
EXECUTE_INSPECTION_TIMEOUT_MS,
|
|
102
|
+
)
|
|
103
|
+
}),
|
|
104
|
+
])
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function listFrames() {
|
|
111
|
+
return frames().map(({ id, name, url }) => ({
|
|
112
|
+
id,
|
|
113
|
+
name,
|
|
114
|
+
url: publicFrameUrl(url),
|
|
115
|
+
}))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readStorage(request = {}) {
|
|
119
|
+
const storage = request.storage === 'session' ? 'sessionStorage' : 'localStorage'
|
|
120
|
+
const frameInfo = selectFrame(request.frameId)
|
|
121
|
+
const key = request.key == null ? null : boundedString(request.key, 256, 'key')
|
|
122
|
+
const expression = `(() => {
|
|
123
|
+
const storage = globalThis[${JSON.stringify(storage)}];
|
|
124
|
+
if (!storage) return { available: false, values: {} };
|
|
125
|
+
const requestedKey = ${JSON.stringify(key)};
|
|
126
|
+
if (requestedKey !== null) {
|
|
127
|
+
return {
|
|
128
|
+
available: true,
|
|
129
|
+
values: Object.prototype.hasOwnProperty.call(storage, requestedKey) ||
|
|
130
|
+
storage.getItem(requestedKey) !== null
|
|
131
|
+
? { [requestedKey]: storage.getItem(requestedKey) }
|
|
132
|
+
: {},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const values = {};
|
|
136
|
+
for (let index = 0; index < Math.min(storage.length, 4096); index += 1) {
|
|
137
|
+
const itemKey = storage.key(index);
|
|
138
|
+
if (typeof itemKey === 'string' && itemKey.length <= 256) {
|
|
139
|
+
values[itemKey] = storage.getItem(itemKey);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { available: true, values };
|
|
143
|
+
})()`
|
|
144
|
+
return boundedResult(await execute(frameInfo, expression))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readPath(request = {}) {
|
|
148
|
+
const path = validatePath(request.path)
|
|
149
|
+
const root = validateInspectionRoot(request.root)
|
|
150
|
+
const frameInfo = selectFrame(request.frameId)
|
|
151
|
+
const expression = `(() => {
|
|
152
|
+
const path = ${JSON.stringify(path)};
|
|
153
|
+
const root = ${JSON.stringify(root)};
|
|
154
|
+
let value;
|
|
155
|
+
if (root === 'globalThis') {
|
|
156
|
+
value = globalThis;
|
|
157
|
+
} else {
|
|
158
|
+
const ticker = globalThis.PIXI && globalThis.PIXI.ticker;
|
|
159
|
+
const head = ticker && ticker.shared && ticker.shared._head;
|
|
160
|
+
const interaction = head && head.next && head.next.context;
|
|
161
|
+
const renderer = interaction && interaction.renderer;
|
|
162
|
+
value = renderer && renderer._lastObjectRendered;
|
|
163
|
+
if (!value) return { available: false, reason: 'root_unavailable' };
|
|
164
|
+
}
|
|
165
|
+
for (const segment of path) {
|
|
166
|
+
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
|
|
167
|
+
return { available: false, reason: 'non_object_parent' };
|
|
168
|
+
}
|
|
169
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, segment);
|
|
170
|
+
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
171
|
+
return { available: false, reason: 'missing_or_accessor_property' };
|
|
172
|
+
}
|
|
173
|
+
value = descriptor.value;
|
|
174
|
+
}
|
|
175
|
+
const seen = new WeakSet();
|
|
176
|
+
const copy = (item, depth) => {
|
|
177
|
+
if (item === null || typeof item === 'boolean' || typeof item === 'number') return item;
|
|
178
|
+
if (typeof item === 'string') return item.slice(0, 65536);
|
|
179
|
+
if (typeof item === 'bigint') return String(item);
|
|
180
|
+
if (typeof item === 'function') return { type: 'function', name: item.name || '' };
|
|
181
|
+
if (typeof item !== 'object') return String(item);
|
|
182
|
+
if (seen.has(item)) return '[Circular]';
|
|
183
|
+
if (depth >= 8) return '[MaxDepth]';
|
|
184
|
+
seen.add(item);
|
|
185
|
+
if (Array.isArray(item)) return item.slice(0, 2048).map((entry) => copy(entry, depth + 1));
|
|
186
|
+
const output = {};
|
|
187
|
+
for (const key of Object.getOwnPropertyNames(item).slice(0, 512)) {
|
|
188
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
|
189
|
+
if (descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
190
|
+
output[key] = copy(descriptor.value, depth + 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return output;
|
|
194
|
+
};
|
|
195
|
+
return { available: true, value: copy(value, 0) };
|
|
196
|
+
})()`
|
|
197
|
+
return boundedResult(await execute(frameInfo, expression))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function findObjects(request = {}) {
|
|
201
|
+
const rootName = validateInspectionRoot(request.root)
|
|
202
|
+
const projection = validateFindProjection(request.projection)
|
|
203
|
+
if (projection !== 'pixi.interactive' && request.anyRequiredKeys != null) {
|
|
204
|
+
throw new Error('anyRequiredKeys is only supported by the pixi.interactive projection')
|
|
205
|
+
}
|
|
206
|
+
const frameInfo = selectFrame(request.frameId)
|
|
207
|
+
const rootPath = validatePath(request.rootPath == null ? [] : request.rootPath)
|
|
208
|
+
const anyRequiredKeys = request.anyRequiredKeys == null
|
|
209
|
+
? []
|
|
210
|
+
: validateKeyList(request.anyRequiredKeys, 1, 64, 'anyRequiredKeys')
|
|
211
|
+
const requiredKeys = projection === 'pixi.interactive' && request.requiredKeys == null
|
|
212
|
+
? []
|
|
213
|
+
: validateKeyList(
|
|
214
|
+
request.requiredKeys,
|
|
215
|
+
projection === 'pixi.interactive' ? 0 : 1,
|
|
216
|
+
16,
|
|
217
|
+
'requiredKeys',
|
|
218
|
+
)
|
|
219
|
+
const selectKeys = request.selectKeys == null
|
|
220
|
+
? requiredKeys
|
|
221
|
+
: validateKeyList(
|
|
222
|
+
request.selectKeys,
|
|
223
|
+
projection === 'pixi.interactive' ? 0 : 1,
|
|
224
|
+
64,
|
|
225
|
+
'selectKeys',
|
|
226
|
+
)
|
|
227
|
+
const maxDepth = boundedInteger(request.maxDepth, 6, 0, 12, 'maxDepth')
|
|
228
|
+
const maxNodes = boundedInteger(request.maxNodes, 20_000, 1, 50_000, 'maxNodes')
|
|
229
|
+
const maxMatches = boundedInteger(request.maxMatches, 16, 1, 256, 'maxMatches')
|
|
230
|
+
if (projection === 'pixi.interactive') {
|
|
231
|
+
const capturedAt = timestamp(now())
|
|
232
|
+
const expression = pixiInteractiveExpression({
|
|
233
|
+
anyRequiredKeys,
|
|
234
|
+
maxDepth,
|
|
235
|
+
maxMatches,
|
|
236
|
+
maxNodes,
|
|
237
|
+
requiredKeys,
|
|
238
|
+
rootName,
|
|
239
|
+
rootPath,
|
|
240
|
+
selectKeys,
|
|
241
|
+
})
|
|
242
|
+
const projected = boundedResult(await execute(frameInfo, expression))
|
|
243
|
+
return boundedResult(finalizePixiInteractiveProjection(projected, {
|
|
244
|
+
capturedAt,
|
|
245
|
+
frameId: frameInfo.id,
|
|
246
|
+
frameUrl: publicFrameUrl(frameInfo.url),
|
|
247
|
+
maxMatches,
|
|
248
|
+
rootName,
|
|
249
|
+
rootPath,
|
|
250
|
+
}))
|
|
251
|
+
}
|
|
252
|
+
const expression = `(() => {
|
|
253
|
+
const rootName = ${JSON.stringify(rootName)};
|
|
254
|
+
const rootPath = ${JSON.stringify(rootPath)};
|
|
255
|
+
const requiredKeys = ${JSON.stringify(requiredKeys)};
|
|
256
|
+
const selectKeys = ${JSON.stringify(selectKeys)};
|
|
257
|
+
const maxDepth = ${maxDepth};
|
|
258
|
+
const maxNodes = ${maxNodes};
|
|
259
|
+
const maxMatches = ${maxMatches};
|
|
260
|
+
const blockedKeys = new Set([
|
|
261
|
+
'window', 'self', 'globalThis', 'parent', 'top', 'frames',
|
|
262
|
+
'document', 'ownerDocument', 'prototype', 'constructor',
|
|
263
|
+
'caller', 'callee', 'arguments',
|
|
264
|
+
]);
|
|
265
|
+
let root;
|
|
266
|
+
if (rootName === 'globalThis') {
|
|
267
|
+
root = globalThis;
|
|
268
|
+
} else {
|
|
269
|
+
const ticker = globalThis.PIXI && globalThis.PIXI.ticker;
|
|
270
|
+
const head = ticker && ticker.shared && ticker.shared._head;
|
|
271
|
+
const interaction = head && head.next && head.next.context;
|
|
272
|
+
const renderer = interaction && interaction.renderer;
|
|
273
|
+
root = renderer && renderer._lastObjectRendered;
|
|
274
|
+
if (!root) {
|
|
275
|
+
return { available: false, reason: 'root_unavailable', matches: [] };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const segment of rootPath) {
|
|
279
|
+
if ((typeof root !== 'object' && typeof root !== 'function') || root === null) {
|
|
280
|
+
return { available: false, reason: 'root_path_not_found', matches: [] };
|
|
281
|
+
}
|
|
282
|
+
const descriptor = Object.getOwnPropertyDescriptor(root, segment);
|
|
283
|
+
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
284
|
+
return { available: false, reason: 'root_path_not_found', matches: [] };
|
|
285
|
+
}
|
|
286
|
+
root = descriptor.value;
|
|
287
|
+
}
|
|
288
|
+
const isObject = (value) =>
|
|
289
|
+
(typeof value === 'object' || typeof value === 'function') && value !== null;
|
|
290
|
+
if (!isObject(root)) {
|
|
291
|
+
return { available: false, reason: 'root_is_not_object', matches: [] };
|
|
292
|
+
}
|
|
293
|
+
const scalar = (value) => {
|
|
294
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
|
|
295
|
+
if (typeof value === 'string') return value.slice(0, 65536);
|
|
296
|
+
if (typeof value === 'bigint') return String(value);
|
|
297
|
+
if (Array.isArray(value)) {
|
|
298
|
+
return value.slice(0, 20000).map((item) => {
|
|
299
|
+
if (item && typeof item === 'object') {
|
|
300
|
+
const id = Object.getOwnPropertyDescriptor(item, 'api_id');
|
|
301
|
+
return id && Object.prototype.hasOwnProperty.call(id, 'value')
|
|
302
|
+
? { api_id: id.value }
|
|
303
|
+
: '[Object]';
|
|
304
|
+
}
|
|
305
|
+
return scalar(item);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (typeof value === 'function') return { type: 'function', name: value.name || '' };
|
|
309
|
+
if (typeof value === 'object') {
|
|
310
|
+
const output = {};
|
|
311
|
+
for (const key of Object.getOwnPropertyNames(value).slice(0, 256)) {
|
|
312
|
+
if (blockedKeys.has(key)) continue;
|
|
313
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
314
|
+
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) continue;
|
|
315
|
+
const item = descriptor.value;
|
|
316
|
+
if (item === null || ['boolean', 'number', 'string', 'bigint'].includes(typeof item)) {
|
|
317
|
+
output[key] = scalar(item);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return output;
|
|
321
|
+
}
|
|
322
|
+
return String(value);
|
|
323
|
+
};
|
|
324
|
+
const queue = [{ value: root, path: rootPath, depth: 0 }];
|
|
325
|
+
const seen = new WeakSet();
|
|
326
|
+
const matches = [];
|
|
327
|
+
let visited = 0;
|
|
328
|
+
while (queue.length > 0 && visited < maxNodes && matches.length < maxMatches) {
|
|
329
|
+
const current = queue.shift();
|
|
330
|
+
if (!isObject(current.value) || seen.has(current.value)) continue;
|
|
331
|
+
seen.add(current.value);
|
|
332
|
+
visited += 1;
|
|
333
|
+
let names;
|
|
334
|
+
try {
|
|
335
|
+
names = Object.getOwnPropertyNames(current.value).slice(0, 2048);
|
|
336
|
+
} catch (_) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (requiredKeys.every((key) => names.includes(key))) {
|
|
340
|
+
const selected = {};
|
|
341
|
+
for (const key of selectKeys) {
|
|
342
|
+
const descriptor = Object.getOwnPropertyDescriptor(current.value, key);
|
|
343
|
+
if (descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
344
|
+
selected[key] = scalar(descriptor.value);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
matches.push({ path: current.path, selected });
|
|
348
|
+
}
|
|
349
|
+
if (current.depth >= maxDepth) continue;
|
|
350
|
+
for (const key of names) {
|
|
351
|
+
if (blockedKeys.has(key) || key.length > 256) continue;
|
|
352
|
+
let descriptor;
|
|
353
|
+
try {
|
|
354
|
+
descriptor = Object.getOwnPropertyDescriptor(current.value, key);
|
|
355
|
+
} catch (_) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) continue;
|
|
359
|
+
const child = descriptor.value;
|
|
360
|
+
if (!isObject(child) || seen.has(child)) continue;
|
|
361
|
+
if (child.nodeType || child.ownerDocument) continue;
|
|
362
|
+
queue.push({
|
|
363
|
+
value: child,
|
|
364
|
+
path: [...current.path, key],
|
|
365
|
+
depth: current.depth + 1,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
available: true,
|
|
371
|
+
visited,
|
|
372
|
+
exhausted: queue.length === 0,
|
|
373
|
+
matches,
|
|
374
|
+
};
|
|
375
|
+
})()`
|
|
376
|
+
return boundedResult(await execute(frameInfo, expression))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function evaluate(request = {}) {
|
|
380
|
+
const script = boundedString(
|
|
381
|
+
request.script,
|
|
382
|
+
MAX_DEBUG_SCRIPT_LENGTH,
|
|
383
|
+
'script',
|
|
384
|
+
)
|
|
385
|
+
const timeoutMs = boundedInteger(request.timeoutMs, 1000, 50, 5000)
|
|
386
|
+
const frameInfo = selectFrame(request.frameId)
|
|
387
|
+
const scriptHash = crypto.createHash('sha256').update(script).digest('hex')
|
|
388
|
+
const startedAt = timestamp(now())
|
|
389
|
+
const expression = `(() => {
|
|
390
|
+
const script = ${JSON.stringify(script)};
|
|
391
|
+
const timeoutMs = ${timeoutMs};
|
|
392
|
+
const timeout = new Promise((_, reject) => setTimeout(
|
|
393
|
+
() => reject(new Error('Debug evaluation timed out')), timeoutMs));
|
|
394
|
+
return Promise.race([
|
|
395
|
+
Promise.resolve().then(() => (0, eval)(script)),
|
|
396
|
+
timeout,
|
|
397
|
+
]);
|
|
398
|
+
})()`
|
|
399
|
+
try {
|
|
400
|
+
const value = boundedResult(await execute(frameInfo, expression))
|
|
401
|
+
logger.log(
|
|
402
|
+
`[poi-plugin-mcp] debug eval ${scriptHash} frame=${frameInfo.id} ok`,
|
|
403
|
+
)
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
frameId: frameInfo.id,
|
|
407
|
+
startedAt,
|
|
408
|
+
scriptHash,
|
|
409
|
+
value,
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
logger.error(
|
|
413
|
+
`[poi-plugin-mcp] debug eval ${scriptHash} frame=${frameInfo.id} failed: ${error.message}`,
|
|
414
|
+
)
|
|
415
|
+
const failure = new Error(error.message)
|
|
416
|
+
failure.code = 'DEBUG_EVAL_FAILED'
|
|
417
|
+
failure.details = { frameId: frameInfo.id, startedAt, scriptHash }
|
|
418
|
+
throw failure
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return Object.freeze({ evaluate, findObjects, listFrames, readPath, readStorage })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function pixiInteractiveExpression({
|
|
426
|
+
anyRequiredKeys,
|
|
427
|
+
maxDepth,
|
|
428
|
+
maxMatches,
|
|
429
|
+
maxNodes,
|
|
430
|
+
requiredKeys,
|
|
431
|
+
rootName,
|
|
432
|
+
rootPath,
|
|
433
|
+
selectKeys,
|
|
434
|
+
}) {
|
|
435
|
+
return `(() => {
|
|
436
|
+
const rootName = ${JSON.stringify(rootName)};
|
|
437
|
+
const rootPath = ${JSON.stringify(rootPath)};
|
|
438
|
+
const anyRequiredKeys = ${JSON.stringify(anyRequiredKeys)};
|
|
439
|
+
const requiredKeys = ${JSON.stringify(requiredKeys)};
|
|
440
|
+
const selectKeys = ${JSON.stringify(selectKeys)};
|
|
441
|
+
const maxDepth = ${maxDepth};
|
|
442
|
+
const maxNodes = ${maxNodes};
|
|
443
|
+
const maxMatches = ${maxMatches};
|
|
444
|
+
const isObject = (value) =>
|
|
445
|
+
(typeof value === 'object' || typeof value === 'function') && value !== null;
|
|
446
|
+
const ownDescriptor = (object, key) => {
|
|
447
|
+
if (!isObject(object)) return null;
|
|
448
|
+
try {
|
|
449
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
450
|
+
return descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')
|
|
451
|
+
? descriptor
|
|
452
|
+
: null;
|
|
453
|
+
} catch (_) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const ownData = (object, key) => {
|
|
458
|
+
const descriptor = ownDescriptor(object, key);
|
|
459
|
+
return descriptor ? descriptor.value : undefined;
|
|
460
|
+
};
|
|
461
|
+
const finiteNumber = (value) =>
|
|
462
|
+
typeof value === 'number' && Number.isFinite(value)
|
|
463
|
+
? (Object.is(value, -0) ? 0 : value)
|
|
464
|
+
: null;
|
|
465
|
+
const scalar = (value) => {
|
|
466
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
467
|
+
if (typeof value === 'number') return finiteNumber(value);
|
|
468
|
+
if (typeof value === 'string') return value.slice(0, 65536);
|
|
469
|
+
if (typeof value === 'bigint') return String(value);
|
|
470
|
+
return undefined;
|
|
471
|
+
};
|
|
472
|
+
const scalarOrNull = (value) => {
|
|
473
|
+
const selected = scalar(value);
|
|
474
|
+
return selected === undefined ? null : selected;
|
|
475
|
+
};
|
|
476
|
+
const booleanOrNull = (value) => typeof value === 'boolean' ? value : null;
|
|
477
|
+
const textureImageUrl = (value) => {
|
|
478
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > ${MAX_TEXTURE_URL_LENGTH}) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
const relative = !/^[a-z][a-z0-9+.-]*:/iu.test(value);
|
|
483
|
+
const url = new URL(value, 'https://poi-texture.invalid/');
|
|
484
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
485
|
+
if (relative && url.origin !== 'https://poi-texture.invalid') return null;
|
|
486
|
+
return relative
|
|
487
|
+
? (value.startsWith('/') ? url.pathname : url.pathname.slice(1))
|
|
488
|
+
: url.origin + url.pathname;
|
|
489
|
+
} catch (_) {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const rectangle = (value) => {
|
|
494
|
+
if (!isObject(value)) return null;
|
|
495
|
+
const x = finiteNumber(ownData(value, 'x'));
|
|
496
|
+
const y = finiteNumber(ownData(value, 'y'));
|
|
497
|
+
const width = finiteNumber(ownData(value, 'width'));
|
|
498
|
+
const height = finiteNumber(ownData(value, 'height'));
|
|
499
|
+
if (x === null || y === null || width === null || height === null || width < 0 || height < 0) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
return { x, y, width, height };
|
|
503
|
+
};
|
|
504
|
+
const textureRectangle = (value) => {
|
|
505
|
+
const rect = rectangle(value);
|
|
506
|
+
if (
|
|
507
|
+
rect === null ||
|
|
508
|
+
Math.abs(rect.x) > ${MAX_TEXTURE_RECTANGLE_VALUE} ||
|
|
509
|
+
Math.abs(rect.y) > ${MAX_TEXTURE_RECTANGLE_VALUE} ||
|
|
510
|
+
rect.width > ${MAX_TEXTURE_RECTANGLE_VALUE} ||
|
|
511
|
+
rect.height > ${MAX_TEXTURE_RECTANGLE_VALUE}
|
|
512
|
+
) return null;
|
|
513
|
+
return rect;
|
|
514
|
+
};
|
|
515
|
+
const textureIdentity = (node) => {
|
|
516
|
+
const texture = ownData(node, '_texture') ?? ownData(node, 'texture');
|
|
517
|
+
if (!isObject(texture)) return { imageUrl: null, frame: null, orig: null };
|
|
518
|
+
const baseTexture = ownData(texture, 'baseTexture');
|
|
519
|
+
return {
|
|
520
|
+
imageUrl: textureImageUrl(ownData(baseTexture, 'imageUrl')),
|
|
521
|
+
frame: textureRectangle(ownData(texture, '_frame') ?? ownData(texture, 'frame')),
|
|
522
|
+
orig: textureRectangle(ownData(texture, '_orig') ?? ownData(texture, 'orig')),
|
|
523
|
+
};
|
|
524
|
+
};
|
|
525
|
+
const affineTransform = (value) => {
|
|
526
|
+
if (!isObject(value)) return null;
|
|
527
|
+
const transform = {};
|
|
528
|
+
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty']) {
|
|
529
|
+
const item = finiteNumber(ownData(value, key));
|
|
530
|
+
if (item === null) return null;
|
|
531
|
+
transform[key] = item;
|
|
532
|
+
}
|
|
533
|
+
return transform;
|
|
534
|
+
};
|
|
535
|
+
const unionBounds = (left, right) => {
|
|
536
|
+
if (!left) return right;
|
|
537
|
+
if (!right) return left;
|
|
538
|
+
const x = Math.min(left.x, right.x);
|
|
539
|
+
const y = Math.min(left.y, right.y);
|
|
540
|
+
const farX = Math.max(left.x + left.width, right.x + right.width);
|
|
541
|
+
const farY = Math.max(left.y + left.height, right.y + right.height);
|
|
542
|
+
return { x, y, width: farX - x, height: farY - y };
|
|
543
|
+
};
|
|
544
|
+
const circleBounds = (value) => {
|
|
545
|
+
if (!isObject(value)) return null;
|
|
546
|
+
const x = finiteNumber(ownData(value, 'x'));
|
|
547
|
+
const y = finiteNumber(ownData(value, 'y'));
|
|
548
|
+
const radius = finiteNumber(ownData(value, 'radius'));
|
|
549
|
+
if (x === null || y === null || radius === null || radius < 0) return null;
|
|
550
|
+
return { x: x - radius, y: y - radius, width: radius * 2, height: radius * 2 };
|
|
551
|
+
};
|
|
552
|
+
const polygonBounds = (value) => {
|
|
553
|
+
if (!isObject(value)) return null;
|
|
554
|
+
const points = ownData(value, 'points');
|
|
555
|
+
if (!Array.isArray(points) || points.length < 4 || points.length > 4096) return null;
|
|
556
|
+
const numbers = [];
|
|
557
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
558
|
+
const point = ownData(points, String(index));
|
|
559
|
+
const number = finiteNumber(point);
|
|
560
|
+
if (number === null) return null;
|
|
561
|
+
numbers.push(number);
|
|
562
|
+
}
|
|
563
|
+
if (numbers.length % 2 !== 0) return null;
|
|
564
|
+
const xs = [];
|
|
565
|
+
const ys = [];
|
|
566
|
+
for (let index = 0; index < numbers.length; index += 2) {
|
|
567
|
+
xs.push(numbers[index]);
|
|
568
|
+
ys.push(numbers[index + 1]);
|
|
569
|
+
}
|
|
570
|
+
const x = Math.min(...xs);
|
|
571
|
+
const y = Math.min(...ys);
|
|
572
|
+
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
|
573
|
+
};
|
|
574
|
+
const shapeBounds = (shape) => {
|
|
575
|
+
if (!isObject(shape)) return null;
|
|
576
|
+
const type = finiteNumber(ownData(shape, 'type'));
|
|
577
|
+
if (type === 2) return circleBounds(shape);
|
|
578
|
+
if (type === 0) return polygonBounds(shape);
|
|
579
|
+
const rect = rectangle(shape);
|
|
580
|
+
if (rect === null) return null;
|
|
581
|
+
if (type === 3) {
|
|
582
|
+
return {
|
|
583
|
+
x: rect.x - rect.width,
|
|
584
|
+
y: rect.y - rect.height,
|
|
585
|
+
width: rect.width * 2,
|
|
586
|
+
height: rect.height * 2,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
return rect;
|
|
590
|
+
};
|
|
591
|
+
const graphicsBounds = (node) => {
|
|
592
|
+
const graphicsData = ownData(node, 'graphicsData');
|
|
593
|
+
if (!Array.isArray(graphicsData)) return null;
|
|
594
|
+
const length = ownData(graphicsData, 'length');
|
|
595
|
+
if (!Number.isInteger(length) || length < 0 || length > 256) return null;
|
|
596
|
+
let bounds = null;
|
|
597
|
+
for (let index = 0; index < length; index += 1) {
|
|
598
|
+
const item = ownData(graphicsData, String(index));
|
|
599
|
+
const shape = ownData(item, 'shape');
|
|
600
|
+
bounds = unionBounds(bounds, shapeBounds(shape));
|
|
601
|
+
}
|
|
602
|
+
return bounds;
|
|
603
|
+
};
|
|
604
|
+
const spriteBounds = (node) => {
|
|
605
|
+
const texture = ownData(node, '_texture') || ownData(node, 'texture');
|
|
606
|
+
if (!isObject(texture)) return null;
|
|
607
|
+
const frame = ownData(texture, '_orig') || ownData(texture, 'orig') ||
|
|
608
|
+
ownData(texture, '_frame') || ownData(texture, 'frame');
|
|
609
|
+
const rect = rectangle(frame);
|
|
610
|
+
if (rect === null || rect.width <= 0 || rect.height <= 0) return null;
|
|
611
|
+
const anchor = ownData(node, '_anchor') || ownData(node, 'anchor');
|
|
612
|
+
const anchorX = finiteNumber(ownData(anchor, '_x')) ?? finiteNumber(ownData(anchor, 'x')) ?? 0;
|
|
613
|
+
const anchorY = finiteNumber(ownData(anchor, '_y')) ?? finiteNumber(ownData(anchor, 'y')) ?? 0;
|
|
614
|
+
return {
|
|
615
|
+
x: -anchorX * rect.width,
|
|
616
|
+
y: -anchorY * rect.height,
|
|
617
|
+
width: rect.width,
|
|
618
|
+
height: rect.height,
|
|
619
|
+
};
|
|
620
|
+
};
|
|
621
|
+
const localBounds = (node) => {
|
|
622
|
+
const hitArea = rectangle(ownData(node, 'hitArea'));
|
|
623
|
+
if (hitArea !== null) return hitArea;
|
|
624
|
+
const graphics = graphicsBounds(node);
|
|
625
|
+
if (graphics !== null) return graphics;
|
|
626
|
+
return spriteBounds(node);
|
|
627
|
+
};
|
|
628
|
+
const worldTransform = (node) => {
|
|
629
|
+
const direct = affineTransform(ownData(node, 'worldTransform'));
|
|
630
|
+
if (direct !== null) return direct;
|
|
631
|
+
return affineTransform(ownData(ownData(node, 'transform'), 'worldTransform'));
|
|
632
|
+
};
|
|
633
|
+
const shown = (node) => {
|
|
634
|
+
if (ownData(node, 'visible') === false || ownData(node, 'renderable') === false) return false;
|
|
635
|
+
const alpha = finiteNumber(ownData(node, 'worldAlpha'));
|
|
636
|
+
return alpha === null || alpha > 0;
|
|
637
|
+
};
|
|
638
|
+
const transformedBounds = (hitArea, transform) => {
|
|
639
|
+
if (!hitArea || !transform) return null;
|
|
640
|
+
const corners = [
|
|
641
|
+
[hitArea.x, hitArea.y],
|
|
642
|
+
[hitArea.x + hitArea.width, hitArea.y],
|
|
643
|
+
[hitArea.x, hitArea.y + hitArea.height],
|
|
644
|
+
[hitArea.x + hitArea.width, hitArea.y + hitArea.height],
|
|
645
|
+
].map(([x, y]) => ({
|
|
646
|
+
x: transform.a * x + transform.c * y + transform.tx,
|
|
647
|
+
y: transform.b * x + transform.d * y + transform.ty,
|
|
648
|
+
}));
|
|
649
|
+
if (corners.some((corner) => !Number.isFinite(corner.x) || !Number.isFinite(corner.y))) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const xs = corners.map((corner) => corner.x);
|
|
653
|
+
const ys = corners.map((corner) => corner.y);
|
|
654
|
+
const left = Math.min(...xs);
|
|
655
|
+
const top = Math.min(...ys);
|
|
656
|
+
const right = Math.max(...xs);
|
|
657
|
+
const bottom = Math.max(...ys);
|
|
658
|
+
return {
|
|
659
|
+
x: Object.is(left, -0) ? 0 : left,
|
|
660
|
+
y: Object.is(top, -0) ? 0 : top,
|
|
661
|
+
width: Object.is(right - left, -0) ? 0 : right - left,
|
|
662
|
+
height: Object.is(bottom - top, -0) ? 0 : bottom - top,
|
|
663
|
+
};
|
|
664
|
+
};
|
|
665
|
+
let root;
|
|
666
|
+
if (rootName === 'globalThis') {
|
|
667
|
+
root = globalThis;
|
|
668
|
+
} else {
|
|
669
|
+
const ticker = globalThis.PIXI && globalThis.PIXI.ticker;
|
|
670
|
+
const head = ticker && ticker.shared && ticker.shared._head;
|
|
671
|
+
const interaction = head && head.next && head.next.context;
|
|
672
|
+
const renderer = interaction && interaction.renderer;
|
|
673
|
+
root = renderer && renderer._lastObjectRendered;
|
|
674
|
+
if (!isObject(root)) return { available: false, matches: [] };
|
|
675
|
+
}
|
|
676
|
+
for (const segment of rootPath) {
|
|
677
|
+
root = ownData(root, segment);
|
|
678
|
+
if (!isObject(root)) return { available: false, matches: [] };
|
|
679
|
+
}
|
|
680
|
+
if (!isObject(root)) return { available: false, matches: [] };
|
|
681
|
+
|
|
682
|
+
const queue = [{ node: root, path: rootPath, depth: 0, parent: -1 }];
|
|
683
|
+
const seen = new WeakSet();
|
|
684
|
+
const entries = [];
|
|
685
|
+
let cursor = 0;
|
|
686
|
+
let visited = 0;
|
|
687
|
+
let inspectedChildren = 0;
|
|
688
|
+
let depthLimited = false;
|
|
689
|
+
let childScanLimited = false;
|
|
690
|
+
let truncatedBy = null;
|
|
691
|
+
while (cursor < queue.length) {
|
|
692
|
+
const current = queue[cursor];
|
|
693
|
+
cursor += 1;
|
|
694
|
+
if (!isObject(current.node) || seen.has(current.node)) continue;
|
|
695
|
+
if (visited >= maxNodes) {
|
|
696
|
+
truncatedBy = 'maxNodes';
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
seen.add(current.node);
|
|
700
|
+
visited += 1;
|
|
701
|
+
const entryIndex = entries.length;
|
|
702
|
+
entries.push({
|
|
703
|
+
node: current.node,
|
|
704
|
+
path: current.path,
|
|
705
|
+
depth: current.depth,
|
|
706
|
+
parent: current.parent,
|
|
707
|
+
ownWorldBounds: shown(current.node)
|
|
708
|
+
? transformedBounds(localBounds(current.node), worldTransform(current.node))
|
|
709
|
+
: null,
|
|
710
|
+
subtreeWorldBounds: null,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const children = ownData(current.node, 'children');
|
|
714
|
+
if (!Array.isArray(children)) continue;
|
|
715
|
+
const length = ownData(children, 'length');
|
|
716
|
+
if (!Number.isInteger(length) || length < 0) continue;
|
|
717
|
+
for (let index = 0; index < length; index += 1) {
|
|
718
|
+
if (inspectedChildren >= maxNodes) {
|
|
719
|
+
if (index < length) childScanLimited = true;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
inspectedChildren += 1;
|
|
723
|
+
const child = ownData(children, String(index));
|
|
724
|
+
if (!isObject(child) || seen.has(child)) continue;
|
|
725
|
+
if (current.depth >= maxDepth) {
|
|
726
|
+
depthLimited = true;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
queue.push({
|
|
730
|
+
node: child,
|
|
731
|
+
path: [...current.path, 'children', String(index)],
|
|
732
|
+
depth: current.depth + 1,
|
|
733
|
+
parent: entryIndex,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (truncatedBy === null && cursor < queue.length) truncatedBy = 'maxNodes';
|
|
738
|
+
if (truncatedBy === null && depthLimited) truncatedBy = 'maxDepth';
|
|
739
|
+
if (truncatedBy === null && childScanLimited) truncatedBy = 'maxNodes';
|
|
740
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
741
|
+
const entry = entries[index];
|
|
742
|
+
entry.subtreeWorldBounds = unionBounds(entry.ownWorldBounds, entry.subtreeWorldBounds);
|
|
743
|
+
if (entry.parent >= 0 && entry.subtreeWorldBounds !== null) {
|
|
744
|
+
entries[entry.parent].subtreeWorldBounds = unionBounds(
|
|
745
|
+
entries[entry.parent].subtreeWorldBounds,
|
|
746
|
+
entry.subtreeWorldBounds,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const matches = [];
|
|
751
|
+
for (const entry of entries) {
|
|
752
|
+
if (
|
|
753
|
+
anyRequiredKeys.length > 0 &&
|
|
754
|
+
!anyRequiredKeys.some((key) => ownDescriptor(entry.node, key) !== null)
|
|
755
|
+
) continue;
|
|
756
|
+
if (!requiredKeys.every((key) => ownDescriptor(entry.node, key) !== null)) continue;
|
|
757
|
+
const name = scalarOrNull(ownData(entry.node, 'name'));
|
|
758
|
+
const id = scalarOrNull(ownData(entry.node, 'id'));
|
|
759
|
+
const hitArea = rectangle(ownData(entry.node, 'hitArea'));
|
|
760
|
+
const attributes = {};
|
|
761
|
+
for (const key of selectKeys) {
|
|
762
|
+
const selected = scalar(ownData(entry.node, key));
|
|
763
|
+
if (selected !== undefined) attributes[key] = selected;
|
|
764
|
+
}
|
|
765
|
+
const interaction = {
|
|
766
|
+
interactive: booleanOrNull(ownData(entry.node, 'interactive')),
|
|
767
|
+
eventMode: scalarOrNull(ownData(entry.node, 'eventMode')),
|
|
768
|
+
enabled: booleanOrNull(ownData(entry.node, 'enabled')),
|
|
769
|
+
visible: booleanOrNull(ownData(entry.node, 'visible')),
|
|
770
|
+
renderable: booleanOrNull(ownData(entry.node, 'renderable')),
|
|
771
|
+
worldAlpha: finiteNumber(ownData(entry.node, 'worldAlpha')),
|
|
772
|
+
};
|
|
773
|
+
const texture = textureIdentity(entry.node);
|
|
774
|
+
const hasInteractionIdentity = interaction.interactive !== null || interaction.eventMode !== null;
|
|
775
|
+
const hasSemanticIdentity = name !== null || id !== null;
|
|
776
|
+
const hasSelectedScalar = Object.keys(attributes).length > 0;
|
|
777
|
+
const hasTextureIdentity = texture.imageUrl !== null || texture.frame !== null || texture.orig !== null;
|
|
778
|
+
if (
|
|
779
|
+
entry.subtreeWorldBounds === null ||
|
|
780
|
+
!(hasInteractionIdentity || hasSemanticIdentity || hasSelectedScalar || hasTextureIdentity)
|
|
781
|
+
) continue;
|
|
782
|
+
if (matches.length >= maxMatches) {
|
|
783
|
+
truncatedBy = 'maxMatches';
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
matches.push({
|
|
787
|
+
path: entry.path,
|
|
788
|
+
semanticId: { name, id },
|
|
789
|
+
interaction,
|
|
790
|
+
texture,
|
|
791
|
+
hitArea,
|
|
792
|
+
worldBounds: entry.subtreeWorldBounds,
|
|
793
|
+
attributes,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
available: true,
|
|
798
|
+
visited,
|
|
799
|
+
exhausted: truncatedBy === null,
|
|
800
|
+
truncatedBy,
|
|
801
|
+
matches,
|
|
802
|
+
};
|
|
803
|
+
})()`
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function finalizePixiInteractiveProjection(value, options) {
|
|
807
|
+
const available = Boolean(value && value.available === true)
|
|
808
|
+
const rawMatches = available && Array.isArray(value.matches)
|
|
809
|
+
? Array.from(value.matches).slice(0, options.maxMatches)
|
|
810
|
+
: []
|
|
811
|
+
const matches = rawMatches.map((match) => normalizeProjectedMatch(match))
|
|
812
|
+
const semanticCounts = new Map()
|
|
813
|
+
for (const match of matches) {
|
|
814
|
+
const signature = semanticSignature(match)
|
|
815
|
+
if (signature !== null) {
|
|
816
|
+
semanticCounts.set(signature, (semanticCounts.get(signature) || 0) + 1)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const identifiedMatches = matches.map((match) => {
|
|
820
|
+
const signature = semanticSignature(match)
|
|
821
|
+
const semanticId = {
|
|
822
|
+
name: match.semanticId.name,
|
|
823
|
+
id: match.semanticId.id,
|
|
824
|
+
unique: signature !== null && semanticCounts.get(signature) === 1,
|
|
825
|
+
}
|
|
826
|
+
const normalized = {
|
|
827
|
+
path: match.path,
|
|
828
|
+
semanticId,
|
|
829
|
+
interaction: match.interaction,
|
|
830
|
+
texture: match.texture,
|
|
831
|
+
hitArea: match.hitArea,
|
|
832
|
+
worldBounds: match.worldBounds,
|
|
833
|
+
attributes: match.attributes,
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
path: normalized.path,
|
|
837
|
+
semanticId: normalized.semanticId,
|
|
838
|
+
nodeToken: sha256Token({
|
|
839
|
+
kind: 'poi.webview.pixi.node.v1',
|
|
840
|
+
path: normalized.path,
|
|
841
|
+
semanticId: {
|
|
842
|
+
name: normalized.semanticId.name,
|
|
843
|
+
id: normalized.semanticId.id,
|
|
844
|
+
},
|
|
845
|
+
interaction: normalized.interaction,
|
|
846
|
+
texture: normalized.texture,
|
|
847
|
+
hitArea: normalized.hitArea,
|
|
848
|
+
worldBounds: normalized.worldBounds,
|
|
849
|
+
attributes: normalized.attributes,
|
|
850
|
+
}),
|
|
851
|
+
interaction: normalized.interaction,
|
|
852
|
+
texture: normalized.texture,
|
|
853
|
+
hitArea: normalized.hitArea,
|
|
854
|
+
worldBounds: normalized.worldBounds,
|
|
855
|
+
attributes: normalized.attributes,
|
|
856
|
+
}
|
|
857
|
+
})
|
|
858
|
+
const documentToken = sha256Token({
|
|
859
|
+
kind: 'poi.webview.document.v1',
|
|
860
|
+
frameId: options.frameId,
|
|
861
|
+
frameUrl: options.frameUrl,
|
|
862
|
+
root: options.rootName,
|
|
863
|
+
rootPath: options.rootPath,
|
|
864
|
+
})
|
|
865
|
+
const pageToken = sha256Token({
|
|
866
|
+
kind: 'poi.webview.pixi.page.v1',
|
|
867
|
+
documentToken,
|
|
868
|
+
matches: identifiedMatches.map((match) => ({
|
|
869
|
+
path: match.path,
|
|
870
|
+
semanticId: match.semanticId,
|
|
871
|
+
texture: match.texture,
|
|
872
|
+
attributes: match.attributes,
|
|
873
|
+
})),
|
|
874
|
+
})
|
|
875
|
+
const visited = available && Number.isInteger(value.visited) && value.visited >= 0
|
|
876
|
+
? value.visited
|
|
877
|
+
: 0
|
|
878
|
+
const truncatedBy = available && ['maxDepth', 'maxNodes', 'maxMatches'].includes(value.truncatedBy)
|
|
879
|
+
? value.truncatedBy
|
|
880
|
+
: null
|
|
881
|
+
const exhausted = available && value.exhausted === true && truncatedBy === null
|
|
882
|
+
const snapshotDigest = sha256Token({
|
|
883
|
+
kind: 'poi.webview.pixi.snapshot.v1',
|
|
884
|
+
available,
|
|
885
|
+
documentToken,
|
|
886
|
+
pageToken,
|
|
887
|
+
visited,
|
|
888
|
+
exhausted,
|
|
889
|
+
truncatedBy,
|
|
890
|
+
matches: identifiedMatches,
|
|
891
|
+
})
|
|
892
|
+
return {
|
|
893
|
+
available,
|
|
894
|
+
frameId: options.frameId,
|
|
895
|
+
root: options.rootName,
|
|
896
|
+
capturedAt: options.capturedAt,
|
|
897
|
+
documentToken,
|
|
898
|
+
pageToken,
|
|
899
|
+
snapshotDigest,
|
|
900
|
+
visited,
|
|
901
|
+
exhausted,
|
|
902
|
+
truncatedBy,
|
|
903
|
+
matches: identifiedMatches,
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function normalizeProjectedMatch(value) {
|
|
908
|
+
const match = value && typeof value === 'object' ? value : {}
|
|
909
|
+
const semanticId = match.semanticId && typeof match.semanticId === 'object'
|
|
910
|
+
? match.semanticId
|
|
911
|
+
: {}
|
|
912
|
+
const interaction = match.interaction && typeof match.interaction === 'object'
|
|
913
|
+
? match.interaction
|
|
914
|
+
: {}
|
|
915
|
+
const attributes = match.attributes && typeof match.attributes === 'object' && !Array.isArray(match.attributes)
|
|
916
|
+
? match.attributes
|
|
917
|
+
: {}
|
|
918
|
+
const normalizedAttributes = {}
|
|
919
|
+
for (const [key, item] of Object.entries(attributes)) {
|
|
920
|
+
const scalar = normalizedScalar(item)
|
|
921
|
+
if (scalar !== undefined) normalizedAttributes[key] = scalar
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
path: Array.isArray(match.path)
|
|
925
|
+
? Array.from(match.path, (segment) => String(segment).slice(0, 256))
|
|
926
|
+
.slice(0, MAX_PATH_SEGMENTS)
|
|
927
|
+
: [],
|
|
928
|
+
semanticId: {
|
|
929
|
+
name: normalizedScalarOrNull(semanticId.name),
|
|
930
|
+
id: normalizedScalarOrNull(semanticId.id),
|
|
931
|
+
},
|
|
932
|
+
interaction: {
|
|
933
|
+
interactive: normalizedBooleanOrNull(interaction.interactive),
|
|
934
|
+
eventMode: normalizedScalarOrNull(interaction.eventMode),
|
|
935
|
+
enabled: normalizedBooleanOrNull(interaction.enabled),
|
|
936
|
+
visible: normalizedBooleanOrNull(interaction.visible),
|
|
937
|
+
renderable: normalizedBooleanOrNull(interaction.renderable),
|
|
938
|
+
worldAlpha: normalizedFiniteOrNull(interaction.worldAlpha),
|
|
939
|
+
},
|
|
940
|
+
texture: normalizedTextureIdentity(match.texture),
|
|
941
|
+
hitArea: normalizedRectangle(match.hitArea),
|
|
942
|
+
worldBounds: normalizedRectangle(match.worldBounds),
|
|
943
|
+
attributes: normalizedAttributes,
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function normalizedTextureIdentity(value) {
|
|
948
|
+
const texture = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
949
|
+
return {
|
|
950
|
+
imageUrl: normalizedTextureImageUrl(texture.imageUrl),
|
|
951
|
+
frame: normalizedTextureRectangle(texture.frame),
|
|
952
|
+
orig: normalizedTextureRectangle(texture.orig),
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function normalizedTextureImageUrl(value) {
|
|
957
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_TEXTURE_URL_LENGTH) return null
|
|
958
|
+
try {
|
|
959
|
+
const relative = !/^[a-z][a-z0-9+.-]*:/iu.test(value)
|
|
960
|
+
const url = new URL(value, 'https://poi-texture.invalid/')
|
|
961
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
962
|
+
if (relative && url.origin !== 'https://poi-texture.invalid') return null
|
|
963
|
+
return relative
|
|
964
|
+
? (value.startsWith('/') ? url.pathname : url.pathname.slice(1))
|
|
965
|
+
: `${url.origin}${url.pathname}`
|
|
966
|
+
} catch (_) {
|
|
967
|
+
return null
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function normalizedTextureRectangle(value) {
|
|
972
|
+
const rectangle = normalizedRectangle(value)
|
|
973
|
+
if (
|
|
974
|
+
rectangle === null ||
|
|
975
|
+
Math.abs(rectangle.x) > MAX_TEXTURE_RECTANGLE_VALUE ||
|
|
976
|
+
Math.abs(rectangle.y) > MAX_TEXTURE_RECTANGLE_VALUE ||
|
|
977
|
+
rectangle.width > MAX_TEXTURE_RECTANGLE_VALUE ||
|
|
978
|
+
rectangle.height > MAX_TEXTURE_RECTANGLE_VALUE
|
|
979
|
+
) return null
|
|
980
|
+
return rectangle
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function normalizedScalar(value) {
|
|
984
|
+
if (value === null || typeof value === 'boolean') return value
|
|
985
|
+
if (typeof value === 'number') return Number.isFinite(value) ? normalizeZero(value) : null
|
|
986
|
+
if (typeof value === 'string') return value.slice(0, 65536)
|
|
987
|
+
if (typeof value === 'bigint') return String(value)
|
|
988
|
+
return undefined
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function normalizedScalarOrNull(value) {
|
|
992
|
+
const scalar = normalizedScalar(value)
|
|
993
|
+
return scalar === undefined ? null : scalar
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function normalizedBooleanOrNull(value) {
|
|
997
|
+
return typeof value === 'boolean' ? value : null
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function normalizedFiniteOrNull(value) {
|
|
1001
|
+
return typeof value === 'number' && Number.isFinite(value) ? normalizeZero(value) : null
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function normalizedRectangle(value) {
|
|
1005
|
+
if (!value || typeof value !== 'object') return null
|
|
1006
|
+
const x = normalizedFiniteOrNull(value.x)
|
|
1007
|
+
const y = normalizedFiniteOrNull(value.y)
|
|
1008
|
+
const width = normalizedFiniteOrNull(value.width)
|
|
1009
|
+
const height = normalizedFiniteOrNull(value.height)
|
|
1010
|
+
if (x === null || y === null || width === null || height === null || width < 0 || height < 0) {
|
|
1011
|
+
return null
|
|
1012
|
+
}
|
|
1013
|
+
return { x, y, width, height }
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function semanticSignature(match) {
|
|
1017
|
+
if (
|
|
1018
|
+
match.semanticId.name === null &&
|
|
1019
|
+
match.semanticId.id === null &&
|
|
1020
|
+
Object.keys(match.attributes).length === 0
|
|
1021
|
+
) return null
|
|
1022
|
+
return canonicalJson({
|
|
1023
|
+
attributes: match.attributes,
|
|
1024
|
+
id: match.semanticId.id,
|
|
1025
|
+
name: match.semanticId.name,
|
|
1026
|
+
})
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function sha256Token(value) {
|
|
1030
|
+
return `sha256:${crypto.createHash('sha256').update(canonicalJson(value)).digest('hex')}`
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function canonicalJson(value) {
|
|
1034
|
+
if (value === null || typeof value === 'boolean') return JSON.stringify(value)
|
|
1035
|
+
if (typeof value === 'number') return JSON.stringify(normalizeZero(value))
|
|
1036
|
+
if (typeof value === 'string') return JSON.stringify(value.normalize('NFC'))
|
|
1037
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`
|
|
1038
|
+
const keys = Object.keys(value).sort()
|
|
1039
|
+
return `{${keys.map((key) =>
|
|
1040
|
+
`${JSON.stringify(key.normalize('NFC'))}:${canonicalJson(value[key])}`).join(',')}}`
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function normalizeZero(value) {
|
|
1044
|
+
return Object.is(value, -0) ? 0 : value
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function frameId(frame, fallbackIndex) {
|
|
1048
|
+
const processId = Number(frame.processId)
|
|
1049
|
+
const routingId = Number(frame.routingId)
|
|
1050
|
+
if (Number.isInteger(processId) && Number.isInteger(routingId)) {
|
|
1051
|
+
return `${processId}:${routingId}`
|
|
1052
|
+
}
|
|
1053
|
+
return fallbackIndex === 0 ? 'main' : `frame-${fallbackIndex}`
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function isKanColleGameUrl(value) {
|
|
1057
|
+
if (typeof value !== 'string' || value.length === 0) return false
|
|
1058
|
+
try {
|
|
1059
|
+
const url = new URL(value)
|
|
1060
|
+
return (
|
|
1061
|
+
url.protocol === 'https:' &&
|
|
1062
|
+
(
|
|
1063
|
+
url.hostname === 'kancolle-server.com' ||
|
|
1064
|
+
url.hostname.endsWith('.kancolle-server.com')
|
|
1065
|
+
)
|
|
1066
|
+
)
|
|
1067
|
+
} catch (_) {
|
|
1068
|
+
return false
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function publicFrameUrl(value) {
|
|
1073
|
+
try {
|
|
1074
|
+
const url = new URL(value)
|
|
1075
|
+
return `${url.origin}${url.pathname}`
|
|
1076
|
+
} catch (_) {
|
|
1077
|
+
return ''
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function validateInspectionRoot(value) {
|
|
1082
|
+
const root = value == null ? 'globalThis' : boundedString(value, 64, 'root')
|
|
1083
|
+
if (!WEBVIEW_ROOTS.includes(root)) {
|
|
1084
|
+
throw new Error(`Unsupported WebView inspection root: ${root}`)
|
|
1085
|
+
}
|
|
1086
|
+
return root
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function validateFindProjection(value) {
|
|
1090
|
+
if (value == null) return null
|
|
1091
|
+
const projection = boundedString(value, 64, 'projection')
|
|
1092
|
+
if (!WEBVIEW_FIND_PROJECTIONS.includes(projection)) {
|
|
1093
|
+
throw new Error(`Unsupported WebView find projection: ${projection}`)
|
|
1094
|
+
}
|
|
1095
|
+
return projection
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function validatePath(value) {
|
|
1099
|
+
if (!Array.isArray(value) || value.length > MAX_PATH_SEGMENTS) {
|
|
1100
|
+
throw new Error(`path must be an array with at most ${MAX_PATH_SEGMENTS} segments`)
|
|
1101
|
+
}
|
|
1102
|
+
return value.map((segment) => {
|
|
1103
|
+
const text = boundedString(segment, 256, 'path segment')
|
|
1104
|
+
if (
|
|
1105
|
+
['__proto__', 'prototype', 'constructor'].includes(text) ||
|
|
1106
|
+
SENSITIVE_KEY.test(text)
|
|
1107
|
+
) {
|
|
1108
|
+
throw new Error(`Unsafe path segment: ${text}`)
|
|
1109
|
+
}
|
|
1110
|
+
return text
|
|
1111
|
+
})
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function validateKeyList(value, minimum, maximum, name) {
|
|
1115
|
+
if (!Array.isArray(value) || value.length < minimum || value.length > maximum) {
|
|
1116
|
+
throw new Error(`${name} must contain ${minimum} to ${maximum} property names`)
|
|
1117
|
+
}
|
|
1118
|
+
const keys = value.map((key) => boundedString(key, 256, `${name} item`))
|
|
1119
|
+
if (
|
|
1120
|
+
new Set(keys).size !== keys.length ||
|
|
1121
|
+
keys.some((key) =>
|
|
1122
|
+
['__proto__', 'prototype', 'constructor'].includes(key) ||
|
|
1123
|
+
SENSITIVE_KEY.test(key))
|
|
1124
|
+
) {
|
|
1125
|
+
throw new Error(`${name} contains duplicate or unsafe property names`)
|
|
1126
|
+
}
|
|
1127
|
+
return keys
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function boundedResult(value) {
|
|
1131
|
+
let encoded
|
|
1132
|
+
try {
|
|
1133
|
+
encoded = JSON.stringify(value)
|
|
1134
|
+
} catch (_) {
|
|
1135
|
+
throw new Error('WebView result is not serializable')
|
|
1136
|
+
}
|
|
1137
|
+
if (Buffer.byteLength(encoded, 'utf8') > MAX_RESULT_BYTES) {
|
|
1138
|
+
throw new Error('WebView result exceeds 2MB')
|
|
1139
|
+
}
|
|
1140
|
+
return value
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function boundedString(value, maximum, name) {
|
|
1144
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
1145
|
+
throw new Error(`${name} must be a non-empty string up to ${maximum} characters`)
|
|
1146
|
+
}
|
|
1147
|
+
return value
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function boundedInteger(value, fallback, minimum, maximum, name = 'timeoutMs') {
|
|
1151
|
+
if (value == null) return fallback
|
|
1152
|
+
const parsed = Number(value)
|
|
1153
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
1154
|
+
throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`)
|
|
1155
|
+
}
|
|
1156
|
+
return parsed
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function timestamp(value) {
|
|
1160
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
1161
|
+
if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
|
|
1162
|
+
return date.toISOString()
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function defaultGetStore(path) {
|
|
1166
|
+
if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
|
|
1167
|
+
return window.getStore(path)
|
|
1168
|
+
}
|
|
1169
|
+
return null
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function defaultResolveWebContents(id) {
|
|
1173
|
+
return require('@electron/remote').webContents.fromId(id)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
module.exports = {
|
|
1177
|
+
MAX_DEBUG_SCRIPT_LENGTH,
|
|
1178
|
+
MAX_RESULT_BYTES,
|
|
1179
|
+
createPoiWebviewRuntime,
|
|
1180
|
+
isKanColleGameUrl,
|
|
1181
|
+
}
|