@swmansion/argent 0.5.3 → 0.6.0-next.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.
Files changed (52) hide show
  1. package/README.md +11 -14
  2. package/bin/ax-service +0 -0
  3. package/bin/simulator-server +0 -0
  4. package/dist/bundled-paths.d.ts +2 -0
  5. package/dist/bundled-paths.js +9 -0
  6. package/dist/bundled-paths.js.map +1 -0
  7. package/dist/cli-cmds.mjs +729 -0
  8. package/dist/cli.d.ts +15 -6
  9. package/dist/cli.js +59 -12
  10. package/dist/cli.js.map +1 -1
  11. package/dist/installer.mjs +14730 -0
  12. package/dist/mcp-server.mjs +14352 -0
  13. package/dist/preview-ui/index.html +799 -0
  14. package/dist/tool-server.cjs +4223 -1169
  15. package/dylibs/libArgentInjectionBootstrap.dylib +0 -0
  16. package/dylibs/libKeyboardPatch.dylib +0 -0
  17. package/dylibs/libNativeDevtoolsIos.dylib +0 -0
  18. package/package.json +15 -12
  19. package/skills/argent-react-native-profiler/SKILL.md +16 -12
  20. package/dist/auto-screenshot.d.ts +0 -22
  21. package/dist/auto-screenshot.js +0 -79
  22. package/dist/auto-screenshot.js.map +0 -1
  23. package/dist/cli/constants.d.ts +0 -6
  24. package/dist/cli/constants.js +0 -12
  25. package/dist/cli/constants.js.map +0 -1
  26. package/dist/cli/init.d.ts +0 -2
  27. package/dist/cli/init.js +0 -529
  28. package/dist/cli/init.js.map +0 -1
  29. package/dist/cli/mcp-configs.d.ts +0 -38
  30. package/dist/cli/mcp-configs.js +0 -779
  31. package/dist/cli/mcp-configs.js.map +0 -1
  32. package/dist/cli/skills.d.ts +0 -14
  33. package/dist/cli/skills.js +0 -95
  34. package/dist/cli/skills.js.map +0 -1
  35. package/dist/cli/uninstall.d.ts +0 -13
  36. package/dist/cli/uninstall.js +0 -389
  37. package/dist/cli/uninstall.js.map +0 -1
  38. package/dist/cli/update.d.ts +0 -1
  39. package/dist/cli/update.js +0 -115
  40. package/dist/cli/update.js.map +0 -1
  41. package/dist/cli/utils.d.ts +0 -38
  42. package/dist/cli/utils.js +0 -272
  43. package/dist/cli/utils.js.map +0 -1
  44. package/dist/content.d.ts +0 -31
  45. package/dist/content.js +0 -59
  46. package/dist/content.js.map +0 -1
  47. package/dist/launcher.d.ts +0 -8
  48. package/dist/launcher.js +0 -183
  49. package/dist/launcher.js.map +0 -1
  50. package/dist/mcp-server.d.ts +0 -1
  51. package/dist/mcp-server.js +0 -228
  52. package/dist/mcp-server.js.map +0 -1
