@stim-cli/server 1.10.1 → 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,19 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-B3k9Ykuu.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
- import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
- import { homedir, hostname, tmpdir } from "node:os";
4
+ import { accessSync, appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, watch, writeFileSync } from "node:fs";
5
+ import { availableParallelism, cpus, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
6
6
  import { parseArgs } from "node:util";
7
7
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
8
8
  import { basename, delimiter, dirname, join } from "node:path";
9
+ import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
9
10
  import { createHash, randomBytes } from "node:crypto";
10
11
  import { configDir, withDirLock } from "@stim-cli/core";
11
- import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
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
15
  import { connect } from "node:http2";
16
+ import { fileURLToPath } from "node:url";
17
+ import { createInterface } from "node:readline";
17
18
  //#region src/environment.ts
18
19
  function parseEnvironment(output) {
19
20
  const environment = {};
@@ -90,13 +91,18 @@ function parseIdentity(value) {
90
91
  };
91
92
  return null;
92
93
  }
94
+ function parseCapabilities(value) {
95
+ return Array.isArray(value) ? CAPABILITIES.filter((capability) => value.includes(capability)) : [];
96
+ }
97
+ function capabilitiesFor(control) {
98
+ return control ? ["read", "control"] : ["read"];
99
+ }
93
100
  function parseDevice(value) {
94
101
  if (!isJsonObject(value)) return null;
95
102
  const { id, name, tokenHash, pairedAt, lastSeenAt } = value;
96
103
  const identity = parseIdentity(value.identity);
97
104
  if (typeof id !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || !identity) return null;
98
105
  if (typeof pairedAt !== "string") return null;
99
- const capabilities = Array.isArray(value.capabilities) ? CAPABILITIES.filter((capability) => value.capabilities.includes(capability)) : [];
100
106
  return {
101
107
  id,
102
108
  name,
@@ -104,7 +110,7 @@ function parseDevice(value) {
104
110
  identity,
105
111
  pairedAt,
106
112
  lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
107
- capabilities
113
+ capabilities: parseCapabilities(value.capabilities)
108
114
  };
109
115
  }
110
116
  function readDevices() {
@@ -116,7 +122,8 @@ function readPairings() {
116
122
  if (!Array.isArray(tokens)) return [];
117
123
  return tokens.flatMap((entry) => isJsonObject(entry) && typeof entry.tokenHash === "string" && typeof entry.expiresAt === "string" ? [{
118
124
  tokenHash: entry.tokenHash,
119
- expiresAt: entry.expiresAt
125
+ expiresAt: entry.expiresAt,
126
+ capabilities: "capabilities" in entry ? parseCapabilities(entry.capabilities) : capabilitiesFor(false)
120
127
  }] : []);
121
128
  }
122
129
  function writeJson(file, value) {
@@ -138,7 +145,8 @@ function transaction(fn) {
138
145
  function unexpired(record, now) {
139
146
  return Date.parse(record.expiresAt) > now;
140
147
  }
141
- function createPairingToken(now = Date.now()) {
148
+ /** The device that spends the token gets `capabilities`. */
149
+ function createPairingToken(now = Date.now(), capabilities = capabilitiesFor(false)) {
142
150
  const token = newToken();
143
151
  const expiresAt = new Date(now + PAIRING_TTL_MS).toISOString();
144
152
  transaction(() => {
@@ -147,7 +155,8 @@ function createPairingToken(now = Date.now()) {
147
155
  version: 1,
148
156
  tokens: [...pending, {
149
157
  tokenHash: hashToken(token),
150
- expiresAt
158
+ expiresAt,
159
+ capabilities
151
160
  }]
152
161
  });
153
162
  });
@@ -187,7 +196,7 @@ function spendPairingToken(token, name, identity, now = Date.now()) {
187
196
  identity,
188
197
  pairedAt: at,
189
198
  lastSeenAt: at,
190
- capabilities: ["read"]
199
+ capabilities: match.capabilities
191
200
  };
192
201
  writeJson(devicesFile(), {
193
202
  version: 1,
@@ -224,6 +233,19 @@ function authenticateDevice(token, identity, now = Date.now()) {
224
233
  };
225
234
  });
226
235
  }
236
+ function grantDevice(id, capabilities) {
237
+ return transaction(() => {
238
+ const devices = readDevices();
239
+ const device = devices.find((entry) => entry.id === id);
240
+ if (!device) return false;
241
+ device.capabilities = capabilities;
242
+ writeJson(devicesFile(), {
243
+ version: 1,
244
+ devices
245
+ });
246
+ return true;
247
+ });
248
+ }
227
249
  function revokeDevice(id) {
228
250
  return transaction(() => {
229
251
  const devices = readDevices();
@@ -237,6 +259,113 @@ function revokeDevice(id) {
237
259
  });
238
260
  }
239
261
  //#endregion
262
+ //#region src/actions.ts
263
+ function parseAction(params) {
264
+ if (!isJsonObject(params)) return {
265
+ code: "bad-request",
266
+ message: "params must be an object."
267
+ };
268
+ const { action, workspace, platform, ...rest } = params;
269
+ if (typeof action !== "string" || !ACTIONS.includes(action)) return {
270
+ code: "unknown-action",
271
+ message: `Unknown action ${JSON.stringify(action)}. Actions: ${ACTIONS.join(", ")}.`
272
+ };
273
+ if (typeof workspace !== "string") return {
274
+ code: "bad-request",
275
+ message: "params.workspace must be an environment path from a status payload."
276
+ };
277
+ const extra = Object.keys(rest);
278
+ if (action === "stop" && platform !== void 0) extra.push("platform");
279
+ if (extra.length) return {
280
+ code: "bad-request",
281
+ message: `${action} does not take ${extra.join(", ")}.`
282
+ };
283
+ if (action === "stop") return { action: {
284
+ action,
285
+ workspace
286
+ } };
287
+ if (platform !== void 0 && platform !== "ios" && platform !== "android") return {
288
+ code: "bad-request",
289
+ message: "platform must be ios or android."
290
+ };
291
+ return { action: {
292
+ action: "reload",
293
+ workspace,
294
+ ...platform ? { platform } : {}
295
+ } };
296
+ }
297
+ function actionArgs(action) {
298
+ if (action.action === "stop") return ["stop", "--json"];
299
+ return [
300
+ "reload",
301
+ ...action.platform ? [action.platform] : [],
302
+ "--json"
303
+ ];
304
+ }
305
+ /** The JSON object the command printed, and on failure the message and remedy it printed, if any. */
306
+ function actionOutcome(outcome) {
307
+ let printed = null;
308
+ try {
309
+ printed = JSON.parse(outcome.stdout?.trim() ?? "");
310
+ } catch {
311
+ printed = null;
312
+ }
313
+ if (outcome.ok) return isJsonObject(printed) ? {
314
+ ok: true,
315
+ output: printed
316
+ } : {
317
+ ok: false,
318
+ error: {
319
+ code: "action-failed",
320
+ message: "The command printed output that is not a JSON object."
321
+ }
322
+ };
323
+ if (isJsonObject(printed) && typeof printed.message === "string") {
324
+ const code = typeof printed.code === "string" ? `${printed.code}: ` : "";
325
+ const remedy = typeof printed.remedy === "string" ? ` ${printed.remedy}` : "";
326
+ return {
327
+ ok: false,
328
+ error: {
329
+ code: "action-failed",
330
+ message: `${code}${printed.message}${remedy}`
331
+ }
332
+ };
333
+ }
334
+ return {
335
+ ok: false,
336
+ error: {
337
+ code: "action-failed",
338
+ message: outcome.message
339
+ }
340
+ };
341
+ }
342
+ function auditFile() {
343
+ return join(serverDir(), "actions.ndjson");
344
+ }
345
+ function appendAudit(record) {
346
+ mkdirSync(serverDir(), {
347
+ recursive: true,
348
+ mode: 448
349
+ });
350
+ appendFileSync(auditFile(), `${JSON.stringify(record)}\n`, { mode: 384 });
351
+ }
352
+ function readAudit() {
353
+ let text;
354
+ try {
355
+ text = readFileSync(auditFile(), "utf8");
356
+ } catch {
357
+ return [];
358
+ }
359
+ return text.split("\n").flatMap((line) => {
360
+ try {
361
+ const value = JSON.parse(line);
362
+ return isJsonObject(value) ? [value] : [];
363
+ } catch {
364
+ return [];
365
+ }
366
+ });
367
+ }
368
+ //#endregion
240
369
  //#region src/stim-command.ts
241
370
  const KILL_GRACE_MS = 1e3;
242
371
  function terminate(child) {
@@ -305,7 +434,8 @@ function runStim(stimCli, env, args, cwd, limits) {
305
434
  const detail = stderr.trim();
306
435
  settle({
307
436
  ok: false,
308
- message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`
437
+ message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`,
438
+ stdout: Buffer.concat(chunks).toString("utf8")
309
439
  });
310
440
  });
311
441
  }),
@@ -317,92 +447,387 @@ function runStim(stimCli, env, args, cwd, limits) {
317
447
  };
318
448
  }
319
449
  //#endregion
320
- //#region src/feed.ts
321
- var Feed = class {
322
- listeners = /* @__PURE__ */ new Set();
323
- kept = [];
450
+ //#region src/video.ts
451
+ const FIXED_HEADER_BYTES = 21;
452
+ /** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
453
+ function videoPacket(subscription, sequence, unit) {
454
+ const id = Buffer.from(subscription, "ascii");
455
+ const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
456
+ header.writeUInt8(1, 0);
457
+ header.writeUInt8(unit.keyframe ? 1 : 0, 1);
458
+ header.writeUInt16BE(header.length, 2);
459
+ header.writeUInt32BE(sequence >>> 0, 4);
460
+ header.writeDoubleBE(unit.capturedAt, 8);
461
+ header.writeUInt16BE(unit.width, 16);
462
+ header.writeUInt16BE(unit.height, 18);
463
+ header.writeUInt8(id.length, 20);
464
+ id.copy(header, FIXED_HEADER_BYTES);
465
+ return Buffer.concat([header, unit.data]);
466
+ }
467
+ const DEFAULT_VIDEO_LIMITS = {
468
+ congestedBytes: 262144,
469
+ startBitrate: 3e6,
470
+ minBitrate: 25e4,
471
+ maxBitrate: 8e6,
472
+ recoverMs: 2e3
473
+ };
474
+ /**
475
+ * Decides, for one subscriber, whether each access unit goes out. A subscriber starts waiting for a keyframe.
476
+ * When its socket backs up it drops everything until the next keyframe, because a P-frame is useless without
477
+ * the frames before it; `congested` is reported once per episode.
478
+ */
479
+ var VideoGate = class {
480
+ waiting = true;
481
+ congestedBytes;
482
+ constructor(congestedBytes) {
483
+ this.congestedBytes = congestedBytes;
484
+ }
485
+ admit(unit, bufferedBytes) {
486
+ if (bufferedBytes > this.congestedBytes) {
487
+ if (this.waiting) return "drop";
488
+ this.waiting = true;
489
+ return "congested";
490
+ }
491
+ if (this.waiting && !unit.keyframe) return "drop";
492
+ this.waiting = false;
493
+ return "send";
494
+ }
495
+ /** The client lost its decoder state; drop until the keyframe it asked for. */
496
+ reset() {
497
+ this.waiting = true;
498
+ }
499
+ };
500
+ /**
501
+ * One encoder's bitrate, shared by all subscribers of a device: halved on congestion at most once per `recoverMs`,
502
+ * and raised by a quarter after `recoverMs` without congestion.
503
+ */
504
+ var Bitrate = class {
505
+ value;
506
+ calmSince;
507
+ cutAt = -Infinity;
508
+ limits;
509
+ constructor(limits, now) {
510
+ this.limits = limits;
511
+ this.value = limits.startBitrate;
512
+ this.calmSince = now;
513
+ }
514
+ get current() {
515
+ return this.value;
516
+ }
517
+ /** Called while any subscriber is congested. Returns the new bitrate when it changed. */
518
+ congested(now) {
519
+ this.calmSince = now;
520
+ if (now - this.cutAt < this.limits.recoverMs) return null;
521
+ this.cutAt = now;
522
+ return this.set(Math.max(this.limits.minBitrate, Math.round(this.value / 2)));
523
+ }
524
+ /** Returns the new bitrate when it changed. */
525
+ tick(now) {
526
+ if (now - this.calmSince < this.limits.recoverMs) return null;
527
+ this.calmSince = now;
528
+ return this.set(Math.min(this.limits.maxBitrate, Math.round(this.value * 1.25)));
529
+ }
530
+ set(next) {
531
+ if (next === this.value) return null;
532
+ this.value = next;
533
+ return next;
534
+ }
535
+ };
536
+ //#endregion
537
+ //#region src/frame-helper.ts
538
+ const DEFAULT_FRAME_HINT = {
539
+ fps: FRAME_FPS.default,
540
+ maxEdge: FRAME_EDGE.default
541
+ };
542
+ const SOURCES_DIR = fileURLToPath(new URL("./stim-frames/", import.meta.url));
543
+ const BUILD_TIMEOUT_MS = 18e4;
544
+ const VERSION_TIMEOUT_MS = 3e4;
545
+ const MAX_MESSAGE_BYTES = 16777216;
546
+ const FRAME_MESSAGE = 1;
547
+ const NOTICE_MESSAGE = 2;
548
+ const VIDEO_MESSAGE = 3;
549
+ const VIDEO_HEADER_BYTES = 14;
550
+ const KEYFRAME_INTERVAL_MS = 250;
551
+ /** Runs the compiler in its own process group, so a timeout or `signal` also stops `swift-frontend` and `ld`. */
552
+ function run(file, args, env, timeoutMs, signal) {
553
+ return new Promise((resolve, reject) => {
554
+ const child = spawn(file, args, {
555
+ env,
556
+ stdio: [
557
+ "ignore",
558
+ "pipe",
559
+ "pipe"
560
+ ],
561
+ detached: true
562
+ });
563
+ let output = "";
564
+ let stopped = null;
565
+ const stop = (reason) => {
566
+ stopped ??= reason;
567
+ try {
568
+ process.kill(-child.pid, "SIGKILL");
569
+ } catch {}
570
+ };
571
+ const timer = setTimeout(() => stop(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`), timeoutMs);
572
+ const abort = () => stop(`${file} ${args[0]} was stopped with the server.`);
573
+ signal?.addEventListener("abort", abort);
574
+ child.stdout.setEncoding("utf8");
575
+ child.stderr.setEncoding("utf8");
576
+ child.stdout.on("data", (chunk) => output = (output + chunk).slice(-4e3));
577
+ child.stderr.on("data", (chunk) => output = (output + chunk).slice(-4e3));
578
+ child.on("error", (error) => {
579
+ clearTimeout(timer);
580
+ signal?.removeEventListener("abort", abort);
581
+ reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
582
+ });
583
+ child.on("close", (code) => {
584
+ clearTimeout(timer);
585
+ signal?.removeEventListener("abort", abort);
586
+ if (stopped) reject(new Error(stopped));
587
+ else if (code === 0) resolve(output);
588
+ else reject(/* @__PURE__ */ new Error(`${file} ${args[0]} exited (code ${code}): ${output.trim()}`));
589
+ });
590
+ });
591
+ }
592
+ /**
593
+ * Compiles the `stim-frames` helper from the Swift sources shipped in `dist/stim-frames/` into
594
+ * `$STIM_HOME/server/helpers/`, named by a hash of the sources and the compiler version. Resolves to the
595
+ * helper's path, or rejects with the reason it cannot be built.
596
+ */
597
+ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
598
+ if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
599
+ const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
600
+ if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
601
+ return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
602
+ await run("xcrun", [
603
+ "swiftc",
604
+ "-O",
605
+ "-swift-version",
606
+ "5",
607
+ "-module-name",
608
+ "StimFrames",
609
+ "-o",
610
+ output,
611
+ ...sources
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);
666
+ renameSync(output, helper);
667
+ } finally {
668
+ rmSync(output, { force: true });
669
+ }
670
+ return helper;
671
+ }
672
+ /**
673
+ * One `stim-frames` process per device, shared by its subscribers and stopped with the last of them. It runs
674
+ * at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
675
+ * helper exits when its stdin closes, so it cannot outlive the server.
676
+ */
677
+ var HelperSource = class {
678
+ listeners = /* @__PURE__ */ new Map();
324
679
  child;
680
+ last = null;
681
+ config = "";
682
+ stopped = false;
683
+ notice = null;
684
+ keyboard = null;
685
+ stderr = "";
325
686
  ended;
326
- constructor(spec, stimCli, env, ended) {
687
+ bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
688
+ keyframeAt = -Infinity;
689
+ keyframeTimer = null;
690
+ constructor(helper, device, env, ended) {
327
691
  this.ended = ended;
328
- const child = spawn(process.execPath, [stimCli, ...spec.args], {
329
- cwd: spec.cwd,
692
+ const id = device.platform === "ios" ? device.udid : device.serial;
693
+ this.child = spawn(helper, [device.platform, id], {
330
694
  env,
331
695
  stdio: [
332
- "ignore",
696
+ "pipe",
333
697
  "pipe",
334
698
  "pipe"
335
699
  ]
336
700
  });
337
- this.child = child;
338
- let stderr = "";
339
- child.stderr?.setEncoding("utf8");
340
- child.stderr?.on("data", (chunk) => {
341
- stderr = (stderr + chunk).slice(-2e3);
701
+ this.child.stdin.on("error", () => {});
702
+ this.child.stderr.setEncoding("utf8");
703
+ this.child.stderr.on("data", (chunk) => {
704
+ this.stderr = (this.stderr + chunk).slice(-1e3);
342
705
  });
343
- createInterface({ input: child.stdout }).on("line", (line) => {
344
- if (this.child !== child) return;
345
- let value;
346
- try {
347
- value = JSON.parse(line);
348
- } catch {
349
- return;
350
- }
351
- if (!isJsonObject(value)) return;
352
- this.kept.push(value);
353
- if (this.kept.length > spec.keep) this.kept.shift();
354
- for (const listener of this.listeners) listener.item(value);
706
+ this.readMessages();
707
+ this.child.on("error", (error) => this.fail(`stim-frames could not start (${error.message}).`));
708
+ this.child.on("close", (code, signal) => {
709
+ const detail = this.notice ?? this.stderr.trim();
710
+ this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
355
711
  });
356
- const exited = (reason) => {
357
- if (this.child !== child) return;
358
- this.child = null;
359
- this.ended();
360
- const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
361
- const listeners = [...this.listeners];
362
- this.listeners.clear();
363
- for (const listener of listeners) listener.failed(message);
364
- };
365
- child.on("error", (error) => exited(`could not start (${error.message})`));
366
- child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
367
712
  }
368
- add(listener) {
369
- this.listeners.add(listener);
370
- for (const value of this.kept) listener.item(value);
713
+ /** A null `hint` keeps the helper running for input without asking for frames. */
714
+ add(listener, hint) {
715
+ this.listeners.set(listener, hint);
716
+ this.configure();
717
+ if (listener.video) this.keyframe();
718
+ else if (this.last) listener.frame(this.last);
371
719
  return () => {
372
- if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
720
+ if (!this.listeners.delete(listener)) return;
721
+ if (this.listeners.size === 0) this.stop();
722
+ else this.configure();
373
723
  };
374
724
  }
375
725
  stop() {
376
- const child = this.child;
377
- if (!child) return Promise.resolve();
378
- this.child = null;
726
+ if (this.stopped) return Promise.resolve();
727
+ this.stopped = true;
728
+ if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
379
729
  this.listeners.clear();
380
730
  this.ended();
381
- return terminate(child);
382
- }
383
- };
384
- var FeedPool = class {
385
- feeds = /* @__PURE__ */ new Map();
386
- stimCli;
387
- env;
388
- constructor(stimCli, env) {
389
- this.stimCli = stimCli;
390
- this.env = env;
731
+ this.child.stdin.end();
732
+ return terminate(this.child);
391
733
  }
392
- subscribe(spec, listener) {
393
- const key = JSON.stringify([spec.cwd, spec.args]);
394
- let feed = this.feeds.get(key);
395
- if (!feed) {
396
- const created = new Feed(spec, this.stimCli, this.env, () => {
397
- if (this.feeds.get(key) === created) this.feeds.delete(key);
398
- });
399
- this.feeds.set(key, created);
400
- feed = created;
734
+ /** Asks for at most one keyframe per {@link KEYFRAME_INTERVAL_MS}; a request inside the interval is sent at its end. */
735
+ keyframe() {
736
+ if (this.stopped || this.keyframeTimer) return;
737
+ const wait = this.keyframeAt + KEYFRAME_INTERVAL_MS - Date.now();
738
+ if (wait > 0) {
739
+ this.keyframeTimer = setTimeout(() => {
740
+ this.keyframeTimer = null;
741
+ this.keyframe();
742
+ }, wait);
743
+ return;
401
744
  }
402
- return feed.add(listener);
745
+ this.keyframeAt = Date.now();
746
+ this.child.stdin.write("{\"keyframe\":true}\n");
403
747
  }
404
- async close() {
405
- await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
748
+ congested() {
749
+ if (this.bitrate.congested(Date.now()) !== null) this.configure();
750
+ }
751
+ send(command) {
752
+ if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
753
+ }
754
+ configure() {
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]);
761
+ const config = JSON.stringify({
762
+ fps: Math.max(0, ...hints.map((hint) => hint.fps)),
763
+ maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
764
+ jpeg: jpegFps.length > 0,
765
+ ...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
766
+ video: jpegFps.length < hints.length,
767
+ bitrate: this.bitrate.current
768
+ });
769
+ if (!jpegFps.length) this.last = null;
770
+ if (config === this.config || this.stopped) return;
771
+ this.config = config;
772
+ this.child.stdin.write(`${config}\n`);
773
+ }
774
+ fail(message) {
775
+ if (this.stopped) return;
776
+ const listeners = [...this.listeners.keys()];
777
+ this.stop();
778
+ for (const listener of listeners) listener.failed(message);
779
+ }
780
+ readMessages() {
781
+ let buffer = Buffer.alloc(0);
782
+ this.child.stdout.on("data", (chunk) => {
783
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
784
+ while (buffer.length >= 4) {
785
+ const length = buffer.readUInt32BE(0);
786
+ if (length < 1 || length > MAX_MESSAGE_BYTES) {
787
+ this.child.stdout.removeAllListeners("data");
788
+ return this.fail("stim-frames wrote a malformed message.");
789
+ }
790
+ if (buffer.length < 4 + length) break;
791
+ const body = buffer.subarray(4, 4 + length);
792
+ buffer = buffer.subarray(4 + length);
793
+ if (body[0] === FRAME_MESSAGE && body.length > 5) this.frame(body);
794
+ else if (body[0] === VIDEO_MESSAGE && body.length > VIDEO_HEADER_BYTES) this.video(body);
795
+ else if (body[0] === NOTICE_MESSAGE) this.readNotice(body.subarray(1).toString("utf8"));
796
+ }
797
+ });
798
+ }
799
+ frame(body) {
800
+ if (this.stopped) return;
801
+ this.last = {
802
+ width: body.readUInt16BE(1),
803
+ height: body.readUInt16BE(3),
804
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
805
+ data: body.subarray(5).toString("base64")
806
+ };
807
+ for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
808
+ }
809
+ video(body) {
810
+ if (this.stopped) return;
811
+ const unit = {
812
+ keyframe: (body[1] & 1) !== 0,
813
+ capturedAt: body.readDoubleBE(2),
814
+ width: body.readUInt16BE(10),
815
+ height: body.readUInt16BE(12),
816
+ data: body.subarray(VIDEO_HEADER_BYTES)
817
+ };
818
+ if (this.bitrate.tick(Date.now()) !== null) this.configure();
819
+ for (const listener of this.listeners.keys()) listener.video?.(unit);
820
+ }
821
+ readNotice(text) {
822
+ try {
823
+ const notice = JSON.parse(text);
824
+ const error = notice?.error;
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}`);
830
+ } catch {}
406
831
  }
407
832
  };
408
833
  //#endregion
@@ -437,7 +862,8 @@ function ownedDevice(payload, target, attached) {
437
862
  if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
438
863
  return {
439
864
  platform: "ios",
440
- udid: sim.udid
865
+ udid: sim.udid,
866
+ foldable: /\bDuo\b/.test(sim.name ?? "")
441
867
  };
442
868
  }
443
869
  const emulator = devices?.android;
@@ -523,36 +949,90 @@ async function stopTools(running, tmp) {
523
949
  });
524
950
  }
525
951
  /**
526
- * Without `--display`, simctl captures the first panel it finds, which on a foldable simulator (iPhone Duo)
527
- * can be the unlit one and comes back black; `primary` is the panel CoreDevice reports as primary. A simctl
528
- * that rejects `primary` gets the default display instead.
952
+ * Without `--display`, simctl captures the first panel it finds; `primary` is the panel CoreDevice reports as
953
+ * primary. A simctl that rejects `primary` gets the default display instead.
954
+ *
955
+ * An iPhone Duo lights one of two panels: `primary` is the cover, lit when folded, and `primary-1` the inner
956
+ * panel, lit when unfolded; the other one's framebuffer is all black. `litPanels` remembers the lit panel per
957
+ * simulator across subscriptions, and a black capture tries the other panel.
529
958
  */
530
- function simulatorCapturer(udid, env, limits) {
959
+ function simulatorCapturer(device, env, limits, litPanels) {
531
960
  let tmp = null;
532
961
  let display = ["--display=primary"];
533
962
  let closed = false;
534
963
  const running = /* @__PURE__ */ new Set();
535
- const screenshot = (output) => runTool("xcrun", [
536
- "simctl",
537
- "io",
538
- udid,
539
- "screenshot",
540
- "--type=jpeg",
541
- ...display,
542
- output
543
- ], env, running, limits.toolTimeoutMs);
964
+ const screenshot = async (panel) => {
965
+ tmp ??= mkdtempSync(join(serverDir(), "frames-"));
966
+ const output = join(tmp, "frame.jpg");
967
+ await runTool("xcrun", [
968
+ "simctl",
969
+ "io",
970
+ device.udid,
971
+ "screenshot",
972
+ "--type=jpeg",
973
+ ...panel,
974
+ output
975
+ ], env, running, limits.toolTimeoutMs);
976
+ return readFileSync(output);
977
+ };
978
+ const isBlack = async (jpeg) => {
979
+ const input = join(tmp, "check.jpg");
980
+ const output = join(tmp, "check.bmp");
981
+ writeFileSync(input, jpeg);
982
+ await runTool("sips", [
983
+ "-s",
984
+ "format",
985
+ "bmp",
986
+ "-z",
987
+ "24",
988
+ "24",
989
+ input,
990
+ "--out",
991
+ output
992
+ ], env, running, limits.toolTimeoutMs);
993
+ return bmpIsBlack(readFileSync(output));
994
+ };
995
+ const open = () => {
996
+ if (closed) throw new Error("The capture was stopped.");
997
+ };
998
+ const captureDuo = async () => {
999
+ const lit = litPanels.get(device.udid) ?? "primary";
1000
+ const jpeg = await screenshot([`--display=${lit}`]);
1001
+ open();
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
+ }
1010
+ const other = lit === "primary" ? "primary-1" : "primary";
1011
+ open();
1012
+ const otherJpeg = await screenshot([`--display=${other}`]);
1013
+ open();
1014
+ if (await isBlack(otherJpeg)) return {
1015
+ raw: jpeg,
1016
+ jpeg: async () => jpeg
1017
+ };
1018
+ litPanels.set(device.udid, other);
1019
+ return {
1020
+ raw: otherJpeg,
1021
+ jpeg: async () => otherJpeg,
1022
+ posture: POSTURES[other]
1023
+ };
1024
+ };
544
1025
  return {
545
1026
  capture: async () => {
546
- tmp ??= mkdtempSync(join(serverDir(), "frames-"));
547
- const output = join(tmp, "frame.jpg");
1027
+ if (device.foldable) return captureDuo();
1028
+ let jpeg;
548
1029
  try {
549
- await screenshot(output);
1030
+ jpeg = await screenshot(display);
550
1031
  } catch (error) {
551
1032
  if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
552
1033
  display = [];
553
- await screenshot(output);
1034
+ jpeg = await screenshot(display);
554
1035
  }
555
- const jpeg = readFileSync(output);
556
1036
  return {
557
1037
  raw: jpeg,
558
1038
  jpeg: async () => jpeg
@@ -564,6 +1044,18 @@ function simulatorCapturer(udid, env, limits) {
564
1044
  }
565
1045
  };
566
1046
  }
1047
+ const POSTURES = {
1048
+ primary: "folded",
1049
+ "primary-1": "unfolded"
1050
+ };
1051
+ /** Whether every pixel of an uncompressed 24- or 32-bit BMP, as `sips -s format bmp` writes it, is black. */
1052
+ function bmpIsBlack(bmp) {
1053
+ if (bmp.length < 54 || bmp.toString("latin1", 0, 2) !== "BM") throw new Error("sips did not write a BMP image.");
1054
+ const offset = bmp.readUInt32LE(10);
1055
+ const bytesPerPixel = bmp.readUInt16LE(28) / 8;
1056
+ for (let at = offset; at + 3 <= bmp.length; at += bytesPerPixel) if (bmp[at] > 8 || bmp[at + 1] > 8 || bmp[at + 2] > 8) return false;
1057
+ return true;
1058
+ }
567
1059
  /**
568
1060
  * The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
569
1061
  * Stim passes when it boots an owned AVD (#1064).
@@ -648,36 +1140,58 @@ function* protoFields(bytes) {
648
1140
  else throw new Error(`unsupported protobuf wire type ${type}`);
649
1141
  }
650
1142
  }
651
- /** `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. */
652
- function screenshotRequest() {
653
- const message = Buffer.from([
654
- 24,
655
- ...varint(MAX_EDGE),
656
- 32,
657
- ...varint(MAX_EDGE)
658
- ]);
1143
+ function grpcMessage(message) {
659
1144
  const header = Buffer.alloc(5);
660
1145
  header.writeUInt32BE(message.length, 1);
661
- return Buffer.concat([header, message]);
1146
+ return Buffer.concat([header, Buffer.from(message)]);
662
1147
  }
663
- function screenshotImage(body) {
664
- if (body.length < 5 || body[0] !== 0) throw new Error("getScreenshot returned no message.");
665
- const message = body.subarray(5, 5 + body.readUInt32BE(1));
666
- for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
667
- throw new Error("getScreenshot returned no image.");
1148
+ function grpcReply(body, method) {
1149
+ if (body.length < 5 || body[0] !== 0) throw new Error(`${method} returned no message.`);
1150
+ return body.subarray(5, 5 + body.readUInt32BE(1));
668
1151
  }
669
- function emulatorCapturer(serial, env, limits) {
670
- let session = null;
671
- let tmp = null;
672
- const running = /* @__PURE__ */ new Set();
673
- const call = (endpoint) => new Promise((resolve, reject) => {
674
- if (!session || session.closed || session.destroyed) {
675
- session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
676
- session.on("error", () => {});
1152
+ /**
1153
+ * `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. The reply's
1154
+ * format carries `foldedDisplay` (field 7) while a foldable is folded.
1155
+ */
1156
+ function screenshotReply(body) {
1157
+ let png = null;
1158
+ let folded = false;
1159
+ for (const entry of protoFields(grpcReply(body, "getScreenshot"))) {
1160
+ if (entry.field === 4 && "bytes" in entry) png = Buffer.from(entry.bytes);
1161
+ if (entry.field === 1 && "bytes" in entry) folded = [...protoFields(entry.bytes)].some((format) => format.field === 7);
1162
+ }
1163
+ if (!png) throw new Error("getScreenshot returned no image.");
1164
+ return {
1165
+ png,
1166
+ folded
1167
+ };
1168
+ }
1169
+ /**
1170
+ * `PhysicalModelValue` for POSTURE (PhysicalType 16), whose one float is a `Posture.PostureValue`; 1 to 5
1171
+ * are real postures, so any of them means the emulator has a hinge.
1172
+ */
1173
+ function hasHinge(body) {
1174
+ for (const entry of protoFields(grpcReply(body, "getPhysicalModel"))) {
1175
+ if (entry.field === 2 && "varint" in entry && entry.varint !== 0) return false;
1176
+ if (entry.field !== 3 || !("bytes" in entry)) continue;
1177
+ for (const value of protoFields(entry.bytes)) {
1178
+ if (value.field !== 1 || !("bytes" in value) || value.bytes.length < 4) continue;
1179
+ const posture = Math.round(value.bytes.readFloatLE(0));
1180
+ return posture >= 1 && posture <= 5;
677
1181
  }
1182
+ }
1183
+ return false;
1184
+ }
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) => {
678
1192
  const request = session.request({
679
1193
  ":method": "POST",
680
- ":path": "/android.emulation.control.EmulatorController/getScreenshot",
1194
+ ":path": `/android.emulation.control.EmulatorController/${method}`,
681
1195
  "content-type": "application/grpc",
682
1196
  te: "trailers",
683
1197
  ...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
@@ -690,7 +1204,7 @@ function emulatorCapturer(serial, env, limits) {
690
1204
  if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
691
1205
  if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
692
1206
  };
693
- request.setTimeout(limits.toolTimeoutMs, () => {
1207
+ request.setTimeout(timeoutMs, () => {
694
1208
  timedOut = true;
695
1209
  request.close();
696
1210
  });
@@ -698,23 +1212,62 @@ function emulatorCapturer(serial, env, limits) {
698
1212
  request.on("trailers", record);
699
1213
  request.on("data", (chunk) => chunks.push(chunk));
700
1214
  request.on("error", (error) => {
701
- const text = `getScreenshot failed on ${serial}: ${error.message}`;
1215
+ const text = `${method} failed on ${serial}: ${error.message}`;
702
1216
  reject(timedOut ? timeoutError(text) : new Error(text));
703
1217
  });
704
1218
  request.on("close", () => {
705
1219
  if (status === "0") return resolve(Buffer.concat(chunks));
706
- const text = `getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
1220
+ const text = `${method} failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
707
1221
  reject(timedOut ? timeoutError(text) : new Error(text));
708
1222
  });
709
- request.end(screenshotRequest());
1223
+ request.end(requestBody);
710
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
+ };
711
1257
  return {
712
1258
  capture: async () => {
713
1259
  const endpoint = emulatorEndpoint(env, serial);
714
1260
  if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
715
- const png = screenshotImage(await call(endpoint));
1261
+ hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
1262
+ const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
1263
+ 24,
1264
+ ...varint(MAX_EDGE),
1265
+ 32,
1266
+ ...varint(MAX_EDGE)
1267
+ ])));
716
1268
  return {
717
1269
  raw: png,
1270
+ ...hinged ? { posture: folded ? "folded" : "unfolded" } : {},
718
1271
  jpeg: async () => {
719
1272
  tmp ??= mkdtempSync(join(serverDir(), "frames-"));
720
1273
  const input = join(tmp, "frame.png");
@@ -821,7 +1374,8 @@ var FrameSource = class {
821
1374
  this.last = {
822
1375
  ...size,
823
1376
  capturedAt: new Date(started).toISOString(),
824
- data: jpeg.toString("base64")
1377
+ data: jpeg.toString("base64"),
1378
+ ...capture.posture ? { posture: capture.posture } : {}
825
1379
  };
826
1380
  took = Date.now() - started;
827
1381
  return true;
@@ -841,40 +1395,766 @@ var FrameSource = class {
841
1395
  return;
842
1396
  }
843
1397
  const listeners = [...this.listeners];
844
- this.stop();
845
- for (const listener of listeners) listener.failed(error.message);
846
- }
1398
+ this.stop();
1399
+ for (const listener of listeners) listener.failed(error.message);
1400
+ }
1401
+ }
1402
+ };
1403
+ /**
1404
+ * One capture per device, shared by its subscribers and stopped with the last of them. With the `stim-frames`
1405
+ * helper, a device streams frames as its screen changes, at the rate its subscribers ask for. Without it, for
1406
+ * an iPhone Duo, and while the helper is still being built, a screenshot loop sends a frame only when the
1407
+ * screen changed: up to 5 times a second while it changes, backing off to once a second while it does not,
1408
+ * spending at most half of its time capturing, with at most two captures at once across all devices. A helper
1409
+ * that fails before its first frame gives way to the screenshot loop.
1410
+ */
1411
+ var FramePool = class {
1412
+ sources = /* @__PURE__ */ new Map();
1413
+ limiter = new Limiter(MAX_CAPTURES);
1414
+ litPanels = /* @__PURE__ */ new Map();
1415
+ env;
1416
+ limits;
1417
+ helper;
1418
+ constructor(env, limits = DEFAULT_FRAME_LIMITS, helper = () => null) {
1419
+ this.env = env;
1420
+ this.limits = limits;
1421
+ this.helper = helper;
1422
+ }
1423
+ subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
1424
+ const helper = this.helper();
1425
+ if (helper === null || device.platform === "ios" && device.foldable) return this.screenshots(device).add(listener);
1426
+ let streamed = false;
1427
+ let cancelled = false;
1428
+ const { video } = listener;
1429
+ let detach = this.stream(helper, device).add({
1430
+ frame: (frame) => {
1431
+ streamed = true;
1432
+ listener.frame(frame);
1433
+ },
1434
+ ...video ? { video: (unit) => {
1435
+ streamed = true;
1436
+ video(unit);
1437
+ } } : {},
1438
+ delayed: listener.delayed,
1439
+ failed: (message) => {
1440
+ if (streamed || cancelled) return listener.failed(message);
1441
+ console.error(`stim-server: ${message} Falling back to screenshots.`);
1442
+ detach = this.screenshots(device).add(listener);
1443
+ }
1444
+ }, hint);
1445
+ return () => {
1446
+ cancelled = true;
1447
+ detach();
1448
+ };
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
+ }
1459
+ /** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
1460
+ keyframe(device) {
1461
+ this.helperSource(device)?.keyframe();
1462
+ }
1463
+ /** A subscriber of `device` is behind; called until its socket drains, it lowers the shared bitrate. */
1464
+ congested(device) {
1465
+ this.helperSource(device)?.congested();
1466
+ }
1467
+ helperSource(device) {
1468
+ const source = this.sources.get(`helper:${deviceKey(device)}`);
1469
+ return source instanceof HelperSource ? source : null;
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
+ }
1489
+ stream(helper, device) {
1490
+ const key = `helper:${deviceKey(device)}`;
1491
+ const existing = this.sources.get(key);
1492
+ if (existing instanceof HelperSource) return existing;
1493
+ const created = new HelperSource(helper, device, this.env, () => {
1494
+ if (this.sources.get(key) === created) this.sources.delete(key);
1495
+ });
1496
+ this.sources.set(key, created);
1497
+ return created;
1498
+ }
1499
+ screenshots(device) {
1500
+ const key = deviceKey(device);
1501
+ const existing = this.sources.get(key);
1502
+ if (existing instanceof FrameSource) return existing;
1503
+ const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device, this.env, this.limits, this.litPanels) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
1504
+ if (this.sources.get(key) === created) this.sources.delete(key);
1505
+ });
1506
+ this.sources.set(key, created);
1507
+ return created;
1508
+ }
1509
+ async close() {
1510
+ await Promise.all([...this.sources.values()].map((source) => source.stop()));
1511
+ }
1512
+ };
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);
847
2134
  }
848
2135
  };
849
- /**
850
- * One capture loop per device, shared by its subscribers and stopped with the last of them. A loop sends a
851
- * frame only when the screen changed; it captures up to 5 times a second while the screen changes and backs
852
- * off to once a second while it does not, and spends at most half of its time capturing. At most two
853
- * captures run at once across all devices.
854
- */
855
- var FramePool = class {
856
- sources = /* @__PURE__ */ new Map();
857
- limiter = new Limiter(MAX_CAPTURES);
2136
+ var FeedPool = class {
2137
+ feeds = /* @__PURE__ */ new Map();
2138
+ stimCli;
858
2139
  env;
859
- limits;
860
- constructor(env, limits = DEFAULT_FRAME_LIMITS) {
2140
+ constructor(stimCli, env) {
2141
+ this.stimCli = stimCli;
861
2142
  this.env = env;
862
- this.limits = limits;
863
2143
  }
864
- subscribe(device, listener) {
865
- const key = deviceKey(device);
866
- let source = this.sources.get(key);
867
- if (!source) {
868
- const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env, this.limits) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
869
- if (this.sources.get(key) === created) this.sources.delete(key);
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);
870
2150
  });
871
- this.sources.set(key, created);
872
- source = created;
2151
+ this.feeds.set(key, created);
2152
+ feed = created;
873
2153
  }
874
- return source.add(listener);
2154
+ return feed.add(listener);
875
2155
  }
876
2156
  async close() {
877
- await Promise.all([...this.sources.values()].map((source) => source.stop()));
2157
+ await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
878
2158
  }
879
2159
  };
880
2160
  //#endregion
@@ -884,8 +2164,8 @@ function parseLogFilter(params) {
884
2164
  const { sources, level, slot, grep, errors, tail } = params;
885
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(", ")}.` };
886
2166
  if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
887
- if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return { error: "slot must be a slot name." };
888
- 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." };
889
2169
  if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
890
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}.` };
891
2171
  return { filter: {
@@ -951,6 +2231,198 @@ var LogBatcher = class {
951
2231
  }
952
2232
  };
953
2233
  //#endregion
2234
+ //#region src/machine.ts
2235
+ function stimDiskLocations() {
2236
+ let workspaces = [];
2237
+ try {
2238
+ workspaces = Object.keys(loadConfig()?.projects ?? {});
2239
+ } catch {}
2240
+ return [
2241
+ ...workspaces.map((path) => ({
2242
+ label: "Workspaces",
2243
+ path
2244
+ })),
2245
+ {
2246
+ label: "Stim home",
2247
+ path: configDir()
2248
+ },
2249
+ ...process.platform === "darwin" ? [{
2250
+ label: "Simulators",
2251
+ path: join(homedir(), "Library", "Developer", "CoreSimulator")
2252
+ }] : []
2253
+ ];
2254
+ }
2255
+ function existingAncestor(path) {
2256
+ let current = path;
2257
+ while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
2258
+ return current;
2259
+ }
2260
+ function mountOf(path) {
2261
+ return /^\/Volumes\/[^/]+/.exec(path)?.[0] ?? "/";
2262
+ }
2263
+ function readVolumes(locations) {
2264
+ const byDevice = /* @__PURE__ */ new Map();
2265
+ for (const { label, path } of locations) try {
2266
+ const existing = existingAncestor(path);
2267
+ const device = statSync(existing).dev;
2268
+ const known = byDevice.get(device);
2269
+ if (known) {
2270
+ if (!known.holds.includes(label)) known.holds.push(label);
2271
+ continue;
2272
+ }
2273
+ const fs = statfsSync(existing);
2274
+ byDevice.set(device, {
2275
+ mount: mountOf(existing),
2276
+ holds: [label],
2277
+ freeBytes: fs.bavail * fs.bsize,
2278
+ totalBytes: fs.blocks * fs.bsize
2279
+ });
2280
+ } catch {}
2281
+ return [...byDevice.values()];
2282
+ }
2283
+ function readMemoryPressure() {
2284
+ if (process.platform !== "darwin") return Promise.resolve(null);
2285
+ return new Promise((resolve) => {
2286
+ execFile("/usr/sbin/sysctl", ["-n", "kern.memorystatus_vm_pressure_level"], { timeout: 2e3 }, (error, stdout) => {
2287
+ const level = error ? "" : stdout.trim();
2288
+ resolve(level === "1" ? "normal" : level === "2" ? "warning" : level === "4" ? "critical" : null);
2289
+ });
2290
+ });
2291
+ }
2292
+ /**
2293
+ * Activity Monitor's "Memory Used" from `vm_stat` output: app memory (anonymous minus purgeable pages),
2294
+ * wired, and compressed pages, times the page size its header names. Null when a count is missing.
2295
+ */
2296
+ function parseVmStatUsedBytes(output) {
2297
+ const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
2298
+ const count = (label) => {
2299
+ const value = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(output)?.[1];
2300
+ return value === void 0 ? null : Number(value);
2301
+ };
2302
+ const anonymous = count("Anonymous pages");
2303
+ const purgeable = count("Pages purgeable");
2304
+ const wired = count("Pages wired down");
2305
+ const compressed = count("Pages occupied by compressor");
2306
+ if (!pageSize || anonymous === null || purgeable === null || wired === null || compressed === null) return null;
2307
+ return (Math.max(0, anonymous - purgeable) + wired + compressed) * Number(pageSize);
2308
+ }
2309
+ function readMemoryUsed() {
2310
+ if (process.platform !== "darwin") return Promise.resolve(null);
2311
+ return new Promise((resolve) => {
2312
+ execFile("/usr/bin/vm_stat", { timeout: 2e3 }, (error, stdout) => {
2313
+ resolve(error ? null : parseVmStatUsedBytes(stdout));
2314
+ });
2315
+ });
2316
+ }
2317
+ function cpuTicks(list) {
2318
+ let idle = 0;
2319
+ let total = 0;
2320
+ for (const cpu of list) {
2321
+ idle += cpu.times.idle;
2322
+ total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
2323
+ }
2324
+ return {
2325
+ idle,
2326
+ total
2327
+ };
2328
+ }
2329
+ /** The busy fraction (0..1) between two `os.cpus()` tick totals. Null when the totals did not advance. */
2330
+ function cpuUsageFraction(previous, current) {
2331
+ const totalDelta = current.total - previous.total;
2332
+ if (totalDelta <= 0) return null;
2333
+ const idleDelta = current.idle - previous.idle;
2334
+ return Math.min(1, Math.max(0, 1 - idleDelta / totalDelta));
2335
+ }
2336
+ let previousCpuTicks = null;
2337
+ function readCpuUsage() {
2338
+ const current = cpuTicks(cpus());
2339
+ const usage = previousCpuTicks ? cpuUsageFraction(previousCpuTicks, current) : null;
2340
+ previousCpuTicks = current;
2341
+ return {
2342
+ usage,
2343
+ cores: availableParallelism()
2344
+ };
2345
+ }
2346
+ async function readMachineUsage() {
2347
+ const [avg1 = 0, avg5 = 0, avg15 = 0] = loadavg();
2348
+ const [pressure, usedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
2349
+ return {
2350
+ volumes: readVolumes(stimDiskLocations()),
2351
+ memory: {
2352
+ totalBytes: totalmem(),
2353
+ usedBytes,
2354
+ pressure
2355
+ },
2356
+ load: {
2357
+ avg1,
2358
+ avg5,
2359
+ avg15,
2360
+ cpus: availableParallelism()
2361
+ },
2362
+ cpu: readCpuUsage(),
2363
+ sampledAt: (/* @__PURE__ */ new Date()).toISOString()
2364
+ };
2365
+ }
2366
+ const PRESSURE_LEVEL = {
2367
+ normal: 0,
2368
+ warning: 1,
2369
+ critical: 2
2370
+ };
2371
+ const HISTORY_INTERVAL_MS = 5e3;
2372
+ const HISTORY_CAPACITY = 720;
2373
+ var UsageSampler = class {
2374
+ samples = [];
2375
+ previousTicks = null;
2376
+ timer = null;
2377
+ intervalMs;
2378
+ capacity;
2379
+ constructor(intervalMs = HISTORY_INTERVAL_MS, capacity = HISTORY_CAPACITY) {
2380
+ this.intervalMs = intervalMs;
2381
+ this.capacity = capacity;
2382
+ }
2383
+ start() {
2384
+ if (this.timer) return;
2385
+ this.previousTicks = cpuTicks(cpus());
2386
+ this.timer = setInterval(() => void this.sample(), this.intervalMs);
2387
+ }
2388
+ stop() {
2389
+ if (this.timer) clearInterval(this.timer);
2390
+ this.timer = null;
2391
+ this.previousTicks = null;
2392
+ }
2393
+ record(sample) {
2394
+ this.samples.push(sample);
2395
+ if (this.samples.length > this.capacity) this.samples.splice(0, this.samples.length - this.capacity);
2396
+ }
2397
+ history(sinceMs) {
2398
+ const samples = sinceMs === void 0 ? [...this.samples] : this.samples.filter((s) => s.at > sinceMs);
2399
+ return {
2400
+ intervalMs: this.intervalMs,
2401
+ samples
2402
+ };
2403
+ }
2404
+ async sample() {
2405
+ const at = Date.now();
2406
+ const ticks = cpuTicks(cpus());
2407
+ const cpu = this.previousTicks ? cpuUsageFraction(this.previousTicks, ticks) : null;
2408
+ this.previousTicks = ticks;
2409
+ const [pressure, memoryUsedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
2410
+ let diskFreeBytes = null;
2411
+ try {
2412
+ const fs = statfsSync("/");
2413
+ diskFreeBytes = fs.bavail * fs.bsize;
2414
+ } catch {}
2415
+ if (!this.timer) return;
2416
+ this.record({
2417
+ at,
2418
+ cpu,
2419
+ memoryUsedBytes,
2420
+ memoryPressure: pressure === null ? null : PRESSURE_LEVEL[pressure],
2421
+ diskFreeBytes
2422
+ });
2423
+ }
2424
+ };
2425
+ //#endregion
954
2426
  //#region src/tailscale.ts
955
2427
  const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
956
2428
  const TIMEOUT_MS = 3e3;
@@ -1192,8 +2664,21 @@ const LOG_LIMITS = {
1192
2664
  maxBufferedBytes: 4194304,
1193
2665
  maxPendingRecords: 2e4
1194
2666
  };
1195
- const FRAME_BUFFER_BYTES = 1048576;
1196
- const FRAME_RETRY_MS = 100;
2667
+ const FRAME_BUFFER_FRAMES = 2;
2668
+ const FRAME_RETRY_MS = 50;
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
+ };
1197
2682
  const STATUS_FEED = {
1198
2683
  args: [
1199
2684
  "status",
@@ -1209,6 +2694,12 @@ const COMMAND_LIMITS = {
1209
2694
  timeoutMs: 6e4,
1210
2695
  maxOutputBytes: 33554432
1211
2696
  };
2697
+ const PLAN_TIMEOUT_MS = 15e4;
2698
+ const AUDIT_FIELD_CHARS = 256;
2699
+ const ACTION_LIMITS = {
2700
+ timeoutMs: 12e4,
2701
+ maxOutputBytes: 1048576
2702
+ };
1212
2703
  const AUTH_REFUSALS = {
1213
2704
  "pairing-unknown": {
1214
2705
  code: "pairing-expired",
@@ -1261,12 +2752,52 @@ var FailureLimiter = class {
1261
2752
  this.failures.set(key, [...this.recent(key, now), now]);
1262
2753
  }
1263
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
+ }
1264
2770
  function send(socket, message) {
1265
2771
  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
1266
2772
  }
1267
2773
  function requestId(value) {
1268
2774
  return typeof value === "number" || typeof value === "string" ? value : null;
1269
2775
  }
2776
+ function registeredWorkspace(workspace) {
2777
+ if (typeof workspace !== "string") return {
2778
+ code: "bad-request",
2779
+ message: "params.workspace must be an environment path from a status payload."
2780
+ };
2781
+ let projects;
2782
+ try {
2783
+ projects = loadConfig()?.projects ?? {};
2784
+ } catch (cause) {
2785
+ return {
2786
+ code: "stim-failed",
2787
+ message: cause.message
2788
+ };
2789
+ }
2790
+ const dir = Object.keys(projects).find((path) => path === workspace);
2791
+ if (dir === void 0) return {
2792
+ code: "unknown-workspace",
2793
+ message: `${workspace} is not a Stim workspace on this Mac.`
2794
+ };
2795
+ if (!existsSync(dir)) return {
2796
+ code: "unknown-workspace",
2797
+ message: `${dir} is registered but no longer exists on this Mac.`
2798
+ };
2799
+ return { dir };
2800
+ }
1270
2801
  async function startServer(options) {
1271
2802
  const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
1272
2803
  const authTimeoutMs = options.authTimeoutMs ?? 5e3;
@@ -1275,7 +2806,38 @@ async function startServer(options) {
1275
2806
  ...DEFAULT_FRAME_LIMITS,
1276
2807
  ...options.frameLimits
1277
2808
  };
1278
- const frames = new FramePool(options.env, frameLimits);
2809
+ let helperPath = options.frameHelper ?? null;
2810
+ let helperBuilding = false;
2811
+ let helperRetryAt = 0;
2812
+ const helperAbort = new AbortController();
2813
+ const buildHelper = () => {
2814
+ helperBuilding = true;
2815
+ (async () => {
2816
+ try {
2817
+ helperPath = await buildFrameHelper(options.env, helperAbort.signal);
2818
+ } catch (cause) {
2819
+ helperRetryAt = Date.now() + HELPER_RETRY_MS;
2820
+ if (!helperAbort.signal.aborted) console.error(`stim-server: frames come from screenshots: ${cause.message}`);
2821
+ } finally {
2822
+ helperBuilding = false;
2823
+ }
2824
+ })();
2825
+ };
2826
+ const frameHelper = () => {
2827
+ if (options.frameHelper === void 0 && !helperPath && !helperBuilding && Date.now() >= helperRetryAt) buildHelper();
2828
+ return helperPath;
2829
+ };
2830
+ if (options.frameHelper === void 0) buildHelper();
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
+ };
1279
2841
  const running = /* @__PURE__ */ new Set();
1280
2842
  const logLimits = {
1281
2843
  ...LOG_LIMITS,
@@ -1285,7 +2847,32 @@ async function startServer(options) {
1285
2847
  ...COMMAND_LIMITS,
1286
2848
  ...options.commandLimits
1287
2849
  };
2850
+ const actionLimits = {
2851
+ ...ACTION_LIMITS,
2852
+ ...options.actionLimits
2853
+ };
2854
+ const busyWorkspaces = /* @__PURE__ */ new Set();
2855
+ const planQueues = /* @__PURE__ */ new Map();
1288
2856
  const sessions = /* @__PURE__ */ new Map();
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
+ });
1289
2876
  const wss = new WebSocketServer({
1290
2877
  noServer: true,
1291
2878
  maxPayload: MAX_PAYLOAD
@@ -1298,13 +2885,18 @@ async function startServer(options) {
1298
2885
  const watcher = watch(serverDir(), () => {
1299
2886
  revocationCheck ??= setTimeout(() => {
1300
2887
  revocationCheck = null;
1301
- const paired = new Set(readDevices().map((device) => device.id));
2888
+ const paired = new Map(readDevices().map((device) => [device.id, device]));
1302
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
+ }
1303
2894
  }, 50);
1304
2895
  });
1305
2896
  function connection(socket, peer) {
1306
2897
  const limitKey = peer ?? "local";
1307
2898
  const subscriptions = /* @__PURE__ */ new Map();
2899
+ const keyframes = /* @__PURE__ */ new Map();
1308
2900
  const commands = /* @__PURE__ */ new Set();
1309
2901
  let nextSubscription = 1;
1310
2902
  let device = null;
@@ -1353,6 +2945,7 @@ async function startServer(options) {
1353
2945
  }
1354
2946
  device = outcome.device;
1355
2947
  sessions.set(socket, device);
2948
+ sampler.start();
1356
2949
  send(socket, {
1357
2950
  id,
1358
2951
  result: {
@@ -1360,9 +2953,11 @@ async function startServer(options) {
1360
2953
  server: {
1361
2954
  name: options.name,
1362
2955
  version: options.serverVersion,
1363
- stim: options.stimVersion
2956
+ stim: options.stimVersion,
2957
+ home: homedir()
1364
2958
  },
1365
2959
  capabilities: device.capabilities,
2960
+ actions: device.capabilities.includes("control") ? [...ACTIONS] : [],
1366
2961
  device: {
1367
2962
  id: device.id,
1368
2963
  name: device.name
@@ -1380,30 +2975,92 @@ async function startServer(options) {
1380
2975
  }
1381
2976
  });
1382
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
+ }
1383
3054
  function workspaceDir(id, workspace, required) {
1384
3055
  if (workspace === void 0 && !required) return homedir();
1385
- if (typeof workspace !== "string") {
1386
- error(id, "bad-request", "params.workspace must be an environment path from a status payload.");
1387
- return null;
1388
- }
1389
- let registered;
1390
- try {
1391
- registered = Object.hasOwn(loadConfig()?.projects ?? {}, workspace);
1392
- } catch (cause) {
1393
- error(id, "stim-failed", cause.message);
1394
- return null;
1395
- }
1396
- if (!registered) {
1397
- error(id, "unknown-workspace", `${workspace} is not a Stim workspace on this Mac.`);
1398
- return null;
1399
- }
1400
- if (!existsSync(workspace)) {
1401
- error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
3056
+ const resolved = registeredWorkspace(workspace);
3057
+ if ("code" in resolved) {
3058
+ error(id, resolved.code, resolved.message);
1402
3059
  return null;
1403
3060
  }
1404
- return workspace;
3061
+ return resolved.dir;
1405
3062
  }
1406
- function openSubscription(id) {
3063
+ function openSubscription(id, result = {}) {
1407
3064
  if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
1408
3065
  error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
1409
3066
  return null;
@@ -1411,7 +3068,10 @@ async function startServer(options) {
1411
3068
  const subscription = `s${nextSubscription++}`;
1412
3069
  send(socket, {
1413
3070
  id,
1414
- result: { subscription }
3071
+ result: {
3072
+ subscription,
3073
+ ...result
3074
+ }
1415
3075
  });
1416
3076
  return subscription;
1417
3077
  }
@@ -1495,31 +3155,59 @@ async function startServer(options) {
1495
3155
  });
1496
3156
  }
1497
3157
  function subscribeFrames(id, params) {
1498
- const { workspace, platform, slot } = isJsonObject(params) ? params : {};
3158
+ const { workspace, platform, slot, fps, maxEdge, video } = isJsonObject(params) ? params : {};
1499
3159
  if (typeof workspace !== "string" || platform !== "ios" && platform !== "android") return error(id, "bad-request", "frames.subscribe needs params.workspace and params.platform (ios or android).");
1500
3160
  if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
3161
+ if (video !== void 0 && (!Array.isArray(video) || !video.every((codec) => typeof codec === "string"))) return error(id, "bad-request", "video must be a list of codec names.");
3162
+ const wantsVideo = video?.includes("h264") === true;
3163
+ const offersVideo = wantsVideo && frameHelper() !== null;
3164
+ const maxFps = wantsVideo ? FRAME_FPS.video : FRAME_FPS.max;
3165
+ if (fps !== void 0 && (!Number.isInteger(fps) || fps < 1 || fps > maxFps)) return error(id, "bad-request", `fps must be a whole number from 1 to ${maxFps}.`);
3166
+ if (maxEdge !== void 0 && (!Number.isInteger(maxEdge) || maxEdge < FRAME_EDGE.min || maxEdge > FRAME_EDGE.max)) return error(id, "bad-request", `maxEdge must be a whole number of pixels from ${FRAME_EDGE.min} to ${FRAME_EDGE.max}.`);
3167
+ const hint = {
3168
+ fps: Math.min(fps ?? FRAME_FPS.default, offersVideo ? FRAME_FPS.video : FRAME_FPS.max),
3169
+ maxEdge: maxEdge ?? FRAME_EDGE.default
3170
+ };
1501
3171
  if (!workspaceDir(id, workspace, true)) return;
1502
- const subscription = openSubscription(id);
3172
+ const subscription = openSubscription(id, offersVideo ? { video: "h264" } : {});
1503
3173
  if (!subscription) return;
1504
3174
  const frameTarget = {
1505
3175
  workspace,
1506
3176
  platform,
1507
3177
  ...slot ? { slot } : {}
1508
3178
  };
3179
+ const gate = new VideoGate(DEFAULT_VIDEO_LIMITS.congestedBytes);
3180
+ let sequence = 0;
3181
+ let streamed = null;
3182
+ let draining = null;
3183
+ const drain = () => {
3184
+ draining = null;
3185
+ if (ended || !streamed) return;
3186
+ if (socket.bufferedAmount <= DEFAULT_VIDEO_LIMITS.congestedBytes) return frames.keyframe(streamed);
3187
+ frames.congested(streamed);
3188
+ draining = setTimeout(drain, FRAME_RETRY_MS);
3189
+ };
1509
3190
  let attached = null;
1510
3191
  let detach = null;
1511
3192
  let pending = null;
1512
3193
  let retry = null;
1513
3194
  let ended = false;
3195
+ let sentAt = 0;
1514
3196
  const flush = () => {
1515
3197
  retry = null;
1516
3198
  if (!pending || ended) return;
1517
- if (socket.bufferedAmount > FRAME_BUFFER_BYTES) {
3199
+ const wait = sentAt + 1e3 / hint.fps - Date.now();
3200
+ if (wait > 0) {
3201
+ retry = setTimeout(flush, wait);
3202
+ return;
3203
+ }
3204
+ if (socket.bufferedAmount > FRAME_BUFFER_FRAMES * pending.data.length) {
1518
3205
  retry = setTimeout(flush, FRAME_RETRY_MS);
1519
3206
  return;
1520
3207
  }
1521
3208
  const frame = pending;
1522
3209
  pending = null;
3210
+ sentAt = Date.now();
1523
3211
  send(socket, {
1524
3212
  event: "frame",
1525
3213
  subscription,
@@ -1531,7 +3219,9 @@ async function startServer(options) {
1531
3219
  };
1532
3220
  const cleanup = () => {
1533
3221
  ended = true;
3222
+ keyframes.delete(subscription);
1534
3223
  if (retry) clearTimeout(retry);
3224
+ if (draining) clearTimeout(draining);
1535
3225
  detach?.();
1536
3226
  detach = null;
1537
3227
  unsubscribeStatus?.();
@@ -1561,8 +3251,18 @@ async function startServer(options) {
1561
3251
  delayed
1562
3252
  });
1563
3253
  },
1564
- failed: end
3254
+ failed: end,
3255
+ ...offersVideo ? { video: (unit) => {
3256
+ if (ended || socket.readyState !== socket.OPEN) return;
3257
+ const verdict = gate.admit(unit, socket.bufferedAmount);
3258
+ if (verdict === "send") socket.send(videoPacket(subscription, sequence++, unit));
3259
+ else if (verdict === "congested" && !draining) drain();
3260
+ } } : {}
1565
3261
  };
3262
+ if (offersVideo) keyframes.set(subscription, () => {
3263
+ gate.reset();
3264
+ if (streamed) frames.keyframe(streamed);
3265
+ });
1566
3266
  let unsubscribeStatus = null;
1567
3267
  unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
1568
3268
  item: (payload) => {
@@ -1571,34 +3271,62 @@ async function startServer(options) {
1571
3271
  if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
1572
3272
  if (deviceKey(resolved) === attached) return;
1573
3273
  detach?.();
3274
+ gate.reset();
3275
+ streamed = resolved;
1574
3276
  attached = deviceKey(resolved);
1575
- detach = frames.subscribe(resolved, listener);
3277
+ detach = frames.subscribe(resolved, listener, hint);
1576
3278
  },
1577
3279
  failed: (message) => queueMicrotask(() => end(message))
1578
3280
  });
1579
3281
  subscriptions.set(subscription, cleanup);
1580
3282
  }
1581
- function command(id, args, cwd, result) {
1582
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
1583
- const run = runStim(options.stimCli, options.env, args, cwd, commandLimits);
1584
- commands.add(run.cancel);
1585
- running.add(run.cancel);
1586
- (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);
1587
3303
  const outcome = await run.outcome;
1588
- commands.delete(run.cancel);
1589
- running.delete(run.cancel);
1590
- if (!outcome.ok) return error(id, "stim-failed", outcome.message);
1591
- let value;
3304
+ commands.delete(cancel);
3305
+ running.delete(cancel);
1592
3306
  try {
1593
- value = result(outcome.stdout);
1594
- } catch {
1595
- 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();
1596
3323
  }
1597
- send(socket, {
1598
- id,
1599
- result: value
1600
- });
1601
- })();
3324
+ };
3325
+ commands.add(cancel);
3326
+ running.add(cancel);
3327
+ if (turn) turn.then(start);
3328
+ else start();
3329
+ return released;
1602
3330
  }
1603
3331
  function queryLogs(id, params) {
1604
3332
  const parsed = parseLogFilter(params);
@@ -1617,6 +3345,123 @@ async function startServer(options) {
1617
3345
  return value;
1618
3346
  });
1619
3347
  }
3348
+ function runAction(id, params, session) {
3349
+ const startedAt = Date.now();
3350
+ const raw = isJsonObject(params) ? params : {};
3351
+ const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
3352
+ const record = {
3353
+ at: new Date(startedAt).toISOString(),
3354
+ device: {
3355
+ id: session.id,
3356
+ name: session.name
3357
+ },
3358
+ action: clip(raw.action),
3359
+ workspace: clip(raw.workspace),
3360
+ ...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
3361
+ ok: false
3362
+ };
3363
+ const audit = (outcome) => {
3364
+ const logged = outcome.error ? {
3365
+ ...outcome.error,
3366
+ message: clip(outcome.error.message)
3367
+ } : void 0;
3368
+ try {
3369
+ appendAudit({
3370
+ ...record,
3371
+ ...outcome,
3372
+ ...logged ? { error: logged } : {}
3373
+ });
3374
+ } catch (cause) {
3375
+ console.error(`stim-server: could not append to the action log: ${cause.message}`);
3376
+ }
3377
+ };
3378
+ const refuseAction = (code, message) => {
3379
+ audit({
3380
+ ok: false,
3381
+ error: {
3382
+ code,
3383
+ message
3384
+ }
3385
+ });
3386
+ error(id, code, message);
3387
+ };
3388
+ if (!readDevices().find((entry) => entry.id === session.id)?.capabilities.includes("control")) return refuseAction("forbidden", `This device can only read. On the Mac, run \`stim-server devices grant ${session.id} --control\` to let it run actions.`);
3389
+ const parsed = parseAction(params);
3390
+ if ("code" in parsed) return refuseAction(parsed.code, parsed.message);
3391
+ const { action } = parsed;
3392
+ const resolved = registeredWorkspace(action.workspace);
3393
+ if ("code" in resolved) return refuseAction(resolved.code, resolved.message);
3394
+ if (busyWorkspaces.has(resolved.dir)) return refuseAction("action-busy", `An action is already running in ${resolved.dir}. Try again when it finishes.`);
3395
+ let run;
3396
+ try {
3397
+ run = runStim(options.stimCli, options.env, actionArgs(action), resolved.dir, actionLimits);
3398
+ } catch (cause) {
3399
+ return refuseAction("action-failed", `stim ${action.action} could not start (${cause.message}).`);
3400
+ }
3401
+ busyWorkspaces.add(resolved.dir);
3402
+ let finished = false;
3403
+ const finish = (outcome) => {
3404
+ if (finished) return;
3405
+ finished = true;
3406
+ running.delete(cancel);
3407
+ busyWorkspaces.delete(resolved.dir);
3408
+ audit({
3409
+ ok: outcome.ok,
3410
+ ...outcome.ok ? {} : { error: outcome.error },
3411
+ durationMs: Date.now() - startedAt
3412
+ });
3413
+ if (outcome.ok) send(socket, {
3414
+ id,
3415
+ result: {
3416
+ action: action.action,
3417
+ workspace: resolved.dir,
3418
+ output: outcome.output
3419
+ }
3420
+ });
3421
+ else send(socket, {
3422
+ id,
3423
+ error: outcome.error
3424
+ });
3425
+ };
3426
+ const cancel = async () => {
3427
+ finish({
3428
+ ok: false,
3429
+ error: {
3430
+ code: "action-failed",
3431
+ message: "stim-server stopped before the action finished."
3432
+ }
3433
+ });
3434
+ await run.cancel();
3435
+ };
3436
+ running.add(cancel);
3437
+ run.outcome.then((outcome) => finish(actionOutcome(outcome)));
3438
+ }
3439
+ function planBuild(id, params) {
3440
+ if (!isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
3441
+ const { platform, slot } = params;
3442
+ if (platform !== "ios" && platform !== "android") return error(id, "bad-request", "params.platform must be ios or android.");
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.");
3444
+ const cwd = workspaceDir(id, params.workspace, true);
3445
+ if (!cwd) return;
3446
+ const finished = command(id, [
3447
+ platform,
3448
+ "--plan",
3449
+ "--json",
3450
+ ...slot === void 0 ? [] : [`--slot=${slot}`]
3451
+ ], cwd, (stdout) => {
3452
+ const value = JSON.parse(stdout);
3453
+ if (!isJsonObject(value) || value.platform !== platform) throw new Error("not a plan");
3454
+ return value;
3455
+ }, {
3456
+ ...commandLimits,
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);
3463
+ });
3464
+ }
1620
3465
  async function handle(raw) {
1621
3466
  if (socket.readyState !== socket.OPEN) return;
1622
3467
  let message;
@@ -1642,7 +3487,55 @@ async function startServer(options) {
1642
3487
  if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
1643
3488
  if (message.method === "logs.query") return queryLogs(id, message.params);
1644
3489
  if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
3490
+ if (message.method === "frames.keyframe") {
3491
+ const name = isJsonObject(message.params) ? message.params.subscription : void 0;
3492
+ const keyframe = typeof name === "string" ? keyframes.get(name) : void 0;
3493
+ if (!keyframe) return send(socket, {
3494
+ id,
3495
+ error: {
3496
+ code: "unknown-subscription",
3497
+ message: `No video subscription ${String(name)}.`
3498
+ }
3499
+ });
3500
+ keyframe();
3501
+ return send(socket, {
3502
+ id,
3503
+ result: {}
3504
+ });
3505
+ }
3506
+ if (message.method === "build.plan") return planBuild(id, message.params);
3507
+ if (message.method === "machine.get") return send(socket, {
3508
+ id,
3509
+ result: await readMachineUsage()
3510
+ });
3511
+ if (message.method === "machine.history") {
3512
+ const params = message.params ?? {};
3513
+ const sinceMs = isJsonObject(params) ? params.sinceMs : void 0;
3514
+ if (!isJsonObject(params) || sinceMs !== void 0 && typeof sinceMs !== "number") return error(id, "bad-request", "machine.history takes an optional numeric sinceMs.");
3515
+ return send(socket, {
3516
+ id,
3517
+ result: sampler.history(sinceMs)
3518
+ });
3519
+ }
1645
3520
  if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
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
+ }
1646
3539
  if (message.method === "unsubscribe") {
1647
3540
  const name = isJsonObject(message.params) ? message.params.subscription : void 0;
1648
3541
  const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
@@ -1675,6 +3568,10 @@ async function startServer(options) {
1675
3568
  socket.on("close", () => {
1676
3569
  clearTimeout(timer);
1677
3570
  sessions.delete(socket);
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);
1678
3575
  for (const unsubscribe of subscriptions.values()) unsubscribe();
1679
3576
  subscriptions.clear();
1680
3577
  for (const cancel of commands) {
@@ -1713,8 +3610,11 @@ async function startServer(options) {
1713
3610
  const addresses = [];
1714
3611
  const close = async () => {
1715
3612
  watcher.close();
3613
+ helperAbort.abort();
3614
+ sampler.stop();
1716
3615
  if (revocationCheck) clearTimeout(revocationCheck);
1717
3616
  for (const client of wss.clients) client.terminate();
3617
+ await control.close();
1718
3618
  await Promise.all([
1719
3619
  frames.close(),
1720
3620
  feeds.close(),
@@ -1760,11 +3660,15 @@ async function startServer(options) {
1760
3660
  const DEFAULT_PORT = 7787;
1761
3661
  const USAGE = `Usage:
1762
3662
  stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
1763
- stim-server pair [--port <n>] [--json]
1764
- print a single-use pairing payload for the QR code
3663
+ stim-server pair [--port <n>] [--control] [--json]
3664
+ print a single-use pairing payload for the QR code;
3665
+ --control lets the paired device run actions
1765
3666
  stim-server devices [list] [--json]
1766
3667
  list paired devices
1767
- stim-server devices revoke <id> revoke a paired device`;
3668
+ stim-server devices grant <id> --control|--read
3669
+ let a paired device run actions, or only read
3670
+ stim-server devices revoke <id> revoke a paired device
3671
+ stim-server log [--json] list the actions paired devices ran`;
1768
3672
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1769
3673
  function fail(message) {
1770
3674
  console.error(`stim-server: ${message}`);
@@ -1829,7 +3733,7 @@ async function serve(port) {
1829
3733
  process.on("SIGTERM", shutdown);
1830
3734
  process.on("SIGHUP", shutdown);
1831
3735
  }
1832
- async function pair(port, json) {
3736
+ async function pair(port, json, control) {
1833
3737
  const binary = findTailscale(process.env);
1834
3738
  const tailscale = tailscaleStatus(binary, process.env);
1835
3739
  let endpoint = `ws://127.0.0.1:${port}`;
@@ -1841,7 +3745,7 @@ async function pair(port, json) {
1841
3745
  endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
1842
3746
  note = routeNote(route, port);
1843
3747
  }
1844
- const { token, expiresAt } = createPairingToken();
3748
+ const { token, expiresAt } = createPairingToken(Date.now(), capabilitiesFor(control));
1845
3749
  const payload = {
1846
3750
  v: 1,
1847
3751
  name: macName(tailscale),
@@ -1853,12 +3757,13 @@ async function pair(port, json) {
1853
3757
  expiresAt
1854
3758
  }));
1855
3759
  console.log(JSON.stringify(payload));
1856
- console.error(`The pairing token is single use and expires at ${expiresAt}.`);
3760
+ console.error(`The pairing token is single use and expires at ${expiresAt}. The device it pairs can ${control ? "run actions" : "only read"}.`);
1857
3761
  if (note) console.error(note);
1858
3762
  }
1859
3763
  function describe(device) {
1860
3764
  const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
1861
- return `${device.id} ${device.name} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
3765
+ const scope = device.capabilities.includes("control") ? "control" : "read";
3766
+ return `${device.id} ${device.name} ${scope} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
1862
3767
  }
1863
3768
  async function main() {
1864
3769
  const { values, positionals } = parseArgs({
@@ -1866,6 +3771,8 @@ async function main() {
1866
3771
  options: {
1867
3772
  port: { type: "string" },
1868
3773
  json: { type: "boolean" },
3774
+ control: { type: "boolean" },
3775
+ read: { type: "boolean" },
1869
3776
  help: {
1870
3777
  type: "boolean",
1871
3778
  short: "h"
@@ -1882,7 +3789,27 @@ async function main() {
1882
3789
  if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
1883
3790
  const [command, sub, arg, ...rest] = positionals;
1884
3791
  if (command === void 0) return serve(port);
1885
- if (command === "pair" && sub === void 0) return pair(port, values.json === true);
3792
+ const grant = command === "devices" && sub === "grant";
3793
+ if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
3794
+ if (values.control && !grant && command !== "pair") fail(`--control applies only to \`pair\` and \`devices grant\`.\n${USAGE}`);
3795
+ if (command === "pair" && sub === void 0) return pair(port, values.json === true, values.control === true);
3796
+ if (grant && arg !== void 0 && rest.length === 0) {
3797
+ if (values.control === values.read) fail("devices grant takes exactly one of --control or --read.");
3798
+ if (!grantDevice(arg, capabilitiesFor(values.control === true))) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
3799
+ console.log(`${arg} can now ${values.control ? "run actions" : "only read"}.`);
3800
+ return;
3801
+ }
3802
+ if (command === "log" && sub === void 0) {
3803
+ const records = readAudit();
3804
+ if (values.json) return void console.log(JSON.stringify({ actions: records }));
3805
+ if (!records.length) console.log("No actions.");
3806
+ for (const record of records) {
3807
+ const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
3808
+ const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
3809
+ console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
3810
+ }
3811
+ return;
3812
+ }
1886
3813
  if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
1887
3814
  const devices = readDevices();
1888
3815
  if (values.json) {