@stim-cli/server 1.11.0 → 1.12.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 TOUCH_PHASES, c as INPUT_BUTTONS, f as MAX_LOG_TAIL, g as ROTATE_DIRECTIONS, l as LOG_LEVELS, n as CAPABILITIES, o as FRAME_EDGE, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES } from "./protocol-BElQc2vm.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 = {};
@@ -447,95 +447,6 @@ function runStim(stimCli, env, args, cwd, limits) {
447
447
  };
448
448
  }
449
449
  //#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
450
  //#region src/video.ts
540
451
  const FIXED_HEADER_BYTES = 21;
541
452
  /** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
@@ -685,20 +596,9 @@ function run(file, args, env, timeoutMs, signal) {
685
596
  */
686
597
  async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
687
598
  if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
688
- const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted();
599
+ const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
689
600
  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 {
601
+ return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
702
602
  await run("xcrun", [
703
603
  "swiftc",
704
604
  "-O",
@@ -708,8 +608,61 @@ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
708
608
  "StimFrames",
709
609
  "-o",
710
610
  output,
711
- ...sources.map((source) => join(sourcesDir, source))
611
+ ...sources
712
612
  ], env, BUILD_TIMEOUT_MS, signal);
613
+ });
614
+ }
615
+ /**
616
+ * Compiles `sim-fold`, which folds or unfolds an iPhone Duo from inside the simulator, from the Stim Desktop
617
+ * sources shipped in `dist/stim-frames/`, the way Stim Desktop's bundle script builds it.
618
+ */
619
+ async function buildFoldHelper(env, signal, sourcesDir = SOURCES_DIR) {
620
+ if (process.platform !== "darwin") throw new Error("sim-fold runs only on macOS.");
621
+ const source = join(sourcesDir, "sim-fold.m");
622
+ const entitlements = join(sourcesDir, "sim-fold.entitlements");
623
+ const clang = [
624
+ "-sdk",
625
+ "iphonesimulator",
626
+ "clang"
627
+ ];
628
+ const version = await run("xcrun", [...clang, "--version"], env, VERSION_TIMEOUT_MS, signal);
629
+ return compiled("sim-fold", [source, entitlements], version, async (output) => {
630
+ await run("xcrun", [
631
+ ...clang,
632
+ "-fobjc-arc",
633
+ "-arch",
634
+ "arm64",
635
+ "-arch",
636
+ "x86_64",
637
+ "-mios-simulator-version-min=18.0",
638
+ "-framework",
639
+ "Foundation",
640
+ source,
641
+ "-o",
642
+ output,
643
+ `-Wl,-sectcreate,__TEXT,__entitlements,${entitlements}`
644
+ ], env, BUILD_TIMEOUT_MS, signal);
645
+ await run("codesign", [
646
+ "--force",
647
+ "--sign",
648
+ "-",
649
+ output
650
+ ], env, VERSION_TIMEOUT_MS, signal);
651
+ });
652
+ }
653
+ async function compiled(name, inputs, version, compile) {
654
+ const hash = createHash("sha256").update(version);
655
+ for (const input of inputs) hash.update(basename(input)).update(readFileSync(input));
656
+ const dir = join(serverDir(), "helpers");
657
+ const helper = join(dir, `${name}-${hash.digest("hex").slice(0, 16)}`);
658
+ if (existsSync(helper)) return helper;
659
+ mkdirSync(dir, {
660
+ recursive: true,
661
+ mode: 448
662
+ });
663
+ const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
664
+ try {
665
+ await compile(output);
713
666
  renameSync(output, helper);
714
667
  } finally {
715
668
  rmSync(output, { force: true });
@@ -728,6 +681,7 @@ var HelperSource = class {
728
681
  config = "";
729
682
  stopped = false;
730
683
  notice = null;
684
+ keyboard = null;
731
685
  stderr = "";
732
686
  ended;
733
687
  bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
@@ -756,6 +710,7 @@ var HelperSource = class {
756
710
  this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
757
711
  });
758
712
  }
713
+ /** A null `hint` keeps the helper running for input without asking for frames. */
759
714
  add(listener, hint) {
760
715
  this.listeners.set(listener, hint);
761
716
  this.configure();
@@ -793,12 +748,19 @@ var HelperSource = class {
793
748
  congested() {
794
749
  if (this.bitrate.congested(Date.now()) !== null) this.configure();
795
750
  }
751
+ send(command) {
752
+ if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
753
+ }
796
754
  configure() {
797
- const hints = [...this.listeners.values()];
798
- const jpegFps = [...this.listeners].flatMap(([listener, hint]) => listener.video ? [] : [hint.fps]);
755
+ const watching = [...this.listeners].flatMap(([listener, hint]) => hint ? [{
756
+ listener,
757
+ hint
758
+ }] : []);
759
+ const hints = watching.map(({ hint }) => hint);
760
+ const jpegFps = watching.flatMap(({ listener, hint }) => listener.video ? [] : [hint.fps]);
799
761
  const config = JSON.stringify({
800
- fps: Math.max(...hints.map((hint) => hint.fps)),
801
- maxEdge: Math.max(...hints.map((hint) => hint.maxEdge)),
762
+ fps: Math.max(0, ...hints.map((hint) => hint.fps)),
763
+ maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
802
764
  jpeg: jpegFps.length > 0,
803
765
  ...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
804
766
  video: jpegFps.length < hints.length,
@@ -858,8 +820,13 @@ var HelperSource = class {
858
820
  }
859
821
  readNotice(text) {
860
822
  try {
861
- const error = JSON.parse(text)?.error;
823
+ const notice = JSON.parse(text);
824
+ const error = notice?.error;
862
825
  if (typeof error === "string") this.notice = error;
826
+ const keyboard = notice?.keyboard;
827
+ if (keyboard === "yes" || keyboard === "no") this.keyboard = keyboard === "yes";
828
+ const inputError = notice?.inputError;
829
+ if (typeof inputError === "string") console.error(`stim-server: stim-frames: ${inputError}`);
863
830
  } catch {}
864
831
  }
865
832
  };
@@ -1032,11 +999,14 @@ function simulatorCapturer(device, env, limits, litPanels) {
1032
999
  const lit = litPanels.get(device.udid) ?? "primary";
1033
1000
  const jpeg = await screenshot([`--display=${lit}`]);
1034
1001
  open();
1035
- if (!await isBlack(jpeg)) return {
1036
- raw: jpeg,
1037
- jpeg: async () => jpeg,
1038
- posture: POSTURES[lit]
1039
- };
1002
+ if (!await isBlack(jpeg)) {
1003
+ litPanels.set(device.udid, lit);
1004
+ return {
1005
+ raw: jpeg,
1006
+ jpeg: async () => jpeg,
1007
+ posture: POSTURES[lit]
1008
+ };
1009
+ }
1040
1010
  const other = lit === "primary" ? "primary-1" : "primary";
1041
1011
  open();
1042
1012
  const otherJpeg = await screenshot([`--display=${other}`]);
@@ -1212,16 +1182,13 @@ function hasHinge(body) {
1212
1182
  }
1213
1183
  return false;
1214
1184
  }
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
- }
1185
+ function grpcSession(endpoint) {
1186
+ const session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
1187
+ session.on("error", () => {});
1188
+ return session;
1189
+ }
1190
+ function grpcCall(session, endpoint, serial, method, requestBody, timeoutMs) {
1191
+ return new Promise((resolve, reject) => {
1225
1192
  const request = session.request({
1226
1193
  ":method": "POST",
1227
1194
  ":path": `/android.emulation.control.EmulatorController/${method}`,
@@ -1237,7 +1204,7 @@ function emulatorCapturer(serial, env, limits) {
1237
1204
  if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
1238
1205
  if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
1239
1206
  };
1240
- request.setTimeout(limits.toolTimeoutMs, () => {
1207
+ request.setTimeout(timeoutMs, () => {
1241
1208
  timedOut = true;
1242
1209
  request.close();
1243
1210
  });
@@ -1255,11 +1222,43 @@ function emulatorCapturer(serial, env, limits) {
1255
1222
  });
1256
1223
  request.end(requestBody);
1257
1224
  });
1225
+ }
1226
+ const POSTURE_MODEL = grpcMessage([8, 16]);
1227
+ /**
1228
+ * The postures `input.posture` accepts for an owned device: the two panels of an iPhone Duo, and the hinge
1229
+ * positions of an emulator whose physical model has a posture.
1230
+ */
1231
+ async function devicePostures(device, env, timeoutMs) {
1232
+ if (device.platform === "ios") return device.foldable ? ["folded", "unfolded"] : [];
1233
+ const endpoint = emulatorEndpoint(env, device.serial);
1234
+ if (!endpoint) return [];
1235
+ const session = grpcSession(endpoint);
1236
+ try {
1237
+ return hasHinge(await grpcCall(session, endpoint, device.serial, "getPhysicalModel", POSTURE_MODEL, timeoutMs)) ? [
1238
+ "folded",
1239
+ "half-open",
1240
+ "unfolded"
1241
+ ] : [];
1242
+ } catch {
1243
+ return [];
1244
+ } finally {
1245
+ session.close();
1246
+ }
1247
+ }
1248
+ function emulatorCapturer(serial, env, limits) {
1249
+ let session = null;
1250
+ let tmp = null;
1251
+ const running = /* @__PURE__ */ new Set();
1252
+ let hinged = null;
1253
+ const call = (endpoint, method, requestBody) => {
1254
+ if (!session || session.closed || session.destroyed) session = grpcSession(endpoint);
1255
+ return grpcCall(session, endpoint, serial, method, requestBody, limits.toolTimeoutMs);
1256
+ };
1258
1257
  return {
1259
1258
  capture: async () => {
1260
1259
  const endpoint = emulatorEndpoint(env, serial);
1261
1260
  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);
1261
+ hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
1263
1262
  const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
1264
1263
  24,
1265
1264
  ...varint(MAX_EDGE),
@@ -1448,6 +1447,15 @@ var FramePool = class {
1448
1447
  detach();
1449
1448
  };
1450
1449
  }
1450
+ /** The posture of an iPhone Duo as its last frame showed it; null before a frame and for other devices. */
1451
+ litPosture(device) {
1452
+ const lit = device.platform === "ios" && device.foldable ? this.litPanels.get(device.udid) : void 0;
1453
+ return lit ? POSTURES[lit] : null;
1454
+ }
1455
+ /** Records the panel a finished fold lit, so a request before the next frame sees the new posture. */
1456
+ folded(udid, posture) {
1457
+ this.litPanels.set(udid, posture === "folded" ? "primary" : "primary-1");
1458
+ }
1451
1459
  /** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
1452
1460
  keyframe(device) {
1453
1461
  this.helperSource(device)?.keyframe();
@@ -1460,6 +1468,24 @@ var FramePool = class {
1460
1468
  const source = this.sources.get(`helper:${deviceKey(device)}`);
1461
1469
  return source instanceof HelperSource ? source : null;
1462
1470
  }
1471
+ /**
1472
+ * Keeps the device's helper running to send it input, or returns null without a helper. `failed` runs when
1473
+ * the helper exits.
1474
+ */
1475
+ control(device, failed) {
1476
+ const helper = this.helper();
1477
+ if (helper === null) return null;
1478
+ const source = this.stream(helper, device);
1479
+ return {
1480
+ send: (command) => source.send(command),
1481
+ keys: () => source.keyboard === true,
1482
+ detach: source.add({
1483
+ frame: () => {},
1484
+ delayed: () => {},
1485
+ failed
1486
+ }, null)
1487
+ };
1488
+ }
1463
1489
  stream(helper, device) {
1464
1490
  const key = `helper:${deviceKey(device)}`;
1465
1491
  const existing = this.sources.get(key);
@@ -1485,14 +1511,661 @@ var FramePool = class {
1485
1511
  }
1486
1512
  };
1487
1513
  //#endregion
1514
+ //#region src/control.ts
1515
+ const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
1516
+ const TEXT = /^[\x20-\x7e\n\t\b]+$/;
1517
+ function parseControlBegin(params) {
1518
+ if (!isJsonObject(params)) return {
1519
+ code: "bad-request",
1520
+ message: "params must be an object."
1521
+ };
1522
+ const { workspace, platform, slot, takeOver, ...rest } = params;
1523
+ if (Object.keys(rest).length) return {
1524
+ code: "bad-request",
1525
+ message: `control.begin does not take ${Object.keys(rest).join(", ")}.`
1526
+ };
1527
+ if (typeof workspace !== "string") return {
1528
+ code: "bad-request",
1529
+ message: "params.workspace must be an environment path from a status payload."
1530
+ };
1531
+ if (platform !== "ios" && platform !== "android") return {
1532
+ code: "bad-request",
1533
+ message: "params.platform must be ios or android."
1534
+ };
1535
+ if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return {
1536
+ code: "bad-request",
1537
+ message: "params.slot must be 1-64 letters, digits, underscores or hyphens."
1538
+ };
1539
+ if (takeOver !== void 0 && typeof takeOver !== "boolean") return {
1540
+ code: "bad-request",
1541
+ message: "params.takeOver must be true or false."
1542
+ };
1543
+ return { value: {
1544
+ workspace,
1545
+ platform,
1546
+ ...slot ? { slot } : {},
1547
+ ...takeOver ? { takeOver } : {}
1548
+ } };
1549
+ }
1550
+ const IOS_BUTTONS = ["home", "lock"];
1551
+ function fraction(value) {
1552
+ return typeof value === "number" && value >= 0 && value <= 1;
1553
+ }
1554
+ /** Validates an `input.*` request for one of the connection's sessions; returns its session id and command. */
1555
+ function parseInput(method, params, targetOf) {
1556
+ if (!isJsonObject(params) || typeof params.session !== "string") return {
1557
+ code: "bad-request",
1558
+ message: `${method} needs params.session from control.begin.`
1559
+ };
1560
+ const target = targetOf(params.session);
1561
+ if (!target) return {
1562
+ code: "unknown-session",
1563
+ message: `No control session ${params.session} on this connection.`
1564
+ };
1565
+ const { platform, postures } = target;
1566
+ const session = params.session;
1567
+ if (method === "input.rotate") {
1568
+ const { direction } = params;
1569
+ if (!ROTATE_DIRECTIONS.includes(direction)) return {
1570
+ code: "bad-request",
1571
+ message: "input.rotate needs direction left or right."
1572
+ };
1573
+ return { value: {
1574
+ session,
1575
+ command: {
1576
+ input: "rotate",
1577
+ direction
1578
+ }
1579
+ } };
1580
+ }
1581
+ if (method === "input.posture") {
1582
+ const { posture } = params;
1583
+ if (!postures.includes(posture)) return {
1584
+ code: "bad-request",
1585
+ message: `This device ${postures.length ? `takes these postures: ${postures.join(", ")}` : "has no hinge"}.`
1586
+ };
1587
+ return { value: {
1588
+ session,
1589
+ command: {
1590
+ input: "posture",
1591
+ posture
1592
+ }
1593
+ } };
1594
+ }
1595
+ if (method === "input.touch") {
1596
+ const { phase, x, y, display = 0 } = params;
1597
+ if (!TOUCH_PHASES.includes(phase) || !fraction(x) || !fraction(y)) return {
1598
+ code: "bad-request",
1599
+ message: "input.touch needs phase (down, move or up), and x and y from 0 to 1."
1600
+ };
1601
+ if (!Number.isInteger(display) || display < 0 || display > 3) return {
1602
+ code: "bad-request",
1603
+ message: "display must be a display index from 0 to 3."
1604
+ };
1605
+ if (platform === "android" && display !== 0) return {
1606
+ code: "bad-request",
1607
+ message: "An emulator takes input on its main display (0) only."
1608
+ };
1609
+ return { value: {
1610
+ session,
1611
+ command: {
1612
+ input: "touch",
1613
+ phase,
1614
+ x,
1615
+ y,
1616
+ display
1617
+ }
1618
+ } };
1619
+ }
1620
+ if (method === "input.text") {
1621
+ const { text } = params;
1622
+ if (typeof text !== "string" || !text.length || text.length > 256 || !TEXT.test(text)) return {
1623
+ code: "bad-request",
1624
+ message: `input.text takes 1 to 256 printable ASCII characters, with \\n, \\t and \\b.`
1625
+ };
1626
+ return { value: {
1627
+ session,
1628
+ command: {
1629
+ input: "text",
1630
+ text
1631
+ }
1632
+ } };
1633
+ }
1634
+ const { button } = params;
1635
+ const allowed = platform === "ios" ? IOS_BUTTONS : INPUT_BUTTONS;
1636
+ if (!allowed.includes(button)) return {
1637
+ code: "bad-request",
1638
+ message: `An ${platform} device takes these buttons: ${allowed.join(", ")}.`
1639
+ };
1640
+ return { value: {
1641
+ session,
1642
+ command: {
1643
+ input: "button",
1644
+ button
1645
+ }
1646
+ } };
1647
+ }
1648
+ const ANDROID_KEYS = {
1649
+ home: "KEYCODE_HOME",
1650
+ back: "KEYCODE_BACK",
1651
+ "app-switch": "KEYCODE_APP_SWITCH",
1652
+ lock: "KEYCODE_POWER",
1653
+ "\n": "KEYCODE_ENTER",
1654
+ " ": "KEYCODE_TAB",
1655
+ "\b": "KEYCODE_DEL"
1656
+ };
1657
+ /**
1658
+ * The `adb shell` argument lists that type `text` or press `button` on an emulator without a hardware keyboard
1659
+ * (`hw.keyboard=no`), which drops gRPC key events. `adb shell` joins its arguments into one device shell
1660
+ * command, so text goes single-quoted; `input text` reads `%s` as a space.
1661
+ */
1662
+ function adbInputArgs(command) {
1663
+ if (command.input === "button") return [[
1664
+ "shell",
1665
+ "input",
1666
+ "keyevent",
1667
+ ANDROID_KEYS[command.button]
1668
+ ]];
1669
+ const calls = [];
1670
+ for (const part of command.text.split(/([\n\t\b]+)/)) {
1671
+ if (!part) continue;
1672
+ if (/^[\n\t\b]+$/.test(part)) {
1673
+ calls.push([
1674
+ "shell",
1675
+ "input",
1676
+ "keyevent",
1677
+ ...Array.from(part, (key) => ANDROID_KEYS[key])
1678
+ ]);
1679
+ continue;
1680
+ }
1681
+ calls.push([
1682
+ "shell",
1683
+ "input",
1684
+ "text",
1685
+ `'${part.replaceAll(" ", "%s").replaceAll("'", "'\\''")}'`
1686
+ ]);
1687
+ }
1688
+ return calls;
1689
+ }
1690
+ function adbPath(env) {
1691
+ for (const sdk of [
1692
+ env.ANDROID_HOME,
1693
+ env.ANDROID_SDK_ROOT,
1694
+ join(env.HOME ?? homedir(), "Library/Android/sdk")
1695
+ ]) if (sdk && existsSync(join(sdk, "platform-tools/adb"))) return join(sdk, "platform-tools/adb");
1696
+ return "adb";
1697
+ }
1698
+ const ADB_TIMEOUT_MS = 1e4;
1699
+ /** `sim-fold` waits up to 20 seconds for SpringBoard to finish the fold; Stim Desktop stops it after 40. */
1700
+ const FOLD_TIMEOUT_MS = 4e4;
1701
+ const POSTURE_TIMEOUT_MS = 5e3;
1702
+ function runQuietly(env, file, args, label, timeoutMs) {
1703
+ return new Promise((resolve, reject) => {
1704
+ const child = spawn(file, args, {
1705
+ env,
1706
+ stdio: [
1707
+ "ignore",
1708
+ "ignore",
1709
+ "pipe"
1710
+ ]
1711
+ });
1712
+ let stderr = "";
1713
+ const timer = setTimeout(() => void terminate(child), timeoutMs);
1714
+ child.stderr.setEncoding("utf8");
1715
+ child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-500));
1716
+ child.on("error", (error) => {
1717
+ clearTimeout(timer);
1718
+ reject(/* @__PURE__ */ new Error(`${label} could not start (${error.message}).`));
1719
+ });
1720
+ child.on("close", (code) => {
1721
+ clearTimeout(timer);
1722
+ if (code === 0) resolve();
1723
+ else reject(/* @__PURE__ */ new Error(`${label} failed (code ${code}): ${stderr.trim()}`));
1724
+ });
1725
+ });
1726
+ }
1727
+ function runAdb(env, serial, args) {
1728
+ return runQuietly(env, adbPath(env), [
1729
+ "-s",
1730
+ serial,
1731
+ ...args
1732
+ ], `adb ${args.slice(0, 3).join(" ")}`, ADB_TIMEOUT_MS);
1733
+ }
1734
+ function activityOf(payload, target) {
1735
+ const environment = payload.environments?.find((candidate) => candidate.path === target.workspace);
1736
+ const slot = target.slot ?? "default";
1737
+ const devices = slot === "default" ? environment : environment?.slots?.find((candidate) => candidate.slot === slot);
1738
+ return target.platform === "ios" ? devices?.ios?.activity : devices?.android?.activity;
1739
+ }
1740
+ function otherDriver(activity, ownLeases) {
1741
+ if (activity?.state !== "driven" || !activity.driver) return null;
1742
+ const { tool, since } = activity.driver;
1743
+ if (tool === "stim device lock" && since && ownLeases.has(since)) return null;
1744
+ return since ? `${tool} (since ${since})` : tool;
1745
+ }
1746
+ const STATUS_WAIT_MS = 6e4;
1747
+ /**
1748
+ * Control sessions across all connections: at most one per device. A session holds the device's `stim-frames`
1749
+ * helper for input and a `stim device lock` lease, renewed while it lasts and released when it ends if the
1750
+ * session took it.
1751
+ */
1752
+ var ControlHub = class {
1753
+ options;
1754
+ sessions = /* @__PURE__ */ new Map();
1755
+ byDevice = /* @__PURE__ */ new Map();
1756
+ ownLeases = /* @__PURE__ */ new Set();
1757
+ starting = /* @__PURE__ */ new Set();
1758
+ folding = /* @__PURE__ */ new Set();
1759
+ closing = false;
1760
+ next = 1;
1761
+ constructor(options) {
1762
+ this.options = options;
1763
+ }
1764
+ targetOf(owner, session) {
1765
+ const found = this.sessions.get(session);
1766
+ return found && found.owner === owner && !found.ended ? {
1767
+ platform: found.target.platform,
1768
+ postures: found.postures
1769
+ } : null;
1770
+ }
1771
+ /**
1772
+ * `stillAllowed` is checked again after the status read and the lock, which can take seconds: the client may
1773
+ * have disconnected or lost `control` meanwhile.
1774
+ */
1775
+ async begin(owner, target, cwd, stillAllowed) {
1776
+ const beganAt = Date.now();
1777
+ if (this.closing) return {
1778
+ code: "action-failed",
1779
+ message: "stim-server is stopping."
1780
+ };
1781
+ const status = await this.status();
1782
+ if ("code" in status) return status;
1783
+ const device = ownedDevice(status, target, null);
1784
+ if (typeof device === "string") return {
1785
+ code: "action-failed",
1786
+ message: device
1787
+ };
1788
+ const key = deviceKey(device);
1789
+ if (this.starting.has(key)) return {
1790
+ code: "device-busy",
1791
+ message: "Another client is starting to control this device. Try again."
1792
+ };
1793
+ const earlier = this.byDevice.get(key);
1794
+ const driver = earlier ? `${earlier.owner.device.name} through stim-server` : otherDriver(activityOf(status, target), this.ownLeases);
1795
+ if (driver && !target.takeOver) return {
1796
+ code: "device-busy",
1797
+ message: `This device is driven by ${driver}. Take over to control it anyway.`
1798
+ };
1799
+ this.starting.add(key);
1800
+ let lease;
1801
+ let postures;
1802
+ try {
1803
+ [lease, postures] = await Promise.all([this.lock(device, target, cwd, beganAt), devicePostures(device, this.options.env, POSTURE_TIMEOUT_MS)]);
1804
+ } finally {
1805
+ this.starting.delete(key);
1806
+ }
1807
+ const granted = "code" in lease ? null : lease;
1808
+ if (!granted && !target.takeOver) return lease;
1809
+ const current = this.byDevice.get(key);
1810
+ const refuse = (refusal) => {
1811
+ if (granted?.mine && !current?.lease?.mine) this.unlock(target, cwd);
1812
+ return refusal;
1813
+ };
1814
+ if (this.closing || !stillAllowed()) return refuse({
1815
+ code: "forbidden",
1816
+ message: "This device can no longer control devices."
1817
+ });
1818
+ if (current && current !== earlier && !target.takeOver) return refuse({
1819
+ code: "device-busy",
1820
+ message: `${current.owner.device.name} started controlling this device.`
1821
+ });
1822
+ let session;
1823
+ const input = this.options.frames.control(device, (message) => queueMicrotask(() => void this.end(session, "failed", message)));
1824
+ if (!input) return refuse({
1825
+ code: "action-failed",
1826
+ message: "Input needs the stim-frames helper, which this Mac has not built."
1827
+ });
1828
+ const inherited = granted !== null && current?.lease?.mine === true;
1829
+ if (current) this.end(current, "taken-over", `${owner.device.name} took over this device.`, !inherited);
1830
+ const id = `c${this.next++}`;
1831
+ session = {
1832
+ id,
1833
+ owner,
1834
+ device,
1835
+ key,
1836
+ target,
1837
+ cwd,
1838
+ lease: granted ? {
1839
+ ...granted,
1840
+ mine: granted.mine || inherited
1841
+ } : null,
1842
+ input,
1843
+ postures,
1844
+ startedAt: Date.now(),
1845
+ idle: setTimeout(() => void this.end(session, "idle", "No input for 5 minutes."), this.options.idleMs),
1846
+ renew: setInterval(() => this.renew(session, beganAt), this.options.renewMs),
1847
+ renewing: Promise.resolve(),
1848
+ unwatch: () => {},
1849
+ adb: Promise.resolve(),
1850
+ ended: false
1851
+ };
1852
+ this.sessions.set(id, session);
1853
+ this.byDevice.set(key, session);
1854
+ session.unwatch = this.options.feeds.subscribe(this.options.statusFeed, {
1855
+ item: (payload) => {
1856
+ const resolved = ownedDevice(payload, target, key);
1857
+ if (typeof resolved === "string" || deviceKey(resolved) !== key) {
1858
+ const message = typeof resolved === "string" ? resolved : "The device changed.";
1859
+ queueMicrotask(() => void this.end(session, "device-gone", message));
1860
+ }
1861
+ },
1862
+ failed: () => {}
1863
+ });
1864
+ this.audit(owner, target, driver || current ? "control.take-over" : "control.begin", {
1865
+ ok: true,
1866
+ ...driver || current ? { reason: `took over from ${driver ?? current.owner.device.name}` } : {}
1867
+ });
1868
+ return {
1869
+ session: id,
1870
+ platform: target.platform,
1871
+ lease: session.lease ? {
1872
+ grantedAt: session.lease.grantedAt,
1873
+ expiresAt: session.lease.expiresAt
1874
+ } : null,
1875
+ postures
1876
+ };
1877
+ }
1878
+ renew(session, beganAt) {
1879
+ if (session.ended || !session.lease?.mine) return;
1880
+ session.renewing = (async () => {
1881
+ const renewed = await this.lock(session.device, session.target, session.cwd, beganAt);
1882
+ if ("code" in renewed) console.error(`stim-server: could not renew the device lease: ${renewed.message}`);
1883
+ else if (session.lease) session.lease.expiresAt = renewed.expiresAt;
1884
+ })();
1885
+ }
1886
+ input(owner, id, command) {
1887
+ const session = this.sessions.get(id);
1888
+ if (!session || session.owner !== owner || session.ended) return Promise.resolve({
1889
+ code: "unknown-session",
1890
+ message: `No control session ${id} on this connection.`
1891
+ });
1892
+ session.idle.refresh();
1893
+ if (command.input === "posture" && session.device.platform === "ios") return this.fold(session, session.device.udid, command.posture);
1894
+ if (session.device.platform === "ios" || command.input === "touch" || command.input === "rotate" || command.input === "posture" || session.input.keys()) {
1895
+ session.input.send(command);
1896
+ return Promise.resolve(null);
1897
+ }
1898
+ const serial = session.device.serial;
1899
+ const previous = session.adb;
1900
+ const run = (async () => {
1901
+ await previous;
1902
+ try {
1903
+ for (const args of adbInputArgs(command)) {
1904
+ if (session.ended) return null;
1905
+ await runAdb(this.options.env, serial, args);
1906
+ }
1907
+ return null;
1908
+ } catch (cause) {
1909
+ return {
1910
+ code: "action-failed",
1911
+ message: cause.message
1912
+ };
1913
+ }
1914
+ })();
1915
+ session.adb = run.then(() => void 0);
1916
+ return run;
1917
+ }
1918
+ /**
1919
+ * `sim-fold` sweeps the hinge to the other posture, so it runs only when the Duo's last frame shows the
1920
+ * other one.
1921
+ */
1922
+ async fold(session, udid, posture) {
1923
+ if (this.folding.has(udid)) return {
1924
+ code: "device-busy",
1925
+ message: "The device is still folding."
1926
+ };
1927
+ const current = this.options.frames.litPosture(session.device);
1928
+ if (!current) return {
1929
+ code: "action-failed",
1930
+ message: "Subscribe to frames of this device to learn its posture first."
1931
+ };
1932
+ if (current === posture) return null;
1933
+ this.folding.add(udid);
1934
+ try {
1935
+ const helper = await this.options.foldHelper();
1936
+ if (session.ended) return null;
1937
+ await runQuietly(this.options.env, "xcrun", [
1938
+ "simctl",
1939
+ "spawn",
1940
+ udid,
1941
+ helper
1942
+ ], "sim-fold", FOLD_TIMEOUT_MS);
1943
+ this.options.frames.folded(udid, posture === "folded" ? "folded" : "unfolded");
1944
+ return null;
1945
+ } catch (cause) {
1946
+ return {
1947
+ code: "action-failed",
1948
+ message: cause.message
1949
+ };
1950
+ } finally {
1951
+ this.folding.delete(udid);
1952
+ }
1953
+ }
1954
+ endById(owner, id) {
1955
+ const session = this.sessions.get(id);
1956
+ if (!session || session.owner !== owner) return false;
1957
+ this.end(session, null, "The client ended the session.");
1958
+ return true;
1959
+ }
1960
+ endFor(owner, reason, message) {
1961
+ for (const session of this.sessions.values()) if (session.owner === owner) this.end(session, reason, message);
1962
+ }
1963
+ async close() {
1964
+ this.closing = true;
1965
+ const releases = [...this.sessions.values()].map((session) => this.end(session, null, "stim-server stopped."));
1966
+ await Promise.all(releases);
1967
+ }
1968
+ end(session, reason, message, release = true) {
1969
+ if (session.ended) return Promise.resolve();
1970
+ session.ended = true;
1971
+ clearTimeout(session.idle);
1972
+ clearInterval(session.renew);
1973
+ session.unwatch();
1974
+ session.input.detach();
1975
+ this.sessions.delete(session.id);
1976
+ if (this.byDevice.get(session.key) === session) this.byDevice.delete(session.key);
1977
+ if (reason) session.owner.send({
1978
+ event: "control-ended",
1979
+ session: session.id,
1980
+ reason,
1981
+ message
1982
+ });
1983
+ this.audit(session.owner, session.target, "control.end", {
1984
+ ok: true,
1985
+ durationMs: Date.now() - session.startedAt,
1986
+ reason: `${reason ?? "ended"}: ${message}`
1987
+ });
1988
+ if (!release || !session.lease?.mine) return Promise.resolve();
1989
+ return session.renewing.then(() => this.unlock(session.target, session.cwd));
1990
+ }
1991
+ status() {
1992
+ return new Promise((resolve) => {
1993
+ let settled = false;
1994
+ let unsubscribe = null;
1995
+ const finish = (value) => {
1996
+ if (settled) return;
1997
+ settled = true;
1998
+ clearTimeout(timer);
1999
+ unsubscribe?.();
2000
+ resolve(value);
2001
+ };
2002
+ const timer = setTimeout(() => finish({
2003
+ code: "status-failed",
2004
+ message: "stim status did not report the device in time."
2005
+ }), STATUS_WAIT_MS);
2006
+ unsubscribe = this.options.feeds.subscribe(this.options.statusFeed, {
2007
+ item: (payload) => finish(payload),
2008
+ failed: (message) => finish({
2009
+ code: "status-failed",
2010
+ message
2011
+ })
2012
+ });
2013
+ if (settled) unsubscribe();
2014
+ });
2015
+ }
2016
+ async lock(device, target, cwd, beganAt) {
2017
+ const id = device.platform === "ios" ? device.udid : device.serial;
2018
+ const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
2019
+ const args = [
2020
+ "device",
2021
+ "lock",
2022
+ target.platform,
2023
+ id,
2024
+ "--for",
2025
+ this.options.leaseFor,
2026
+ "--wait",
2027
+ "0",
2028
+ "--json",
2029
+ ...slot
2030
+ ];
2031
+ const printed = actionOutcome(await runStim(this.options.stimCli, this.options.env, args, cwd, this.options.lockLimits).outcome);
2032
+ if (!printed.ok) return {
2033
+ code: printed.error.message.startsWith("STIM_DEVICE_BUSY") ? "device-busy" : "action-failed",
2034
+ message: `stim device lock: ${printed.error.message}`
2035
+ };
2036
+ const grantedAt = typeof printed.output.grantedAt === "string" ? printed.output.grantedAt : null;
2037
+ const expiresAt = typeof printed.output.expiresAt === "string" ? printed.output.expiresAt : "";
2038
+ const mine = grantedAt !== null && Date.parse(grantedAt) >= beganAt - 1e3;
2039
+ if (mine) this.ownLeases.add(grantedAt);
2040
+ return {
2041
+ grantedAt,
2042
+ expiresAt,
2043
+ mine
2044
+ };
2045
+ }
2046
+ async unlock(target, cwd) {
2047
+ const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
2048
+ const outcome = await runStim(this.options.stimCli, this.options.env, [
2049
+ "device",
2050
+ "unlock",
2051
+ target.platform,
2052
+ "--json",
2053
+ ...slot
2054
+ ], cwd, this.options.lockLimits).outcome;
2055
+ if (!outcome.ok) console.error(`stim-server: could not release the device lease: ${outcome.message}`);
2056
+ }
2057
+ audit(owner, target, action, outcome) {
2058
+ this.options.audit({
2059
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2060
+ device: {
2061
+ id: owner.device.id,
2062
+ name: owner.device.name
2063
+ },
2064
+ action,
2065
+ workspace: target.workspace,
2066
+ platform: target.platform,
2067
+ ...outcome
2068
+ });
2069
+ }
2070
+ };
2071
+ //#endregion
2072
+ //#region src/feed.ts
2073
+ var Feed = class {
2074
+ listeners = /* @__PURE__ */ new Set();
2075
+ kept = [];
2076
+ child;
2077
+ ended;
2078
+ constructor(spec, stimCli, env, ended) {
2079
+ this.ended = ended;
2080
+ const child = spawn(process.execPath, [stimCli, ...spec.args], {
2081
+ cwd: spec.cwd,
2082
+ env,
2083
+ stdio: [
2084
+ "ignore",
2085
+ "pipe",
2086
+ "pipe"
2087
+ ]
2088
+ });
2089
+ this.child = child;
2090
+ let stderr = "";
2091
+ child.stderr?.setEncoding("utf8");
2092
+ child.stderr?.on("data", (chunk) => {
2093
+ stderr = (stderr + chunk).slice(-2e3);
2094
+ });
2095
+ createInterface({ input: child.stdout }).on("line", (line) => {
2096
+ if (this.child !== child) return;
2097
+ let value;
2098
+ try {
2099
+ value = JSON.parse(line);
2100
+ } catch {
2101
+ return;
2102
+ }
2103
+ if (!isJsonObject(value)) return;
2104
+ this.kept.push(value);
2105
+ if (this.kept.length > spec.keep) this.kept.shift();
2106
+ for (const listener of this.listeners) listener.item(value);
2107
+ });
2108
+ const exited = (reason) => {
2109
+ if (this.child !== child) return;
2110
+ this.child = null;
2111
+ this.ended();
2112
+ const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
2113
+ const listeners = [...this.listeners];
2114
+ this.listeners.clear();
2115
+ for (const listener of listeners) listener.failed(message);
2116
+ };
2117
+ child.on("error", (error) => exited(`could not start (${error.message})`));
2118
+ child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
2119
+ }
2120
+ add(listener) {
2121
+ this.listeners.add(listener);
2122
+ for (const value of this.kept) listener.item(value);
2123
+ return () => {
2124
+ if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
2125
+ };
2126
+ }
2127
+ stop() {
2128
+ const child = this.child;
2129
+ if (!child) return Promise.resolve();
2130
+ this.child = null;
2131
+ this.listeners.clear();
2132
+ this.ended();
2133
+ return terminate(child);
2134
+ }
2135
+ };
2136
+ var FeedPool = class {
2137
+ feeds = /* @__PURE__ */ new Map();
2138
+ stimCli;
2139
+ env;
2140
+ constructor(stimCli, env) {
2141
+ this.stimCli = stimCli;
2142
+ this.env = env;
2143
+ }
2144
+ subscribe(spec, listener) {
2145
+ const key = JSON.stringify([spec.cwd, spec.args]);
2146
+ let feed = this.feeds.get(key);
2147
+ if (!feed) {
2148
+ const created = new Feed(spec, this.stimCli, this.env, () => {
2149
+ if (this.feeds.get(key) === created) this.feeds.delete(key);
2150
+ });
2151
+ this.feeds.set(key, created);
2152
+ feed = created;
2153
+ }
2154
+ return feed.add(listener);
2155
+ }
2156
+ async close() {
2157
+ await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
2158
+ }
2159
+ };
2160
+ //#endregion
1488
2161
  //#region src/logs.ts
1489
2162
  function parseLogFilter(params) {
1490
2163
  if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
1491
2164
  const { sources, level, slot, grep, errors, tail } = params;
1492
2165
  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
2166
  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." };
2167
+ if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return { error: "slot must be 1-64 letters, digits, underscores or hyphens." };
2168
+ 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
2169
  if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
1497
2170
  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
2171
  return { filter: {
@@ -1994,6 +2667,18 @@ const LOG_LIMITS = {
1994
2667
  const FRAME_BUFFER_FRAMES = 2;
1995
2668
  const FRAME_RETRY_MS = 50;
1996
2669
  const HELPER_RETRY_MS = 3e5;
2670
+ const CONTROL_LIMITS = {
2671
+ idleMs: 3e5,
2672
+ renewMs: 6e4,
2673
+ leaseFor: "2m",
2674
+ inputPerSecond: 120,
2675
+ textCharsPerSecond: 40,
2676
+ shapeChangesPerSecond: 2
2677
+ };
2678
+ const LOCK_LIMITS = {
2679
+ timeoutMs: 3e4,
2680
+ maxOutputBytes: 65536
2681
+ };
1997
2682
  const STATUS_FEED = {
1998
2683
  args: [
1999
2684
  "status",
@@ -2010,7 +2695,6 @@ const COMMAND_LIMITS = {
2010
2695
  maxOutputBytes: 33554432
2011
2696
  };
2012
2697
  const PLAN_TIMEOUT_MS = 15e4;
2013
- const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
2014
2698
  const AUDIT_FIELD_CHARS = 256;
2015
2699
  const ACTION_LIMITS = {
2016
2700
  timeoutMs: 12e4,
@@ -2068,6 +2752,21 @@ var FailureLimiter = class {
2068
2752
  this.failures.set(key, [...this.recent(key, now), now]);
2069
2753
  }
2070
2754
  };
2755
+ function auditSafely(record) {
2756
+ try {
2757
+ appendAudit(record);
2758
+ } catch (cause) {
2759
+ console.error(`stim-server: could not append to the action log: ${cause.message}`);
2760
+ }
2761
+ }
2762
+ function take(bucket, cost, perSecond, capacity) {
2763
+ const now = Date.now();
2764
+ bucket.tokens = Math.min(capacity, bucket.tokens + (now - bucket.at) / 1e3 * perSecond);
2765
+ bucket.at = now;
2766
+ if (bucket.tokens < cost) return false;
2767
+ bucket.tokens -= cost;
2768
+ return true;
2769
+ }
2071
2770
  function send(socket, message) {
2072
2771
  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
2073
2772
  }
@@ -2130,6 +2829,15 @@ async function startServer(options) {
2130
2829
  };
2131
2830
  if (options.frameHelper === void 0) buildHelper();
2132
2831
  const frames = new FramePool(options.env, frameLimits, frameHelper);
2832
+ let foldBuild = null;
2833
+ const foldHelper = () => {
2834
+ if (options.foldHelper !== void 0) return Promise.resolve(options.foldHelper);
2835
+ foldBuild ??= buildFoldHelper(options.env, helperAbort.signal).catch((cause) => {
2836
+ foldBuild = null;
2837
+ throw new Error(`sim-fold could not be built: ${cause.message}`);
2838
+ });
2839
+ return foldBuild;
2840
+ };
2133
2841
  const running = /* @__PURE__ */ new Set();
2134
2842
  const logLimits = {
2135
2843
  ...LOG_LIMITS,
@@ -2144,8 +2852,27 @@ async function startServer(options) {
2144
2852
  ...options.actionLimits
2145
2853
  };
2146
2854
  const busyWorkspaces = /* @__PURE__ */ new Set();
2855
+ const planQueues = /* @__PURE__ */ new Map();
2147
2856
  const sessions = /* @__PURE__ */ new Map();
2148
2857
  const sampler = new UsageSampler();
2858
+ const controllers = /* @__PURE__ */ new Map();
2859
+ const controlLimits = {
2860
+ ...CONTROL_LIMITS,
2861
+ ...options.controlLimits
2862
+ };
2863
+ const control = new ControlHub({
2864
+ env: options.env,
2865
+ stimCli: options.stimCli,
2866
+ feeds,
2867
+ frames,
2868
+ statusFeed: STATUS_FEED,
2869
+ audit: auditSafely,
2870
+ lockLimits: LOCK_LIMITS,
2871
+ idleMs: controlLimits.idleMs,
2872
+ renewMs: controlLimits.renewMs,
2873
+ leaseFor: controlLimits.leaseFor,
2874
+ foldHelper
2875
+ });
2149
2876
  const wss = new WebSocketServer({
2150
2877
  noServer: true,
2151
2878
  maxPayload: MAX_PAYLOAD
@@ -2158,8 +2885,12 @@ async function startServer(options) {
2158
2885
  const watcher = watch(serverDir(), () => {
2159
2886
  revocationCheck ??= setTimeout(() => {
2160
2887
  revocationCheck = null;
2161
- const paired = new Set(readDevices().map((device) => device.id));
2888
+ const paired = new Map(readDevices().map((device) => [device.id, device]));
2162
2889
  for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
2890
+ for (const [socket, controller] of controllers) if (!paired.get(controller.device.id)?.capabilities.includes("control")) {
2891
+ control.endFor(controller, "forbidden", "This device can no longer control devices.");
2892
+ controllers.delete(socket);
2893
+ }
2163
2894
  }, 50);
2164
2895
  });
2165
2896
  function connection(socket, peer) {
@@ -2244,6 +2975,82 @@ async function startServer(options) {
2244
2975
  }
2245
2976
  });
2246
2977
  }
2978
+ const inputs = {
2979
+ tokens: controlLimits.inputPerSecond,
2980
+ at: Date.now()
2981
+ };
2982
+ const characters = {
2983
+ tokens: 256,
2984
+ at: Date.now()
2985
+ };
2986
+ const shapeChanges = {
2987
+ tokens: controlLimits.shapeChangesPerSecond,
2988
+ at: Date.now()
2989
+ };
2990
+ function controller(session) {
2991
+ let found = controllers.get(socket);
2992
+ if (!found) {
2993
+ found = {
2994
+ device: session,
2995
+ send: (message) => send(socket, message)
2996
+ };
2997
+ controllers.set(socket, found);
2998
+ }
2999
+ return found;
3000
+ }
3001
+ async function beginControl(id, params, session) {
3002
+ const raw = isJsonObject(params) ? params : {};
3003
+ const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
3004
+ const refuseControl = (code, message) => {
3005
+ auditSafely({
3006
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3007
+ device: {
3008
+ id: session.id,
3009
+ name: session.name
3010
+ },
3011
+ action: raw.takeOver === true ? "control.take-over" : "control.begin",
3012
+ workspace: clip(raw.workspace),
3013
+ ...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
3014
+ ok: false,
3015
+ error: {
3016
+ code,
3017
+ message: clip(message)
3018
+ }
3019
+ });
3020
+ error(id, code, message);
3021
+ };
3022
+ 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.`);
3023
+ const parsed = parseControlBegin(params);
3024
+ if ("code" in parsed) return refuseControl(parsed.code, parsed.message);
3025
+ const resolved = registeredWorkspace(parsed.value.workspace);
3026
+ if ("code" in resolved) return refuseControl(resolved.code, resolved.message);
3027
+ const owner = controller(session);
3028
+ const outcome = await control.begin(owner, {
3029
+ ...parsed.value,
3030
+ workspace: resolved.dir
3031
+ }, resolved.dir, () => socket.readyState === socket.OPEN && controllers.get(socket) === owner && readDevices().some((entry) => entry.id === session.id && entry.capabilities.includes("control")));
3032
+ if ("code" in outcome) return refuseControl(outcome.code, outcome.message);
3033
+ send(socket, {
3034
+ id,
3035
+ result: outcome
3036
+ });
3037
+ }
3038
+ async function input(id, method, params, session) {
3039
+ const owner = controller(session);
3040
+ const parsed = parseInput(method, params, (name) => control.targetOf(owner, name));
3041
+ if ("code" in parsed) return error(id, parsed.code, parsed.message);
3042
+ if (!take(inputs, 1, controlLimits.inputPerSecond, controlLimits.inputPerSecond)) return error(id, "limit-exceeded", `A connection can send ${controlLimits.inputPerSecond} inputs a second.`);
3043
+ const { command: sent } = parsed.value;
3044
+ 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.`);
3045
+ const { shapeChangesPerSecond } = controlLimits;
3046
+ 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.`);
3047
+ const refused = await control.input(owner, parsed.value.session, parsed.value.command);
3048
+ if (refused) return error(id, refused.code, refused.message);
3049
+ send(socket, {
3050
+ id,
3051
+ result: {}
3052
+ });
3053
+ }
2247
3054
  function workspaceDir(id, workspace, required) {
2248
3055
  if (workspace === void 0 && !required) return homedir();
2249
3056
  const resolved = registeredWorkspace(workspace);
@@ -2473,30 +3280,53 @@ async function startServer(options) {
2473
3280
  });
2474
3281
  subscriptions.set(subscription, cleanup);
2475
3282
  }
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 () => {
3283
+ function command(id, args, cwd, result, limits = commandLimits, turn = null) {
3284
+ if (commands.size >= MAX_COMMANDS) {
3285
+ error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
3286
+ return null;
3287
+ }
3288
+ let run = null;
3289
+ let dropped = false;
3290
+ let release;
3291
+ const released = new Promise((resolve) => {
3292
+ release = resolve;
3293
+ });
3294
+ const cancel = async () => {
3295
+ dropped = true;
3296
+ if (!run) return void (turn ?? Promise.resolve()).then(release);
3297
+ await run.cancel();
3298
+ release();
3299
+ };
3300
+ const start = async () => {
3301
+ if (dropped) return;
3302
+ run = runStim(options.stimCli, options.env, args, cwd, limits);
2482
3303
  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;
3304
+ commands.delete(cancel);
3305
+ running.delete(cancel);
2490
3306
  try {
2491
- value = result(outcome.stdout);
2492
- } catch {
2493
- return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
3307
+ if (!outcome.ok) {
3308
+ const printed = actionOutcome(outcome);
3309
+ return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
3310
+ }
3311
+ let value;
3312
+ try {
3313
+ value = result(outcome.stdout);
3314
+ } catch {
3315
+ return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
3316
+ }
3317
+ send(socket, {
3318
+ id,
3319
+ result: value
3320
+ });
3321
+ } finally {
3322
+ release();
2494
3323
  }
2495
- send(socket, {
2496
- id,
2497
- result: value
2498
- });
2499
- })();
3324
+ };
3325
+ commands.add(cancel);
3326
+ running.add(cancel);
3327
+ if (turn) turn.then(start);
3328
+ else start();
3329
+ return released;
2500
3330
  }
2501
3331
  function queryLogs(id, params) {
2502
3332
  const parsed = parseLogFilter(params);
@@ -2613,7 +3443,7 @@ async function startServer(options) {
2613
3443
  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
3444
  const cwd = workspaceDir(id, params.workspace, true);
2615
3445
  if (!cwd) return;
2616
- command(id, [
3446
+ const finished = command(id, [
2617
3447
  platform,
2618
3448
  "--plan",
2619
3449
  "--json",
@@ -2625,6 +3455,11 @@ async function startServer(options) {
2625
3455
  }, {
2626
3456
  ...commandLimits,
2627
3457
  timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
3458
+ }, planQueues.get(cwd) ?? null);
3459
+ if (!finished) return;
3460
+ planQueues.set(cwd, finished);
3461
+ finished.finally(() => {
3462
+ if (planQueues.get(cwd) === finished) planQueues.delete(cwd);
2628
3463
  });
2629
3464
  }
2630
3465
  async function handle(raw) {
@@ -2684,6 +3519,23 @@ async function startServer(options) {
2684
3519
  }
2685
3520
  if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
2686
3521
  if (message.method === "action") return runAction(id, message.params, device);
3522
+ if (message.method === "control.begin") {
3523
+ beginControl(id, message.params, device);
3524
+ return;
3525
+ }
3526
+ if (message.method === "control.end") {
3527
+ const name = isJsonObject(message.params) ? message.params.session : void 0;
3528
+ if (typeof name !== "string" || !control.endById(controller(device), name)) return error(id, "unknown-session", `No control session ${String(name)} on this connection.`);
3529
+ return send(socket, {
3530
+ id,
3531
+ result: {}
3532
+ });
3533
+ }
3534
+ if (message.method === "input.touch" || message.method === "input.text" || message.method === "input.button" || message.method === "input.rotate" || message.method === "input.posture") {
3535
+ const method = message.method;
3536
+ input(id, method, message.params, device);
3537
+ return;
3538
+ }
2687
3539
  if (message.method === "unsubscribe") {
2688
3540
  const name = isJsonObject(message.params) ? message.params.subscription : void 0;
2689
3541
  const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
@@ -2717,6 +3569,9 @@ async function startServer(options) {
2717
3569
  clearTimeout(timer);
2718
3570
  sessions.delete(socket);
2719
3571
  if (sessions.size === 0) sampler.stop();
3572
+ const owner = controllers.get(socket);
3573
+ if (owner) control.endFor(owner, null, "The client disconnected.");
3574
+ controllers.delete(socket);
2720
3575
  for (const unsubscribe of subscriptions.values()) unsubscribe();
2721
3576
  subscriptions.clear();
2722
3577
  for (const cancel of commands) {
@@ -2759,6 +3614,7 @@ async function startServer(options) {
2759
3614
  sampler.stop();
2760
3615
  if (revocationCheck) clearTimeout(revocationCheck);
2761
3616
  for (const client of wss.clients) client.terminate();
3617
+ await control.close();
2762
3618
  await Promise.all([
2763
3619
  frames.close(),
2764
3620
  feeds.close(),
@@ -2948,7 +3804,7 @@ async function main() {
2948
3804
  if (values.json) return void console.log(JSON.stringify({ actions: records }));
2949
3805
  if (!records.length) console.log("No actions.");
2950
3806
  for (const record of records) {
2951
- const outcome = record.ok ? "ok" : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
3807
+ const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
2952
3808
  const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
2953
3809
  console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
2954
3810
  }