@@ -0,0 +1,799 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Argent Preview</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --bg: #0e0e10;
11
+ --fg: #eaeaea;
12
+ --muted: #8a8a90;
13
+ --accent: #6aa6ff;
14
+ --border: #2a2a2e;
15
+ --surface: #16161a;
16
+ --error: #ff8080;
17
+ --ok: #5cdc8b;
18
+ }
19
+ * {
20
+ box-sizing: border-box;
21
+ }
22
+ html,
23
+ body {
24
+ margin: 0;
25
+ padding: 0;
26
+ height: 100%;
27
+ background: var(--bg);
28
+ color: var(--fg);
29
+ font-family:
30
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
31
+ font-size: 13px;
32
+ }
33
+ #root {
34
+ display: flex;
35
+ flex-direction: column;
36
+ height: 100%;
37
+ }
38
+ .toolbar {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 10px;
42
+ padding: 8px 12px;
43
+ border-bottom: 1px solid var(--border);
44
+ background: var(--surface);
45
+ flex-shrink: 0;
46
+ }
47
+ .toolbar label {
48
+ color: var(--muted);
49
+ font-size: 11px;
50
+ text-transform: uppercase;
51
+ letter-spacing: 0.04em;
52
+ }
53
+ .toolbar select,
54
+ .toolbar button {
55
+ background: var(--bg);
56
+ color: var(--fg);
57
+ border: 1px solid var(--border);
58
+ padding: 4px 10px;
59
+ border-radius: 4px;
60
+ font-size: 13px;
61
+ }
62
+ .toolbar select {
63
+ min-width: 240px;
64
+ }
65
+ .toolbar button {
66
+ cursor: pointer;
67
+ }
68
+ .toolbar .status {
69
+ margin-left: auto;
70
+ color: var(--muted);
71
+ font-size: 12px;
72
+ }
73
+ .toolbar .debug {
74
+ color: var(--muted);
75
+ font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
76
+ font-size: 11px;
77
+ min-width: 220px;
78
+ text-align: right;
79
+ }
80
+ .status.live::before,
81
+ .status.error::before {
82
+ content: "";
83
+ display: inline-block;
84
+ width: 8px;
85
+ height: 8px;
86
+ border-radius: 50%;
87
+ margin-right: 6px;
88
+ vertical-align: middle;
89
+ }
90
+ .status.live::before {
91
+ background: var(--ok);
92
+ animation: pulse 1.6s ease-in-out infinite;
93
+ }
94
+ .status.error::before {
95
+ background: var(--error);
96
+ }
97
+ @keyframes pulse {
98
+ 0%,
99
+ 100% {
100
+ opacity: 1;
101
+ }
102
+ 50% {
103
+ opacity: 0.4;
104
+ }
105
+ }
106
+ .viewport {
107
+ flex: 1;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ padding: 12px;
112
+ min-height: 0;
113
+ position: relative;
114
+ }
115
+ .stage {
116
+ position: relative;
117
+ max-width: 100%;
118
+ max-height: 100%;
119
+ display: flex;
120
+ }
121
+ .stage img {
122
+ max-width: 100%;
123
+ max-height: 80vh;
124
+ object-fit: contain;
125
+ border-radius: 10px;
126
+ box-shadow: 0 10px 48px #0008;
127
+ user-select: none;
128
+ -webkit-user-drag: none;
129
+ -webkit-user-select: none;
130
+ touch-action: none;
131
+ pointer-events: auto;
132
+ cursor: crosshair;
133
+ }
134
+ /* Multi-touch indicators overlay on the image, positioned via CSS vars. */
135
+ .touch-dot {
136
+ position: absolute;
137
+ top: var(--y);
138
+ left: var(--x);
139
+ width: var(--size);
140
+ height: var(--size);
141
+ transform: translate(-50%, -50%);
142
+ border-radius: 50%;
143
+ background: rgba(106, 166, 255, 0.25);
144
+ border: 2px solid rgba(106, 166, 255, 0.9);
145
+ pointer-events: none;
146
+ box-shadow: 0 0 12px rgba(106, 166, 255, 0.5);
147
+ }
148
+ .touch-dot.pressed {
149
+ background: rgba(106, 166, 255, 0.55);
150
+ }
151
+ .touch-dot.anchor {
152
+ background: rgba(255, 200, 80, 0.9);
153
+ border-color: rgba(255, 200, 80, 1);
154
+ }
155
+ .empty,
156
+ .error-box {
157
+ color: var(--muted);
158
+ text-align: center;
159
+ padding: 32px;
160
+ max-width: 480px;
161
+ line-height: 1.5;
162
+ }
163
+ .error-box {
164
+ color: var(--error);
165
+ }
166
+ code {
167
+ background: var(--surface);
168
+ padding: 2px 6px;
169
+ border-radius: 3px;
170
+ font-size: 12px;
171
+ }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <div id="root">
176
+ <div class="toolbar">
177
+ <label for="sim-select">Simulator</label>
178
+ <select id="sim-select">
179
+ <option>Loading…</option>
180
+ </select>
181
+ <button id="refresh-btn" title="Refresh simulator list">⟳</button>
182
+ <span id="hint" class="debug" style="min-width: auto"></span>
183
+ <span id="debug" class="debug"></span>
184
+ <span id="status" class="status">idle</span>
185
+ </div>
186
+ <div class="viewport" id="viewport">
187
+ <div class="empty">Select a booted simulator to start streaming.</div>
188
+ </div>
189
+ </div>
190
+
191
+ <script>
192
+ "use strict";
193
+
194
+ // ── Config ──────────────────────────────────────────────────────────
195
+ // Tool-server base URL. When this page is served by tool-server itself,
196
+ // same-origin is correct. Override via ?toolServer=http://host:port for
197
+ // standalone testing.
198
+ const urlParams = new URLSearchParams(window.location.search);
199
+ const TOOL_SERVER =
200
+ urlParams.get("toolServer") ||
201
+ (window.location.origin.startsWith("http")
202
+ ? window.location.origin
203
+ : "http://127.0.0.1:3001");
204
+
205
+ // Browser KeyboardEvent.code → USB HID Keyboard Usage Page (0x07) keycode.
206
+ // sim-server's `key` command expects these raw HID codes — shift/modifier
207
+ // state is conveyed by the browser issuing separate ShiftLeft/ShiftRight
208
+ // key events which we forward verbatim.
209
+ const HID = {
210
+ KeyA: 4,
211
+ KeyB: 5,
212
+ KeyC: 6,
213
+ KeyD: 7,
214
+ KeyE: 8,
215
+ KeyF: 9,
216
+ KeyG: 10,
217
+ KeyH: 11,
218
+ KeyI: 12,
219
+ KeyJ: 13,
220
+ KeyK: 14,
221
+ KeyL: 15,
222
+ KeyM: 16,
223
+ KeyN: 17,
224
+ KeyO: 18,
225
+ KeyP: 19,
226
+ KeyQ: 20,
227
+ KeyR: 21,
228
+ KeyS: 22,
229
+ KeyT: 23,
230
+ KeyU: 24,
231
+ KeyV: 25,
232
+ KeyW: 26,
233
+ KeyX: 27,
234
+ KeyY: 28,
235
+ KeyZ: 29,
236
+ Digit1: 30,
237
+ Digit2: 31,
238
+ Digit3: 32,
239
+ Digit4: 33,
240
+ Digit5: 34,
241
+ Digit6: 35,
242
+ Digit7: 36,
243
+ Digit8: 37,
244
+ Digit9: 38,
245
+ Digit0: 39,
246
+ Enter: 40,
247
+ Escape: 41,
248
+ Backspace: 42,
249
+ Tab: 43,
250
+ Space: 44,
251
+ Minus: 45,
252
+ Equal: 46,
253
+ BracketLeft: 47,
254
+ BracketRight: 48,
255
+ Backslash: 49,
256
+ Semicolon: 51,
257
+ Quote: 52,
258
+ Backquote: 53,
259
+ Comma: 54,
260
+ Period: 55,
261
+ Slash: 56,
262
+ CapsLock: 57,
263
+ F1: 58,
264
+ F2: 59,
265
+ F3: 60,
266
+ F4: 61,
267
+ F5: 62,
268
+ F6: 63,
269
+ F7: 64,
270
+ F8: 65,
271
+ F9: 66,
272
+ F10: 67,
273
+ F11: 68,
274
+ F12: 69,
275
+ ArrowRight: 79,
276
+ ArrowLeft: 80,
277
+ ArrowDown: 81,
278
+ ArrowUp: 82,
279
+ NumpadDivide: 84,
280
+ NumpadMultiply: 85,
281
+ NumpadSubtract: 86,
282
+ NumpadAdd: 87,
283
+ NumpadEnter: 88,
284
+ Numpad1: 89,
285
+ Numpad2: 90,
286
+ Numpad3: 91,
287
+ Numpad4: 92,
288
+ Numpad5: 93,
289
+ Numpad6: 94,
290
+ Numpad7: 95,
291
+ Numpad8: 96,
292
+ Numpad9: 97,
293
+ Numpad0: 98,
294
+ NumpadDecimal: 99,
295
+ ControlLeft: 224,
296
+ ShiftLeft: 225,
297
+ AltLeft: 226,
298
+ MetaLeft: 227,
299
+ ControlRight: 228,
300
+ ShiftRight: 229,
301
+ AltRight: 230,
302
+ MetaRight: 231,
303
+ IntlBackslash: 86,
304
+ };
305
+ const CAPS_LOCK_HID = 57;
306
+
307
+ // Multi-touch modifier — matches radon-ide: Alt on macOS, Ctrl elsewhere.
308
+ const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
309
+ const MULTITOUCH_CODES = IS_MAC
310
+ ? new Set(["AltLeft", "AltRight"])
311
+ : new Set(["ControlLeft", "ControlRight"]);
312
+
313
+ // ── DOM ─────────────────────────────────────────────────────────────
314
+ const select = document.getElementById("sim-select");
315
+ const status = document.getElementById("status");
316
+ const viewport = document.getElementById("viewport");
317
+ const refreshBtn = document.getElementById("refresh-btn");
318
+ const debugEl = document.getElementById("debug");
319
+ const hintEl = document.getElementById("hint");
320
+ hintEl.textContent = `Hold ${IS_MAC ? "Option" : "Ctrl"} for pinch · +Shift to move anchor`;
321
+
322
+ // ── State ───────────────────────────────────────────────────────────
323
+ /** @type {WebSocket | null} */
324
+ let ws = null;
325
+ let wsOpen = false;
326
+ let currentUdid = null;
327
+ let img = null;
328
+ let stage = null;
329
+ /** Elements for the three multi-touch indicators (primary / mirror / anchor). */
330
+ let touchDots = { primary: null, mirror: null, anchor: null };
331
+ let activePointerId = null;
332
+ /** True while the mouse button is held inside the stage (finger is "down"). */
333
+ let isPressing = false;
334
+ /** True while the multi-touch modifier is held. */
335
+ let isMultiTouching = false;
336
+ /** Primary and anchor points in normalized [0,1] space. */
337
+ let touchPoint = { x: 0.5, y: 0.5 };
338
+ let anchorPoint = { x: 0.5, y: 0.5 };
339
+ /** Keys currently held down (HID codes) — released on blur. */
340
+ const pressedKeys = new Set();
341
+ /** Last known CapsLock state to resync iOS if it drifts. */
342
+ let lastCapsLockState = false;
343
+ let cmdCounter = 0;
344
+
345
+ // ── Helpers ─────────────────────────────────────────────────────────
346
+ function setStatus(text, kind = "") {
347
+ status.textContent = text;
348
+ status.className = "status" + (kind ? " " + kind : "");
349
+ }
350
+
351
+ function nextCmdId() {
352
+ return String(++cmdCounter);
353
+ }
354
+
355
+ function sendCmd(obj) {
356
+ if (!ws || !wsOpen) {
357
+ debugEl.textContent = `ws not open — dropped ${obj.cmd}`;
358
+ return;
359
+ }
360
+ ws.send(JSON.stringify({ id: nextCmdId(), ...obj }));
361
+ if (obj.cmd === "touch") {
362
+ const tail =
363
+ obj.second_x != null
364
+ ? ` / (${obj.second_x.toFixed(3)}, ${obj.second_y.toFixed(3)})`
365
+ : "";
366
+ debugEl.textContent = `${obj.type} (${obj.x.toFixed(3)}, ${obj.y.toFixed(3)})${tail}`;
367
+ } else if (obj.cmd === "key") {
368
+ debugEl.textContent = `key ${obj.direction} ${obj.code}`;
369
+ } else if (obj.cmd === "wheel") {
370
+ debugEl.textContent = `wheel dx=${obj.dx} dy=${obj.dy}`;
371
+ } else {
372
+ debugEl.textContent = `${obj.cmd}`;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Convert a pointer/mouse/wheel event (in client coords) into the
378
+ * normalized 0.0–1.0 device space the simulator-server expects.
379
+ */
380
+ function toDeviceCoords(ev) {
381
+ if (!img) return null;
382
+ const rect = img.getBoundingClientRect();
383
+ if (!rect.width || !rect.height) return null;
384
+ const xRatio = (ev.clientX - rect.left) / rect.width;
385
+ const yRatio = (ev.clientY - rect.top) / rect.height;
386
+ return {
387
+ x: Math.max(0, Math.min(1, xRatio)),
388
+ y: Math.max(0, Math.min(1, yRatio)),
389
+ };
390
+ }
391
+
392
+ function mirrorAround(pt, anchor) {
393
+ return {
394
+ x: Math.max(0, Math.min(1, 2 * anchor.x - pt.x)),
395
+ y: Math.max(0, Math.min(1, 2 * anchor.y - pt.y)),
396
+ };
397
+ }
398
+
399
+ /**
400
+ * sim-server wheel deltas are small integers (~3–7). Browser deltaY can be
401
+ * anything from 0.001 to 400+, so we clamp by magnitude the way radon-ide
402
+ * does. Sign is negated so scroll direction matches iOS natural-scroll.
403
+ */
404
+ function normalizeWheel(delta) {
405
+ const abs = Math.abs(delta);
406
+ const magnitude = abs > 150 ? 7 : abs > 50 ? 5 : 3;
407
+ return -Math.sign(delta) * magnitude;
408
+ }
409
+
410
+ function sendTouchEvent(type) {
411
+ if (isMultiTouching) {
412
+ const secondary = mirrorAround(touchPoint, anchorPoint);
413
+ sendCmd({
414
+ cmd: "touch",
415
+ type,
416
+ x: touchPoint.x,
417
+ y: touchPoint.y,
418
+ second_x: secondary.x,
419
+ second_y: secondary.y,
420
+ });
421
+ } else {
422
+ sendCmd({ cmd: "touch", type, x: touchPoint.x, y: touchPoint.y });
423
+ }
424
+ }
425
+
426
+ // ── Multi-touch indicators ─────────────────────────────────────────
427
+ function updateIndicators() {
428
+ if (!stage) return;
429
+ if (!isMultiTouching) {
430
+ for (const k of ["primary", "mirror", "anchor"]) {
431
+ if (touchDots[k]) touchDots[k].remove();
432
+ touchDots[k] = null;
433
+ }
434
+ return;
435
+ }
436
+ const mirror = mirrorAround(touchPoint, anchorPoint);
437
+ ensureDot("primary", touchPoint, 28, false);
438
+ ensureDot("mirror", mirror, 28, false);
439
+ ensureDot("anchor", anchorPoint, 10, true);
440
+ }
441
+ function ensureDot(key, pt, size, isAnchor) {
442
+ const imgRect = img.getBoundingClientRect();
443
+ const stageRect = stage.getBoundingClientRect();
444
+ // Position relative to the stage, inside the image area.
445
+ const offsetX = imgRect.left - stageRect.left;
446
+ const offsetY = imgRect.top - stageRect.top;
447
+ const x = offsetX + pt.x * imgRect.width;
448
+ const y = offsetY + pt.y * imgRect.height;
449
+ let el = touchDots[key];
450
+ if (!el) {
451
+ el = document.createElement("div");
452
+ el.className = "touch-dot" + (isAnchor ? " anchor" : "");
453
+ stage.appendChild(el);
454
+ touchDots[key] = el;
455
+ }
456
+ el.style.setProperty("--x", `${x}px`);
457
+ el.style.setProperty("--y", `${y}px`);
458
+ el.style.setProperty("--size", `${size}px`);
459
+ el.classList.toggle("pressed", isPressing && !isAnchor);
460
+ }
461
+
462
+ // ── Simulator list ─────────────────────────────────────────────────
463
+ async function refreshSimulators() {
464
+ setStatus("loading simulators…");
465
+ try {
466
+ const res = await fetch(`${TOOL_SERVER}/preview/simulators`);
467
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
468
+ const { simulators } = await res.json();
469
+ select.innerHTML = "";
470
+ if (!simulators || simulators.length === 0) {
471
+ const opt = document.createElement("option");
472
+ opt.textContent = "(no simulators found)";
473
+ select.appendChild(opt);
474
+ select.disabled = true;
475
+ setStatus("no simulators", "error");
476
+ return;
477
+ }
478
+ select.disabled = false;
479
+ const placeholder = document.createElement("option");
480
+ placeholder.value = "";
481
+ placeholder.textContent = "— choose simulator —";
482
+ select.appendChild(placeholder);
483
+ for (const s of simulators) {
484
+ const opt = document.createElement("option");
485
+ opt.value = s.udid;
486
+ opt.textContent = `${s.state === "Booted" ? "● " : "○ "}${s.name}`;
487
+ opt.disabled = s.state !== "Booted";
488
+ select.appendChild(opt);
489
+ }
490
+ setStatus("idle");
491
+ } catch (err) {
492
+ setStatus(`failed: ${err.message || err}`, "error");
493
+ }
494
+ }
495
+
496
+ // ── Stream + WS wiring ─────────────────────────────────────────────
497
+ function teardownStream() {
498
+ releaseAllInput();
499
+ if (ws) {
500
+ try {
501
+ ws.close();
502
+ } catch {}
503
+ ws = null;
504
+ }
505
+ wsOpen = false;
506
+ // Explicitly clear the img src — otherwise the browser keeps the MJPEG
507
+ // HTTP connection open after the element is detached, and re-selecting
508
+ // a simulator leaks more connections. Same trick radon-ide uses.
509
+ if (img) {
510
+ img.src = "";
511
+ img.removeAttribute("src");
512
+ }
513
+ img = null;
514
+ stage = null;
515
+ touchDots = { primary: null, mirror: null, anchor: null };
516
+ viewport.innerHTML = "";
517
+ }
518
+
519
+ async function connect(udid) {
520
+ teardownStream();
521
+ currentUdid = udid;
522
+ setStatus("starting simulator-server…");
523
+ viewport.innerHTML = '<div class="empty">Starting simulator-server…</div>';
524
+ let info;
525
+ try {
526
+ const res = await fetch(
527
+ `${TOOL_SERVER}/preview/simulator-server/${encodeURIComponent(udid)}`
528
+ );
529
+ if (!res.ok) {
530
+ const body = await res.json().catch(() => ({}));
531
+ throw new Error(body.error || `HTTP ${res.status}`);
532
+ }
533
+ info = await res.json();
534
+ } catch (err) {
535
+ setStatus(`failed: ${err.message || err}`, "error");
536
+ viewport.innerHTML = `<div class="error-box">Could not start simulator-server.<br/>${escapeHtml(String(err.message || err))}</div>`;
537
+ return;
538
+ }
539
+
540
+ if (!info.streamUrl) {
541
+ setStatus("no stream URL", "error");
542
+ viewport.innerHTML =
543
+ '<div class="error-box">Simulator-server is running but did not advertise a stream URL.<br/>Rebuild with the <code>argent</code> cargo feature (which enables <code>streaming</code>).</div>';
544
+ return;
545
+ }
546
+
547
+ // Build the viewport.
548
+ stage = document.createElement("div");
549
+ stage.className = "stage";
550
+ img = document.createElement("img");
551
+ img.alt = `Simulator ${udid.slice(0, 8)}`;
552
+ img.draggable = false;
553
+ // Append a cache-buster so re-connects don't reuse a stale MJPEG stream.
554
+ img.src = `${info.streamUrl}?t=${Date.now()}`;
555
+ img.onerror = () => setStatus("stream error", "error");
556
+ stage.appendChild(img);
557
+ viewport.innerHTML = "";
558
+ viewport.appendChild(stage);
559
+
560
+ attachInputHandlers(img);
561
+
562
+ // Open WebSocket to sim-server.
563
+ ws = new WebSocket(info.wsUrl);
564
+ ws.onopen = () => {
565
+ wsOpen = true;
566
+ setStatus("live", "live");
567
+ };
568
+ ws.onclose = () => {
569
+ wsOpen = false;
570
+ if (currentUdid === udid) setStatus("disconnected", "error");
571
+ };
572
+ ws.onerror = () => {
573
+ setStatus("ws error", "error");
574
+ };
575
+ }
576
+
577
+ // ── Input handlers ─────────────────────────────────────────────────
578
+ /**
579
+ * Release any held touch/keys. Called on teardown and on window blur —
580
+ * without this, losing focus mid-drag (or Cmd-tabbing with Shift held)
581
+ * leaves the device stuck with a phantom touch / modifier.
582
+ */
583
+ function releaseAllInput() {
584
+ if (isPressing) {
585
+ sendTouchEvent("Up");
586
+ isPressing = false;
587
+ }
588
+ isMultiTouching = false;
589
+ updateIndicators();
590
+ for (const code of pressedKeys) {
591
+ sendCmd({ cmd: "key", direction: "Up", code });
592
+ }
593
+ pressedKeys.clear();
594
+ activePointerId = null;
595
+ }
596
+
597
+ function attachInputHandlers(target) {
598
+ target.addEventListener("dragstart", (ev) => ev.preventDefault());
599
+
600
+ target.addEventListener("pointerdown", (ev) => {
601
+ if (activePointerId !== null) return;
602
+ const pt = toDeviceCoords(ev);
603
+ if (!pt) return;
604
+ activePointerId = ev.pointerId;
605
+ try {
606
+ target.setPointerCapture(ev.pointerId);
607
+ } catch {}
608
+ touchPoint = pt;
609
+ isPressing = true;
610
+ sendTouchEvent("Down");
611
+ updateIndicators();
612
+ ev.preventDefault();
613
+ });
614
+ target.addEventListener("pointermove", (ev) => {
615
+ const pt = toDeviceCoords(ev);
616
+ if (!pt) return;
617
+ // Shift + Multi-touch + drag → move the anchor point along with the finger.
618
+ if (isMultiTouching && ev.shiftKey) {
619
+ anchorPoint = {
620
+ x: Math.max(0, Math.min(1, anchorPoint.x + (pt.x - touchPoint.x))),
621
+ y: Math.max(0, Math.min(1, anchorPoint.y + (pt.y - touchPoint.y))),
622
+ };
623
+ }
624
+ touchPoint = pt;
625
+ if (ev.pointerId === activePointerId && isPressing) {
626
+ sendTouchEvent("Move");
627
+ }
628
+ updateIndicators();
629
+ ev.preventDefault();
630
+ });
631
+ const endPointer = (ev) => {
632
+ if (ev.pointerId !== activePointerId) return;
633
+ const pt = toDeviceCoords(ev);
634
+ if (pt) touchPoint = pt;
635
+ if (isPressing) sendTouchEvent("Up");
636
+ isPressing = false;
637
+ try {
638
+ target.releasePointerCapture(ev.pointerId);
639
+ } catch {}
640
+ activePointerId = null;
641
+ updateIndicators();
642
+ ev.preventDefault();
643
+ };
644
+ target.addEventListener("pointerup", endPointer);
645
+ target.addEventListener("pointercancel", endPointer);
646
+
647
+ // Mouse leaves the image while dragging — commit an Up so the device
648
+ // doesn't stay stuck. If the user drags back in with the button still
649
+ // held, pointerenter (re)starts a Down at the entry point.
650
+ target.addEventListener("pointerleave", (ev) => {
651
+ if (isPressing) {
652
+ sendTouchEvent("Up");
653
+ isPressing = false;
654
+ updateIndicators();
655
+ }
656
+ });
657
+ target.addEventListener("pointerenter", (ev) => {
658
+ // Only auto-resume if the button is still held (buttons bitmask).
659
+ if (ev.buttons & 1 && !isPressing) {
660
+ const pt = toDeviceCoords(ev);
661
+ if (pt) {
662
+ touchPoint = pt;
663
+ isPressing = true;
664
+ sendTouchEvent("Down");
665
+ updateIndicators();
666
+ }
667
+ }
668
+ });
669
+
670
+ target.addEventListener(
671
+ "wheel",
672
+ (ev) => {
673
+ const pt = toDeviceCoords(ev);
674
+ if (!pt) return;
675
+ const dx = normalizeWheel(ev.deltaX);
676
+ const dy = normalizeWheel(ev.deltaY);
677
+ if (dx === 0 && dy === 0) {
678
+ ev.preventDefault();
679
+ return;
680
+ }
681
+ sendCmd({ cmd: "wheel", x: pt.x, y: pt.y, dx, dy });
682
+ ev.preventDefault();
683
+ },
684
+ { passive: false }
685
+ );
686
+ }
687
+
688
+ function escapeHtml(s) {
689
+ return s.replace(
690
+ /[&<>"']/g,
691
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
692
+ );
693
+ }
694
+
695
+ // ── Events ──────────────────────────────────────────────────────────
696
+ select.addEventListener("change", () => {
697
+ const udid = select.value;
698
+ if (!udid) return;
699
+ // Blur the select so keys (including the multi-touch Option/Ctrl modifier)
700
+ // route to our window-level handler instead of being swallowed by the
701
+ // dropdown's built-in keyboard handling.
702
+ select.blur();
703
+ connect(udid).catch((err) => setStatus(String(err.message || err), "error"));
704
+ });
705
+ refreshBtn.addEventListener("click", () => {
706
+ refreshBtn.blur();
707
+ refreshSimulators();
708
+ });
709
+
710
+ // Global keyboard forwarding — active whenever a simulator is connected
711
+ // and focus is not on a toolbar control. We forward raw keydown/keyup so
712
+ // the simulator sees modifier keys (Shift, Ctrl, etc.) as real key events.
713
+ function shouldForwardKey(ev) {
714
+ if (!wsOpen) return false;
715
+ const ae = document.activeElement;
716
+ if (ae && (ae.tagName === "SELECT" || ae.tagName === "BUTTON" || ae.tagName === "INPUT")) {
717
+ return false;
718
+ }
719
+ // Let the browser keep its own shortcuts (Cmd+R, Cmd+T, Cmd+W, Cmd+L, …).
720
+ if (ev.metaKey && ev.code !== "MetaLeft" && ev.code !== "MetaRight") return false;
721
+ return true;
722
+ }
723
+
724
+ /**
725
+ * Sync CapsLock state to the device. Browser only fires keydown when
726
+ * CapsLock turns on and keyup when it turns off — never a full sequence.
727
+ * iOS expects a full down-up to toggle, and can drift out of sync when
728
+ * CapsLock is pressed outside the webview. On any key event we check if
729
+ * our cached CapsLock state diverges from the event's modifier state and
730
+ * emit a full toggle sequence to resync.
731
+ */
732
+ function syncCapsLock(ev) {
733
+ const current = ev.getModifierState && ev.getModifierState("CapsLock");
734
+ if (current === undefined) return;
735
+ if (current !== lastCapsLockState) {
736
+ lastCapsLockState = current;
737
+ sendCmd({ cmd: "key", direction: "Down", code: CAPS_LOCK_HID });
738
+ sendCmd({ cmd: "key", direction: "Up", code: CAPS_LOCK_HID });
739
+ }
740
+ }
741
+
742
+ function handleKeyEvent(ev) {
743
+ if (!shouldForwardKey(ev)) return;
744
+ syncCapsLock(ev);
745
+
746
+ const isDown = ev.type === "keydown";
747
+
748
+ // Multi-touch modifier → enter/exit two-finger mode, anchored at screen
749
+ // center with the primary finger at the cursor. Shift+drag during
750
+ // multi-touch moves the anchor.
751
+ if (MULTITOUCH_CODES.has(ev.code)) {
752
+ if (isDown && !isMultiTouching) {
753
+ anchorPoint = { x: 0.5, y: 0.5 };
754
+ // If we're mid-drag, smoothly promote the single touch to multi-touch
755
+ // by issuing a Move with the second finger materialized.
756
+ isMultiTouching = true;
757
+ updateIndicators();
758
+ if (isPressing) sendTouchEvent("Move");
759
+ } else if (!isDown && isMultiTouching) {
760
+ if (isPressing) sendTouchEvent("Up");
761
+ isPressing = false;
762
+ isMultiTouching = false;
763
+ updateIndicators();
764
+ }
765
+ ev.preventDefault();
766
+ return;
767
+ }
768
+
769
+ const code = HID[ev.code];
770
+ if (code == null) return;
771
+ if (code === CAPS_LOCK_HID) return; // handled by syncCapsLock
772
+
773
+ if (isDown) {
774
+ pressedKeys.add(code);
775
+ } else {
776
+ pressedKeys.delete(code);
777
+ }
778
+ sendCmd({ cmd: "key", direction: isDown ? "Down" : "Up", code });
779
+ ev.preventDefault();
780
+ }
781
+ window.addEventListener("keydown", handleKeyEvent);
782
+ window.addEventListener("keyup", handleKeyEvent);
783
+
784
+ // Disable the right-click context menu over the stage so right-button
785
+ // drags (if the user tries) don't pop up a menu on top of the stream.
786
+ window.addEventListener("contextmenu", (ev) => {
787
+ if (stage && stage.contains(ev.target)) ev.preventDefault();
788
+ });
789
+
790
+ // On focus loss, release any active touches/keys so the device doesn't
791
+ // stay stuck with a held finger or modifier.
792
+ window.addEventListener("blur", releaseAllInput);
793
+
794
+ window.addEventListener("beforeunload", teardownStream);
795
+
796
+ refreshSimulators();
797
+ </script>
798
+ </body>
799
+ </html>