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.
@@ -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: inputSequence,
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
- sendInputJson(res, 200, await enqueueInput(operation))
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, { error: error.message })
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 endpoint = new URL(req.url, `http://127.0.0.1:${actualPort || configuredPort}`).pathname
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
  }