@jian-ai/remote-control 0.8.0 → 0.8.2

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * macOS computer-use: primary-display screenshots, coord mapping, Jian overlay helper.
3
- * English-only messages. Imported by jian-remote.mjs on darwin.
3
+ * English-only messages. Imported by computer.mjs on darwin.
4
4
  */
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
6
  import { createHash } from 'node:crypto';
@@ -9,40 +9,38 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
9
  import { homedir } from 'node:os';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { COMPUTER_CAPABILITIES, imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
12
13
 
13
- export const COMPUTER_CAPABILITY = 'computer-macos-v1';
14
+ export { imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
15
+
16
+ export const COMPUTER_CAPABILITY = COMPUTER_CAPABILITIES.darwin;
14
17
  const IDLE_MS = 30_000;
15
18
  const HELPER_NAME = 'computer-helper';
19
+ /** How long we cache lock / display-asleep probes between HTTPS polls. */
20
+ const UI_STATE_CACHE_MS = 5_000;
21
+ export const MAC_UI_STATES = Object.freeze(['unlocked', 'locked', 'display_asleep', 'unknown']);
16
22
 
17
- /** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
18
- export function imagePixelToPoint(x, y, geometry) {
19
- const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
20
- if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
21
- throw new Error('Invalid screenshot geometry for coordinate mapping');
22
- }
23
- return {
24
- pointX: originX + (x + 0.5) * (pointWidth / widthPx),
25
- pointY: originY + (y + 0.5) * (pointHeight / heightPx),
26
- };
27
- }
23
+ const LOCKED_COMPUTER_MESSAGE =
24
+ 'Mac is locked — unlock the screen to use Computer Use. Shell and file tools may still work while the worker is online.';
28
25
 
29
- export function scaleFromGeometry(geometry) {
30
- return {
31
- imageToPointX: geometry.pointWidth / geometry.widthPx,
32
- imageToPointY: geometry.pointHeight / geometry.heightPx,
33
- };
34
- }
26
+ let cachedUiState = { at: 0, state: 'unknown' };
27
+ let workerIdleCaffeinate = null;
28
+ let sessionDisplayCaffeinate = null;
35
29
 
36
- /** Capture primary display and compress like the gateway’s former screenshotCommand. */
30
+ /**
31
+ * Capture primary display and compress for the worker channel.
32
+ * Single-pass ladder starts at 1600/42 (skips a full-res 1920@50 encode that often
33
+ * exceeded the budget and forced a second sips pass — common waste on Retina).
34
+ */
37
35
  export function capturePrimaryScreenshotBase64() {
38
36
  const script = [
39
37
  'tmp=$(mktemp /tmp/jian-shot.XXXXXX.jpg);',
40
38
  'screencapture -x -t jpg -D 1 "$tmp" || { rm -f "$tmp"; exit 1; };',
41
- 'sips -Z 1920 -s format jpeg -s formatOptions 50 "$tmp" >/dev/null 2>&1 || true;',
39
+ 'sips -Z 1600 -s format jpeg -s formatOptions 42 "$tmp" >/dev/null 2>&1 || true;',
42
40
  'size=$(wc -c < "$tmp" | tr -d " ");',
43
- 'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 40 "$tmp" >/dev/null 2>&1 || true; fi;',
41
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 38 "$tmp" >/dev/null 2>&1 || true; fi;',
44
42
  'size=$(wc -c < "$tmp" | tr -d " ");',
45
- 'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 35 "$tmp" >/dev/null 2>&1 || true; fi;',
43
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 34 "$tmp" >/dev/null 2>&1 || true; fi;',
46
44
  'w=$(sips -g pixelWidth "$tmp" 2>/dev/null | awk "/pixelWidth/{print \\$2}");',
47
45
  'h=$(sips -g pixelHeight "$tmp" 2>/dev/null | awk "/pixelHeight/{print \\$2}");',
48
46
  'echo "$w $h";',
@@ -75,22 +73,77 @@ export function capturePrimaryScreenshotBase64() {
75
73
  return { base64, widthPx, heightPx };
76
74
  }
77
75
 
78
- function helperPaths() {
79
- const support = join(homedir(), 'Library', 'Application Support', 'Jian Remote');
76
+ function helperPaths(home = homedir()) {
77
+ const support = join(home, 'Library', 'Application Support', 'Jian Remote');
80
78
  return {
81
79
  support,
82
80
  source: join(support, `${HELPER_NAME}.swift`),
83
81
  binary: join(support, HELPER_NAME),
82
+ stamp: join(support, `${HELPER_NAME}.sha256`),
83
+ };
84
+ }
85
+
86
+ /** Paths inside the installed npm package (or a checkout). */
87
+ export function packageHelperPaths(
88
+ packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'),
89
+ ) {
90
+ return {
91
+ swift: join(packageRoot, 'macos', `${HELPER_NAME}.swift`),
92
+ binary: join(packageRoot, 'macos', HELPER_NAME),
84
93
  };
85
94
  }
86
95
 
87
96
  function packageSwiftPath() {
88
- return join(dirname(fileURLToPath(import.meta.url)), '..', 'macos', `${HELPER_NAME}.swift`);
97
+ return packageHelperPaths().swift;
89
98
  }
90
99
 
100
+ function packageBinaryPath() {
101
+ return packageHelperPaths().binary;
102
+ }
103
+
104
+ function stampForBytes(bytes) {
105
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 16);
106
+ }
107
+
108
+ /** Drop download quarantine so a helper from npm can exec under the LaunchAgent. */
109
+ function clearQuarantine(path) {
110
+ spawnSync('xattr', ['-d', 'com.apple.quarantine', path], { encoding: 'utf8' });
111
+ }
112
+
113
+ /**
114
+ * Prefer a Publish-built universal binary from the npm package; otherwise compile with swiftc.
115
+ * Stamp is over the installed artifact bytes so updates replace a stale helper.
116
+ */
91
117
  async function ensureHelperBinary() {
92
118
  const paths = helperPaths();
93
119
  await mkdir(paths.support, { recursive: true, mode: 0o755 });
120
+
121
+ let previous = '';
122
+ try {
123
+ previous = (await readFile(paths.stamp, 'utf8')).trim();
124
+ } catch {
125
+ previous = '';
126
+ }
127
+
128
+ const packagedBinary = packageBinaryPath();
129
+ if (existsSync(packagedBinary)) {
130
+ const bytes = await readFile(packagedBinary);
131
+ const hash = `bin:${stampForBytes(bytes)}`;
132
+ if (previous !== hash || !existsSync(paths.binary)) {
133
+ await copyFile(packagedBinary, paths.binary);
134
+ await chmod(paths.binary, 0o755);
135
+ clearQuarantine(paths.binary);
136
+ await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
137
+ }
138
+ try {
139
+ const swift = packageSwiftPath();
140
+ if (existsSync(swift)) await copyFile(swift, paths.source);
141
+ } catch {
142
+ // optional
143
+ }
144
+ return paths.binary;
145
+ }
146
+
94
147
  let sourceText;
95
148
  try {
96
149
  sourceText = await readFile(packageSwiftPath(), 'utf8');
@@ -99,18 +152,11 @@ async function ensureHelperBinary() {
99
152
  sourceText = await readFile(paths.source, 'utf8');
100
153
  } catch {
101
154
  throw new Error(
102
- 'Computer-use helper source is missing. Reinstall or update @jian-ai/remote-control.',
155
+ 'Computer-use helper is missing from this install. Reinstall or update @jian-ai/remote-control.',
103
156
  );
104
157
  }
105
158
  }
106
- const hash = createHash('sha256').update(sourceText).digest('hex').slice(0, 16);
107
- const stamp = join(paths.support, `${HELPER_NAME}.sha256`);
108
- let previous = '';
109
- try {
110
- previous = (await readFile(stamp, 'utf8')).trim();
111
- } catch {
112
- previous = '';
113
- }
159
+ const hash = `src:${stampForBytes(Buffer.from(sourceText, 'utf8'))}`;
114
160
  const needsBuild = previous !== hash || !existsSync(paths.binary);
115
161
  await writeFile(paths.source, sourceText, { mode: 0o644 });
116
162
  if (needsBuild) {
@@ -122,27 +168,44 @@ async function ensureHelperBinary() {
122
168
  if (compiled.status !== 0) {
123
169
  throw new Error(
124
170
  (compiled.stderr || compiled.stdout || 'swiftc failed').trim() ||
125
- 'Could not build the Jian computer-use helper. Install Xcode Command Line Tools.',
171
+ 'Could not build the Jian computer-use helper. Published packages ship a prebuilt helper; for source builds install Xcode Command Line Tools, or reinstall @jian-ai/remote-control.',
126
172
  );
127
173
  }
128
174
  await chmod(paths.binary, 0o755);
129
- await writeFile(stamp, `${hash}\n`, { mode: 0o644 });
175
+ clearQuarantine(paths.binary);
176
+ await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
130
177
  }
131
178
  return paths.binary;
132
179
  }
133
180
 
134
- /** Copy Swift sources next to the installed worker when present in the npm package. */
181
+ /** Copy Swift source and any prebuilt helper next to the installed worker. */
135
182
  export async function materializeComputerHelperSources(configDir) {
136
183
  if (process.platform !== 'darwin') return;
137
184
  try {
138
- const from = packageSwiftPath();
139
- if (!existsSync(from)) return;
185
+ const pkg = packageHelperPaths();
186
+ if (!existsSync(pkg.swift) && !existsSync(pkg.binary)) return;
140
187
  const support = join(homedir(), 'Library', 'Application Support', 'Jian Remote');
141
188
  await mkdir(support, { recursive: true, mode: 0o755 });
142
- await copyFile(from, join(support, `${HELPER_NAME}.swift`));
143
189
  const localMacos = join(configDir, 'macos');
144
190
  await mkdir(localMacos, { recursive: true, mode: 0o755 });
145
- await copyFile(from, join(localMacos, `${HELPER_NAME}.swift`));
191
+ if (existsSync(pkg.swift)) {
192
+ await copyFile(pkg.swift, join(support, `${HELPER_NAME}.swift`));
193
+ await copyFile(pkg.swift, join(localMacos, `${HELPER_NAME}.swift`));
194
+ }
195
+ if (existsSync(pkg.binary)) {
196
+ const destSupport = join(support, HELPER_NAME);
197
+ const destLocal = join(localMacos, HELPER_NAME);
198
+ await copyFile(pkg.binary, destSupport);
199
+ await copyFile(pkg.binary, destLocal);
200
+ await chmod(destSupport, 0o755);
201
+ await chmod(destLocal, 0o755);
202
+ clearQuarantine(destSupport);
203
+ clearQuarantine(destLocal);
204
+ const bytes = await readFile(pkg.binary);
205
+ await writeFile(join(support, `${HELPER_NAME}.sha256`), `bin:${stampForBytes(bytes)}\n`, {
206
+ mode: 0o644,
207
+ });
208
+ }
146
209
  } catch {
147
210
  // Best-effort on install; computer session will surface a clear error.
148
211
  }
@@ -161,8 +224,11 @@ export class MacComputerSession {
161
224
 
162
225
  async ensure() {
163
226
  if (this.child && !this.child.killed) {
164
- await this.send({ cmd: 'ensureSession' });
165
- this.active = true;
227
+ if (!this.active) {
228
+ await this.send({ cmd: 'ensureSession' });
229
+ this.active = true;
230
+ }
231
+ startSessionDisplayAssertion(this.child.pid || process.pid);
166
232
  this.bumpIdle();
167
233
  return;
168
234
  }
@@ -181,6 +247,7 @@ export class MacComputerSession {
181
247
  this.failAll('Computer-use helper exited unexpectedly.');
182
248
  this.child = null;
183
249
  this.active = false;
250
+ stopSessionDisplayAssertion();
184
251
  });
185
252
  const ping = await this.send({ cmd: 'ping' });
186
253
  if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
@@ -197,6 +264,7 @@ export class MacComputerSession {
197
264
  };
198
265
  await this.send({ cmd: 'ensureSession' });
199
266
  this.active = true;
267
+ startSessionDisplayAssertion(this.child.pid || process.pid);
200
268
  this.bumpIdle();
201
269
  }
202
270
 
@@ -277,6 +345,7 @@ export class MacComputerSession {
277
345
  async end() {
278
346
  if (this.idleTimer) clearTimeout(this.idleTimer);
279
347
  this.idleTimer = null;
348
+ stopSessionDisplayAssertion();
280
349
  if (this.child) {
281
350
  try {
282
351
  await this.send({ cmd: 'endSession' });
@@ -305,7 +374,7 @@ export class MacComputerSession {
305
374
  }
306
375
 
307
376
  if (action === 'wait') {
308
- const ms = Math.round((computer.duration ?? 0.5) * 1000);
377
+ const ms = Math.round((computer.duration ?? 0.35) * 1000);
309
378
  await new Promise((r) => setTimeout(r, ms));
310
379
  if (this.active) this.bumpIdle();
311
380
  return {
@@ -317,6 +386,17 @@ export class MacComputerSession {
317
386
  };
318
387
  }
319
388
 
389
+ const uiBlocked = assertComputerUiAllowed();
390
+ if (uiBlocked) {
391
+ return {
392
+ exitCode: 1,
393
+ stdout: '',
394
+ stderr: uiBlocked,
395
+ timedOut: false,
396
+ computer: { ok: false, error: uiBlocked },
397
+ };
398
+ }
399
+
320
400
  if (action === 'screenshot') {
321
401
  await this.ensure();
322
402
  const shot = capturePrimaryScreenshotBase64();
@@ -344,22 +424,19 @@ export class MacComputerSession {
344
424
  await this.ensure();
345
425
 
346
426
  try {
347
- if (
348
- action === 'mouse_move' ||
349
- action === 'left_click' ||
350
- action === 'right_click' ||
351
- action === 'double_click'
352
- ) {
427
+ if (action === 'mouse_move') {
353
428
  if (!computer.coordinate) throw new Error('coordinate is required for this action.');
354
429
  const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
355
430
  const moved = await this.send({ cmd: 'move', pointX, pointY });
356
431
  if (!moved.ok) throw new Error(moved.error || 'Move failed.');
357
- if (action !== 'mouse_move') {
358
- const button = action === 'right_click' ? 'right' : 'left';
359
- const count = action === 'double_click' ? 2 : 1;
360
- const clicked = await this.send({ cmd: 'click', button, count });
361
- if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
362
- }
432
+ } else if (action === 'left_click' || action === 'right_click' || action === 'double_click') {
433
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
434
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
435
+ const button = action === 'right_click' ? 'right' : 'left';
436
+ const count = action === 'double_click' ? 2 : 1;
437
+ // One RPC: animate overlay, click, restore user cursor (helper-side).
438
+ const clicked = await this.send({ cmd: 'clickAt', pointX, pointY, button, count });
439
+ if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
363
440
  } else if (action === 'left_click_drag') {
364
441
  if (!computer.startCoordinate || !computer.coordinate) {
365
442
  throw new Error('startCoordinate and coordinate are required for drag.');
@@ -469,11 +546,130 @@ export async function probeComputerCapability() {
469
546
  await ensureHelperBinary();
470
547
  return true;
471
548
  } catch {
472
- // Still advertise: screenshot-only path can work; input fails with TCC guidance.
473
- // Prefer advertising when swiftc exists so gateway can send jobs after first grant.
474
549
  return (
475
550
  Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
476
551
  existsSync(helperPaths().binary)
477
552
  );
478
553
  }
479
554
  }
555
+
556
+ function normalizeUiState(value) {
557
+ return MAC_UI_STATES.includes(value) ? value : 'unknown';
558
+ }
559
+
560
+ /**
561
+ * Lock screen vs display sleep vs unlocked. Prefer the helper binary (CGSession +
562
+ * CGDisplayIsAsleep); fall back to unknown so we never invent "offline".
563
+ */
564
+ export function probeMacUiState({ force = false } = {}) {
565
+ if (process.platform !== 'darwin') return 'unknown';
566
+ const now = Date.now();
567
+ if (!force && now - cachedUiState.at < UI_STATE_CACHE_MS) return cachedUiState.state;
568
+
569
+ let state = 'unknown';
570
+ const binary = helperPaths().binary;
571
+ if (existsSync(binary)) {
572
+ const probed = spawnSync(binary, ['--session-status'], {
573
+ encoding: 'utf8',
574
+ timeout: 4_000,
575
+ maxBuffer: 16_000,
576
+ });
577
+ if (probed.status === 0) {
578
+ try {
579
+ const line = (probed.stdout || '').trim().split('\n').pop();
580
+ const parsed = JSON.parse(line || '{}');
581
+ if (parsed?.ok) state = normalizeUiState(parsed.state);
582
+ } catch {
583
+ state = 'unknown';
584
+ }
585
+ }
586
+ }
587
+
588
+ cachedUiState = { at: now, state };
589
+ return state;
590
+ }
591
+
592
+ /**
593
+ * Prevent idle system sleep for the worker process so HTTPS polls keep firing when the
594
+ * screen is locked. Does not claim lid-closed / clamshell immunity (see docs/remote.md).
595
+ */
596
+ export function startWorkerIdleAssertion(pid = process.pid) {
597
+ if (process.platform !== 'darwin') return null;
598
+ stopWorkerIdleAssertion();
599
+ try {
600
+ workerIdleCaffeinate = spawn('/usr/bin/caffeinate', ['-i', '-w', String(pid)], {
601
+ stdio: 'ignore',
602
+ detached: false,
603
+ });
604
+ workerIdleCaffeinate.on('error', () => {
605
+ workerIdleCaffeinate = null;
606
+ });
607
+ workerIdleCaffeinate.unref?.();
608
+ return workerIdleCaffeinate;
609
+ } catch {
610
+ workerIdleCaffeinate = null;
611
+ return null;
612
+ }
613
+ }
614
+
615
+ export function stopWorkerIdleAssertion() {
616
+ if (!workerIdleCaffeinate) return;
617
+ try {
618
+ workerIdleCaffeinate.kill('SIGTERM');
619
+ } catch {
620
+ // already gone
621
+ }
622
+ workerIdleCaffeinate = null;
623
+ }
624
+
625
+ /** While a computer-use session is open, also keep the display from idling off. */
626
+ function startSessionDisplayAssertion(pid = process.pid) {
627
+ if (process.platform !== 'darwin') return;
628
+ stopSessionDisplayAssertion();
629
+ try {
630
+ sessionDisplayCaffeinate = spawn('/usr/bin/caffeinate', ['-dimsu', '-w', String(pid)], {
631
+ stdio: 'ignore',
632
+ detached: false,
633
+ });
634
+ sessionDisplayCaffeinate.on('error', () => {
635
+ sessionDisplayCaffeinate = null;
636
+ });
637
+ sessionDisplayCaffeinate.unref?.();
638
+ } catch {
639
+ sessionDisplayCaffeinate = null;
640
+ }
641
+ }
642
+
643
+ function stopSessionDisplayAssertion() {
644
+ if (!sessionDisplayCaffeinate) return;
645
+ try {
646
+ sessionDisplayCaffeinate.kill('SIGTERM');
647
+ } catch {
648
+ // already gone
649
+ }
650
+ sessionDisplayCaffeinate = null;
651
+ }
652
+
653
+ /** User-activity assertion: nudge a sleeping (unlocked) display awake. */
654
+ export function wakeMacDisplay(seconds = 3) {
655
+ if (process.platform !== 'darwin') return;
656
+ const secs = Math.max(1, Math.min(30, Math.round(seconds)));
657
+ spawnSync('/usr/bin/caffeinate', ['-u', '-t', String(secs)], {
658
+ encoding: 'utf8',
659
+ timeout: (secs + 2) * 1000,
660
+ });
661
+ }
662
+
663
+ /**
664
+ * Before GUI actions: wake a sleeping display; refuse clearly when the session is locked.
665
+ * Returns null when Computer Use may proceed, or an English error string.
666
+ */
667
+ export function assertComputerUiAllowed() {
668
+ let state = probeMacUiState({ force: true });
669
+ if (state === 'display_asleep' || state === 'unknown') {
670
+ wakeMacDisplay(3);
671
+ state = probeMacUiState({ force: true });
672
+ }
673
+ if (state === 'locked') return LOCKED_COMPUTER_MESSAGE;
674
+ return null;
675
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared computer-use helpers: coords, capabilities, motion easing.
3
+ * Platform backends live in computer-{macos,linux,windows}.mjs.
4
+ */
5
+
6
+ export const COMPUTER_CAPABILITIES = Object.freeze({
7
+ darwin: 'computer-macos-v1',
8
+ linux: 'computer-linux-v1',
9
+ win32: 'computer-windows-v1',
10
+ });
11
+
12
+ const CAPABILITY_SET = new Set(Object.values(COMPUTER_CAPABILITIES));
13
+
14
+ /** True when a worker capability string means remote_computer is supported. */
15
+ export function isComputerCapability(value) {
16
+ return CAPABILITY_SET.has(value);
17
+ }
18
+
19
+ export function capabilityForPlatform(platform = process.platform) {
20
+ return COMPUTER_CAPABILITIES[platform] ?? null;
21
+ }
22
+
23
+ /** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
24
+ export function imagePixelToPoint(x, y, geometry) {
25
+ const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
26
+ if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
27
+ throw new Error('Invalid screenshot geometry for coordinate mapping');
28
+ }
29
+ return {
30
+ pointX: originX + (x + 0.5) * (pointWidth / widthPx),
31
+ pointY: originY + (y + 0.5) * (pointHeight / heightPx),
32
+ };
33
+ }
34
+
35
+ export function scaleFromGeometry(geometry) {
36
+ return {
37
+ imageToPointX: geometry.pointWidth / geometry.widthPx,
38
+ imageToPointY: geometry.pointHeight / geometry.heightPx,
39
+ };
40
+ }
41
+
42
+ /** Ease-out cubic — snappy agent loops, still readable as motion. */
43
+ export function easeOutCubic(t) {
44
+ const x = Math.min(1, Math.max(0, t));
45
+ return 1 - (1 - x) ** 3;
46
+ }
47
+
48
+ /**
49
+ * Duration (ms) for overlay travel. Caps keep agent loops fast on long jumps.
50
+ * ~0.25 ms/px with floor 28 ms / ceiling 110 ms.
51
+ */
52
+ export function motionDurationMs(distancePx) {
53
+ const d = Math.max(0, distancePx);
54
+ return Math.round(Math.min(110, Math.max(28, d * 0.25)));
55
+ }
56
+
57
+ export function pointDistance(a, b) {
58
+ const dx = (b.pointX ?? b.x) - (a.pointX ?? a.x);
59
+ const dy = (b.pointY ?? b.y) - (a.pointY ?? a.y);
60
+ return Math.hypot(dx, dy);
61
+ }