@stim-cli/server 1.11.0 → 1.13.0

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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as FRAME_FPS, c as MAX_LOG_TAIL, i as FRAME_EDGE, n as CAPABILITIES, o as LOG_LEVELS, s as LOG_SOURCES, t as ACTIONS } from "./protocol-CmlG7X7_.mjs";
2
+ import { _ as ROTATE_DIRECTIONS, c as INPUT_BUTTONS, f as MAX_LOG_TAIL, g as RELOAD_PLATFORMS, l as LOG_LEVELS, n as CAPABILITIES, o as FRAME_EDGE, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES, v as TOUCH_PHASES } from "./protocol-rC-5KT1O.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { accessSync, appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, watch, writeFileSync } from "node:fs";
5
5
  import { availableParallelism, cpus, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
@@ -12,9 +12,9 @@ import { configDir, withDirLock } from "@stim-cli/core";
12
12
  import { createServer } from "node:http";
13
13
  import { isIP } from "node:net";
14
14
  import { WebSocketServer } from "ws";
15
- import { createInterface } from "node:readline";
16
- import { fileURLToPath } from "node:url";
17
15
  import { connect } from "node:http2";
16
+ import { fileURLToPath } from "node:url";
17
+ import { createInterface } from "node:readline";
18
18
  //#region src/environment.ts
19
19
  function parseEnvironment(output) {
20
20
  const environment = {};
@@ -284,9 +284,9 @@ function parseAction(params) {
284
284
  action,
285
285
  workspace
286
286
  } };
287
- if (platform !== void 0 && platform !== "ios" && platform !== "android") return {
287
+ if (platform !== void 0 && !RELOAD_PLATFORMS.includes(platform)) return {
288
288
  code: "bad-request",
289
- message: "platform must be ios or android."
289
+ message: `platform must be one of ${RELOAD_PLATFORMS.join(", ")}.`
290
290
  };
291
291
  return { action: {
292
292
  action: "reload",
@@ -397,12 +397,20 @@ function runStim(stimCli, env, args, cwd, limits) {
397
397
  const chunks = [];
398
398
  let size = 0;
399
399
  let stderr = "";
400
+ const release = () => {
401
+ child.stdout.destroy();
402
+ child.stderr.destroy();
403
+ };
400
404
  const fail = (message) => {
401
405
  if (failure) return;
402
406
  failure = message;
407
+ release();
403
408
  terminate(child);
404
409
  };
405
- const timer = setTimeout(() => fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`), limits.timeoutMs);
410
+ const timer = setTimeout(() => {
411
+ if (child.exitCode !== null || child.signalCode !== null) return release();
412
+ fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`);
413
+ }, limits.timeoutMs);
406
414
  child.stdout.on("data", (chunk) => {
407
415
  size += chunk.length;
408
416
  if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
@@ -447,95 +455,6 @@ function runStim(stimCli, env, args, cwd, limits) {
447
455
  };
448
456
  }
449
457
  //#endregion
450
- //#region src/feed.ts
451
- var Feed = class {
452
- listeners = /* @__PURE__ */ new Set();
453
- kept = [];
454
- child;
455
- ended;
456
- constructor(spec, stimCli, env, ended) {
457
- this.ended = ended;
458
- const child = spawn(process.execPath, [stimCli, ...spec.args], {
459
- cwd: spec.cwd,
460
- env,
461
- stdio: [
462
- "ignore",
463
- "pipe",
464
- "pipe"
465
- ]
466
- });
467
- this.child = child;
468
- let stderr = "";
469
- child.stderr?.setEncoding("utf8");
470
- child.stderr?.on("data", (chunk) => {
471
- stderr = (stderr + chunk).slice(-2e3);
472
- });
473
- createInterface({ input: child.stdout }).on("line", (line) => {
474
- if (this.child !== child) return;
475
- let value;
476
- try {
477
- value = JSON.parse(line);
478
- } catch {
479
- return;
480
- }
481
- if (!isJsonObject(value)) return;
482
- this.kept.push(value);
483
- if (this.kept.length > spec.keep) this.kept.shift();
484
- for (const listener of this.listeners) listener.item(value);
485
- });
486
- const exited = (reason) => {
487
- if (this.child !== child) return;
488
- this.child = null;
489
- this.ended();
490
- const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
491
- const listeners = [...this.listeners];
492
- this.listeners.clear();
493
- for (const listener of listeners) listener.failed(message);
494
- };
495
- child.on("error", (error) => exited(`could not start (${error.message})`));
496
- child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
497
- }
498
- add(listener) {
499
- this.listeners.add(listener);
500
- for (const value of this.kept) listener.item(value);
501
- return () => {
502
- if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
503
- };
504
- }
505
- stop() {
506
- const child = this.child;
507
- if (!child) return Promise.resolve();
508
- this.child = null;
509
- this.listeners.clear();
510
- this.ended();
511
- return terminate(child);
512
- }
513
- };
514
- var FeedPool = class {
515
- feeds = /* @__PURE__ */ new Map();
516
- stimCli;
517
- env;
518
- constructor(stimCli, env) {
519
- this.stimCli = stimCli;
520
- this.env = env;
521
- }
522
- subscribe(spec, listener) {
523
- const key = JSON.stringify([spec.cwd, spec.args]);
524
- let feed = this.feeds.get(key);
525
- if (!feed) {
526
- const created = new Feed(spec, this.stimCli, this.env, () => {
527
- if (this.feeds.get(key) === created) this.feeds.delete(key);
528
- });
529
- this.feeds.set(key, created);
530
- feed = created;
531
- }
532
- return feed.add(listener);
533
- }
534
- async close() {
535
- await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
536
- }
537
- };
538
- //#endregion
539
458
  //#region src/video.ts
540
459
  const FIXED_HEADER_BYTES = 21;
541
460
  /** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
@@ -543,7 +462,8 @@ function videoPacket(subscription, sequence, unit) {
543
462
  const id = Buffer.from(subscription, "ascii");
544
463
  const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
545
464
  header.writeUInt8(1, 0);
546
- header.writeUInt8(unit.keyframe ? 1 : 0, 1);
465
+ const posture = unit.posture === "folded" ? 2 : unit.posture === "unfolded" ? 4 : 0;
466
+ header.writeUInt8((unit.keyframe ? 1 : 0) | posture, 1);
547
467
  header.writeUInt16BE(header.length, 2);
548
468
  header.writeUInt32BE(sequence >>> 0, 4);
549
469
  header.writeDoubleBE(unit.capturedAt, 8);
@@ -685,20 +605,9 @@ function run(file, args, env, timeoutMs, signal) {
685
605
  */
686
606
  async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
687
607
  if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
688
- const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted();
608
+ const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
689
609
  if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
690
- const version = await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal);
691
- const hash = createHash("sha256").update(version);
692
- for (const name of sources) hash.update(name).update(readFileSync(join(sourcesDir, name)));
693
- const dir = join(serverDir(), "helpers");
694
- const helper = join(dir, `stim-frames-${hash.digest("hex").slice(0, 16)}`);
695
- if (existsSync(helper)) return helper;
696
- mkdirSync(dir, {
697
- recursive: true,
698
- mode: 448
699
- });
700
- const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
701
- try {
610
+ return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
702
611
  await run("xcrun", [
703
612
  "swiftc",
704
613
  "-O",
@@ -708,8 +617,61 @@ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
708
617
  "StimFrames",
709
618
  "-o",
710
619
  output,
711
- ...sources.map((source) => join(sourcesDir, source))
620
+ ...sources
712
621
  ], env, BUILD_TIMEOUT_MS, signal);
622
+ });
623
+ }
624
+ /**
625
+ * Compiles `sim-fold`, which folds or unfolds an iPhone Duo from inside the simulator, from the Stim Desktop
626
+ * sources shipped in `dist/stim-frames/`, the way Stim Desktop's bundle script builds it.
627
+ */
628
+ async function buildFoldHelper(env, signal, sourcesDir = SOURCES_DIR) {
629
+ if (process.platform !== "darwin") throw new Error("sim-fold runs only on macOS.");
630
+ const source = join(sourcesDir, "sim-fold.m");
631
+ const entitlements = join(sourcesDir, "sim-fold.entitlements");
632
+ const clang = [
633
+ "-sdk",
634
+ "iphonesimulator",
635
+ "clang"
636
+ ];
637
+ const version = await run("xcrun", [...clang, "--version"], env, VERSION_TIMEOUT_MS, signal);
638
+ return compiled("sim-fold", [source, entitlements], version, async (output) => {
639
+ await run("xcrun", [
640
+ ...clang,
641
+ "-fobjc-arc",
642
+ "-arch",
643
+ "arm64",
644
+ "-arch",
645
+ "x86_64",
646
+ "-mios-simulator-version-min=18.0",
647
+ "-framework",
648
+ "Foundation",
649
+ source,
650
+ "-o",
651
+ output,
652
+ `-Wl,-sectcreate,__TEXT,__entitlements,${entitlements}`
653
+ ], env, BUILD_TIMEOUT_MS, signal);
654
+ await run("codesign", [
655
+ "--force",
656
+ "--sign",
657
+ "-",
658
+ output
659
+ ], env, VERSION_TIMEOUT_MS, signal);
660
+ });
661
+ }
662
+ async function compiled(name, inputs, version, compile) {
663
+ const hash = createHash("sha256").update(version);
664
+ for (const input of inputs) hash.update(basename(input)).update(readFileSync(input));
665
+ const dir = join(serverDir(), "helpers");
666
+ const helper = join(dir, `${name}-${hash.digest("hex").slice(0, 16)}`);
667
+ if (existsSync(helper)) return helper;
668
+ mkdirSync(dir, {
669
+ recursive: true,
670
+ mode: 448
671
+ });
672
+ const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
673
+ try {
674
+ await compile(output);
713
675
  renameSync(output, helper);
714
676
  } finally {
715
677
  rmSync(output, { force: true });
@@ -717,9 +679,11 @@ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
717
679
  return helper;
718
680
  }
719
681
  /**
720
- * One `stim-frames` process per device, shared by its subscribers and stopped with the last of them. It runs
682
+ * One `stim-frames` process per device, shared by its subscribers and stopped `lingerMs` after the last of them
683
+ * leaves, unchanged meanwhile, so a subscriber that comes back within that time gets the latest frame at once. It runs
721
684
  * at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
722
- * helper exits when its stdin closes, so it cannot outlive the server.
685
+ * helper exits when its stdin closes, so it cannot outlive the server. `lit` turns the display the helper
686
+ * reports it streams into a posture, which the frames and access units after it carry.
723
687
  */
724
688
  var HelperSource = class {
725
689
  listeners = /* @__PURE__ */ new Map();
@@ -728,13 +692,20 @@ var HelperSource = class {
728
692
  config = "";
729
693
  stopped = false;
730
694
  notice = null;
695
+ keyboard = null;
731
696
  stderr = "";
732
697
  ended;
733
698
  bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
734
699
  keyframeAt = -Infinity;
735
700
  keyframeTimer = null;
736
- constructor(helper, device, env, ended) {
701
+ lit;
702
+ posture;
703
+ lingerMs;
704
+ lingerTimer = null;
705
+ constructor(helper, device, env, ended, lingerMs, lit) {
737
706
  this.ended = ended;
707
+ this.lingerMs = lingerMs;
708
+ this.lit = lit;
738
709
  const id = device.platform === "ios" ? device.udid : device.serial;
739
710
  this.child = spawn(helper, [device.platform, id], {
740
711
  env,
@@ -756,21 +727,25 @@ var HelperSource = class {
756
727
  this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
757
728
  });
758
729
  }
730
+ /** A null `hint` keeps the helper running for input without asking for frames. */
759
731
  add(listener, hint) {
732
+ if (this.lingerTimer) clearTimeout(this.lingerTimer);
733
+ this.lingerTimer = null;
760
734
  this.listeners.set(listener, hint);
761
735
  this.configure();
762
736
  if (listener.video) this.keyframe();
763
737
  else if (this.last) listener.frame(this.last);
764
738
  return () => {
765
739
  if (!this.listeners.delete(listener)) return;
766
- if (this.listeners.size === 0) this.stop();
767
- else this.configure();
740
+ if (this.listeners.size > 0) this.configure();
741
+ else this.lingerTimer = setTimeout(() => void this.stop(), this.lingerMs);
768
742
  };
769
743
  }
770
744
  stop() {
771
745
  if (this.stopped) return Promise.resolve();
772
746
  this.stopped = true;
773
747
  if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
748
+ if (this.lingerTimer) clearTimeout(this.lingerTimer);
774
749
  this.listeners.clear();
775
750
  this.ended();
776
751
  this.child.stdin.end();
@@ -793,12 +768,20 @@ var HelperSource = class {
793
768
  congested() {
794
769
  if (this.bitrate.congested(Date.now()) !== null) this.configure();
795
770
  }
771
+ send(command) {
772
+ if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
773
+ }
796
774
  configure() {
797
- const hints = [...this.listeners.values()];
798
- const jpegFps = [...this.listeners].flatMap(([listener, hint]) => listener.video ? [] : [hint.fps]);
775
+ if (this.listeners.size === 0) return;
776
+ const watching = [...this.listeners].flatMap(([listener, hint]) => hint ? [{
777
+ listener,
778
+ hint
779
+ }] : []);
780
+ const hints = watching.map(({ hint }) => hint);
781
+ const jpegFps = watching.flatMap(({ listener, hint }) => listener.video ? [] : [hint.fps]);
799
782
  const config = JSON.stringify({
800
- fps: Math.max(...hints.map((hint) => hint.fps)),
801
- maxEdge: Math.max(...hints.map((hint) => hint.maxEdge)),
783
+ fps: Math.max(0, ...hints.map((hint) => hint.fps)),
784
+ maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
802
785
  jpeg: jpegFps.length > 0,
803
786
  ...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
804
787
  video: jpegFps.length < hints.length,
@@ -840,7 +823,8 @@ var HelperSource = class {
840
823
  width: body.readUInt16BE(1),
841
824
  height: body.readUInt16BE(3),
842
825
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
843
- data: body.subarray(5).toString("base64")
826
+ data: body.subarray(5).toString("base64"),
827
+ ...this.posture ? { posture: this.posture } : {}
844
828
  };
845
829
  for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
846
830
  }
@@ -851,15 +835,23 @@ var HelperSource = class {
851
835
  capturedAt: body.readDoubleBE(2),
852
836
  width: body.readUInt16BE(10),
853
837
  height: body.readUInt16BE(12),
854
- data: body.subarray(VIDEO_HEADER_BYTES)
838
+ data: body.subarray(VIDEO_HEADER_BYTES),
839
+ ...this.posture ? { posture: this.posture } : {}
855
840
  };
856
841
  if (this.bitrate.tick(Date.now()) !== null) this.configure();
857
842
  for (const listener of this.listeners.keys()) listener.video?.(unit);
858
843
  }
859
844
  readNotice(text) {
860
845
  try {
861
- const error = JSON.parse(text)?.error;
846
+ const notice = JSON.parse(text);
847
+ const error = notice?.error;
862
848
  if (typeof error === "string") this.notice = error;
849
+ const keyboard = notice?.keyboard;
850
+ if (keyboard === "yes" || keyboard === "no") this.keyboard = keyboard === "yes";
851
+ const inputError = notice?.inputError;
852
+ if (typeof inputError === "string") console.error(`stim-server: stim-frames: ${inputError}`);
853
+ const display = notice?.display;
854
+ if (this.lit && typeof display === "number") this.posture = this.lit(display);
863
855
  } catch {}
864
856
  }
865
857
  };
@@ -869,7 +861,8 @@ const DEFAULT_FRAME_LIMITS = {
869
861
  toolTimeoutMs: 3e4,
870
862
  slowCaptureMs: 3e3,
871
863
  failureBackoffMs: 3e3,
872
- maxConsecutiveFailures: 3
864
+ maxConsecutiveFailures: 3,
865
+ lingerMs: 1e4
873
866
  };
874
867
  const MIN_INTERVAL_MS = 200;
875
868
  const MAX_INTERVAL_MS = 1e3;
@@ -1032,11 +1025,14 @@ function simulatorCapturer(device, env, limits, litPanels) {
1032
1025
  const lit = litPanels.get(device.udid) ?? "primary";
1033
1026
  const jpeg = await screenshot([`--display=${lit}`]);
1034
1027
  open();
1035
- if (!await isBlack(jpeg)) return {
1036
- raw: jpeg,
1037
- jpeg: async () => jpeg,
1038
- posture: POSTURES[lit]
1039
- };
1028
+ if (!await isBlack(jpeg)) {
1029
+ litPanels.set(device.udid, lit);
1030
+ return {
1031
+ raw: jpeg,
1032
+ jpeg: async () => jpeg,
1033
+ posture: POSTURES[lit]
1034
+ };
1035
+ }
1040
1036
  const other = lit === "primary" ? "primary-1" : "primary";
1041
1037
  open();
1042
1038
  const otherJpeg = await screenshot([`--display=${other}`]);
@@ -1074,6 +1070,8 @@ function simulatorCapturer(device, env, limits, litPanels) {
1074
1070
  }
1075
1071
  };
1076
1072
  }
1073
+ /** The Duo's panels in the order the helper indexes CoreSimulator's displays, by screen ID. */
1074
+ const DUO_PANELS = ["primary", "primary-1"];
1077
1075
  const POSTURES = {
1078
1076
  primary: "folded",
1079
1077
  "primary-1": "unfolded"
@@ -1212,16 +1210,13 @@ function hasHinge(body) {
1212
1210
  }
1213
1211
  return false;
1214
1212
  }
1215
- function emulatorCapturer(serial, env, limits) {
1216
- let session = null;
1217
- let tmp = null;
1218
- const running = /* @__PURE__ */ new Set();
1219
- let hinged = null;
1220
- const call = (endpoint, method, requestBody) => new Promise((resolve, reject) => {
1221
- if (!session || session.closed || session.destroyed) {
1222
- session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
1223
- session.on("error", () => {});
1224
- }
1213
+ function grpcSession(endpoint) {
1214
+ const session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
1215
+ session.on("error", () => {});
1216
+ return session;
1217
+ }
1218
+ function grpcCall(session, endpoint, serial, method, requestBody, timeoutMs) {
1219
+ return new Promise((resolve, reject) => {
1225
1220
  const request = session.request({
1226
1221
  ":method": "POST",
1227
1222
  ":path": `/android.emulation.control.EmulatorController/${method}`,
@@ -1237,7 +1232,7 @@ function emulatorCapturer(serial, env, limits) {
1237
1232
  if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
1238
1233
  if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
1239
1234
  };
1240
- request.setTimeout(limits.toolTimeoutMs, () => {
1235
+ request.setTimeout(timeoutMs, () => {
1241
1236
  timedOut = true;
1242
1237
  request.close();
1243
1238
  });
@@ -1255,11 +1250,43 @@ function emulatorCapturer(serial, env, limits) {
1255
1250
  });
1256
1251
  request.end(requestBody);
1257
1252
  });
1253
+ }
1254
+ const POSTURE_MODEL = grpcMessage([8, 16]);
1255
+ /**
1256
+ * The postures `input.posture` accepts for an owned device: the two panels of an iPhone Duo, and the hinge
1257
+ * positions of an emulator whose physical model has a posture.
1258
+ */
1259
+ async function devicePostures(device, env, timeoutMs) {
1260
+ if (device.platform === "ios") return device.foldable ? ["folded", "unfolded"] : [];
1261
+ const endpoint = emulatorEndpoint(env, device.serial);
1262
+ if (!endpoint) return [];
1263
+ const session = grpcSession(endpoint);
1264
+ try {
1265
+ return hasHinge(await grpcCall(session, endpoint, device.serial, "getPhysicalModel", POSTURE_MODEL, timeoutMs)) ? [
1266
+ "folded",
1267
+ "half-open",
1268
+ "unfolded"
1269
+ ] : [];
1270
+ } catch {
1271
+ return [];
1272
+ } finally {
1273
+ session.close();
1274
+ }
1275
+ }
1276
+ function emulatorCapturer(serial, env, limits) {
1277
+ let session = null;
1278
+ let tmp = null;
1279
+ const running = /* @__PURE__ */ new Set();
1280
+ let hinged = null;
1281
+ const call = (endpoint, method, requestBody) => {
1282
+ if (!session || session.closed || session.destroyed) session = grpcSession(endpoint);
1283
+ return grpcCall(session, endpoint, serial, method, requestBody, limits.toolTimeoutMs);
1284
+ };
1258
1285
  return {
1259
1286
  capture: async () => {
1260
1287
  const endpoint = emulatorEndpoint(env, serial);
1261
1288
  if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
1262
- hinged ??= await call(endpoint, "getPhysicalModel", grpcMessage([8, 16])).then(hasHinge).catch(() => false);
1289
+ hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
1263
1290
  const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
1264
1291
  24,
1265
1292
  ...varint(MAX_EDGE),
@@ -1402,12 +1429,15 @@ var FrameSource = class {
1402
1429
  }
1403
1430
  };
1404
1431
  /**
1405
- * One capture per device, shared by its subscribers and stopped with the last of them. With the `stim-frames`
1406
- * helper, a device streams frames as its screen changes, at the rate its subscribers ask for. Without it, for
1407
- * an iPhone Duo, and while the helper is still being built, a screenshot loop sends a frame only when the
1408
- * screen changed: up to 5 times a second while it changes, backing off to once a second while it does not,
1409
- * spending at most half of its time capturing, with at most two captures at once across all devices. A helper
1410
- * that fails before its first frame gives way to the screenshot loop.
1432
+ * One capture per device, shared by its subscribers. A `stim-frames` helper stops `lingerMs` after the last of
1433
+ * them leaves, so a client that takes one frame at a time reuses it; a screenshot loop stops with the last of
1434
+ * them. With the `stim-frames`
1435
+ * helper, a device streams frames as its screen changes, at the rate its subscribers ask for; an iPhone Duo
1436
+ * streams its lit panel, which is its posture. Without the helper, and while it is still being built, a
1437
+ * screenshot loop sends a frame only when the screen changed: up to 5 times a second while it changes, backing
1438
+ * off to once a second while it does not, spending at most half of its time capturing, with at most two
1439
+ * captures at once across all devices. A helper that fails before its first frame gives way to the screenshot
1440
+ * loop.
1411
1441
  */
1412
1442
  var FramePool = class {
1413
1443
  sources = /* @__PURE__ */ new Map();
@@ -1423,7 +1453,7 @@ var FramePool = class {
1423
1453
  }
1424
1454
  subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
1425
1455
  const helper = this.helper();
1426
- if (helper === null || device.platform === "ios" && device.foldable) return this.screenshots(device).add(listener);
1456
+ if (helper === null) return this.screenshots(device).add(listener);
1427
1457
  let streamed = false;
1428
1458
  let cancelled = false;
1429
1459
  const { video } = listener;
@@ -1448,6 +1478,15 @@ var FramePool = class {
1448
1478
  detach();
1449
1479
  };
1450
1480
  }
1481
+ /** The posture of an iPhone Duo as its last frame showed it; null before a frame and for other devices. */
1482
+ litPosture(device) {
1483
+ const lit = device.platform === "ios" && device.foldable ? this.litPanels.get(device.udid) : void 0;
1484
+ return lit ? POSTURES[lit] : null;
1485
+ }
1486
+ /** Records the panel a finished fold lit, so a request before the next frame sees the new posture. */
1487
+ folded(udid, posture) {
1488
+ this.litPanels.set(udid, posture === "folded" ? "primary" : "primary-1");
1489
+ }
1451
1490
  /** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
1452
1491
  keyframe(device) {
1453
1492
  this.helperSource(device)?.keyframe();
@@ -1460,13 +1499,37 @@ var FramePool = class {
1460
1499
  const source = this.sources.get(`helper:${deviceKey(device)}`);
1461
1500
  return source instanceof HelperSource ? source : null;
1462
1501
  }
1502
+ /**
1503
+ * Keeps the device's helper running to send it input, or returns null without a helper. `failed` runs when
1504
+ * the helper exits.
1505
+ */
1506
+ control(device, failed) {
1507
+ const helper = this.helper();
1508
+ if (helper === null) return null;
1509
+ const source = this.stream(helper, device);
1510
+ return {
1511
+ send: (command) => source.send(command),
1512
+ keys: () => source.keyboard === true,
1513
+ detach: source.add({
1514
+ frame: () => {},
1515
+ delayed: () => {},
1516
+ failed
1517
+ }, null)
1518
+ };
1519
+ }
1463
1520
  stream(helper, device) {
1464
1521
  const key = `helper:${deviceKey(device)}`;
1465
1522
  const existing = this.sources.get(key);
1466
1523
  if (existing instanceof HelperSource) return existing;
1524
+ const lit = device.platform === "ios" && device.foldable ? (display) => {
1525
+ const panel = DUO_PANELS[display];
1526
+ if (!panel) return void 0;
1527
+ this.litPanels.set(device.udid, panel);
1528
+ return POSTURES[panel];
1529
+ } : void 0;
1467
1530
  const created = new HelperSource(helper, device, this.env, () => {
1468
1531
  if (this.sources.get(key) === created) this.sources.delete(key);
1469
- });
1532
+ }, this.limits.lingerMs, lit);
1470
1533
  this.sources.set(key, created);
1471
1534
  return created;
1472
1535
  }
@@ -1485,14 +1548,668 @@ var FramePool = class {
1485
1548
  }
1486
1549
  };
1487
1550
  //#endregion
1551
+ //#region src/control.ts
1552
+ const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
1553
+ const TEXT = /^[\x20-\x7e\n\t\b]+$/;
1554
+ function parseControlBegin(params) {
1555
+ if (!isJsonObject(params)) return {
1556
+ code: "bad-request",
1557
+ message: "params must be an object."
1558
+ };
1559
+ const { workspace, platform, slot, takeOver, ...rest } = params;
1560
+ if (Object.keys(rest).length) return {
1561
+ code: "bad-request",
1562
+ message: `control.begin does not take ${Object.keys(rest).join(", ")}.`
1563
+ };
1564
+ if (typeof workspace !== "string") return {
1565
+ code: "bad-request",
1566
+ message: "params.workspace must be an environment path from a status payload."
1567
+ };
1568
+ if (platform !== "ios" && platform !== "android") return {
1569
+ code: "bad-request",
1570
+ message: "params.platform must be ios or android."
1571
+ };
1572
+ if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return {
1573
+ code: "bad-request",
1574
+ message: "params.slot must be 1-64 letters, digits, underscores or hyphens."
1575
+ };
1576
+ if (takeOver !== void 0 && typeof takeOver !== "boolean") return {
1577
+ code: "bad-request",
1578
+ message: "params.takeOver must be true or false."
1579
+ };
1580
+ return { value: {
1581
+ workspace,
1582
+ platform,
1583
+ ...slot ? { slot } : {},
1584
+ ...takeOver ? { takeOver } : {}
1585
+ } };
1586
+ }
1587
+ const IOS_BUTTONS = ["home", "lock"];
1588
+ function fraction(value) {
1589
+ return typeof value === "number" && value >= 0 && value <= 1;
1590
+ }
1591
+ /** Validates an `input.*` request for one of the connection's sessions; returns its session id and command. */
1592
+ function parseInput(method, params, targetOf) {
1593
+ if (!isJsonObject(params) || typeof params.session !== "string") return {
1594
+ code: "bad-request",
1595
+ message: `${method} needs params.session from control.begin.`
1596
+ };
1597
+ const target = targetOf(params.session);
1598
+ if (!target) return {
1599
+ code: "unknown-session",
1600
+ message: `No control session ${params.session} on this connection.`
1601
+ };
1602
+ const { platform, postures } = target;
1603
+ const session = params.session;
1604
+ if (method === "input.rotate") {
1605
+ const { direction } = params;
1606
+ if (!ROTATE_DIRECTIONS.includes(direction)) return {
1607
+ code: "bad-request",
1608
+ message: "input.rotate needs direction left or right."
1609
+ };
1610
+ return { value: {
1611
+ session,
1612
+ command: {
1613
+ input: "rotate",
1614
+ direction
1615
+ }
1616
+ } };
1617
+ }
1618
+ if (method === "input.posture") {
1619
+ const { posture } = params;
1620
+ if (!postures.includes(posture)) return {
1621
+ code: "bad-request",
1622
+ message: `This device ${postures.length ? `takes these postures: ${postures.join(", ")}` : "has no hinge"}.`
1623
+ };
1624
+ return { value: {
1625
+ session,
1626
+ command: {
1627
+ input: "posture",
1628
+ posture
1629
+ }
1630
+ } };
1631
+ }
1632
+ if (method === "input.touch") {
1633
+ const { phase, x, y, display = 0 } = params;
1634
+ if (!TOUCH_PHASES.includes(phase) || !fraction(x) || !fraction(y)) return {
1635
+ code: "bad-request",
1636
+ message: "input.touch needs phase (down, move or up), and x and y from 0 to 1."
1637
+ };
1638
+ if (!Number.isInteger(display) || display < 0 || display > 3) return {
1639
+ code: "bad-request",
1640
+ message: "display must be a display index from 0 to 3."
1641
+ };
1642
+ if (platform === "android" && display !== 0) return {
1643
+ code: "bad-request",
1644
+ message: "An emulator takes input on its main display (0) only."
1645
+ };
1646
+ return { value: {
1647
+ session,
1648
+ command: {
1649
+ input: "touch",
1650
+ phase,
1651
+ x,
1652
+ y,
1653
+ display
1654
+ }
1655
+ } };
1656
+ }
1657
+ if (method === "input.text") {
1658
+ const { text } = params;
1659
+ if (typeof text !== "string" || !text.length || text.length > 256 || !TEXT.test(text)) return {
1660
+ code: "bad-request",
1661
+ message: `input.text takes 1 to 256 printable ASCII characters, with \\n, \\t and \\b.`
1662
+ };
1663
+ return { value: {
1664
+ session,
1665
+ command: {
1666
+ input: "text",
1667
+ text
1668
+ }
1669
+ } };
1670
+ }
1671
+ const { button } = params;
1672
+ const allowed = platform === "ios" ? IOS_BUTTONS : INPUT_BUTTONS;
1673
+ if (!allowed.includes(button)) return {
1674
+ code: "bad-request",
1675
+ message: `An ${platform} device takes these buttons: ${allowed.join(", ")}.`
1676
+ };
1677
+ return { value: {
1678
+ session,
1679
+ command: {
1680
+ input: "button",
1681
+ button
1682
+ }
1683
+ } };
1684
+ }
1685
+ const ANDROID_KEYS = {
1686
+ home: "KEYCODE_HOME",
1687
+ back: "KEYCODE_BACK",
1688
+ "app-switch": "KEYCODE_APP_SWITCH",
1689
+ lock: "KEYCODE_POWER",
1690
+ "\n": "KEYCODE_ENTER",
1691
+ " ": "KEYCODE_TAB",
1692
+ "\b": "KEYCODE_DEL"
1693
+ };
1694
+ /**
1695
+ * The `adb shell` argument lists that type `text` or press `button` on an emulator without a hardware keyboard
1696
+ * (`hw.keyboard=no`), which drops gRPC key events. `adb shell` joins its arguments into one device shell
1697
+ * command, so text goes single-quoted; `input text` reads `%s` as a space.
1698
+ */
1699
+ function adbInputArgs(command) {
1700
+ if (command.input === "button") return [[
1701
+ "shell",
1702
+ "input",
1703
+ "keyevent",
1704
+ ANDROID_KEYS[command.button]
1705
+ ]];
1706
+ const calls = [];
1707
+ for (const part of command.text.split(/([\n\t\b]+)/)) {
1708
+ if (!part) continue;
1709
+ if (/^[\n\t\b]+$/.test(part)) {
1710
+ calls.push([
1711
+ "shell",
1712
+ "input",
1713
+ "keyevent",
1714
+ ...Array.from(part, (key) => ANDROID_KEYS[key])
1715
+ ]);
1716
+ continue;
1717
+ }
1718
+ calls.push([
1719
+ "shell",
1720
+ "input",
1721
+ "text",
1722
+ `'${part.replaceAll(" ", "%s").replaceAll("'", "'\\''")}'`
1723
+ ]);
1724
+ }
1725
+ return calls;
1726
+ }
1727
+ function adbPath(env) {
1728
+ for (const sdk of [
1729
+ env.ANDROID_HOME,
1730
+ env.ANDROID_SDK_ROOT,
1731
+ join(env.HOME ?? homedir(), "Library/Android/sdk")
1732
+ ]) if (sdk && existsSync(join(sdk, "platform-tools/adb"))) return join(sdk, "platform-tools/adb");
1733
+ return "adb";
1734
+ }
1735
+ const ADB_TIMEOUT_MS = 1e4;
1736
+ const POSTURE_TIMEOUT_MS = 5e3;
1737
+ function runQuietly(env, file, args, label, timeoutMs) {
1738
+ return new Promise((resolve, reject) => {
1739
+ const child = spawn(file, args, {
1740
+ env,
1741
+ stdio: [
1742
+ "ignore",
1743
+ "ignore",
1744
+ "pipe"
1745
+ ]
1746
+ });
1747
+ let stderr = "";
1748
+ const finish = (code) => {
1749
+ clearTimeout(timer);
1750
+ if (code === 0) resolve();
1751
+ else reject(/* @__PURE__ */ new Error(`${label} failed (code ${code}): ${stderr.trim()}`));
1752
+ };
1753
+ const timer = setTimeout(() => {
1754
+ child.stderr.destroy();
1755
+ if (child.exitCode !== null || child.signalCode !== null) return finish(child.exitCode);
1756
+ terminate(child);
1757
+ reject(/* @__PURE__ */ new Error(`${label} did not finish within ${timeoutMs / 1e3} s.`));
1758
+ }, timeoutMs);
1759
+ child.stderr.setEncoding("utf8");
1760
+ child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-500));
1761
+ child.on("error", (error) => {
1762
+ clearTimeout(timer);
1763
+ reject(/* @__PURE__ */ new Error(`${label} could not start (${error.message}).`));
1764
+ });
1765
+ child.on("close", finish);
1766
+ });
1767
+ }
1768
+ function runAdb(env, serial, args) {
1769
+ return runQuietly(env, adbPath(env), [
1770
+ "-s",
1771
+ serial,
1772
+ ...args
1773
+ ], `adb ${args.slice(0, 3).join(" ")}`, ADB_TIMEOUT_MS);
1774
+ }
1775
+ function activityOf(payload, target) {
1776
+ const environment = payload.environments?.find((candidate) => candidate.path === target.workspace);
1777
+ const slot = target.slot ?? "default";
1778
+ const devices = slot === "default" ? environment : environment?.slots?.find((candidate) => candidate.slot === slot);
1779
+ return target.platform === "ios" ? devices?.ios?.activity : devices?.android?.activity;
1780
+ }
1781
+ function otherDriver(activity, ownLeases) {
1782
+ if (activity?.state !== "driven" || !activity.driver) return null;
1783
+ const { tool, since } = activity.driver;
1784
+ if (tool === "stim device lock" && since && ownLeases.has(since)) return null;
1785
+ return since ? `${tool} (since ${since})` : tool;
1786
+ }
1787
+ const STATUS_WAIT_MS = 6e4;
1788
+ /**
1789
+ * Control sessions across all connections: at most one per device. A session holds the device's `stim-frames`
1790
+ * helper for input and a `stim device lock` lease, renewed while it lasts and released when it ends if the
1791
+ * session took it.
1792
+ */
1793
+ var ControlHub = class {
1794
+ options;
1795
+ sessions = /* @__PURE__ */ new Map();
1796
+ byDevice = /* @__PURE__ */ new Map();
1797
+ ownLeases = /* @__PURE__ */ new Set();
1798
+ starting = /* @__PURE__ */ new Set();
1799
+ folding = /* @__PURE__ */ new Set();
1800
+ closing = false;
1801
+ next = 1;
1802
+ constructor(options) {
1803
+ this.options = options;
1804
+ }
1805
+ targetOf(owner, session) {
1806
+ const found = this.sessions.get(session);
1807
+ return found && found.owner === owner && !found.ended ? {
1808
+ platform: found.target.platform,
1809
+ postures: found.postures
1810
+ } : null;
1811
+ }
1812
+ /**
1813
+ * `stillAllowed` is checked again after the status read and the lock, which can take seconds: the client may
1814
+ * have disconnected or lost `control` meanwhile.
1815
+ */
1816
+ async begin(owner, target, cwd, stillAllowed) {
1817
+ const beganAt = Date.now();
1818
+ if (this.closing) return {
1819
+ code: "action-failed",
1820
+ message: "stim-server is stopping."
1821
+ };
1822
+ const status = await this.status();
1823
+ if ("code" in status) return status;
1824
+ const device = ownedDevice(status, target, null);
1825
+ if (typeof device === "string") return {
1826
+ code: "action-failed",
1827
+ message: device
1828
+ };
1829
+ const key = deviceKey(device);
1830
+ if (this.starting.has(key)) return {
1831
+ code: "device-busy",
1832
+ message: "Another client is starting to control this device. Try again."
1833
+ };
1834
+ const earlier = this.byDevice.get(key);
1835
+ const driver = earlier ? `${earlier.owner.device.name} through stim-server` : otherDriver(activityOf(status, target), this.ownLeases);
1836
+ if (driver && !target.takeOver) return {
1837
+ code: "device-busy",
1838
+ message: `This device is driven by ${driver}. Take over to control it anyway.`
1839
+ };
1840
+ this.starting.add(key);
1841
+ let lease;
1842
+ let postures;
1843
+ try {
1844
+ [lease, postures] = await Promise.all([this.lock(device, target, cwd, beganAt), devicePostures(device, this.options.env, POSTURE_TIMEOUT_MS)]);
1845
+ } finally {
1846
+ this.starting.delete(key);
1847
+ }
1848
+ const granted = "code" in lease ? null : lease;
1849
+ if (!granted && !target.takeOver) return lease;
1850
+ const current = this.byDevice.get(key);
1851
+ const refuse = (refusal) => {
1852
+ if (granted?.mine && !current?.lease?.mine) this.unlock(target, cwd);
1853
+ return refusal;
1854
+ };
1855
+ if (this.closing || !stillAllowed()) return refuse({
1856
+ code: "forbidden",
1857
+ message: "This device can no longer control devices."
1858
+ });
1859
+ if (current && current !== earlier && !target.takeOver) return refuse({
1860
+ code: "device-busy",
1861
+ message: `${current.owner.device.name} started controlling this device.`
1862
+ });
1863
+ let session;
1864
+ const input = this.options.frames.control(device, (message) => queueMicrotask(() => void this.end(session, "failed", message)));
1865
+ if (!input) return refuse({
1866
+ code: "action-failed",
1867
+ message: "Input needs the stim-frames helper, which this Mac has not built."
1868
+ });
1869
+ const inherited = granted !== null && current?.lease?.mine === true;
1870
+ if (current) this.end(current, "taken-over", `${owner.device.name} took over this device.`, !inherited);
1871
+ const id = `c${this.next++}`;
1872
+ session = {
1873
+ id,
1874
+ owner,
1875
+ device,
1876
+ key,
1877
+ target,
1878
+ cwd,
1879
+ lease: granted ? {
1880
+ ...granted,
1881
+ mine: granted.mine || inherited
1882
+ } : null,
1883
+ input,
1884
+ postures,
1885
+ startedAt: Date.now(),
1886
+ idle: setTimeout(() => void this.end(session, "idle", "No input for 5 minutes."), this.options.idleMs),
1887
+ renew: setInterval(() => this.renew(session, beganAt), this.options.renewMs),
1888
+ renewing: Promise.resolve(),
1889
+ unwatch: () => {},
1890
+ adb: Promise.resolve(),
1891
+ ended: false
1892
+ };
1893
+ this.sessions.set(id, session);
1894
+ this.byDevice.set(key, session);
1895
+ session.unwatch = this.options.feeds.subscribe(this.options.statusFeed, {
1896
+ item: (payload) => {
1897
+ const resolved = ownedDevice(payload, target, key);
1898
+ if (typeof resolved === "string" || deviceKey(resolved) !== key) {
1899
+ const message = typeof resolved === "string" ? resolved : "The device changed.";
1900
+ queueMicrotask(() => void this.end(session, "device-gone", message));
1901
+ }
1902
+ },
1903
+ failed: () => {}
1904
+ });
1905
+ this.audit(owner, target, driver || current ? "control.take-over" : "control.begin", {
1906
+ ok: true,
1907
+ ...driver || current ? { reason: `took over from ${driver ?? current.owner.device.name}` } : {}
1908
+ });
1909
+ return {
1910
+ session: id,
1911
+ platform: target.platform,
1912
+ lease: session.lease ? {
1913
+ grantedAt: session.lease.grantedAt,
1914
+ expiresAt: session.lease.expiresAt
1915
+ } : null,
1916
+ postures
1917
+ };
1918
+ }
1919
+ renew(session, beganAt) {
1920
+ if (session.ended || !session.lease?.mine) return;
1921
+ session.renewing = (async () => {
1922
+ const renewed = await this.lock(session.device, session.target, session.cwd, beganAt);
1923
+ if ("code" in renewed) console.error(`stim-server: could not renew the device lease: ${renewed.message}`);
1924
+ else if (session.lease) session.lease.expiresAt = renewed.expiresAt;
1925
+ })();
1926
+ }
1927
+ input(owner, id, command) {
1928
+ const session = this.sessions.get(id);
1929
+ if (!session || session.owner !== owner || session.ended) return Promise.resolve({
1930
+ code: "unknown-session",
1931
+ message: `No control session ${id} on this connection.`
1932
+ });
1933
+ session.idle.refresh();
1934
+ if (command.input === "posture" && session.device.platform === "ios") return this.fold(session, session.device.udid, command.posture);
1935
+ if (session.device.platform === "ios" || command.input === "touch" || command.input === "rotate" || command.input === "posture" || session.input.keys()) {
1936
+ session.input.send(command);
1937
+ return Promise.resolve(null);
1938
+ }
1939
+ const serial = session.device.serial;
1940
+ const previous = session.adb;
1941
+ const run = (async () => {
1942
+ await previous;
1943
+ try {
1944
+ for (const args of adbInputArgs(command)) {
1945
+ if (session.ended) return null;
1946
+ await runAdb(this.options.env, serial, args);
1947
+ }
1948
+ return null;
1949
+ } catch (cause) {
1950
+ return {
1951
+ code: "action-failed",
1952
+ message: cause.message
1953
+ };
1954
+ }
1955
+ })();
1956
+ session.adb = run.then(() => void 0);
1957
+ return run;
1958
+ }
1959
+ /**
1960
+ * `sim-fold` sweeps the hinge to the other posture, so it runs only when the Duo's last frame shows the
1961
+ * other one.
1962
+ */
1963
+ async fold(session, udid, posture) {
1964
+ if (this.folding.has(udid)) return {
1965
+ code: "device-busy",
1966
+ message: "The device is still folding."
1967
+ };
1968
+ const current = this.options.frames.litPosture(session.device);
1969
+ if (!current) return {
1970
+ code: "action-failed",
1971
+ message: "Subscribe to frames of this device to learn its posture first."
1972
+ };
1973
+ if (current === posture) return null;
1974
+ this.folding.add(udid);
1975
+ try {
1976
+ const helper = await this.options.foldHelper();
1977
+ if (session.ended) return null;
1978
+ await runQuietly(this.options.env, "xcrun", [
1979
+ "simctl",
1980
+ "spawn",
1981
+ udid,
1982
+ helper
1983
+ ], "sim-fold", this.options.foldTimeoutMs);
1984
+ this.options.frames.folded(udid, posture === "folded" ? "folded" : "unfolded");
1985
+ return null;
1986
+ } catch (cause) {
1987
+ return {
1988
+ code: "action-failed",
1989
+ message: cause.message
1990
+ };
1991
+ } finally {
1992
+ this.folding.delete(udid);
1993
+ }
1994
+ }
1995
+ endById(owner, id) {
1996
+ const session = this.sessions.get(id);
1997
+ if (!session || session.owner !== owner) return false;
1998
+ this.end(session, null, "The client ended the session.");
1999
+ return true;
2000
+ }
2001
+ endFor(owner, reason, message) {
2002
+ for (const session of this.sessions.values()) if (session.owner === owner) this.end(session, reason, message);
2003
+ }
2004
+ async close() {
2005
+ this.closing = true;
2006
+ const releases = [...this.sessions.values()].map((session) => this.end(session, null, "stim-server stopped."));
2007
+ await Promise.all(releases);
2008
+ }
2009
+ end(session, reason, message, release = true) {
2010
+ if (session.ended) return Promise.resolve();
2011
+ session.ended = true;
2012
+ clearTimeout(session.idle);
2013
+ clearInterval(session.renew);
2014
+ session.unwatch();
2015
+ session.input.detach();
2016
+ this.sessions.delete(session.id);
2017
+ if (this.byDevice.get(session.key) === session) this.byDevice.delete(session.key);
2018
+ if (reason) session.owner.send({
2019
+ event: "control-ended",
2020
+ session: session.id,
2021
+ reason,
2022
+ message
2023
+ });
2024
+ this.audit(session.owner, session.target, "control.end", {
2025
+ ok: true,
2026
+ durationMs: Date.now() - session.startedAt,
2027
+ reason: `${reason ?? "ended"}: ${message}`
2028
+ });
2029
+ if (!release || !session.lease?.mine) return Promise.resolve();
2030
+ return session.renewing.then(() => this.unlock(session.target, session.cwd));
2031
+ }
2032
+ status() {
2033
+ return new Promise((resolve) => {
2034
+ let settled = false;
2035
+ let unsubscribe = null;
2036
+ const finish = (value) => {
2037
+ if (settled) return;
2038
+ settled = true;
2039
+ clearTimeout(timer);
2040
+ unsubscribe?.();
2041
+ resolve(value);
2042
+ };
2043
+ const timer = setTimeout(() => finish({
2044
+ code: "status-failed",
2045
+ message: "stim status did not report the device in time."
2046
+ }), STATUS_WAIT_MS);
2047
+ unsubscribe = this.options.feeds.subscribe(this.options.statusFeed, {
2048
+ item: (payload) => finish(payload),
2049
+ failed: (message) => finish({
2050
+ code: "status-failed",
2051
+ message
2052
+ })
2053
+ });
2054
+ if (settled) unsubscribe();
2055
+ });
2056
+ }
2057
+ async lock(device, target, cwd, beganAt) {
2058
+ const id = device.platform === "ios" ? device.udid : device.serial;
2059
+ const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
2060
+ const args = [
2061
+ "device",
2062
+ "lock",
2063
+ target.platform,
2064
+ id,
2065
+ "--for",
2066
+ this.options.leaseFor,
2067
+ "--wait",
2068
+ "0",
2069
+ "--json",
2070
+ ...slot
2071
+ ];
2072
+ const printed = actionOutcome(await runStim(this.options.stimCli, this.options.env, args, cwd, this.options.lockLimits).outcome);
2073
+ if (!printed.ok) return {
2074
+ code: printed.error.message.startsWith("STIM_DEVICE_BUSY") ? "device-busy" : "action-failed",
2075
+ message: `stim device lock: ${printed.error.message}`
2076
+ };
2077
+ const grantedAt = typeof printed.output.grantedAt === "string" ? printed.output.grantedAt : null;
2078
+ const expiresAt = typeof printed.output.expiresAt === "string" ? printed.output.expiresAt : "";
2079
+ const mine = grantedAt !== null && Date.parse(grantedAt) >= beganAt - 1e3;
2080
+ if (mine) this.ownLeases.add(grantedAt);
2081
+ return {
2082
+ grantedAt,
2083
+ expiresAt,
2084
+ mine
2085
+ };
2086
+ }
2087
+ async unlock(target, cwd) {
2088
+ const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
2089
+ const outcome = await runStim(this.options.stimCli, this.options.env, [
2090
+ "device",
2091
+ "unlock",
2092
+ target.platform,
2093
+ "--json",
2094
+ ...slot
2095
+ ], cwd, this.options.lockLimits).outcome;
2096
+ if (!outcome.ok) console.error(`stim-server: could not release the device lease: ${outcome.message}`);
2097
+ }
2098
+ audit(owner, target, action, outcome) {
2099
+ this.options.audit({
2100
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2101
+ device: {
2102
+ id: owner.device.id,
2103
+ name: owner.device.name
2104
+ },
2105
+ action,
2106
+ workspace: target.workspace,
2107
+ platform: target.platform,
2108
+ ...outcome
2109
+ });
2110
+ }
2111
+ };
2112
+ //#endregion
2113
+ //#region src/feed.ts
2114
+ var Feed = class {
2115
+ listeners = /* @__PURE__ */ new Set();
2116
+ kept = [];
2117
+ child;
2118
+ ended;
2119
+ constructor(spec, stimCli, env, ended) {
2120
+ this.ended = ended;
2121
+ const child = spawn(process.execPath, [stimCli, ...spec.args], {
2122
+ cwd: spec.cwd,
2123
+ env,
2124
+ stdio: [
2125
+ "ignore",
2126
+ "pipe",
2127
+ "pipe"
2128
+ ]
2129
+ });
2130
+ this.child = child;
2131
+ let stderr = "";
2132
+ child.stderr?.setEncoding("utf8");
2133
+ child.stderr?.on("data", (chunk) => {
2134
+ stderr = (stderr + chunk).slice(-2e3);
2135
+ });
2136
+ createInterface({ input: child.stdout }).on("line", (line) => {
2137
+ if (this.child !== child) return;
2138
+ let value;
2139
+ try {
2140
+ value = JSON.parse(line);
2141
+ } catch {
2142
+ return;
2143
+ }
2144
+ if (!isJsonObject(value)) return;
2145
+ this.kept.push({
2146
+ value,
2147
+ text: line
2148
+ });
2149
+ if (this.kept.length > spec.keep) this.kept.shift();
2150
+ for (const listener of this.listeners) listener.item(value, line);
2151
+ });
2152
+ const exited = (reason) => {
2153
+ if (this.child !== child) return;
2154
+ this.child = null;
2155
+ this.ended();
2156
+ const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
2157
+ const listeners = [...this.listeners];
2158
+ this.listeners.clear();
2159
+ for (const listener of listeners) listener.failed(message);
2160
+ };
2161
+ child.on("error", (error) => exited(`could not start (${error.message})`));
2162
+ child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
2163
+ }
2164
+ add(listener) {
2165
+ this.listeners.add(listener);
2166
+ for (const { value, text } of this.kept) listener.item(value, text);
2167
+ return () => {
2168
+ if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
2169
+ };
2170
+ }
2171
+ stop() {
2172
+ const child = this.child;
2173
+ if (!child) return Promise.resolve();
2174
+ this.child = null;
2175
+ this.listeners.clear();
2176
+ this.ended();
2177
+ return terminate(child);
2178
+ }
2179
+ };
2180
+ var FeedPool = class {
2181
+ feeds = /* @__PURE__ */ new Map();
2182
+ stimCli;
2183
+ env;
2184
+ constructor(stimCli, env) {
2185
+ this.stimCli = stimCli;
2186
+ this.env = env;
2187
+ }
2188
+ subscribe(spec, listener) {
2189
+ const key = JSON.stringify([spec.cwd, spec.args]);
2190
+ let feed = this.feeds.get(key);
2191
+ if (!feed) {
2192
+ const created = new Feed(spec, this.stimCli, this.env, () => {
2193
+ if (this.feeds.get(key) === created) this.feeds.delete(key);
2194
+ });
2195
+ this.feeds.set(key, created);
2196
+ feed = created;
2197
+ }
2198
+ return feed.add(listener);
2199
+ }
2200
+ async close() {
2201
+ await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
2202
+ }
2203
+ };
2204
+ //#endregion
1488
2205
  //#region src/logs.ts
1489
2206
  function parseLogFilter(params) {
1490
2207
  if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
1491
2208
  const { sources, level, slot, grep, errors, tail } = params;
1492
2209
  if (sources !== void 0 && (!Array.isArray(sources) || sources.length === 0 || !sources.every((source) => LOG_SOURCES.includes(source)))) return { error: `sources must be a non-empty list of ${LOG_SOURCES.join(", ")}.` };
1493
2210
  if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
1494
- if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return { error: "slot must be a slot name." };
1495
- if (grep !== void 0 && (typeof grep !== "string" || compileGrep(grep).error)) return { error: "grep must be a valid regular expression." };
2211
+ if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return { error: "slot must be 1-64 letters, digits, underscores or hyphens." };
2212
+ if (grep !== void 0 && (typeof grep !== "string" || grep.includes("\0") || compileGrep(grep).error)) return { error: "grep must be a valid regular expression without NUL characters." };
1496
2213
  if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
1497
2214
  if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > 5e3)) return { error: `tail must be an integer from 1 to ${MAX_LOG_TAIL}.` };
1498
2215
  return { filter: {
@@ -1994,6 +2711,19 @@ const LOG_LIMITS = {
1994
2711
  const FRAME_BUFFER_FRAMES = 2;
1995
2712
  const FRAME_RETRY_MS = 50;
1996
2713
  const HELPER_RETRY_MS = 3e5;
2714
+ const CONTROL_LIMITS = {
2715
+ idleMs: 3e5,
2716
+ renewMs: 6e4,
2717
+ leaseFor: "2m",
2718
+ inputPerSecond: 120,
2719
+ textCharsPerSecond: 40,
2720
+ shapeChangesPerSecond: 2,
2721
+ foldTimeoutMs: 4e4
2722
+ };
2723
+ const LOCK_LIMITS = {
2724
+ timeoutMs: 3e4,
2725
+ maxOutputBytes: 65536
2726
+ };
1997
2727
  const STATUS_FEED = {
1998
2728
  args: [
1999
2729
  "status",
@@ -2010,7 +2740,6 @@ const COMMAND_LIMITS = {
2010
2740
  maxOutputBytes: 33554432
2011
2741
  };
2012
2742
  const PLAN_TIMEOUT_MS = 15e4;
2013
- const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
2014
2743
  const AUDIT_FIELD_CHARS = 256;
2015
2744
  const ACTION_LIMITS = {
2016
2745
  timeoutMs: 12e4,
@@ -2068,8 +2797,23 @@ var FailureLimiter = class {
2068
2797
  this.failures.set(key, [...this.recent(key, now), now]);
2069
2798
  }
2070
2799
  };
2800
+ function auditSafely(record) {
2801
+ try {
2802
+ appendAudit(record);
2803
+ } catch (cause) {
2804
+ console.error(`stim-server: could not append to the action log: ${cause.message}`);
2805
+ }
2806
+ }
2807
+ function take(bucket, cost, perSecond, capacity) {
2808
+ const now = Date.now();
2809
+ bucket.tokens = Math.min(capacity, bucket.tokens + (now - bucket.at) / 1e3 * perSecond);
2810
+ bucket.at = now;
2811
+ if (bucket.tokens < cost) return false;
2812
+ bucket.tokens -= cost;
2813
+ return true;
2814
+ }
2071
2815
  function send(socket, message) {
2072
- if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
2816
+ if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
2073
2817
  }
2074
2818
  function requestId(value) {
2075
2819
  return typeof value === "number" || typeof value === "string" ? value : null;
@@ -2130,6 +2874,15 @@ async function startServer(options) {
2130
2874
  };
2131
2875
  if (options.frameHelper === void 0) buildHelper();
2132
2876
  const frames = new FramePool(options.env, frameLimits, frameHelper);
2877
+ let foldBuild = null;
2878
+ const foldHelper = () => {
2879
+ if (options.foldHelper !== void 0) return Promise.resolve(options.foldHelper);
2880
+ foldBuild ??= buildFoldHelper(options.env, helperAbort.signal).catch((cause) => {
2881
+ foldBuild = null;
2882
+ throw new Error(`sim-fold could not be built: ${cause.message}`);
2883
+ });
2884
+ return foldBuild;
2885
+ };
2133
2886
  const running = /* @__PURE__ */ new Set();
2134
2887
  const logLimits = {
2135
2888
  ...LOG_LIMITS,
@@ -2144,8 +2897,28 @@ async function startServer(options) {
2144
2897
  ...options.actionLimits
2145
2898
  };
2146
2899
  const busyWorkspaces = /* @__PURE__ */ new Set();
2900
+ const planQueues = /* @__PURE__ */ new Map();
2147
2901
  const sessions = /* @__PURE__ */ new Map();
2148
2902
  const sampler = new UsageSampler();
2903
+ const controllers = /* @__PURE__ */ new Map();
2904
+ const controlLimits = {
2905
+ ...CONTROL_LIMITS,
2906
+ ...options.controlLimits
2907
+ };
2908
+ const control = new ControlHub({
2909
+ env: options.env,
2910
+ stimCli: options.stimCli,
2911
+ feeds,
2912
+ frames,
2913
+ statusFeed: STATUS_FEED,
2914
+ audit: auditSafely,
2915
+ lockLimits: LOCK_LIMITS,
2916
+ idleMs: controlLimits.idleMs,
2917
+ renewMs: controlLimits.renewMs,
2918
+ leaseFor: controlLimits.leaseFor,
2919
+ foldHelper,
2920
+ foldTimeoutMs: controlLimits.foldTimeoutMs
2921
+ });
2149
2922
  const wss = new WebSocketServer({
2150
2923
  noServer: true,
2151
2924
  maxPayload: MAX_PAYLOAD
@@ -2158,8 +2931,12 @@ async function startServer(options) {
2158
2931
  const watcher = watch(serverDir(), () => {
2159
2932
  revocationCheck ??= setTimeout(() => {
2160
2933
  revocationCheck = null;
2161
- const paired = new Set(readDevices().map((device) => device.id));
2934
+ const paired = new Map(readDevices().map((device) => [device.id, device]));
2162
2935
  for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
2936
+ for (const [socket, controller] of controllers) if (!paired.get(controller.device.id)?.capabilities.includes("control")) {
2937
+ control.endFor(controller, "forbidden", "This device can no longer control devices.");
2938
+ controllers.delete(socket);
2939
+ }
2163
2940
  }, 50);
2164
2941
  });
2165
2942
  function connection(socket, peer) {
@@ -2244,6 +3021,82 @@ async function startServer(options) {
2244
3021
  }
2245
3022
  });
2246
3023
  }
3024
+ const inputs = {
3025
+ tokens: controlLimits.inputPerSecond,
3026
+ at: Date.now()
3027
+ };
3028
+ const characters = {
3029
+ tokens: 256,
3030
+ at: Date.now()
3031
+ };
3032
+ const shapeChanges = {
3033
+ tokens: controlLimits.shapeChangesPerSecond,
3034
+ at: Date.now()
3035
+ };
3036
+ function controller(session) {
3037
+ let found = controllers.get(socket);
3038
+ if (!found) {
3039
+ found = {
3040
+ device: session,
3041
+ send: (message) => send(socket, message)
3042
+ };
3043
+ controllers.set(socket, found);
3044
+ }
3045
+ return found;
3046
+ }
3047
+ async function beginControl(id, params, session) {
3048
+ const raw = isJsonObject(params) ? params : {};
3049
+ const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
3050
+ const refuseControl = (code, message) => {
3051
+ auditSafely({
3052
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3053
+ device: {
3054
+ id: session.id,
3055
+ name: session.name
3056
+ },
3057
+ action: raw.takeOver === true ? "control.take-over" : "control.begin",
3058
+ workspace: clip(raw.workspace),
3059
+ ...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
3060
+ ok: false,
3061
+ error: {
3062
+ code,
3063
+ message: clip(message)
3064
+ }
3065
+ });
3066
+ error(id, code, message);
3067
+ };
3068
+ if (!readDevices().find((entry) => entry.id === session.id)?.capabilities.includes("control")) return refuseControl("forbidden", `This device can only read. On the Mac, run \`stim-server devices grant ${session.id} --control\` to let it control devices.`);
3069
+ const parsed = parseControlBegin(params);
3070
+ if ("code" in parsed) return refuseControl(parsed.code, parsed.message);
3071
+ const resolved = registeredWorkspace(parsed.value.workspace);
3072
+ if ("code" in resolved) return refuseControl(resolved.code, resolved.message);
3073
+ const owner = controller(session);
3074
+ const outcome = await control.begin(owner, {
3075
+ ...parsed.value,
3076
+ workspace: resolved.dir
3077
+ }, resolved.dir, () => socket.readyState === socket.OPEN && controllers.get(socket) === owner && readDevices().some((entry) => entry.id === session.id && entry.capabilities.includes("control")));
3078
+ if ("code" in outcome) return refuseControl(outcome.code, outcome.message);
3079
+ send(socket, {
3080
+ id,
3081
+ result: outcome
3082
+ });
3083
+ }
3084
+ async function input(id, method, params, session) {
3085
+ const owner = controller(session);
3086
+ const parsed = parseInput(method, params, (name) => control.targetOf(owner, name));
3087
+ if ("code" in parsed) return error(id, parsed.code, parsed.message);
3088
+ if (!take(inputs, 1, controlLimits.inputPerSecond, controlLimits.inputPerSecond)) return error(id, "limit-exceeded", `A connection can send ${controlLimits.inputPerSecond} inputs a second.`);
3089
+ const { command: sent } = parsed.value;
3090
+ if (sent.input === "text" && !take(characters, sent.text.length, controlLimits.textCharsPerSecond, 256)) return error(id, "limit-exceeded", `A connection can type ${controlLimits.textCharsPerSecond} characters a second. Send the rest shortly.`);
3091
+ const { shapeChangesPerSecond } = controlLimits;
3092
+ if ((sent.input === "rotate" || sent.input === "posture") && !take(shapeChanges, 1, shapeChangesPerSecond, shapeChangesPerSecond)) return error(id, "limit-exceeded", `A connection can rotate or fold ${shapeChangesPerSecond} times a second.`);
3093
+ const refused = await control.input(owner, parsed.value.session, parsed.value.command);
3094
+ if (refused) return error(id, refused.code, refused.message);
3095
+ send(socket, {
3096
+ id,
3097
+ result: {}
3098
+ });
3099
+ }
2247
3100
  function workspaceDir(id, workspace, required) {
2248
3101
  if (workspace === void 0 && !required) return homedir();
2249
3102
  const resolved = registeredWorkspace(workspace);
@@ -2271,12 +3124,9 @@ async function startServer(options) {
2271
3124
  function subscribeStatus(id) {
2272
3125
  const subscription = openSubscription(id);
2273
3126
  if (!subscription) return;
3127
+ const envelope = `{"event":"status","subscription":${JSON.stringify(subscription)},"payload":`;
2274
3128
  const unsubscribe = feeds.subscribe(STATUS_FEED, {
2275
- item: (payload) => send(socket, {
2276
- event: "status",
2277
- subscription,
2278
- payload
2279
- }),
3129
+ item: (_payload, text) => send(socket, `${envelope}${text}}`),
2280
3130
  failed: (message) => {
2281
3131
  subscriptions.delete(subscription);
2282
3132
  send(socket, {
@@ -2473,30 +3323,53 @@ async function startServer(options) {
2473
3323
  });
2474
3324
  subscriptions.set(subscription, cleanup);
2475
3325
  }
2476
- function command(id, args, cwd, result, limits = commandLimits) {
2477
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
2478
- const run = runStim(options.stimCli, options.env, args, cwd, limits);
2479
- commands.add(run.cancel);
2480
- running.add(run.cancel);
2481
- (async () => {
3326
+ function command(id, args, cwd, result, limits = commandLimits, turn = null) {
3327
+ if (commands.size >= MAX_COMMANDS) {
3328
+ error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
3329
+ return null;
3330
+ }
3331
+ let run = null;
3332
+ let dropped = false;
3333
+ let release;
3334
+ const released = new Promise((resolve) => {
3335
+ release = resolve;
3336
+ });
3337
+ const cancel = async () => {
3338
+ dropped = true;
3339
+ if (!run) return void (turn ?? Promise.resolve()).then(release);
3340
+ await run.cancel();
3341
+ release();
3342
+ };
3343
+ const start = async () => {
3344
+ if (dropped) return;
3345
+ run = runStim(options.stimCli, options.env, args, cwd, limits);
2482
3346
  const outcome = await run.outcome;
2483
- commands.delete(run.cancel);
2484
- running.delete(run.cancel);
2485
- if (!outcome.ok) {
2486
- const printed = actionOutcome(outcome);
2487
- return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
2488
- }
2489
- let value;
3347
+ commands.delete(cancel);
3348
+ running.delete(cancel);
2490
3349
  try {
2491
- value = result(outcome.stdout);
2492
- } catch {
2493
- return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
3350
+ if (!outcome.ok) {
3351
+ const printed = actionOutcome(outcome);
3352
+ return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
3353
+ }
3354
+ let value;
3355
+ try {
3356
+ value = result(outcome.stdout);
3357
+ } catch {
3358
+ return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
3359
+ }
3360
+ send(socket, {
3361
+ id,
3362
+ result: value
3363
+ });
3364
+ } finally {
3365
+ release();
2494
3366
  }
2495
- send(socket, {
2496
- id,
2497
- result: value
2498
- });
2499
- })();
3367
+ };
3368
+ commands.add(cancel);
3369
+ running.add(cancel);
3370
+ if (turn) turn.then(start);
3371
+ else start();
3372
+ return released;
2500
3373
  }
2501
3374
  function queryLogs(id, params) {
2502
3375
  const parsed = parseLogFilter(params);
@@ -2613,7 +3486,7 @@ async function startServer(options) {
2613
3486
  if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return error(id, "bad-request", "params.slot must be 1-64 letters, digits, underscores or hyphens.");
2614
3487
  const cwd = workspaceDir(id, params.workspace, true);
2615
3488
  if (!cwd) return;
2616
- command(id, [
3489
+ const finished = command(id, [
2617
3490
  platform,
2618
3491
  "--plan",
2619
3492
  "--json",
@@ -2625,6 +3498,11 @@ async function startServer(options) {
2625
3498
  }, {
2626
3499
  ...commandLimits,
2627
3500
  timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
3501
+ }, planQueues.get(cwd) ?? null);
3502
+ if (!finished) return;
3503
+ planQueues.set(cwd, finished);
3504
+ finished.finally(() => {
3505
+ if (planQueues.get(cwd) === finished) planQueues.delete(cwd);
2628
3506
  });
2629
3507
  }
2630
3508
  async function handle(raw) {
@@ -2684,6 +3562,23 @@ async function startServer(options) {
2684
3562
  }
2685
3563
  if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
2686
3564
  if (message.method === "action") return runAction(id, message.params, device);
3565
+ if (message.method === "control.begin") {
3566
+ beginControl(id, message.params, device);
3567
+ return;
3568
+ }
3569
+ if (message.method === "control.end") {
3570
+ const name = isJsonObject(message.params) ? message.params.session : void 0;
3571
+ if (typeof name !== "string" || !control.endById(controller(device), name)) return error(id, "unknown-session", `No control session ${String(name)} on this connection.`);
3572
+ return send(socket, {
3573
+ id,
3574
+ result: {}
3575
+ });
3576
+ }
3577
+ if (message.method === "input.touch" || message.method === "input.text" || message.method === "input.button" || message.method === "input.rotate" || message.method === "input.posture") {
3578
+ const method = message.method;
3579
+ input(id, method, message.params, device);
3580
+ return;
3581
+ }
2687
3582
  if (message.method === "unsubscribe") {
2688
3583
  const name = isJsonObject(message.params) ? message.params.subscription : void 0;
2689
3584
  const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
@@ -2717,6 +3612,9 @@ async function startServer(options) {
2717
3612
  clearTimeout(timer);
2718
3613
  sessions.delete(socket);
2719
3614
  if (sessions.size === 0) sampler.stop();
3615
+ const owner = controllers.get(socket);
3616
+ if (owner) control.endFor(owner, null, "The client disconnected.");
3617
+ controllers.delete(socket);
2720
3618
  for (const unsubscribe of subscriptions.values()) unsubscribe();
2721
3619
  subscriptions.clear();
2722
3620
  for (const cancel of commands) {
@@ -2759,6 +3657,7 @@ async function startServer(options) {
2759
3657
  sampler.stop();
2760
3658
  if (revocationCheck) clearTimeout(revocationCheck);
2761
3659
  for (const client of wss.clients) client.terminate();
3660
+ await control.close();
2762
3661
  await Promise.all([
2763
3662
  frames.close(),
2764
3663
  feeds.close(),
@@ -2948,7 +3847,7 @@ async function main() {
2948
3847
  if (values.json) return void console.log(JSON.stringify({ actions: records }));
2949
3848
  if (!records.length) console.log("No actions.");
2950
3849
  for (const record of records) {
2951
- const outcome = record.ok ? "ok" : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
3850
+ const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
2952
3851
  const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
2953
3852
  console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
2954
3853
  }