poi-plugin-mcp 0.2.12 → 0.2.14
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 +150 -93
- package/index.js +2 -0
- package/lib/bridge-controller.js +2 -0
- package/lib/poi-action-events.js +290 -0
- package/lib/poi-http-bridge.js +206 -9
- package/lib/poi-input-lease.js +226 -0
- package/lib/poi-input.js +97 -0
- package/lib/poi-telemetry.js +99 -17
- package/package.json +1 -1
package/lib/poi-http-bridge.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path')
|
|
|
6
6
|
const packageJson = require('../package.json')
|
|
7
7
|
const { loadOrCreateInputToken } = require('./input-token')
|
|
8
8
|
const { createPoiInputProvider } = require('./poi-input')
|
|
9
|
+
const { createPoiInputLease } = require('./poi-input-lease')
|
|
9
10
|
const { createPoiScreenshotProvider } = require('./poi-screenshot')
|
|
10
11
|
|
|
11
12
|
const DEFAULT_PORT = 17777
|
|
@@ -25,6 +26,8 @@ const JSONRPC_VERSION = '2.0'
|
|
|
25
26
|
const MCP_PROTOCOL_VERSION = '2024-11-05'
|
|
26
27
|
const INPUT_BODY_LIMIT = 64 * 1024
|
|
27
28
|
const MASTER_FILE_LIMIT = 16 * 1024 * 1024
|
|
29
|
+
const DEFAULT_ACTION_EVENT_LIMIT = 64
|
|
30
|
+
const MAX_ACTION_EVENT_LIMIT = 256
|
|
28
31
|
|
|
29
32
|
function createPoiDataBridge(options = {}) {
|
|
30
33
|
const getStore = options.getStore || defaultGetStore
|
|
@@ -37,21 +40,30 @@ function createPoiDataBridge(options = {}) {
|
|
|
37
40
|
const getQuestAction = options.getQuestAction || (() => ({ available: false, generation: 0 }))
|
|
38
41
|
const getEquipmentAction = options.getEquipmentAction ||
|
|
39
42
|
(() => ({ available: false, generation: 0 }))
|
|
43
|
+
const getUnsetSlot = options.getUnsetSlot ||
|
|
44
|
+
(() => ({ available: false, generation: 0 }))
|
|
40
45
|
const getFleetAction = options.getFleetAction ||
|
|
41
46
|
(() => ({ available: false, generation: 0 }))
|
|
47
|
+
const getActionEvents = options.getActionEvents ||
|
|
48
|
+
(() => ({
|
|
49
|
+
available: false,
|
|
50
|
+
sessionId: null,
|
|
51
|
+
latestGeneration: 0,
|
|
52
|
+
events: [],
|
|
53
|
+
}))
|
|
42
54
|
const getBattleTelemetry = options.getBattleTelemetry ||
|
|
43
55
|
(() => ({ available: false, generation: 0 }))
|
|
44
56
|
const inputEnabled = options.inputEnabled === true
|
|
45
57
|
const inputToken = options.inputToken || (
|
|
46
58
|
inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
|
|
47
59
|
)
|
|
60
|
+
const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
|
|
48
61
|
let captureScreenshot = options.captureScreenshot || null
|
|
49
62
|
let performInput = options.performInput || null
|
|
50
63
|
|
|
51
64
|
let server = null
|
|
52
65
|
let actualPort = 0
|
|
53
66
|
let inputPending = Promise.resolve()
|
|
54
|
-
let inputSequence = 0
|
|
55
67
|
|
|
56
68
|
function readStore() {
|
|
57
69
|
const store = getStore()
|
|
@@ -84,17 +96,22 @@ function createPoiDataBridge(options = {}) {
|
|
|
84
96
|
})
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
function enqueueInput(operation) {
|
|
99
|
+
function enqueueInput(operation, claim) {
|
|
88
100
|
const execute = async () => {
|
|
101
|
+
inputLease.assertClaimActive(claim)
|
|
89
102
|
if (!performInput) {
|
|
90
103
|
performInput = createPoiInputProvider({ getStore })
|
|
91
104
|
}
|
|
92
105
|
const operationName = await performInput(operation)
|
|
93
|
-
inputSequence += 1
|
|
94
106
|
return {
|
|
95
107
|
ok: true,
|
|
96
108
|
operation: operationName,
|
|
97
|
-
sequence:
|
|
109
|
+
sequence: claim.sequence,
|
|
110
|
+
leaseId: claim.leaseId,
|
|
111
|
+
ownerSessionId: claim.ownerSessionId,
|
|
112
|
+
runId: claim.runId,
|
|
113
|
+
action: claim.action,
|
|
114
|
+
acceptedAt: claim.acceptedAt,
|
|
98
115
|
}
|
|
99
116
|
}
|
|
100
117
|
const result = inputPending.then(execute, execute)
|
|
@@ -136,14 +153,92 @@ function createPoiDataBridge(options = {}) {
|
|
|
136
153
|
'Input request body exceeds 64KB.',
|
|
137
154
|
)
|
|
138
155
|
const operation = JSON.parse(body || '{}')
|
|
139
|
-
|
|
156
|
+
const { input, claim } = claimInputOperation(inputLease, operation)
|
|
157
|
+
sendInputJson(res, 200, await enqueueInput(input, claim))
|
|
140
158
|
} catch (error) {
|
|
141
|
-
const statusCode = error.code === 'BODY_TOO_LARGE'
|
|
159
|
+
const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE'
|
|
142
160
|
? 413
|
|
143
161
|
: /WebView|dimensions/.test(error.message)
|
|
144
162
|
? 503
|
|
145
|
-
: 400
|
|
146
|
-
sendInputJson(res, statusCode, {
|
|
163
|
+
: 400)
|
|
164
|
+
sendInputJson(res, statusCode, {
|
|
165
|
+
...(error.code ? { code: error.code } : {}),
|
|
166
|
+
error: error.message,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleInputLeaseRequest(req, res, endpoint) {
|
|
172
|
+
if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
|
|
173
|
+
drainRequest(req)
|
|
174
|
+
sendInputJson(
|
|
175
|
+
res,
|
|
176
|
+
401,
|
|
177
|
+
{ error: 'A valid Bearer token is required.' },
|
|
178
|
+
{ headers: { 'WWW-Authenticate': 'Bearer' } },
|
|
179
|
+
)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (endpoint === '/input/lease') {
|
|
183
|
+
if (req.method !== 'GET') {
|
|
184
|
+
drainRequest(req)
|
|
185
|
+
sendInputJson(res, 405, { error: 'Input lease status only accepts GET requests.' })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
const lease = inputLease.status()
|
|
189
|
+
sendInputJson(res, 200, { active: lease !== null, lease })
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (req.method !== 'POST') {
|
|
193
|
+
drainRequest(req)
|
|
194
|
+
sendInputJson(res, 405, { error: 'Input lease changes only accept POST requests.' })
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (!inputEnabled) {
|
|
198
|
+
drainRequest(req)
|
|
199
|
+
sendInputJson(res, 403, { error: 'WebView input is disabled.' })
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (requestContentLength(req) > INPUT_BODY_LIMIT) {
|
|
203
|
+
drainRequest(req)
|
|
204
|
+
sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const body = await readRequestBody(
|
|
210
|
+
req,
|
|
211
|
+
INPUT_BODY_LIMIT,
|
|
212
|
+
'Input request body exceeds 64KB.',
|
|
213
|
+
)
|
|
214
|
+
const request = JSON.parse(body || '{}')
|
|
215
|
+
if (endpoint === '/input/lease/acquire') {
|
|
216
|
+
const lease = inputLease.acquire(request)
|
|
217
|
+
sendInputJson(res, 200, { active: true, lease })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (endpoint === '/input/lease/renew') {
|
|
221
|
+
const lease = inputLease.renew(request)
|
|
222
|
+
sendInputJson(res, 200, { active: true, lease })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (endpoint === '/input/lease/release') {
|
|
226
|
+
inputLease.release(request)
|
|
227
|
+
sendInputJson(res, 200, { active: false, released: true })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (endpoint === '/input/lease/revoke') {
|
|
231
|
+
inputLease.revoke(request)
|
|
232
|
+
sendInputJson(res, 200, { active: false, revoked: true })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
sendInputJson(res, 404, { error: `Unknown input lease endpoint: ${endpoint}` })
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE' ? 413 : 400)
|
|
238
|
+
sendInputJson(res, statusCode, {
|
|
239
|
+
...(error.code ? { code: error.code } : {}),
|
|
240
|
+
error: error.message,
|
|
241
|
+
})
|
|
147
242
|
}
|
|
148
243
|
}
|
|
149
244
|
|
|
@@ -182,7 +277,11 @@ function createPoiDataBridge(options = {}) {
|
|
|
182
277
|
return
|
|
183
278
|
}
|
|
184
279
|
|
|
185
|
-
const
|
|
280
|
+
const requestUrl = new URL(
|
|
281
|
+
req.url,
|
|
282
|
+
`http://127.0.0.1:${actualPort || configuredPort}`,
|
|
283
|
+
)
|
|
284
|
+
const endpoint = requestUrl.pathname
|
|
186
285
|
|
|
187
286
|
if (endpoint === '/health') {
|
|
188
287
|
sendJson(res, 200, { status: 'ok' })
|
|
@@ -239,6 +338,17 @@ function createPoiDataBridge(options = {}) {
|
|
|
239
338
|
return
|
|
240
339
|
}
|
|
241
340
|
|
|
341
|
+
if (
|
|
342
|
+
endpoint === '/input/lease' ||
|
|
343
|
+
endpoint === '/input/lease/acquire' ||
|
|
344
|
+
endpoint === '/input/lease/renew' ||
|
|
345
|
+
endpoint === '/input/lease/release' ||
|
|
346
|
+
endpoint === '/input/lease/revoke'
|
|
347
|
+
) {
|
|
348
|
+
await handleInputLeaseRequest(req, res, endpoint)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
242
352
|
if (endpoint === '/input') {
|
|
243
353
|
await handleInputRequest(req, res)
|
|
244
354
|
return
|
|
@@ -259,11 +369,41 @@ function createPoiDataBridge(options = {}) {
|
|
|
259
369
|
return
|
|
260
370
|
}
|
|
261
371
|
|
|
372
|
+
if (endpoint === '/unsetslot') {
|
|
373
|
+
sendJson(res, 200, getUnsetSlot())
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
262
377
|
if (endpoint === '/fleet-action') {
|
|
263
378
|
sendJson(res, 200, getFleetAction())
|
|
264
379
|
return
|
|
265
380
|
}
|
|
266
381
|
|
|
382
|
+
if (endpoint === '/action-events') {
|
|
383
|
+
if (req.method !== 'GET') {
|
|
384
|
+
drainRequest(req)
|
|
385
|
+
sendJson(res, 405, {
|
|
386
|
+
error: 'Action events endpoint only accepts GET requests.',
|
|
387
|
+
})
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
sendJson(res, 200, getActionEvents({
|
|
391
|
+
after: clampedQueryInteger(
|
|
392
|
+
requestUrl.searchParams.get('after'),
|
|
393
|
+
0,
|
|
394
|
+
0,
|
|
395
|
+
Number.MAX_SAFE_INTEGER,
|
|
396
|
+
),
|
|
397
|
+
limit: clampedQueryInteger(
|
|
398
|
+
requestUrl.searchParams.get('limit'),
|
|
399
|
+
DEFAULT_ACTION_EVENT_LIMIT,
|
|
400
|
+
1,
|
|
401
|
+
MAX_ACTION_EVENT_LIMIT,
|
|
402
|
+
),
|
|
403
|
+
}))
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
|
|
267
407
|
const store = readStore()
|
|
268
408
|
const info = store.info
|
|
269
409
|
|
|
@@ -322,6 +462,11 @@ function createPoiDataBridge(options = {}) {
|
|
|
322
462
|
records: (info.quests && info.quests.records) || {},
|
|
323
463
|
},
|
|
324
464
|
airbase: info.airbase || [],
|
|
465
|
+
repairs: info.repairs || [],
|
|
466
|
+
constructions: info.constructions || [],
|
|
467
|
+
maps: info.maps || {},
|
|
468
|
+
useitems: info.useitems || {},
|
|
469
|
+
sortie: store.sortie || {},
|
|
325
470
|
names: extractNames(store),
|
|
326
471
|
})
|
|
327
472
|
break
|
|
@@ -385,6 +530,53 @@ function createPoiDataBridge(options = {}) {
|
|
|
385
530
|
}
|
|
386
531
|
}
|
|
387
532
|
|
|
533
|
+
function clampedQueryInteger(value, fallback, minimum, maximum) {
|
|
534
|
+
if (value == null || value === '') return fallback
|
|
535
|
+
const parsed = Number(value)
|
|
536
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
537
|
+
return Math.min(maximum, Math.max(minimum, Math.trunc(parsed)))
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function claimInputOperation(inputLease, request) {
|
|
541
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
542
|
+
throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
|
|
543
|
+
}
|
|
544
|
+
const {
|
|
545
|
+
leaseId,
|
|
546
|
+
ownerSessionId,
|
|
547
|
+
runId,
|
|
548
|
+
action,
|
|
549
|
+
sequence,
|
|
550
|
+
...input
|
|
551
|
+
} = request
|
|
552
|
+
if (
|
|
553
|
+
typeof leaseId !== 'string' ||
|
|
554
|
+
typeof ownerSessionId !== 'string' ||
|
|
555
|
+
typeof runId !== 'string' ||
|
|
556
|
+
typeof action !== 'string' ||
|
|
557
|
+
!Number.isSafeInteger(sequence)
|
|
558
|
+
) {
|
|
559
|
+
throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
input,
|
|
563
|
+
claim: inputLease.consumeInput({
|
|
564
|
+
leaseId,
|
|
565
|
+
ownerSessionId,
|
|
566
|
+
runId,
|
|
567
|
+
action,
|
|
568
|
+
sequence,
|
|
569
|
+
}),
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function inputBridgeError(code, message, statusCode) {
|
|
574
|
+
const error = new Error(message)
|
|
575
|
+
error.code = code
|
|
576
|
+
error.statusCode = statusCode
|
|
577
|
+
return error
|
|
578
|
+
}
|
|
579
|
+
|
|
388
580
|
function readRequestBody(
|
|
389
581
|
req,
|
|
390
582
|
maxBytes = 1024 * 1024,
|
|
@@ -631,6 +823,11 @@ function readBridgeData(endpoint, readStore, plannerFile) {
|
|
|
631
823
|
records: (info.quests && info.quests.records) || {},
|
|
632
824
|
},
|
|
633
825
|
airbase: info.airbase || [],
|
|
826
|
+
repairs: info.repairs || [],
|
|
827
|
+
constructions: info.constructions || [],
|
|
828
|
+
maps: info.maps || {},
|
|
829
|
+
useitems: info.useitems || {},
|
|
830
|
+
sortie: store.sortie || {},
|
|
634
831
|
names: extractNames(store),
|
|
635
832
|
}
|
|
636
833
|
default:
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TTL_MS = 10_000
|
|
4
|
+
const MAX_TTL_MS = 30_000
|
|
5
|
+
const MAX_ID_LENGTH = 256
|
|
6
|
+
|
|
7
|
+
function createPoiInputLease(options = {}) {
|
|
8
|
+
const now = options.now || Date.now
|
|
9
|
+
const createId = options.createId || crypto.randomUUID
|
|
10
|
+
const defaultTtlMs = positiveInteger(
|
|
11
|
+
options.defaultTtlMs == null ? DEFAULT_TTL_MS : options.defaultTtlMs,
|
|
12
|
+
'defaultTtlMs',
|
|
13
|
+
)
|
|
14
|
+
const maxTtlMs = positiveInteger(
|
|
15
|
+
options.maxTtlMs == null ? MAX_TTL_MS : options.maxTtlMs,
|
|
16
|
+
'maxTtlMs',
|
|
17
|
+
)
|
|
18
|
+
if (defaultTtlMs > maxTtlMs) {
|
|
19
|
+
throw new Error('defaultTtlMs must not exceed maxTtlMs')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let active = null
|
|
23
|
+
|
|
24
|
+
function current() {
|
|
25
|
+
if (active && active.expiresAtMs <= now()) {
|
|
26
|
+
active = null
|
|
27
|
+
}
|
|
28
|
+
return active
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function status() {
|
|
32
|
+
const lease = current()
|
|
33
|
+
return lease ? publicLease(lease) : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function acquire(request) {
|
|
37
|
+
if (current()) {
|
|
38
|
+
throw leaseError('INPUT_LEASE_BUSY', 'Another input lease is active.')
|
|
39
|
+
}
|
|
40
|
+
const normalized = normalizeAcquireRequest(request, defaultTtlMs, maxTtlMs)
|
|
41
|
+
const leaseId = boundedId(createId(), 'leaseId')
|
|
42
|
+
active = {
|
|
43
|
+
leaseId,
|
|
44
|
+
ownerSessionId: normalized.ownerSessionId,
|
|
45
|
+
runId: normalized.runId,
|
|
46
|
+
mode: normalized.mode,
|
|
47
|
+
action: normalized.action,
|
|
48
|
+
expiresAtMs: now() + normalized.ttlMs,
|
|
49
|
+
nextSequence: 1,
|
|
50
|
+
}
|
|
51
|
+
return publicLease(active)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renew(request) {
|
|
55
|
+
const lease = requireMatchingLease(request)
|
|
56
|
+
const ttlMs = normalizeTtl(request && request.ttlMs, defaultTtlMs, maxTtlMs)
|
|
57
|
+
lease.expiresAtMs = now() + ttlMs
|
|
58
|
+
return publicLease(lease)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function release(request) {
|
|
62
|
+
requireMatchingLease(request)
|
|
63
|
+
active = null
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function revoke(request) {
|
|
68
|
+
const lease = current()
|
|
69
|
+
if (!lease) {
|
|
70
|
+
throw leaseError('INPUT_LEASE_REQUIRED', 'No input lease is active.')
|
|
71
|
+
}
|
|
72
|
+
if (boundedId(request && request.leaseId, 'leaseId') !== lease.leaseId) {
|
|
73
|
+
throw leaseError('INPUT_LEASE_MISMATCH', 'Input lease identity does not match.')
|
|
74
|
+
}
|
|
75
|
+
active = null
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function consumeInput(request) {
|
|
80
|
+
const lease = requireMatchingLease(request)
|
|
81
|
+
const action = boundedId(request && request.action, 'action')
|
|
82
|
+
if (lease.mode === 'action' && action !== lease.action) {
|
|
83
|
+
throw leaseError(
|
|
84
|
+
'INPUT_LEASE_ACTION_MISMATCH',
|
|
85
|
+
`Input action ${action} does not match lease action ${lease.action}.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
const sequence = request && request.sequence
|
|
89
|
+
if (!Number.isSafeInteger(sequence) || sequence !== lease.nextSequence) {
|
|
90
|
+
throw leaseError(
|
|
91
|
+
'INPUT_SEQUENCE_MISMATCH',
|
|
92
|
+
`Input sequence must be ${lease.nextSequence}.`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
lease.nextSequence += 1
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
leaseId: lease.leaseId,
|
|
98
|
+
ownerSessionId: lease.ownerSessionId,
|
|
99
|
+
runId: lease.runId,
|
|
100
|
+
action,
|
|
101
|
+
sequence,
|
|
102
|
+
acceptedAt: new Date(now()).toISOString(),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertClaimActive(claim) {
|
|
107
|
+
const lease = requireMatchingLease(claim)
|
|
108
|
+
const action = boundedId(claim && claim.action, 'action')
|
|
109
|
+
const sequence = claim && claim.sequence
|
|
110
|
+
if (
|
|
111
|
+
!Number.isSafeInteger(sequence) ||
|
|
112
|
+
sequence < 1 ||
|
|
113
|
+
sequence >= lease.nextSequence ||
|
|
114
|
+
(lease.mode === 'action' && action !== lease.action)
|
|
115
|
+
) {
|
|
116
|
+
throw leaseError('INPUT_LEASE_MISMATCH', 'Input claim does not match the lease.')
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requireMatchingLease(request) {
|
|
122
|
+
const lease = current()
|
|
123
|
+
if (!lease) {
|
|
124
|
+
throw leaseError('INPUT_LEASE_REQUIRED', 'No input lease is active.')
|
|
125
|
+
}
|
|
126
|
+
const leaseId = boundedId(request && request.leaseId, 'leaseId')
|
|
127
|
+
const ownerSessionId = boundedId(
|
|
128
|
+
request && request.ownerSessionId,
|
|
129
|
+
'ownerSessionId',
|
|
130
|
+
)
|
|
131
|
+
const runId = boundedId(request && request.runId, 'runId')
|
|
132
|
+
if (
|
|
133
|
+
lease.leaseId !== leaseId ||
|
|
134
|
+
lease.ownerSessionId !== ownerSessionId ||
|
|
135
|
+
lease.runId !== runId
|
|
136
|
+
) {
|
|
137
|
+
throw leaseError('INPUT_LEASE_MISMATCH', 'Input lease identity does not match.')
|
|
138
|
+
}
|
|
139
|
+
return lease
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
status,
|
|
144
|
+
acquire,
|
|
145
|
+
renew,
|
|
146
|
+
release,
|
|
147
|
+
revoke,
|
|
148
|
+
consumeInput,
|
|
149
|
+
assertClaimActive,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeAcquireRequest(request, defaultTtlMs, maxTtlMs) {
|
|
154
|
+
const ownerSessionId = boundedId(request && request.ownerSessionId, 'ownerSessionId')
|
|
155
|
+
const runId = boundedId(request && request.runId, 'runId')
|
|
156
|
+
const mode = request && request.mode
|
|
157
|
+
if (mode !== 'action' && mode !== 'workflow') {
|
|
158
|
+
throw leaseError('INVALID_INPUT_LEASE', 'Input lease mode must be action or workflow.')
|
|
159
|
+
}
|
|
160
|
+
const action = request && request.action
|
|
161
|
+
if (
|
|
162
|
+
(mode === 'action' && (typeof action !== 'string' || action.length === 0)) ||
|
|
163
|
+
(mode === 'workflow' && action !== null)
|
|
164
|
+
) {
|
|
165
|
+
throw leaseError(
|
|
166
|
+
'INVALID_INPUT_LEASE',
|
|
167
|
+
'Action leases require an action and workflow leases require action null.',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
ownerSessionId,
|
|
172
|
+
runId,
|
|
173
|
+
mode,
|
|
174
|
+
action: mode === 'action' ? boundedId(action, 'action') : null,
|
|
175
|
+
ttlMs: normalizeTtl(request.ttlMs, defaultTtlMs, maxTtlMs),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeTtl(value, defaultTtlMs, maxTtlMs) {
|
|
180
|
+
const ttlMs = value == null ? defaultTtlMs : value
|
|
181
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
182
|
+
throw leaseError('INVALID_INPUT_LEASE', 'Input lease ttlMs must be a positive integer.')
|
|
183
|
+
}
|
|
184
|
+
return Math.min(ttlMs, maxTtlMs)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function publicLease(lease) {
|
|
188
|
+
return Object.freeze({
|
|
189
|
+
leaseId: lease.leaseId,
|
|
190
|
+
ownerSessionId: lease.ownerSessionId,
|
|
191
|
+
runId: lease.runId,
|
|
192
|
+
mode: lease.mode,
|
|
193
|
+
action: lease.action,
|
|
194
|
+
expiresAt: new Date(lease.expiresAtMs).toISOString(),
|
|
195
|
+
nextSequence: lease.nextSequence,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function boundedId(value, name) {
|
|
200
|
+
if (
|
|
201
|
+
typeof value !== 'string' ||
|
|
202
|
+
value.length === 0 ||
|
|
203
|
+
value.length > MAX_ID_LENGTH
|
|
204
|
+
) {
|
|
205
|
+
throw leaseError('INVALID_INPUT_LEASE', `${name} must be a non-empty string.`)
|
|
206
|
+
}
|
|
207
|
+
return value
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function positiveInteger(value, name) {
|
|
211
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
212
|
+
throw new Error(`${name} must be a positive integer`)
|
|
213
|
+
}
|
|
214
|
+
return value
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function leaseError(code, message) {
|
|
218
|
+
const error = new Error(message)
|
|
219
|
+
error.code = code
|
|
220
|
+
error.statusCode = code === 'INVALID_INPUT_LEASE' ? 400 : 409
|
|
221
|
+
return error
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
createPoiInputLease,
|
|
226
|
+
}
|
package/lib/poi-input.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const CANONICAL_WIDTH = 1200
|
|
2
2
|
const CANONICAL_HEIGHT = 720
|
|
3
3
|
const DEFAULT_CLICK_DELAY_MS = 10
|
|
4
|
+
const DRAG_MOVE_STEPS = 6
|
|
4
5
|
const MAX_TEXT_LENGTH = 256
|
|
5
6
|
|
|
6
7
|
const SUPPORTED_BUTTONS = new Set(['left', 'middle', 'right'])
|
|
@@ -48,6 +49,10 @@ function createPoiInputProvider(options = {}) {
|
|
|
48
49
|
validateClick(operation)
|
|
49
50
|
await sendClick(layout, operation, delay, clickDelayMs)
|
|
50
51
|
return 'click'
|
|
52
|
+
case 'drag':
|
|
53
|
+
validateDrag(operation)
|
|
54
|
+
await sendDrag(layout, operation, delay)
|
|
55
|
+
return 'drag'
|
|
51
56
|
case 'key':
|
|
52
57
|
validateKey(operation)
|
|
53
58
|
await layout.webContents.sendInputEvent({
|
|
@@ -129,6 +134,48 @@ function validateClick(operation) {
|
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
|
|
137
|
+
function validateDrag(operation) {
|
|
138
|
+
assertExactFields(operation, [
|
|
139
|
+
'operation',
|
|
140
|
+
'fromX',
|
|
141
|
+
'fromY',
|
|
142
|
+
'toX',
|
|
143
|
+
'toY',
|
|
144
|
+
'durationMs',
|
|
145
|
+
'button',
|
|
146
|
+
])
|
|
147
|
+
const coordinates = [
|
|
148
|
+
operation.fromX,
|
|
149
|
+
operation.fromY,
|
|
150
|
+
operation.toX,
|
|
151
|
+
operation.toY,
|
|
152
|
+
]
|
|
153
|
+
if (coordinates.some((coordinate) => !Number.isFinite(coordinate))) {
|
|
154
|
+
throw new Error('Drag coordinates must be finite numbers')
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
operation.fromX < 0 ||
|
|
158
|
+
operation.fromX >= CANONICAL_WIDTH ||
|
|
159
|
+
operation.toX < 0 ||
|
|
160
|
+
operation.toX >= CANONICAL_WIDTH ||
|
|
161
|
+
operation.fromY < 0 ||
|
|
162
|
+
operation.fromY >= CANONICAL_HEIGHT ||
|
|
163
|
+
operation.toY < 0 ||
|
|
164
|
+
operation.toY >= CANONICAL_HEIGHT
|
|
165
|
+
) {
|
|
166
|
+
throw new Error('Drag coordinates must be within canonical bounds')
|
|
167
|
+
}
|
|
168
|
+
if (!Number.isInteger(operation.durationMs)) {
|
|
169
|
+
throw new Error('Drag durationMs must be an integer')
|
|
170
|
+
}
|
|
171
|
+
if (operation.durationMs < 50 || operation.durationMs > 2000) {
|
|
172
|
+
throw new Error('Drag durationMs must be from 50 to 2000')
|
|
173
|
+
}
|
|
174
|
+
if (!SUPPORTED_BUTTONS.has(operation.button)) {
|
|
175
|
+
throw new Error(`Unsupported mouse button: ${String(operation.button)}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
132
179
|
function validateKey(operation) {
|
|
133
180
|
assertExactFields(operation, ['operation', 'event', 'key'])
|
|
134
181
|
if (!SUPPORTED_KEY_EVENTS.has(operation.event)) {
|
|
@@ -181,6 +228,56 @@ async function sendClick(layout, operation, delay, clickDelayMs) {
|
|
|
181
228
|
}
|
|
182
229
|
}
|
|
183
230
|
|
|
231
|
+
async function sendDrag(layout, operation, delay) {
|
|
232
|
+
const start = {
|
|
233
|
+
x: Math.floor((operation.fromX * layout.width) / CANONICAL_WIDTH),
|
|
234
|
+
y: Math.floor((operation.fromY * layout.height) / CANONICAL_HEIGHT),
|
|
235
|
+
}
|
|
236
|
+
const destination = {
|
|
237
|
+
x: Math.floor((operation.toX * layout.width) / CANONICAL_WIDTH),
|
|
238
|
+
y: Math.floor((operation.toY * layout.height) / CANONICAL_HEIGHT),
|
|
239
|
+
}
|
|
240
|
+
const mouseButton = { button: operation.button }
|
|
241
|
+
|
|
242
|
+
await layout.webContents.sendInputEvent({
|
|
243
|
+
type: 'mouseMove',
|
|
244
|
+
...start,
|
|
245
|
+
...mouseButton,
|
|
246
|
+
})
|
|
247
|
+
try {
|
|
248
|
+
await layout.webContents.sendInputEvent({
|
|
249
|
+
type: 'mouseDown',
|
|
250
|
+
...start,
|
|
251
|
+
...mouseButton,
|
|
252
|
+
clickCount: 1,
|
|
253
|
+
})
|
|
254
|
+
for (let step = 1; step <= DRAG_MOVE_STEPS; step += 1) {
|
|
255
|
+
const elapsed = Math.round((operation.durationMs * step) / DRAG_MOVE_STEPS)
|
|
256
|
+
const previousElapsed = Math.round(
|
|
257
|
+
(operation.durationMs * (step - 1)) / DRAG_MOVE_STEPS,
|
|
258
|
+
)
|
|
259
|
+
await delay(elapsed - previousElapsed)
|
|
260
|
+
await layout.webContents.sendInputEvent({
|
|
261
|
+
type: 'mouseMove',
|
|
262
|
+
x: Math.round(
|
|
263
|
+
start.x + ((destination.x - start.x) * step) / DRAG_MOVE_STEPS,
|
|
264
|
+
),
|
|
265
|
+
y: Math.round(
|
|
266
|
+
start.y + ((destination.y - start.y) * step) / DRAG_MOVE_STEPS,
|
|
267
|
+
),
|
|
268
|
+
...mouseButton,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
await layout.webContents.sendInputEvent({
|
|
273
|
+
type: 'mouseUp',
|
|
274
|
+
...destination,
|
|
275
|
+
...mouseButton,
|
|
276
|
+
clickCount: 1,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
184
281
|
function defaultDelay(milliseconds) {
|
|
185
282
|
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
186
283
|
}
|