@stim-cli/server 1.10.1 → 1.11.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,18 +1,19 @@
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 { a as FRAME_FPS, c as MAX_LOG_TAIL, i as FRAME_EDGE, n as CAPABILITIES, o as LOG_LEVELS, s as LOG_SOURCES, t as ACTIONS } from "./protocol-CmlG7X7_.mjs";
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
15
  import { createInterface } from "node:readline";
16
+ import { fileURLToPath } from "node:url";
16
17
  import { connect } from "node:http2";
17
18
  //#region src/environment.ts
18
19
  function parseEnvironment(output) {
@@ -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
  }),
@@ -406,6 +536,334 @@ var FeedPool = class {
406
536
  }
407
537
  };
408
538
  //#endregion
539
+ //#region src/video.ts
540
+ const FIXED_HEADER_BYTES = 21;
541
+ /** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
542
+ function videoPacket(subscription, sequence, unit) {
543
+ const id = Buffer.from(subscription, "ascii");
544
+ const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
545
+ header.writeUInt8(1, 0);
546
+ header.writeUInt8(unit.keyframe ? 1 : 0, 1);
547
+ header.writeUInt16BE(header.length, 2);
548
+ header.writeUInt32BE(sequence >>> 0, 4);
549
+ header.writeDoubleBE(unit.capturedAt, 8);
550
+ header.writeUInt16BE(unit.width, 16);
551
+ header.writeUInt16BE(unit.height, 18);
552
+ header.writeUInt8(id.length, 20);
553
+ id.copy(header, FIXED_HEADER_BYTES);
554
+ return Buffer.concat([header, unit.data]);
555
+ }
556
+ const DEFAULT_VIDEO_LIMITS = {
557
+ congestedBytes: 262144,
558
+ startBitrate: 3e6,
559
+ minBitrate: 25e4,
560
+ maxBitrate: 8e6,
561
+ recoverMs: 2e3
562
+ };
563
+ /**
564
+ * Decides, for one subscriber, whether each access unit goes out. A subscriber starts waiting for a keyframe.
565
+ * When its socket backs up it drops everything until the next keyframe, because a P-frame is useless without
566
+ * the frames before it; `congested` is reported once per episode.
567
+ */
568
+ var VideoGate = class {
569
+ waiting = true;
570
+ congestedBytes;
571
+ constructor(congestedBytes) {
572
+ this.congestedBytes = congestedBytes;
573
+ }
574
+ admit(unit, bufferedBytes) {
575
+ if (bufferedBytes > this.congestedBytes) {
576
+ if (this.waiting) return "drop";
577
+ this.waiting = true;
578
+ return "congested";
579
+ }
580
+ if (this.waiting && !unit.keyframe) return "drop";
581
+ this.waiting = false;
582
+ return "send";
583
+ }
584
+ /** The client lost its decoder state; drop until the keyframe it asked for. */
585
+ reset() {
586
+ this.waiting = true;
587
+ }
588
+ };
589
+ /**
590
+ * One encoder's bitrate, shared by all subscribers of a device: halved on congestion at most once per `recoverMs`,
591
+ * and raised by a quarter after `recoverMs` without congestion.
592
+ */
593
+ var Bitrate = class {
594
+ value;
595
+ calmSince;
596
+ cutAt = -Infinity;
597
+ limits;
598
+ constructor(limits, now) {
599
+ this.limits = limits;
600
+ this.value = limits.startBitrate;
601
+ this.calmSince = now;
602
+ }
603
+ get current() {
604
+ return this.value;
605
+ }
606
+ /** Called while any subscriber is congested. Returns the new bitrate when it changed. */
607
+ congested(now) {
608
+ this.calmSince = now;
609
+ if (now - this.cutAt < this.limits.recoverMs) return null;
610
+ this.cutAt = now;
611
+ return this.set(Math.max(this.limits.minBitrate, Math.round(this.value / 2)));
612
+ }
613
+ /** Returns the new bitrate when it changed. */
614
+ tick(now) {
615
+ if (now - this.calmSince < this.limits.recoverMs) return null;
616
+ this.calmSince = now;
617
+ return this.set(Math.min(this.limits.maxBitrate, Math.round(this.value * 1.25)));
618
+ }
619
+ set(next) {
620
+ if (next === this.value) return null;
621
+ this.value = next;
622
+ return next;
623
+ }
624
+ };
625
+ //#endregion
626
+ //#region src/frame-helper.ts
627
+ const DEFAULT_FRAME_HINT = {
628
+ fps: FRAME_FPS.default,
629
+ maxEdge: FRAME_EDGE.default
630
+ };
631
+ const SOURCES_DIR = fileURLToPath(new URL("./stim-frames/", import.meta.url));
632
+ const BUILD_TIMEOUT_MS = 18e4;
633
+ const VERSION_TIMEOUT_MS = 3e4;
634
+ const MAX_MESSAGE_BYTES = 16777216;
635
+ const FRAME_MESSAGE = 1;
636
+ const NOTICE_MESSAGE = 2;
637
+ const VIDEO_MESSAGE = 3;
638
+ const VIDEO_HEADER_BYTES = 14;
639
+ const KEYFRAME_INTERVAL_MS = 250;
640
+ /** Runs the compiler in its own process group, so a timeout or `signal` also stops `swift-frontend` and `ld`. */
641
+ function run(file, args, env, timeoutMs, signal) {
642
+ return new Promise((resolve, reject) => {
643
+ const child = spawn(file, args, {
644
+ env,
645
+ stdio: [
646
+ "ignore",
647
+ "pipe",
648
+ "pipe"
649
+ ],
650
+ detached: true
651
+ });
652
+ let output = "";
653
+ let stopped = null;
654
+ const stop = (reason) => {
655
+ stopped ??= reason;
656
+ try {
657
+ process.kill(-child.pid, "SIGKILL");
658
+ } catch {}
659
+ };
660
+ const timer = setTimeout(() => stop(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`), timeoutMs);
661
+ const abort = () => stop(`${file} ${args[0]} was stopped with the server.`);
662
+ signal?.addEventListener("abort", abort);
663
+ child.stdout.setEncoding("utf8");
664
+ child.stderr.setEncoding("utf8");
665
+ child.stdout.on("data", (chunk) => output = (output + chunk).slice(-4e3));
666
+ child.stderr.on("data", (chunk) => output = (output + chunk).slice(-4e3));
667
+ child.on("error", (error) => {
668
+ clearTimeout(timer);
669
+ signal?.removeEventListener("abort", abort);
670
+ reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
671
+ });
672
+ child.on("close", (code) => {
673
+ clearTimeout(timer);
674
+ signal?.removeEventListener("abort", abort);
675
+ if (stopped) reject(new Error(stopped));
676
+ else if (code === 0) resolve(output);
677
+ else reject(/* @__PURE__ */ new Error(`${file} ${args[0]} exited (code ${code}): ${output.trim()}`));
678
+ });
679
+ });
680
+ }
681
+ /**
682
+ * Compiles the `stim-frames` helper from the Swift sources shipped in `dist/stim-frames/` into
683
+ * `$STIM_HOME/server/helpers/`, named by a hash of the sources and the compiler version. Resolves to the
684
+ * helper's path, or rejects with the reason it cannot be built.
685
+ */
686
+ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
687
+ if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
688
+ const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted();
689
+ if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
690
+ const version = await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal);
691
+ const hash = createHash("sha256").update(version);
692
+ for (const name of sources) hash.update(name).update(readFileSync(join(sourcesDir, name)));
693
+ const dir = join(serverDir(), "helpers");
694
+ const helper = join(dir, `stim-frames-${hash.digest("hex").slice(0, 16)}`);
695
+ if (existsSync(helper)) return helper;
696
+ mkdirSync(dir, {
697
+ recursive: true,
698
+ mode: 448
699
+ });
700
+ const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
701
+ try {
702
+ await run("xcrun", [
703
+ "swiftc",
704
+ "-O",
705
+ "-swift-version",
706
+ "5",
707
+ "-module-name",
708
+ "StimFrames",
709
+ "-o",
710
+ output,
711
+ ...sources.map((source) => join(sourcesDir, source))
712
+ ], env, BUILD_TIMEOUT_MS, signal);
713
+ renameSync(output, helper);
714
+ } finally {
715
+ rmSync(output, { force: true });
716
+ }
717
+ return helper;
718
+ }
719
+ /**
720
+ * One `stim-frames` process per device, shared by its subscribers and stopped with the last of them. It runs
721
+ * at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
722
+ * helper exits when its stdin closes, so it cannot outlive the server.
723
+ */
724
+ var HelperSource = class {
725
+ listeners = /* @__PURE__ */ new Map();
726
+ child;
727
+ last = null;
728
+ config = "";
729
+ stopped = false;
730
+ notice = null;
731
+ stderr = "";
732
+ ended;
733
+ bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
734
+ keyframeAt = -Infinity;
735
+ keyframeTimer = null;
736
+ constructor(helper, device, env, ended) {
737
+ this.ended = ended;
738
+ const id = device.platform === "ios" ? device.udid : device.serial;
739
+ this.child = spawn(helper, [device.platform, id], {
740
+ env,
741
+ stdio: [
742
+ "pipe",
743
+ "pipe",
744
+ "pipe"
745
+ ]
746
+ });
747
+ this.child.stdin.on("error", () => {});
748
+ this.child.stderr.setEncoding("utf8");
749
+ this.child.stderr.on("data", (chunk) => {
750
+ this.stderr = (this.stderr + chunk).slice(-1e3);
751
+ });
752
+ this.readMessages();
753
+ this.child.on("error", (error) => this.fail(`stim-frames could not start (${error.message}).`));
754
+ this.child.on("close", (code, signal) => {
755
+ const detail = this.notice ?? this.stderr.trim();
756
+ this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
757
+ });
758
+ }
759
+ add(listener, hint) {
760
+ this.listeners.set(listener, hint);
761
+ this.configure();
762
+ if (listener.video) this.keyframe();
763
+ else if (this.last) listener.frame(this.last);
764
+ return () => {
765
+ if (!this.listeners.delete(listener)) return;
766
+ if (this.listeners.size === 0) this.stop();
767
+ else this.configure();
768
+ };
769
+ }
770
+ stop() {
771
+ if (this.stopped) return Promise.resolve();
772
+ this.stopped = true;
773
+ if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
774
+ this.listeners.clear();
775
+ this.ended();
776
+ this.child.stdin.end();
777
+ return terminate(this.child);
778
+ }
779
+ /** Asks for at most one keyframe per {@link KEYFRAME_INTERVAL_MS}; a request inside the interval is sent at its end. */
780
+ keyframe() {
781
+ if (this.stopped || this.keyframeTimer) return;
782
+ const wait = this.keyframeAt + KEYFRAME_INTERVAL_MS - Date.now();
783
+ if (wait > 0) {
784
+ this.keyframeTimer = setTimeout(() => {
785
+ this.keyframeTimer = null;
786
+ this.keyframe();
787
+ }, wait);
788
+ return;
789
+ }
790
+ this.keyframeAt = Date.now();
791
+ this.child.stdin.write("{\"keyframe\":true}\n");
792
+ }
793
+ congested() {
794
+ if (this.bitrate.congested(Date.now()) !== null) this.configure();
795
+ }
796
+ configure() {
797
+ const hints = [...this.listeners.values()];
798
+ const jpegFps = [...this.listeners].flatMap(([listener, hint]) => listener.video ? [] : [hint.fps]);
799
+ const config = JSON.stringify({
800
+ fps: Math.max(...hints.map((hint) => hint.fps)),
801
+ maxEdge: Math.max(...hints.map((hint) => hint.maxEdge)),
802
+ jpeg: jpegFps.length > 0,
803
+ ...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
804
+ video: jpegFps.length < hints.length,
805
+ bitrate: this.bitrate.current
806
+ });
807
+ if (!jpegFps.length) this.last = null;
808
+ if (config === this.config || this.stopped) return;
809
+ this.config = config;
810
+ this.child.stdin.write(`${config}\n`);
811
+ }
812
+ fail(message) {
813
+ if (this.stopped) return;
814
+ const listeners = [...this.listeners.keys()];
815
+ this.stop();
816
+ for (const listener of listeners) listener.failed(message);
817
+ }
818
+ readMessages() {
819
+ let buffer = Buffer.alloc(0);
820
+ this.child.stdout.on("data", (chunk) => {
821
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
822
+ while (buffer.length >= 4) {
823
+ const length = buffer.readUInt32BE(0);
824
+ if (length < 1 || length > MAX_MESSAGE_BYTES) {
825
+ this.child.stdout.removeAllListeners("data");
826
+ return this.fail("stim-frames wrote a malformed message.");
827
+ }
828
+ if (buffer.length < 4 + length) break;
829
+ const body = buffer.subarray(4, 4 + length);
830
+ buffer = buffer.subarray(4 + length);
831
+ if (body[0] === FRAME_MESSAGE && body.length > 5) this.frame(body);
832
+ else if (body[0] === VIDEO_MESSAGE && body.length > VIDEO_HEADER_BYTES) this.video(body);
833
+ else if (body[0] === NOTICE_MESSAGE) this.readNotice(body.subarray(1).toString("utf8"));
834
+ }
835
+ });
836
+ }
837
+ frame(body) {
838
+ if (this.stopped) return;
839
+ this.last = {
840
+ width: body.readUInt16BE(1),
841
+ height: body.readUInt16BE(3),
842
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
843
+ data: body.subarray(5).toString("base64")
844
+ };
845
+ for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
846
+ }
847
+ video(body) {
848
+ if (this.stopped) return;
849
+ const unit = {
850
+ keyframe: (body[1] & 1) !== 0,
851
+ capturedAt: body.readDoubleBE(2),
852
+ width: body.readUInt16BE(10),
853
+ height: body.readUInt16BE(12),
854
+ data: body.subarray(VIDEO_HEADER_BYTES)
855
+ };
856
+ if (this.bitrate.tick(Date.now()) !== null) this.configure();
857
+ for (const listener of this.listeners.keys()) listener.video?.(unit);
858
+ }
859
+ readNotice(text) {
860
+ try {
861
+ const error = JSON.parse(text)?.error;
862
+ if (typeof error === "string") this.notice = error;
863
+ } catch {}
864
+ }
865
+ };
866
+ //#endregion
409
867
  //#region src/frames.ts
410
868
  const DEFAULT_FRAME_LIMITS = {
411
869
  toolTimeoutMs: 3e4,
@@ -437,7 +895,8 @@ function ownedDevice(payload, target, attached) {
437
895
  if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
438
896
  return {
439
897
  platform: "ios",
440
- udid: sim.udid
898
+ udid: sim.udid,
899
+ foldable: /\bDuo\b/.test(sim.name ?? "")
441
900
  };
442
901
  }
443
902
  const emulator = devices?.android;
@@ -523,36 +982,87 @@ async function stopTools(running, tmp) {
523
982
  });
524
983
  }
525
984
  /**
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.
985
+ * Without `--display`, simctl captures the first panel it finds; `primary` is the panel CoreDevice reports as
986
+ * primary. A simctl that rejects `primary` gets the default display instead.
987
+ *
988
+ * An iPhone Duo lights one of two panels: `primary` is the cover, lit when folded, and `primary-1` the inner
989
+ * panel, lit when unfolded; the other one's framebuffer is all black. `litPanels` remembers the lit panel per
990
+ * simulator across subscriptions, and a black capture tries the other panel.
529
991
  */
530
- function simulatorCapturer(udid, env, limits) {
992
+ function simulatorCapturer(device, env, limits, litPanels) {
531
993
  let tmp = null;
532
994
  let display = ["--display=primary"];
533
995
  let closed = false;
534
996
  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);
997
+ const screenshot = async (panel) => {
998
+ tmp ??= mkdtempSync(join(serverDir(), "frames-"));
999
+ const output = join(tmp, "frame.jpg");
1000
+ await runTool("xcrun", [
1001
+ "simctl",
1002
+ "io",
1003
+ device.udid,
1004
+ "screenshot",
1005
+ "--type=jpeg",
1006
+ ...panel,
1007
+ output
1008
+ ], env, running, limits.toolTimeoutMs);
1009
+ return readFileSync(output);
1010
+ };
1011
+ const isBlack = async (jpeg) => {
1012
+ const input = join(tmp, "check.jpg");
1013
+ const output = join(tmp, "check.bmp");
1014
+ writeFileSync(input, jpeg);
1015
+ await runTool("sips", [
1016
+ "-s",
1017
+ "format",
1018
+ "bmp",
1019
+ "-z",
1020
+ "24",
1021
+ "24",
1022
+ input,
1023
+ "--out",
1024
+ output
1025
+ ], env, running, limits.toolTimeoutMs);
1026
+ return bmpIsBlack(readFileSync(output));
1027
+ };
1028
+ const open = () => {
1029
+ if (closed) throw new Error("The capture was stopped.");
1030
+ };
1031
+ const captureDuo = async () => {
1032
+ const lit = litPanels.get(device.udid) ?? "primary";
1033
+ const jpeg = await screenshot([`--display=${lit}`]);
1034
+ open();
1035
+ if (!await isBlack(jpeg)) return {
1036
+ raw: jpeg,
1037
+ jpeg: async () => jpeg,
1038
+ posture: POSTURES[lit]
1039
+ };
1040
+ const other = lit === "primary" ? "primary-1" : "primary";
1041
+ open();
1042
+ const otherJpeg = await screenshot([`--display=${other}`]);
1043
+ open();
1044
+ if (await isBlack(otherJpeg)) return {
1045
+ raw: jpeg,
1046
+ jpeg: async () => jpeg
1047
+ };
1048
+ litPanels.set(device.udid, other);
1049
+ return {
1050
+ raw: otherJpeg,
1051
+ jpeg: async () => otherJpeg,
1052
+ posture: POSTURES[other]
1053
+ };
1054
+ };
544
1055
  return {
545
1056
  capture: async () => {
546
- tmp ??= mkdtempSync(join(serverDir(), "frames-"));
547
- const output = join(tmp, "frame.jpg");
1057
+ if (device.foldable) return captureDuo();
1058
+ let jpeg;
548
1059
  try {
549
- await screenshot(output);
1060
+ jpeg = await screenshot(display);
550
1061
  } catch (error) {
551
1062
  if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
552
1063
  display = [];
553
- await screenshot(output);
1064
+ jpeg = await screenshot(display);
554
1065
  }
555
- const jpeg = readFileSync(output);
556
1066
  return {
557
1067
  raw: jpeg,
558
1068
  jpeg: async () => jpeg
@@ -564,6 +1074,18 @@ function simulatorCapturer(udid, env, limits) {
564
1074
  }
565
1075
  };
566
1076
  }
1077
+ const POSTURES = {
1078
+ primary: "folded",
1079
+ "primary-1": "unfolded"
1080
+ };
1081
+ /** Whether every pixel of an uncompressed 24- or 32-bit BMP, as `sips -s format bmp` writes it, is black. */
1082
+ function bmpIsBlack(bmp) {
1083
+ if (bmp.length < 54 || bmp.toString("latin1", 0, 2) !== "BM") throw new Error("sips did not write a BMP image.");
1084
+ const offset = bmp.readUInt32LE(10);
1085
+ const bytesPerPixel = bmp.readUInt16LE(28) / 8;
1086
+ for (let at = offset; at + 3 <= bmp.length; at += bytesPerPixel) if (bmp[at] > 8 || bmp[at + 1] > 8 || bmp[at + 2] > 8) return false;
1087
+ return true;
1088
+ }
567
1089
  /**
568
1090
  * The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
569
1091
  * Stim passes when it boots an owned AVD (#1064).
@@ -648,36 +1170,61 @@ function* protoFields(bytes) {
648
1170
  else throw new Error(`unsupported protobuf wire type ${type}`);
649
1171
  }
650
1172
  }
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
- ]);
1173
+ function grpcMessage(message) {
659
1174
  const header = Buffer.alloc(5);
660
1175
  header.writeUInt32BE(message.length, 1);
661
- return Buffer.concat([header, message]);
1176
+ return Buffer.concat([header, Buffer.from(message)]);
662
1177
  }
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.");
1178
+ function grpcReply(body, method) {
1179
+ if (body.length < 5 || body[0] !== 0) throw new Error(`${method} returned no message.`);
1180
+ return body.subarray(5, 5 + body.readUInt32BE(1));
1181
+ }
1182
+ /**
1183
+ * `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. The reply's
1184
+ * format carries `foldedDisplay` (field 7) while a foldable is folded.
1185
+ */
1186
+ function screenshotReply(body) {
1187
+ let png = null;
1188
+ let folded = false;
1189
+ for (const entry of protoFields(grpcReply(body, "getScreenshot"))) {
1190
+ if (entry.field === 4 && "bytes" in entry) png = Buffer.from(entry.bytes);
1191
+ if (entry.field === 1 && "bytes" in entry) folded = [...protoFields(entry.bytes)].some((format) => format.field === 7);
1192
+ }
1193
+ if (!png) throw new Error("getScreenshot returned no image.");
1194
+ return {
1195
+ png,
1196
+ folded
1197
+ };
1198
+ }
1199
+ /**
1200
+ * `PhysicalModelValue` for POSTURE (PhysicalType 16), whose one float is a `Posture.PostureValue`; 1 to 5
1201
+ * are real postures, so any of them means the emulator has a hinge.
1202
+ */
1203
+ function hasHinge(body) {
1204
+ for (const entry of protoFields(grpcReply(body, "getPhysicalModel"))) {
1205
+ if (entry.field === 2 && "varint" in entry && entry.varint !== 0) return false;
1206
+ if (entry.field !== 3 || !("bytes" in entry)) continue;
1207
+ for (const value of protoFields(entry.bytes)) {
1208
+ if (value.field !== 1 || !("bytes" in value) || value.bytes.length < 4) continue;
1209
+ const posture = Math.round(value.bytes.readFloatLE(0));
1210
+ return posture >= 1 && posture <= 5;
1211
+ }
1212
+ }
1213
+ return false;
668
1214
  }
669
1215
  function emulatorCapturer(serial, env, limits) {
670
1216
  let session = null;
671
1217
  let tmp = null;
672
1218
  const running = /* @__PURE__ */ new Set();
673
- const call = (endpoint) => new Promise((resolve, reject) => {
1219
+ let hinged = null;
1220
+ const call = (endpoint, method, requestBody) => new Promise((resolve, reject) => {
674
1221
  if (!session || session.closed || session.destroyed) {
675
1222
  session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
676
1223
  session.on("error", () => {});
677
1224
  }
678
1225
  const request = session.request({
679
1226
  ":method": "POST",
680
- ":path": "/android.emulation.control.EmulatorController/getScreenshot",
1227
+ ":path": `/android.emulation.control.EmulatorController/${method}`,
681
1228
  "content-type": "application/grpc",
682
1229
  te: "trailers",
683
1230
  ...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
@@ -698,23 +1245,30 @@ function emulatorCapturer(serial, env, limits) {
698
1245
  request.on("trailers", record);
699
1246
  request.on("data", (chunk) => chunks.push(chunk));
700
1247
  request.on("error", (error) => {
701
- const text = `getScreenshot failed on ${serial}: ${error.message}`;
1248
+ const text = `${method} failed on ${serial}: ${error.message}`;
702
1249
  reject(timedOut ? timeoutError(text) : new Error(text));
703
1250
  });
704
1251
  request.on("close", () => {
705
1252
  if (status === "0") return resolve(Buffer.concat(chunks));
706
- const text = `getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
1253
+ const text = `${method} failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
707
1254
  reject(timedOut ? timeoutError(text) : new Error(text));
708
1255
  });
709
- request.end(screenshotRequest());
1256
+ request.end(requestBody);
710
1257
  });
711
1258
  return {
712
1259
  capture: async () => {
713
1260
  const endpoint = emulatorEndpoint(env, serial);
714
1261
  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));
1262
+ hinged ??= await call(endpoint, "getPhysicalModel", grpcMessage([8, 16])).then(hasHinge).catch(() => false);
1263
+ const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
1264
+ 24,
1265
+ ...varint(MAX_EDGE),
1266
+ 32,
1267
+ ...varint(MAX_EDGE)
1268
+ ])));
716
1269
  return {
717
1270
  raw: png,
1271
+ ...hinged ? { posture: folded ? "folded" : "unfolded" } : {},
718
1272
  jpeg: async () => {
719
1273
  tmp ??= mkdtempSync(join(serverDir(), "frames-"));
720
1274
  const input = join(tmp, "frame.png");
@@ -821,7 +1375,8 @@ var FrameSource = class {
821
1375
  this.last = {
822
1376
  ...size,
823
1377
  capturedAt: new Date(started).toISOString(),
824
- data: jpeg.toString("base64")
1378
+ data: jpeg.toString("base64"),
1379
+ ...capture.posture ? { posture: capture.posture } : {}
825
1380
  };
826
1381
  took = Date.now() - started;
827
1382
  return true;
@@ -847,31 +1402,83 @@ var FrameSource = class {
847
1402
  }
848
1403
  };
849
1404
  /**
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.
1405
+ * One capture per device, shared by its subscribers and stopped with the last of them. With the `stim-frames`
1406
+ * helper, a device streams frames as its screen changes, at the rate its subscribers ask for. Without it, for
1407
+ * an iPhone Duo, and while the helper is still being built, a screenshot loop sends a frame only when the
1408
+ * screen changed: up to 5 times a second while it changes, backing off to once a second while it does not,
1409
+ * spending at most half of its time capturing, with at most two captures at once across all devices. A helper
1410
+ * that fails before its first frame gives way to the screenshot loop.
854
1411
  */
855
1412
  var FramePool = class {
856
1413
  sources = /* @__PURE__ */ new Map();
857
1414
  limiter = new Limiter(MAX_CAPTURES);
1415
+ litPanels = /* @__PURE__ */ new Map();
858
1416
  env;
859
1417
  limits;
860
- constructor(env, limits = DEFAULT_FRAME_LIMITS) {
1418
+ helper;
1419
+ constructor(env, limits = DEFAULT_FRAME_LIMITS, helper = () => null) {
861
1420
  this.env = env;
862
1421
  this.limits = limits;
1422
+ this.helper = helper;
863
1423
  }
864
- subscribe(device, listener) {
1424
+ subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
1425
+ const helper = this.helper();
1426
+ if (helper === null || device.platform === "ios" && device.foldable) return this.screenshots(device).add(listener);
1427
+ let streamed = false;
1428
+ let cancelled = false;
1429
+ const { video } = listener;
1430
+ let detach = this.stream(helper, device).add({
1431
+ frame: (frame) => {
1432
+ streamed = true;
1433
+ listener.frame(frame);
1434
+ },
1435
+ ...video ? { video: (unit) => {
1436
+ streamed = true;
1437
+ video(unit);
1438
+ } } : {},
1439
+ delayed: listener.delayed,
1440
+ failed: (message) => {
1441
+ if (streamed || cancelled) return listener.failed(message);
1442
+ console.error(`stim-server: ${message} Falling back to screenshots.`);
1443
+ detach = this.screenshots(device).add(listener);
1444
+ }
1445
+ }, hint);
1446
+ return () => {
1447
+ cancelled = true;
1448
+ detach();
1449
+ };
1450
+ }
1451
+ /** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
1452
+ keyframe(device) {
1453
+ this.helperSource(device)?.keyframe();
1454
+ }
1455
+ /** A subscriber of `device` is behind; called until its socket drains, it lowers the shared bitrate. */
1456
+ congested(device) {
1457
+ this.helperSource(device)?.congested();
1458
+ }
1459
+ helperSource(device) {
1460
+ const source = this.sources.get(`helper:${deviceKey(device)}`);
1461
+ return source instanceof HelperSource ? source : null;
1462
+ }
1463
+ stream(helper, device) {
1464
+ const key = `helper:${deviceKey(device)}`;
1465
+ const existing = this.sources.get(key);
1466
+ if (existing instanceof HelperSource) return existing;
1467
+ const created = new HelperSource(helper, device, this.env, () => {
1468
+ if (this.sources.get(key) === created) this.sources.delete(key);
1469
+ });
1470
+ this.sources.set(key, created);
1471
+ return created;
1472
+ }
1473
+ screenshots(device) {
865
1474
  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);
870
- });
871
- this.sources.set(key, created);
872
- source = created;
873
- }
874
- return source.add(listener);
1475
+ const existing = this.sources.get(key);
1476
+ if (existing instanceof FrameSource) return existing;
1477
+ 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, () => {
1478
+ if (this.sources.get(key) === created) this.sources.delete(key);
1479
+ });
1480
+ this.sources.set(key, created);
1481
+ return created;
875
1482
  }
876
1483
  async close() {
877
1484
  await Promise.all([...this.sources.values()].map((source) => source.stop()));
@@ -951,6 +1558,198 @@ var LogBatcher = class {
951
1558
  }
952
1559
  };
953
1560
  //#endregion
1561
+ //#region src/machine.ts
1562
+ function stimDiskLocations() {
1563
+ let workspaces = [];
1564
+ try {
1565
+ workspaces = Object.keys(loadConfig()?.projects ?? {});
1566
+ } catch {}
1567
+ return [
1568
+ ...workspaces.map((path) => ({
1569
+ label: "Workspaces",
1570
+ path
1571
+ })),
1572
+ {
1573
+ label: "Stim home",
1574
+ path: configDir()
1575
+ },
1576
+ ...process.platform === "darwin" ? [{
1577
+ label: "Simulators",
1578
+ path: join(homedir(), "Library", "Developer", "CoreSimulator")
1579
+ }] : []
1580
+ ];
1581
+ }
1582
+ function existingAncestor(path) {
1583
+ let current = path;
1584
+ while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
1585
+ return current;
1586
+ }
1587
+ function mountOf(path) {
1588
+ return /^\/Volumes\/[^/]+/.exec(path)?.[0] ?? "/";
1589
+ }
1590
+ function readVolumes(locations) {
1591
+ const byDevice = /* @__PURE__ */ new Map();
1592
+ for (const { label, path } of locations) try {
1593
+ const existing = existingAncestor(path);
1594
+ const device = statSync(existing).dev;
1595
+ const known = byDevice.get(device);
1596
+ if (known) {
1597
+ if (!known.holds.includes(label)) known.holds.push(label);
1598
+ continue;
1599
+ }
1600
+ const fs = statfsSync(existing);
1601
+ byDevice.set(device, {
1602
+ mount: mountOf(existing),
1603
+ holds: [label],
1604
+ freeBytes: fs.bavail * fs.bsize,
1605
+ totalBytes: fs.blocks * fs.bsize
1606
+ });
1607
+ } catch {}
1608
+ return [...byDevice.values()];
1609
+ }
1610
+ function readMemoryPressure() {
1611
+ if (process.platform !== "darwin") return Promise.resolve(null);
1612
+ return new Promise((resolve) => {
1613
+ execFile("/usr/sbin/sysctl", ["-n", "kern.memorystatus_vm_pressure_level"], { timeout: 2e3 }, (error, stdout) => {
1614
+ const level = error ? "" : stdout.trim();
1615
+ resolve(level === "1" ? "normal" : level === "2" ? "warning" : level === "4" ? "critical" : null);
1616
+ });
1617
+ });
1618
+ }
1619
+ /**
1620
+ * Activity Monitor's "Memory Used" from `vm_stat` output: app memory (anonymous minus purgeable pages),
1621
+ * wired, and compressed pages, times the page size its header names. Null when a count is missing.
1622
+ */
1623
+ function parseVmStatUsedBytes(output) {
1624
+ const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
1625
+ const count = (label) => {
1626
+ const value = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(output)?.[1];
1627
+ return value === void 0 ? null : Number(value);
1628
+ };
1629
+ const anonymous = count("Anonymous pages");
1630
+ const purgeable = count("Pages purgeable");
1631
+ const wired = count("Pages wired down");
1632
+ const compressed = count("Pages occupied by compressor");
1633
+ if (!pageSize || anonymous === null || purgeable === null || wired === null || compressed === null) return null;
1634
+ return (Math.max(0, anonymous - purgeable) + wired + compressed) * Number(pageSize);
1635
+ }
1636
+ function readMemoryUsed() {
1637
+ if (process.platform !== "darwin") return Promise.resolve(null);
1638
+ return new Promise((resolve) => {
1639
+ execFile("/usr/bin/vm_stat", { timeout: 2e3 }, (error, stdout) => {
1640
+ resolve(error ? null : parseVmStatUsedBytes(stdout));
1641
+ });
1642
+ });
1643
+ }
1644
+ function cpuTicks(list) {
1645
+ let idle = 0;
1646
+ let total = 0;
1647
+ for (const cpu of list) {
1648
+ idle += cpu.times.idle;
1649
+ total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
1650
+ }
1651
+ return {
1652
+ idle,
1653
+ total
1654
+ };
1655
+ }
1656
+ /** The busy fraction (0..1) between two `os.cpus()` tick totals. Null when the totals did not advance. */
1657
+ function cpuUsageFraction(previous, current) {
1658
+ const totalDelta = current.total - previous.total;
1659
+ if (totalDelta <= 0) return null;
1660
+ const idleDelta = current.idle - previous.idle;
1661
+ return Math.min(1, Math.max(0, 1 - idleDelta / totalDelta));
1662
+ }
1663
+ let previousCpuTicks = null;
1664
+ function readCpuUsage() {
1665
+ const current = cpuTicks(cpus());
1666
+ const usage = previousCpuTicks ? cpuUsageFraction(previousCpuTicks, current) : null;
1667
+ previousCpuTicks = current;
1668
+ return {
1669
+ usage,
1670
+ cores: availableParallelism()
1671
+ };
1672
+ }
1673
+ async function readMachineUsage() {
1674
+ const [avg1 = 0, avg5 = 0, avg15 = 0] = loadavg();
1675
+ const [pressure, usedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
1676
+ return {
1677
+ volumes: readVolumes(stimDiskLocations()),
1678
+ memory: {
1679
+ totalBytes: totalmem(),
1680
+ usedBytes,
1681
+ pressure
1682
+ },
1683
+ load: {
1684
+ avg1,
1685
+ avg5,
1686
+ avg15,
1687
+ cpus: availableParallelism()
1688
+ },
1689
+ cpu: readCpuUsage(),
1690
+ sampledAt: (/* @__PURE__ */ new Date()).toISOString()
1691
+ };
1692
+ }
1693
+ const PRESSURE_LEVEL = {
1694
+ normal: 0,
1695
+ warning: 1,
1696
+ critical: 2
1697
+ };
1698
+ const HISTORY_INTERVAL_MS = 5e3;
1699
+ const HISTORY_CAPACITY = 720;
1700
+ var UsageSampler = class {
1701
+ samples = [];
1702
+ previousTicks = null;
1703
+ timer = null;
1704
+ intervalMs;
1705
+ capacity;
1706
+ constructor(intervalMs = HISTORY_INTERVAL_MS, capacity = HISTORY_CAPACITY) {
1707
+ this.intervalMs = intervalMs;
1708
+ this.capacity = capacity;
1709
+ }
1710
+ start() {
1711
+ if (this.timer) return;
1712
+ this.previousTicks = cpuTicks(cpus());
1713
+ this.timer = setInterval(() => void this.sample(), this.intervalMs);
1714
+ }
1715
+ stop() {
1716
+ if (this.timer) clearInterval(this.timer);
1717
+ this.timer = null;
1718
+ this.previousTicks = null;
1719
+ }
1720
+ record(sample) {
1721
+ this.samples.push(sample);
1722
+ if (this.samples.length > this.capacity) this.samples.splice(0, this.samples.length - this.capacity);
1723
+ }
1724
+ history(sinceMs) {
1725
+ const samples = sinceMs === void 0 ? [...this.samples] : this.samples.filter((s) => s.at > sinceMs);
1726
+ return {
1727
+ intervalMs: this.intervalMs,
1728
+ samples
1729
+ };
1730
+ }
1731
+ async sample() {
1732
+ const at = Date.now();
1733
+ const ticks = cpuTicks(cpus());
1734
+ const cpu = this.previousTicks ? cpuUsageFraction(this.previousTicks, ticks) : null;
1735
+ this.previousTicks = ticks;
1736
+ const [pressure, memoryUsedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
1737
+ let diskFreeBytes = null;
1738
+ try {
1739
+ const fs = statfsSync("/");
1740
+ diskFreeBytes = fs.bavail * fs.bsize;
1741
+ } catch {}
1742
+ if (!this.timer) return;
1743
+ this.record({
1744
+ at,
1745
+ cpu,
1746
+ memoryUsedBytes,
1747
+ memoryPressure: pressure === null ? null : PRESSURE_LEVEL[pressure],
1748
+ diskFreeBytes
1749
+ });
1750
+ }
1751
+ };
1752
+ //#endregion
954
1753
  //#region src/tailscale.ts
955
1754
  const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
956
1755
  const TIMEOUT_MS = 3e3;
@@ -1192,8 +1991,9 @@ const LOG_LIMITS = {
1192
1991
  maxBufferedBytes: 4194304,
1193
1992
  maxPendingRecords: 2e4
1194
1993
  };
1195
- const FRAME_BUFFER_BYTES = 1048576;
1196
- const FRAME_RETRY_MS = 100;
1994
+ const FRAME_BUFFER_FRAMES = 2;
1995
+ const FRAME_RETRY_MS = 50;
1996
+ const HELPER_RETRY_MS = 3e5;
1197
1997
  const STATUS_FEED = {
1198
1998
  args: [
1199
1999
  "status",
@@ -1209,6 +2009,13 @@ const COMMAND_LIMITS = {
1209
2009
  timeoutMs: 6e4,
1210
2010
  maxOutputBytes: 33554432
1211
2011
  };
2012
+ const PLAN_TIMEOUT_MS = 15e4;
2013
+ const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
2014
+ const AUDIT_FIELD_CHARS = 256;
2015
+ const ACTION_LIMITS = {
2016
+ timeoutMs: 12e4,
2017
+ maxOutputBytes: 1048576
2018
+ };
1212
2019
  const AUTH_REFUSALS = {
1213
2020
  "pairing-unknown": {
1214
2021
  code: "pairing-expired",
@@ -1267,6 +2074,31 @@ function send(socket, message) {
1267
2074
  function requestId(value) {
1268
2075
  return typeof value === "number" || typeof value === "string" ? value : null;
1269
2076
  }
2077
+ function registeredWorkspace(workspace) {
2078
+ if (typeof workspace !== "string") return {
2079
+ code: "bad-request",
2080
+ message: "params.workspace must be an environment path from a status payload."
2081
+ };
2082
+ let projects;
2083
+ try {
2084
+ projects = loadConfig()?.projects ?? {};
2085
+ } catch (cause) {
2086
+ return {
2087
+ code: "stim-failed",
2088
+ message: cause.message
2089
+ };
2090
+ }
2091
+ const dir = Object.keys(projects).find((path) => path === workspace);
2092
+ if (dir === void 0) return {
2093
+ code: "unknown-workspace",
2094
+ message: `${workspace} is not a Stim workspace on this Mac.`
2095
+ };
2096
+ if (!existsSync(dir)) return {
2097
+ code: "unknown-workspace",
2098
+ message: `${dir} is registered but no longer exists on this Mac.`
2099
+ };
2100
+ return { dir };
2101
+ }
1270
2102
  async function startServer(options) {
1271
2103
  const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
1272
2104
  const authTimeoutMs = options.authTimeoutMs ?? 5e3;
@@ -1275,7 +2107,29 @@ async function startServer(options) {
1275
2107
  ...DEFAULT_FRAME_LIMITS,
1276
2108
  ...options.frameLimits
1277
2109
  };
1278
- const frames = new FramePool(options.env, frameLimits);
2110
+ let helperPath = options.frameHelper ?? null;
2111
+ let helperBuilding = false;
2112
+ let helperRetryAt = 0;
2113
+ const helperAbort = new AbortController();
2114
+ const buildHelper = () => {
2115
+ helperBuilding = true;
2116
+ (async () => {
2117
+ try {
2118
+ helperPath = await buildFrameHelper(options.env, helperAbort.signal);
2119
+ } catch (cause) {
2120
+ helperRetryAt = Date.now() + HELPER_RETRY_MS;
2121
+ if (!helperAbort.signal.aborted) console.error(`stim-server: frames come from screenshots: ${cause.message}`);
2122
+ } finally {
2123
+ helperBuilding = false;
2124
+ }
2125
+ })();
2126
+ };
2127
+ const frameHelper = () => {
2128
+ if (options.frameHelper === void 0 && !helperPath && !helperBuilding && Date.now() >= helperRetryAt) buildHelper();
2129
+ return helperPath;
2130
+ };
2131
+ if (options.frameHelper === void 0) buildHelper();
2132
+ const frames = new FramePool(options.env, frameLimits, frameHelper);
1279
2133
  const running = /* @__PURE__ */ new Set();
1280
2134
  const logLimits = {
1281
2135
  ...LOG_LIMITS,
@@ -1285,7 +2139,13 @@ async function startServer(options) {
1285
2139
  ...COMMAND_LIMITS,
1286
2140
  ...options.commandLimits
1287
2141
  };
2142
+ const actionLimits = {
2143
+ ...ACTION_LIMITS,
2144
+ ...options.actionLimits
2145
+ };
2146
+ const busyWorkspaces = /* @__PURE__ */ new Set();
1288
2147
  const sessions = /* @__PURE__ */ new Map();
2148
+ const sampler = new UsageSampler();
1289
2149
  const wss = new WebSocketServer({
1290
2150
  noServer: true,
1291
2151
  maxPayload: MAX_PAYLOAD
@@ -1305,6 +2165,7 @@ async function startServer(options) {
1305
2165
  function connection(socket, peer) {
1306
2166
  const limitKey = peer ?? "local";
1307
2167
  const subscriptions = /* @__PURE__ */ new Map();
2168
+ const keyframes = /* @__PURE__ */ new Map();
1308
2169
  const commands = /* @__PURE__ */ new Set();
1309
2170
  let nextSubscription = 1;
1310
2171
  let device = null;
@@ -1353,6 +2214,7 @@ async function startServer(options) {
1353
2214
  }
1354
2215
  device = outcome.device;
1355
2216
  sessions.set(socket, device);
2217
+ sampler.start();
1356
2218
  send(socket, {
1357
2219
  id,
1358
2220
  result: {
@@ -1360,9 +2222,11 @@ async function startServer(options) {
1360
2222
  server: {
1361
2223
  name: options.name,
1362
2224
  version: options.serverVersion,
1363
- stim: options.stimVersion
2225
+ stim: options.stimVersion,
2226
+ home: homedir()
1364
2227
  },
1365
2228
  capabilities: device.capabilities,
2229
+ actions: device.capabilities.includes("control") ? [...ACTIONS] : [],
1366
2230
  device: {
1367
2231
  id: device.id,
1368
2232
  name: device.name
@@ -1382,28 +2246,14 @@ async function startServer(options) {
1382
2246
  }
1383
2247
  function workspaceDir(id, workspace, required) {
1384
2248
  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.`);
2249
+ const resolved = registeredWorkspace(workspace);
2250
+ if ("code" in resolved) {
2251
+ error(id, resolved.code, resolved.message);
1398
2252
  return null;
1399
2253
  }
1400
- if (!existsSync(workspace)) {
1401
- error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
1402
- return null;
1403
- }
1404
- return workspace;
2254
+ return resolved.dir;
1405
2255
  }
1406
- function openSubscription(id) {
2256
+ function openSubscription(id, result = {}) {
1407
2257
  if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
1408
2258
  error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
1409
2259
  return null;
@@ -1411,7 +2261,10 @@ async function startServer(options) {
1411
2261
  const subscription = `s${nextSubscription++}`;
1412
2262
  send(socket, {
1413
2263
  id,
1414
- result: { subscription }
2264
+ result: {
2265
+ subscription,
2266
+ ...result
2267
+ }
1415
2268
  });
1416
2269
  return subscription;
1417
2270
  }
@@ -1495,31 +2348,59 @@ async function startServer(options) {
1495
2348
  });
1496
2349
  }
1497
2350
  function subscribeFrames(id, params) {
1498
- const { workspace, platform, slot } = isJsonObject(params) ? params : {};
2351
+ const { workspace, platform, slot, fps, maxEdge, video } = isJsonObject(params) ? params : {};
1499
2352
  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
2353
  if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
2354
+ 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.");
2355
+ const wantsVideo = video?.includes("h264") === true;
2356
+ const offersVideo = wantsVideo && frameHelper() !== null;
2357
+ const maxFps = wantsVideo ? FRAME_FPS.video : FRAME_FPS.max;
2358
+ 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}.`);
2359
+ 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}.`);
2360
+ const hint = {
2361
+ fps: Math.min(fps ?? FRAME_FPS.default, offersVideo ? FRAME_FPS.video : FRAME_FPS.max),
2362
+ maxEdge: maxEdge ?? FRAME_EDGE.default
2363
+ };
1501
2364
  if (!workspaceDir(id, workspace, true)) return;
1502
- const subscription = openSubscription(id);
2365
+ const subscription = openSubscription(id, offersVideo ? { video: "h264" } : {});
1503
2366
  if (!subscription) return;
1504
2367
  const frameTarget = {
1505
2368
  workspace,
1506
2369
  platform,
1507
2370
  ...slot ? { slot } : {}
1508
2371
  };
2372
+ const gate = new VideoGate(DEFAULT_VIDEO_LIMITS.congestedBytes);
2373
+ let sequence = 0;
2374
+ let streamed = null;
2375
+ let draining = null;
2376
+ const drain = () => {
2377
+ draining = null;
2378
+ if (ended || !streamed) return;
2379
+ if (socket.bufferedAmount <= DEFAULT_VIDEO_LIMITS.congestedBytes) return frames.keyframe(streamed);
2380
+ frames.congested(streamed);
2381
+ draining = setTimeout(drain, FRAME_RETRY_MS);
2382
+ };
1509
2383
  let attached = null;
1510
2384
  let detach = null;
1511
2385
  let pending = null;
1512
2386
  let retry = null;
1513
2387
  let ended = false;
2388
+ let sentAt = 0;
1514
2389
  const flush = () => {
1515
2390
  retry = null;
1516
2391
  if (!pending || ended) return;
1517
- if (socket.bufferedAmount > FRAME_BUFFER_BYTES) {
2392
+ const wait = sentAt + 1e3 / hint.fps - Date.now();
2393
+ if (wait > 0) {
2394
+ retry = setTimeout(flush, wait);
2395
+ return;
2396
+ }
2397
+ if (socket.bufferedAmount > FRAME_BUFFER_FRAMES * pending.data.length) {
1518
2398
  retry = setTimeout(flush, FRAME_RETRY_MS);
1519
2399
  return;
1520
2400
  }
1521
2401
  const frame = pending;
1522
2402
  pending = null;
2403
+ sentAt = Date.now();
1523
2404
  send(socket, {
1524
2405
  event: "frame",
1525
2406
  subscription,
@@ -1531,7 +2412,9 @@ async function startServer(options) {
1531
2412
  };
1532
2413
  const cleanup = () => {
1533
2414
  ended = true;
2415
+ keyframes.delete(subscription);
1534
2416
  if (retry) clearTimeout(retry);
2417
+ if (draining) clearTimeout(draining);
1535
2418
  detach?.();
1536
2419
  detach = null;
1537
2420
  unsubscribeStatus?.();
@@ -1561,8 +2444,18 @@ async function startServer(options) {
1561
2444
  delayed
1562
2445
  });
1563
2446
  },
1564
- failed: end
2447
+ failed: end,
2448
+ ...offersVideo ? { video: (unit) => {
2449
+ if (ended || socket.readyState !== socket.OPEN) return;
2450
+ const verdict = gate.admit(unit, socket.bufferedAmount);
2451
+ if (verdict === "send") socket.send(videoPacket(subscription, sequence++, unit));
2452
+ else if (verdict === "congested" && !draining) drain();
2453
+ } } : {}
1565
2454
  };
2455
+ if (offersVideo) keyframes.set(subscription, () => {
2456
+ gate.reset();
2457
+ if (streamed) frames.keyframe(streamed);
2458
+ });
1566
2459
  let unsubscribeStatus = null;
1567
2460
  unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
1568
2461
  item: (payload) => {
@@ -1571,23 +2464,28 @@ async function startServer(options) {
1571
2464
  if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
1572
2465
  if (deviceKey(resolved) === attached) return;
1573
2466
  detach?.();
2467
+ gate.reset();
2468
+ streamed = resolved;
1574
2469
  attached = deviceKey(resolved);
1575
- detach = frames.subscribe(resolved, listener);
2470
+ detach = frames.subscribe(resolved, listener, hint);
1576
2471
  },
1577
2472
  failed: (message) => queueMicrotask(() => end(message))
1578
2473
  });
1579
2474
  subscriptions.set(subscription, cleanup);
1580
2475
  }
1581
- function command(id, args, cwd, result) {
2476
+ function command(id, args, cwd, result, limits = commandLimits) {
1582
2477
  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);
2478
+ const run = runStim(options.stimCli, options.env, args, cwd, limits);
1584
2479
  commands.add(run.cancel);
1585
2480
  running.add(run.cancel);
1586
2481
  (async () => {
1587
2482
  const outcome = await run.outcome;
1588
2483
  commands.delete(run.cancel);
1589
2484
  running.delete(run.cancel);
1590
- if (!outcome.ok) return error(id, "stim-failed", outcome.message);
2485
+ if (!outcome.ok) {
2486
+ const printed = actionOutcome(outcome);
2487
+ return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
2488
+ }
1591
2489
  let value;
1592
2490
  try {
1593
2491
  value = result(outcome.stdout);
@@ -1617,6 +2515,118 @@ async function startServer(options) {
1617
2515
  return value;
1618
2516
  });
1619
2517
  }
2518
+ function runAction(id, params, session) {
2519
+ const startedAt = Date.now();
2520
+ const raw = isJsonObject(params) ? params : {};
2521
+ const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
2522
+ const record = {
2523
+ at: new Date(startedAt).toISOString(),
2524
+ device: {
2525
+ id: session.id,
2526
+ name: session.name
2527
+ },
2528
+ action: clip(raw.action),
2529
+ workspace: clip(raw.workspace),
2530
+ ...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
2531
+ ok: false
2532
+ };
2533
+ const audit = (outcome) => {
2534
+ const logged = outcome.error ? {
2535
+ ...outcome.error,
2536
+ message: clip(outcome.error.message)
2537
+ } : void 0;
2538
+ try {
2539
+ appendAudit({
2540
+ ...record,
2541
+ ...outcome,
2542
+ ...logged ? { error: logged } : {}
2543
+ });
2544
+ } catch (cause) {
2545
+ console.error(`stim-server: could not append to the action log: ${cause.message}`);
2546
+ }
2547
+ };
2548
+ const refuseAction = (code, message) => {
2549
+ audit({
2550
+ ok: false,
2551
+ error: {
2552
+ code,
2553
+ message
2554
+ }
2555
+ });
2556
+ error(id, code, message);
2557
+ };
2558
+ 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.`);
2559
+ const parsed = parseAction(params);
2560
+ if ("code" in parsed) return refuseAction(parsed.code, parsed.message);
2561
+ const { action } = parsed;
2562
+ const resolved = registeredWorkspace(action.workspace);
2563
+ if ("code" in resolved) return refuseAction(resolved.code, resolved.message);
2564
+ if (busyWorkspaces.has(resolved.dir)) return refuseAction("action-busy", `An action is already running in ${resolved.dir}. Try again when it finishes.`);
2565
+ let run;
2566
+ try {
2567
+ run = runStim(options.stimCli, options.env, actionArgs(action), resolved.dir, actionLimits);
2568
+ } catch (cause) {
2569
+ return refuseAction("action-failed", `stim ${action.action} could not start (${cause.message}).`);
2570
+ }
2571
+ busyWorkspaces.add(resolved.dir);
2572
+ let finished = false;
2573
+ const finish = (outcome) => {
2574
+ if (finished) return;
2575
+ finished = true;
2576
+ running.delete(cancel);
2577
+ busyWorkspaces.delete(resolved.dir);
2578
+ audit({
2579
+ ok: outcome.ok,
2580
+ ...outcome.ok ? {} : { error: outcome.error },
2581
+ durationMs: Date.now() - startedAt
2582
+ });
2583
+ if (outcome.ok) send(socket, {
2584
+ id,
2585
+ result: {
2586
+ action: action.action,
2587
+ workspace: resolved.dir,
2588
+ output: outcome.output
2589
+ }
2590
+ });
2591
+ else send(socket, {
2592
+ id,
2593
+ error: outcome.error
2594
+ });
2595
+ };
2596
+ const cancel = async () => {
2597
+ finish({
2598
+ ok: false,
2599
+ error: {
2600
+ code: "action-failed",
2601
+ message: "stim-server stopped before the action finished."
2602
+ }
2603
+ });
2604
+ await run.cancel();
2605
+ };
2606
+ running.add(cancel);
2607
+ run.outcome.then((outcome) => finish(actionOutcome(outcome)));
2608
+ }
2609
+ function planBuild(id, params) {
2610
+ if (!isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
2611
+ const { platform, slot } = params;
2612
+ if (platform !== "ios" && platform !== "android") return error(id, "bad-request", "params.platform must be ios or android.");
2613
+ if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return error(id, "bad-request", "params.slot must be 1-64 letters, digits, underscores or hyphens.");
2614
+ const cwd = workspaceDir(id, params.workspace, true);
2615
+ if (!cwd) return;
2616
+ command(id, [
2617
+ platform,
2618
+ "--plan",
2619
+ "--json",
2620
+ ...slot === void 0 ? [] : [`--slot=${slot}`]
2621
+ ], cwd, (stdout) => {
2622
+ const value = JSON.parse(stdout);
2623
+ if (!isJsonObject(value) || value.platform !== platform) throw new Error("not a plan");
2624
+ return value;
2625
+ }, {
2626
+ ...commandLimits,
2627
+ timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
2628
+ });
2629
+ }
1620
2630
  async function handle(raw) {
1621
2631
  if (socket.readyState !== socket.OPEN) return;
1622
2632
  let message;
@@ -1642,7 +2652,38 @@ async function startServer(options) {
1642
2652
  if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
1643
2653
  if (message.method === "logs.query") return queryLogs(id, message.params);
1644
2654
  if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
2655
+ if (message.method === "frames.keyframe") {
2656
+ const name = isJsonObject(message.params) ? message.params.subscription : void 0;
2657
+ const keyframe = typeof name === "string" ? keyframes.get(name) : void 0;
2658
+ if (!keyframe) return send(socket, {
2659
+ id,
2660
+ error: {
2661
+ code: "unknown-subscription",
2662
+ message: `No video subscription ${String(name)}.`
2663
+ }
2664
+ });
2665
+ keyframe();
2666
+ return send(socket, {
2667
+ id,
2668
+ result: {}
2669
+ });
2670
+ }
2671
+ if (message.method === "build.plan") return planBuild(id, message.params);
2672
+ if (message.method === "machine.get") return send(socket, {
2673
+ id,
2674
+ result: await readMachineUsage()
2675
+ });
2676
+ if (message.method === "machine.history") {
2677
+ const params = message.params ?? {};
2678
+ const sinceMs = isJsonObject(params) ? params.sinceMs : void 0;
2679
+ if (!isJsonObject(params) || sinceMs !== void 0 && typeof sinceMs !== "number") return error(id, "bad-request", "machine.history takes an optional numeric sinceMs.");
2680
+ return send(socket, {
2681
+ id,
2682
+ result: sampler.history(sinceMs)
2683
+ });
2684
+ }
1645
2685
  if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
2686
+ if (message.method === "action") return runAction(id, message.params, device);
1646
2687
  if (message.method === "unsubscribe") {
1647
2688
  const name = isJsonObject(message.params) ? message.params.subscription : void 0;
1648
2689
  const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
@@ -1675,6 +2716,7 @@ async function startServer(options) {
1675
2716
  socket.on("close", () => {
1676
2717
  clearTimeout(timer);
1677
2718
  sessions.delete(socket);
2719
+ if (sessions.size === 0) sampler.stop();
1678
2720
  for (const unsubscribe of subscriptions.values()) unsubscribe();
1679
2721
  subscriptions.clear();
1680
2722
  for (const cancel of commands) {
@@ -1713,6 +2755,8 @@ async function startServer(options) {
1713
2755
  const addresses = [];
1714
2756
  const close = async () => {
1715
2757
  watcher.close();
2758
+ helperAbort.abort();
2759
+ sampler.stop();
1716
2760
  if (revocationCheck) clearTimeout(revocationCheck);
1717
2761
  for (const client of wss.clients) client.terminate();
1718
2762
  await Promise.all([
@@ -1760,11 +2804,15 @@ async function startServer(options) {
1760
2804
  const DEFAULT_PORT = 7787;
1761
2805
  const USAGE = `Usage:
1762
2806
  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
2807
+ stim-server pair [--port <n>] [--control] [--json]
2808
+ print a single-use pairing payload for the QR code;
2809
+ --control lets the paired device run actions
1765
2810
  stim-server devices [list] [--json]
1766
2811
  list paired devices
1767
- stim-server devices revoke <id> revoke a paired device`;
2812
+ stim-server devices grant <id> --control|--read
2813
+ let a paired device run actions, or only read
2814
+ stim-server devices revoke <id> revoke a paired device
2815
+ stim-server log [--json] list the actions paired devices ran`;
1768
2816
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1769
2817
  function fail(message) {
1770
2818
  console.error(`stim-server: ${message}`);
@@ -1829,7 +2877,7 @@ async function serve(port) {
1829
2877
  process.on("SIGTERM", shutdown);
1830
2878
  process.on("SIGHUP", shutdown);
1831
2879
  }
1832
- async function pair(port, json) {
2880
+ async function pair(port, json, control) {
1833
2881
  const binary = findTailscale(process.env);
1834
2882
  const tailscale = tailscaleStatus(binary, process.env);
1835
2883
  let endpoint = `ws://127.0.0.1:${port}`;
@@ -1841,7 +2889,7 @@ async function pair(port, json) {
1841
2889
  endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
1842
2890
  note = routeNote(route, port);
1843
2891
  }
1844
- const { token, expiresAt } = createPairingToken();
2892
+ const { token, expiresAt } = createPairingToken(Date.now(), capabilitiesFor(control));
1845
2893
  const payload = {
1846
2894
  v: 1,
1847
2895
  name: macName(tailscale),
@@ -1853,12 +2901,13 @@ async function pair(port, json) {
1853
2901
  expiresAt
1854
2902
  }));
1855
2903
  console.log(JSON.stringify(payload));
1856
- console.error(`The pairing token is single use and expires at ${expiresAt}.`);
2904
+ console.error(`The pairing token is single use and expires at ${expiresAt}. The device it pairs can ${control ? "run actions" : "only read"}.`);
1857
2905
  if (note) console.error(note);
1858
2906
  }
1859
2907
  function describe(device) {
1860
2908
  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"}`;
2909
+ const scope = device.capabilities.includes("control") ? "control" : "read";
2910
+ return `${device.id} ${device.name} ${scope} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
1862
2911
  }
1863
2912
  async function main() {
1864
2913
  const { values, positionals } = parseArgs({
@@ -1866,6 +2915,8 @@ async function main() {
1866
2915
  options: {
1867
2916
  port: { type: "string" },
1868
2917
  json: { type: "boolean" },
2918
+ control: { type: "boolean" },
2919
+ read: { type: "boolean" },
1869
2920
  help: {
1870
2921
  type: "boolean",
1871
2922
  short: "h"
@@ -1882,7 +2933,27 @@ async function main() {
1882
2933
  if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
1883
2934
  const [command, sub, arg, ...rest] = positionals;
1884
2935
  if (command === void 0) return serve(port);
1885
- if (command === "pair" && sub === void 0) return pair(port, values.json === true);
2936
+ const grant = command === "devices" && sub === "grant";
2937
+ if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
2938
+ if (values.control && !grant && command !== "pair") fail(`--control applies only to \`pair\` and \`devices grant\`.\n${USAGE}`);
2939
+ if (command === "pair" && sub === void 0) return pair(port, values.json === true, values.control === true);
2940
+ if (grant && arg !== void 0 && rest.length === 0) {
2941
+ if (values.control === values.read) fail("devices grant takes exactly one of --control or --read.");
2942
+ if (!grantDevice(arg, capabilitiesFor(values.control === true))) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
2943
+ console.log(`${arg} can now ${values.control ? "run actions" : "only read"}.`);
2944
+ return;
2945
+ }
2946
+ if (command === "log" && sub === void 0) {
2947
+ const records = readAudit();
2948
+ if (values.json) return void console.log(JSON.stringify({ actions: records }));
2949
+ if (!records.length) console.log("No actions.");
2950
+ for (const record of records) {
2951
+ const outcome = record.ok ? "ok" : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
2952
+ const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
2953
+ console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
2954
+ }
2955
+ return;
2956
+ }
1886
2957
  if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
1887
2958
  const devices = readDevices();
1888
2959
  if (values.json) {