@stim-cli/server 1.10.0 → 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-dn2_TUeH.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,11 +536,344 @@ 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
868
+ const DEFAULT_FRAME_LIMITS = {
869
+ toolTimeoutMs: 3e4,
870
+ slowCaptureMs: 3e3,
871
+ failureBackoffMs: 3e3,
872
+ maxConsecutiveFailures: 3
873
+ };
410
874
  const MIN_INTERVAL_MS = 200;
411
875
  const MAX_INTERVAL_MS = 1e3;
412
876
  const MAX_CAPTURES = 2;
413
- const TOOL_TIMEOUT_MS = 1e4;
414
877
  const MAX_EDGE = 1280;
415
878
  const JPEG_QUALITY = 70;
416
879
  function deviceKey(device) {
@@ -432,7 +895,8 @@ function ownedDevice(payload, target, attached) {
432
895
  if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
433
896
  return {
434
897
  platform: "ios",
435
- udid: sim.udid
898
+ udid: sim.udid,
899
+ foldable: /\bDuo\b/.test(sim.name ?? "")
436
900
  };
437
901
  }
438
902
  const emulator = devices?.android;
@@ -469,7 +933,14 @@ function jpegSize(bytes) {
469
933
  }
470
934
  return null;
471
935
  }
472
- function runTool(file, args, env, running) {
936
+ /** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
937
+ function timeoutError(message) {
938
+ return Object.assign(new Error(message), { transient: true });
939
+ }
940
+ function isTransient(error) {
941
+ return error instanceof Error && error.transient === true;
942
+ }
943
+ function runTool(file, args, env, running, timeoutMs) {
473
944
  return new Promise((resolve, reject) => {
474
945
  const child = spawn(file, args, {
475
946
  env,
@@ -485,8 +956,8 @@ function runTool(file, args, env, running) {
485
956
  let stderr = "";
486
957
  const timer = setTimeout(() => {
487
958
  terminate(child);
488
- reject(/* @__PURE__ */ new Error(`${file} ${args[0]} did not finish within ${TOOL_TIMEOUT_MS / 1e3} s.`));
489
- }, TOOL_TIMEOUT_MS);
959
+ reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
960
+ }, timeoutMs);
490
961
  child.stdout.on("data", (chunk) => chunks.push(chunk));
491
962
  child.stderr.setEncoding("utf8");
492
963
  child.stderr.on("data", (chunk) => {
@@ -511,36 +982,87 @@ async function stopTools(running, tmp) {
511
982
  });
512
983
  }
513
984
  /**
514
- * Without `--display`, simctl captures the first panel it finds, which on a foldable simulator (iPhone Duo)
515
- * can be the unlit one and comes back black; `primary` is the panel CoreDevice reports as primary. A simctl
516
- * 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.
517
991
  */
518
- function simulatorCapturer(udid, env) {
992
+ function simulatorCapturer(device, env, limits, litPanels) {
519
993
  let tmp = null;
520
994
  let display = ["--display=primary"];
521
995
  let closed = false;
522
996
  const running = /* @__PURE__ */ new Set();
523
- const screenshot = (output) => runTool("xcrun", [
524
- "simctl",
525
- "io",
526
- udid,
527
- "screenshot",
528
- "--type=jpeg",
529
- ...display,
530
- output
531
- ], env, running);
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
+ };
532
1055
  return {
533
1056
  capture: async () => {
534
- tmp ??= mkdtempSync(join(serverDir(), "frames-"));
535
- const output = join(tmp, "frame.jpg");
1057
+ if (device.foldable) return captureDuo();
1058
+ let jpeg;
536
1059
  try {
537
- await screenshot(output);
1060
+ jpeg = await screenshot(display);
538
1061
  } catch (error) {
539
1062
  if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
540
1063
  display = [];
541
- await screenshot(output);
1064
+ jpeg = await screenshot(display);
542
1065
  }
543
- const jpeg = readFileSync(output);
544
1066
  return {
545
1067
  raw: jpeg,
546
1068
  jpeg: async () => jpeg
@@ -552,6 +1074,18 @@ function simulatorCapturer(udid, env) {
552
1074
  }
553
1075
  };
554
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
+ }
555
1089
  /**
556
1090
  * The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
557
1091
  * Stim passes when it boots an owned AVD (#1064).
@@ -636,36 +1170,61 @@ function* protoFields(bytes) {
636
1170
  else throw new Error(`unsupported protobuf wire type ${type}`);
637
1171
  }
638
1172
  }
639
- /** `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. */
640
- function screenshotRequest() {
641
- const message = Buffer.from([
642
- 24,
643
- ...varint(MAX_EDGE),
644
- 32,
645
- ...varint(MAX_EDGE)
646
- ]);
1173
+ function grpcMessage(message) {
647
1174
  const header = Buffer.alloc(5);
648
1175
  header.writeUInt32BE(message.length, 1);
649
- return Buffer.concat([header, message]);
1176
+ return Buffer.concat([header, Buffer.from(message)]);
1177
+ }
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
+ };
650
1198
  }
651
- function screenshotImage(body) {
652
- if (body.length < 5 || body[0] !== 0) throw new Error("getScreenshot returned no message.");
653
- const message = body.subarray(5, 5 + body.readUInt32BE(1));
654
- for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
655
- throw new Error("getScreenshot returned no image.");
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;
656
1214
  }
657
- function emulatorCapturer(serial, env) {
1215
+ function emulatorCapturer(serial, env, limits) {
658
1216
  let session = null;
659
1217
  let tmp = null;
660
1218
  const running = /* @__PURE__ */ new Set();
661
- const call = (endpoint) => new Promise((resolve, reject) => {
1219
+ let hinged = null;
1220
+ const call = (endpoint, method, requestBody) => new Promise((resolve, reject) => {
662
1221
  if (!session || session.closed || session.destroyed) {
663
1222
  session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
664
1223
  session.on("error", () => {});
665
1224
  }
666
1225
  const request = session.request({
667
1226
  ":method": "POST",
668
- ":path": "/android.emulation.control.EmulatorController/getScreenshot",
1227
+ ":path": `/android.emulation.control.EmulatorController/${method}`,
669
1228
  "content-type": "application/grpc",
670
1229
  te: "trailers",
671
1230
  ...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
@@ -673,28 +1232,43 @@ function emulatorCapturer(serial, env) {
673
1232
  const chunks = [];
674
1233
  let status;
675
1234
  let message;
1235
+ let timedOut = false;
676
1236
  const record = (headers) => {
677
1237
  if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
678
1238
  if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
679
1239
  };
680
- request.setTimeout(TOOL_TIMEOUT_MS, () => request.close());
1240
+ request.setTimeout(limits.toolTimeoutMs, () => {
1241
+ timedOut = true;
1242
+ request.close();
1243
+ });
681
1244
  request.on("response", record);
682
1245
  request.on("trailers", record);
683
1246
  request.on("data", (chunk) => chunks.push(chunk));
684
- request.on("error", (error) => reject(/* @__PURE__ */ new Error(`getScreenshot failed on ${serial}: ${error.message}`)));
1247
+ request.on("error", (error) => {
1248
+ const text = `${method} failed on ${serial}: ${error.message}`;
1249
+ reject(timedOut ? timeoutError(text) : new Error(text));
1250
+ });
685
1251
  request.on("close", () => {
686
1252
  if (status === "0") return resolve(Buffer.concat(chunks));
687
- reject(/* @__PURE__ */ new Error(`getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`));
1253
+ const text = `${method} failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
1254
+ reject(timedOut ? timeoutError(text) : new Error(text));
688
1255
  });
689
- request.end(screenshotRequest());
1256
+ request.end(requestBody);
690
1257
  });
691
1258
  return {
692
1259
  capture: async () => {
693
1260
  const endpoint = emulatorEndpoint(env, serial);
694
1261
  if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
695
- 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
+ ])));
696
1269
  return {
697
1270
  raw: png,
1271
+ ...hinged ? { posture: folded ? "folded" : "unfolded" } : {},
698
1272
  jpeg: async () => {
699
1273
  tmp ??= mkdtempSync(join(serverDir(), "frames-"));
700
1274
  const input = join(tmp, "frame.png");
@@ -712,7 +1286,7 @@ function emulatorCapturer(serial, env) {
712
1286
  input,
713
1287
  "--out",
714
1288
  output
715
- ], env, running);
1289
+ ], env, running, limits.toolTimeoutMs);
716
1290
  return readFileSync(output);
717
1291
  }
718
1292
  };
@@ -749,18 +1323,23 @@ var FrameSource = class {
749
1323
  interval = MIN_INTERVAL_MS;
750
1324
  timer = null;
751
1325
  stopped = false;
1326
+ delayed = false;
1327
+ consecutiveFailures = 0;
752
1328
  capturer;
753
1329
  limiter;
1330
+ limits;
754
1331
  ended;
755
- constructor(capturer, limiter, ended) {
1332
+ constructor(capturer, limiter, limits, ended) {
756
1333
  this.capturer = capturer;
757
1334
  this.limiter = limiter;
1335
+ this.limits = limits;
758
1336
  this.ended = ended;
759
1337
  this.tick();
760
1338
  }
761
1339
  add(listener) {
762
1340
  this.listeners.add(listener);
763
1341
  if (this.last) listener.frame(this.last);
1342
+ if (this.delayed) listener.delayed(true);
764
1343
  return () => {
765
1344
  if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
766
1345
  };
@@ -773,6 +1352,11 @@ var FrameSource = class {
773
1352
  this.ended();
774
1353
  return this.capturer.close();
775
1354
  }
1355
+ setDelayed(delayed) {
1356
+ if (this.delayed === delayed) return;
1357
+ this.delayed = delayed;
1358
+ for (const listener of this.listeners) listener.delayed(delayed);
1359
+ }
776
1360
  async tick() {
777
1361
  this.timer = null;
778
1362
  let took = 0;
@@ -791,17 +1375,26 @@ var FrameSource = class {
791
1375
  this.last = {
792
1376
  ...size,
793
1377
  capturedAt: new Date(started).toISOString(),
794
- data: jpeg.toString("base64")
1378
+ data: jpeg.toString("base64"),
1379
+ ...capture.posture ? { posture: capture.posture } : {}
795
1380
  };
796
1381
  took = Date.now() - started;
797
1382
  return true;
798
1383
  });
799
1384
  if (this.stopped) return;
1385
+ this.consecutiveFailures = 0;
1386
+ this.setDelayed(took > this.limits.slowCaptureMs);
800
1387
  if (changed) for (const listener of this.listeners) listener.frame(this.last);
801
1388
  this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
802
1389
  this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
803
1390
  } catch (error) {
804
1391
  if (this.stopped) return;
1392
+ if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
1393
+ this.consecutiveFailures++;
1394
+ this.setDelayed(true);
1395
+ this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
1396
+ return;
1397
+ }
805
1398
  const listeners = [...this.listeners];
806
1399
  this.stop();
807
1400
  for (const listener of listeners) listener.failed(error.message);
@@ -809,29 +1402,83 @@ var FrameSource = class {
809
1402
  }
810
1403
  };
811
1404
  /**
812
- * One capture loop per device, shared by its subscribers and stopped with the last of them. A loop sends a
813
- * frame only when the screen changed; it captures up to 5 times a second while the screen changes and backs
814
- * off to once a second while it does not, and spends at most half of its time capturing. At most two
815
- * 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.
816
1411
  */
817
1412
  var FramePool = class {
818
1413
  sources = /* @__PURE__ */ new Map();
819
1414
  limiter = new Limiter(MAX_CAPTURES);
1415
+ litPanels = /* @__PURE__ */ new Map();
820
1416
  env;
821
- constructor(env) {
1417
+ limits;
1418
+ helper;
1419
+ constructor(env, limits = DEFAULT_FRAME_LIMITS, helper = () => null) {
822
1420
  this.env = env;
1421
+ this.limits = limits;
1422
+ this.helper = helper;
1423
+ }
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();
823
1458
  }
824
- subscribe(device, listener) {
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) {
825
1474
  const key = deviceKey(device);
826
- let source = this.sources.get(key);
827
- if (!source) {
828
- const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env) : emulatorCapturer(device.serial, this.env), this.limiter, () => {
829
- if (this.sources.get(key) === created) this.sources.delete(key);
830
- });
831
- this.sources.set(key, created);
832
- source = created;
833
- }
834
- 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;
835
1482
  }
836
1483
  async close() {
837
1484
  await Promise.all([...this.sources.values()].map((source) => source.stop()));
@@ -911,9 +1558,203 @@ var LogBatcher = class {
911
1558
  }
912
1559
  };
913
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
914
1753
  //#region src/tailscale.ts
915
1754
  const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
916
1755
  const TIMEOUT_MS = 3e3;
1756
+ /** The tailnet-only HTTPS port the setup command serves stim-server on. */
1757
+ const SERVE_PORT = 7443;
917
1758
  function executable(path) {
918
1759
  try {
919
1760
  accessSync(path, constants.X_OK);
@@ -967,6 +1808,124 @@ function tailscaleStatus(binary, env) {
967
1808
  };
968
1809
  }
969
1810
  }
1811
+ function reaches(address, target, hosts) {
1812
+ if (typeof address !== "string") return null;
1813
+ let url;
1814
+ try {
1815
+ url = new URL(address.includes("://") ? address : `tcp://${address}`);
1816
+ } catch {
1817
+ return null;
1818
+ }
1819
+ return hosts.has(url.hostname) && url.port === String(target) ? url : null;
1820
+ }
1821
+ function portOf(hostPort) {
1822
+ return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
1823
+ }
1824
+ /**
1825
+ * Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
1826
+ * `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
1827
+ * `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
1828
+ * foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
1829
+ * it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
1830
+ */
1831
+ function parseServeStatus(value, target, ips) {
1832
+ if (!isJsonObject(value)) return {
1833
+ state: "unknown",
1834
+ reason: "it printed no serve config",
1835
+ port: SERVE_PORT
1836
+ };
1837
+ const hosts = /* @__PURE__ */ new Set([
1838
+ "127.0.0.1",
1839
+ "localhost",
1840
+ "[::1]",
1841
+ "0.0.0.0",
1842
+ "[::]",
1843
+ ...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
1844
+ ]);
1845
+ const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
1846
+ const used = /* @__PURE__ */ new Set();
1847
+ const funneled = /* @__PURE__ */ new Set();
1848
+ const reaching = /* @__PURE__ */ new Set();
1849
+ const routes = /* @__PURE__ */ new Set();
1850
+ for (const config of configs) {
1851
+ const tcp = isJsonObject(config.TCP) ? config.TCP : {};
1852
+ for (const [port, listener] of Object.entries(tcp)) {
1853
+ used.add(Number(port));
1854
+ if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
1855
+ }
1856
+ for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
1857
+ for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
1858
+ const port = portOf(hostPort);
1859
+ const listener = tcp[String(port)];
1860
+ const https = isJsonObject(listener) && listener.HTTPS === true;
1861
+ const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
1862
+ for (const [mount, handler] of Object.entries(handlers)) {
1863
+ const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
1864
+ if (!url) continue;
1865
+ reaching.add(port);
1866
+ if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
1867
+ }
1868
+ }
1869
+ }
1870
+ let free = SERVE_PORT;
1871
+ while (used.has(free) || funneled.has(free)) free++;
1872
+ const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
1873
+ if (exposed.length) return {
1874
+ state: "funneled",
1875
+ ports: exposed,
1876
+ port: free
1877
+ };
1878
+ const tailnet = [...routes].toSorted((a, b) => a - b);
1879
+ if (tailnet.length) return {
1880
+ state: "routed",
1881
+ port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
1882
+ };
1883
+ return {
1884
+ state: "missing",
1885
+ port: free
1886
+ };
1887
+ }
1888
+ function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
1889
+ if (!binary) return Promise.resolve({
1890
+ state: "unknown",
1891
+ reason: "the tailscale command was not found",
1892
+ port: SERVE_PORT
1893
+ });
1894
+ return new Promise((resolve) => {
1895
+ execFile(binary, [
1896
+ "serve",
1897
+ "status",
1898
+ "--json"
1899
+ ], {
1900
+ env,
1901
+ timeout: timeoutMs,
1902
+ killSignal: "SIGKILL",
1903
+ encoding: "utf8"
1904
+ }, (error, stdout) => {
1905
+ let route;
1906
+ try {
1907
+ route = error ? {
1908
+ state: "unknown",
1909
+ reason: error.killed ? "it timed out" : error.message.split("\n")[0],
1910
+ port: SERVE_PORT
1911
+ } : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
1912
+ } catch {
1913
+ route = {
1914
+ state: "unknown",
1915
+ reason: "it printed output that is not JSON",
1916
+ port: SERVE_PORT
1917
+ };
1918
+ }
1919
+ resolve(route);
1920
+ });
1921
+ });
1922
+ }
1923
+ function tailnetEndpoint(dnsName, port) {
1924
+ return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
1925
+ }
1926
+ function serveCommand(port, target) {
1927
+ return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
1928
+ }
970
1929
  /** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
971
1930
  function parseWhois(value) {
972
1931
  if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
@@ -1032,8 +1991,9 @@ const LOG_LIMITS = {
1032
1991
  maxBufferedBytes: 4194304,
1033
1992
  maxPendingRecords: 2e4
1034
1993
  };
1035
- const FRAME_BUFFER_BYTES = 1048576;
1036
- const FRAME_RETRY_MS = 100;
1994
+ const FRAME_BUFFER_FRAMES = 2;
1995
+ const FRAME_RETRY_MS = 50;
1996
+ const HELPER_RETRY_MS = 3e5;
1037
1997
  const STATUS_FEED = {
1038
1998
  args: [
1039
1999
  "status",
@@ -1044,10 +2004,18 @@ const STATUS_FEED = {
1044
2004
  keep: 1,
1045
2005
  label: "stim status --watch"
1046
2006
  };
2007
+ const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
1047
2008
  const COMMAND_LIMITS = {
1048
2009
  timeoutMs: 6e4,
1049
2010
  maxOutputBytes: 33554432
1050
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
+ };
1051
2019
  const AUTH_REFUSALS = {
1052
2020
  "pairing-unknown": {
1053
2021
  code: "pairing-expired",
@@ -1106,11 +2074,62 @@ function send(socket, message) {
1106
2074
  function requestId(value) {
1107
2075
  return typeof value === "number" || typeof value === "string" ? value : null;
1108
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
+ }
1109
2102
  async function startServer(options) {
1110
2103
  const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
1111
2104
  const authTimeoutMs = options.authTimeoutMs ?? 5e3;
1112
2105
  const feeds = new FeedPool(options.stimCli, options.env);
1113
- const frames = new FramePool(options.env);
2106
+ const frameLimits = {
2107
+ ...DEFAULT_FRAME_LIMITS,
2108
+ ...options.frameLimits
2109
+ };
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);
1114
2133
  const running = /* @__PURE__ */ new Set();
1115
2134
  const logLimits = {
1116
2135
  ...LOG_LIMITS,
@@ -1120,7 +2139,13 @@ async function startServer(options) {
1120
2139
  ...COMMAND_LIMITS,
1121
2140
  ...options.commandLimits
1122
2141
  };
2142
+ const actionLimits = {
2143
+ ...ACTION_LIMITS,
2144
+ ...options.actionLimits
2145
+ };
2146
+ const busyWorkspaces = /* @__PURE__ */ new Set();
1123
2147
  const sessions = /* @__PURE__ */ new Map();
2148
+ const sampler = new UsageSampler();
1124
2149
  const wss = new WebSocketServer({
1125
2150
  noServer: true,
1126
2151
  maxPayload: MAX_PAYLOAD
@@ -1140,6 +2165,7 @@ async function startServer(options) {
1140
2165
  function connection(socket, peer) {
1141
2166
  const limitKey = peer ?? "local";
1142
2167
  const subscriptions = /* @__PURE__ */ new Map();
2168
+ const keyframes = /* @__PURE__ */ new Map();
1143
2169
  const commands = /* @__PURE__ */ new Set();
1144
2170
  let nextSubscription = 1;
1145
2171
  let device = null;
@@ -1188,6 +2214,7 @@ async function startServer(options) {
1188
2214
  }
1189
2215
  device = outcome.device;
1190
2216
  sessions.set(socket, device);
2217
+ sampler.start();
1191
2218
  send(socket, {
1192
2219
  id,
1193
2220
  result: {
@@ -1195,9 +2222,11 @@ async function startServer(options) {
1195
2222
  server: {
1196
2223
  name: options.name,
1197
2224
  version: options.serverVersion,
1198
- stim: options.stimVersion
2225
+ stim: options.stimVersion,
2226
+ home: homedir()
1199
2227
  },
1200
2228
  capabilities: device.capabilities,
2229
+ actions: device.capabilities.includes("control") ? [...ACTIONS] : [],
1201
2230
  device: {
1202
2231
  id: device.id,
1203
2232
  name: device.name
@@ -1217,28 +2246,14 @@ async function startServer(options) {
1217
2246
  }
1218
2247
  function workspaceDir(id, workspace, required) {
1219
2248
  if (workspace === void 0 && !required) return homedir();
1220
- if (typeof workspace !== "string") {
1221
- error(id, "bad-request", "params.workspace must be an environment path from a status payload.");
2249
+ const resolved = registeredWorkspace(workspace);
2250
+ if ("code" in resolved) {
2251
+ error(id, resolved.code, resolved.message);
1222
2252
  return null;
1223
2253
  }
1224
- let registered;
1225
- try {
1226
- registered = Object.hasOwn(loadConfig()?.projects ?? {}, workspace);
1227
- } catch (cause) {
1228
- error(id, "stim-failed", cause.message);
1229
- return null;
1230
- }
1231
- if (!registered) {
1232
- error(id, "unknown-workspace", `${workspace} is not a Stim workspace on this Mac.`);
1233
- return null;
1234
- }
1235
- if (!existsSync(workspace)) {
1236
- error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
1237
- return null;
1238
- }
1239
- return workspace;
2254
+ return resolved.dir;
1240
2255
  }
1241
- function openSubscription(id) {
2256
+ function openSubscription(id, result = {}) {
1242
2257
  if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
1243
2258
  error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
1244
2259
  return null;
@@ -1246,7 +2261,10 @@ async function startServer(options) {
1246
2261
  const subscription = `s${nextSubscription++}`;
1247
2262
  send(socket, {
1248
2263
  id,
1249
- result: { subscription }
2264
+ result: {
2265
+ subscription,
2266
+ ...result
2267
+ }
1250
2268
  });
1251
2269
  return subscription;
1252
2270
  }
@@ -1330,31 +2348,59 @@ async function startServer(options) {
1330
2348
  });
1331
2349
  }
1332
2350
  function subscribeFrames(id, params) {
1333
- const { workspace, platform, slot } = isJsonObject(params) ? params : {};
2351
+ const { workspace, platform, slot, fps, maxEdge, video } = isJsonObject(params) ? params : {};
1334
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).");
1335
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
+ };
1336
2364
  if (!workspaceDir(id, workspace, true)) return;
1337
- const subscription = openSubscription(id);
2365
+ const subscription = openSubscription(id, offersVideo ? { video: "h264" } : {});
1338
2366
  if (!subscription) return;
1339
2367
  const frameTarget = {
1340
2368
  workspace,
1341
2369
  platform,
1342
2370
  ...slot ? { slot } : {}
1343
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
+ };
1344
2383
  let attached = null;
1345
2384
  let detach = null;
1346
2385
  let pending = null;
1347
2386
  let retry = null;
1348
2387
  let ended = false;
2388
+ let sentAt = 0;
1349
2389
  const flush = () => {
1350
2390
  retry = null;
1351
2391
  if (!pending || ended) return;
1352
- 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) {
1353
2398
  retry = setTimeout(flush, FRAME_RETRY_MS);
1354
2399
  return;
1355
2400
  }
1356
2401
  const frame = pending;
1357
2402
  pending = null;
2403
+ sentAt = Date.now();
1358
2404
  send(socket, {
1359
2405
  event: "frame",
1360
2406
  subscription,
@@ -1366,7 +2412,9 @@ async function startServer(options) {
1366
2412
  };
1367
2413
  const cleanup = () => {
1368
2414
  ended = true;
2415
+ keyframes.delete(subscription);
1369
2416
  if (retry) clearTimeout(retry);
2417
+ if (draining) clearTimeout(draining);
1370
2418
  detach?.();
1371
2419
  detach = null;
1372
2420
  unsubscribeStatus?.();
@@ -1389,8 +2437,25 @@ async function startServer(options) {
1389
2437
  pending = frame;
1390
2438
  if (!retry) flush();
1391
2439
  },
1392
- failed: end
2440
+ delayed: (delayed) => {
2441
+ if (!ended) send(socket, {
2442
+ event: "frame-delayed",
2443
+ subscription,
2444
+ delayed
2445
+ });
2446
+ },
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
+ } } : {}
1393
2454
  };
2455
+ if (offersVideo) keyframes.set(subscription, () => {
2456
+ gate.reset();
2457
+ if (streamed) frames.keyframe(streamed);
2458
+ });
1394
2459
  let unsubscribeStatus = null;
1395
2460
  unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
1396
2461
  item: (payload) => {
@@ -1399,23 +2464,28 @@ async function startServer(options) {
1399
2464
  if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
1400
2465
  if (deviceKey(resolved) === attached) return;
1401
2466
  detach?.();
2467
+ gate.reset();
2468
+ streamed = resolved;
1402
2469
  attached = deviceKey(resolved);
1403
- detach = frames.subscribe(resolved, listener);
2470
+ detach = frames.subscribe(resolved, listener, hint);
1404
2471
  },
1405
2472
  failed: (message) => queueMicrotask(() => end(message))
1406
2473
  });
1407
2474
  subscriptions.set(subscription, cleanup);
1408
2475
  }
1409
- function command(id, args, cwd, result) {
2476
+ function command(id, args, cwd, result, limits = commandLimits) {
1410
2477
  if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
1411
- const run = runStim(options.stimCli, options.env, args, cwd, commandLimits);
2478
+ const run = runStim(options.stimCli, options.env, args, cwd, limits);
1412
2479
  commands.add(run.cancel);
1413
2480
  running.add(run.cancel);
1414
2481
  (async () => {
1415
2482
  const outcome = await run.outcome;
1416
2483
  commands.delete(run.cancel);
1417
2484
  running.delete(run.cancel);
1418
- 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
+ }
1419
2489
  let value;
1420
2490
  try {
1421
2491
  value = result(outcome.stdout);
@@ -1445,6 +2515,118 @@ async function startServer(options) {
1445
2515
  return value;
1446
2516
  });
1447
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
+ }
1448
2630
  async function handle(raw) {
1449
2631
  if (socket.readyState !== socket.OPEN) return;
1450
2632
  let message;
@@ -1470,7 +2652,38 @@ async function startServer(options) {
1470
2652
  if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
1471
2653
  if (message.method === "logs.query") return queryLogs(id, message.params);
1472
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
+ }
1473
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);
1474
2687
  if (message.method === "unsubscribe") {
1475
2688
  const name = isJsonObject(message.params) ? message.params.subscription : void 0;
1476
2689
  const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
@@ -1503,6 +2716,7 @@ async function startServer(options) {
1503
2716
  socket.on("close", () => {
1504
2717
  clearTimeout(timer);
1505
2718
  sessions.delete(socket);
2719
+ if (sessions.size === 0) sampler.stop();
1506
2720
  for (const unsubscribe of subscriptions.values()) unsubscribe();
1507
2721
  subscriptions.clear();
1508
2722
  for (const cancel of commands) {
@@ -1529,10 +2743,20 @@ async function startServer(options) {
1529
2743
  stimHome: configDir(),
1530
2744
  tailscale: healthTailscale(options.tailscaleState)
1531
2745
  };
2746
+ const answerHealth = async (response) => {
2747
+ const tailscale = options.tailscaleState;
2748
+ const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
2749
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
2750
+ ...health,
2751
+ route
2752
+ }));
2753
+ };
1532
2754
  const servers = [];
1533
2755
  const addresses = [];
1534
2756
  const close = async () => {
1535
2757
  watcher.close();
2758
+ helperAbort.abort();
2759
+ sampler.stop();
1536
2760
  if (revocationCheck) clearTimeout(revocationCheck);
1537
2761
  for (const client of wss.clients) client.terminate();
1538
2762
  await Promise.all([
@@ -1547,7 +2771,7 @@ async function startServer(options) {
1547
2771
  for (const host of options.hosts) {
1548
2772
  const server = createServer((request, response) => {
1549
2773
  if (localHealthRequest(request)) {
1550
- response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(health));
2774
+ answerHealth(response);
1551
2775
  return;
1552
2776
  }
1553
2777
  response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
@@ -1580,22 +2804,32 @@ async function startServer(options) {
1580
2804
  const DEFAULT_PORT = 7787;
1581
2805
  const USAGE = `Usage:
1582
2806
  stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
1583
- stim-server pair [--port <n>] [--json]
1584
- 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
1585
2810
  stim-server devices [list] [--json]
1586
2811
  list paired devices
1587
- 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`;
1588
2816
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1589
2817
  function fail(message) {
1590
2818
  console.error(`stim-server: ${message}`);
1591
2819
  process.exit(1);
1592
2820
  }
1593
- function tailscaleNote(tailscale, port) {
1594
- const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server and run \`tailscale serve --bg http://127.0.0.1:${port}\` once.`;
2821
+ function tailscaleNote(tailscale) {
2822
+ const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
1595
2823
  if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
1596
2824
  if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
1597
2825
  return null;
1598
2826
  }
2827
+ function routeNote(route, port) {
2828
+ if (route.state === "missing") return `No \`tailscale serve\` route reaches port ${port}, so phones cannot connect yet. Run this once to serve it on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
2829
+ if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
2830
+ if (route.state === "funneled") return `Tailscale Funnel is on for port ${route.ports.join(", ")}, which proxies to stim-server, so the server is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then serve stim-server on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
2831
+ return null;
2832
+ }
1599
2833
  function macName(tailscale) {
1600
2834
  return tailscale.state === "running" && tailscale.hostName || hostname();
1601
2835
  }
@@ -1628,8 +2862,13 @@ async function serve(port) {
1628
2862
  }
1629
2863
  console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
1630
2864
  for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
1631
- if (tailscale.state === "running" && tailscale.dnsName) console.log(`tailnet endpoint: wss://${tailscale.dnsName}`);
1632
- const note = tailscaleNote(tailscale, port);
2865
+ let note = tailscaleNote(tailscale);
2866
+ if (tailscale.state === "running" && tailscale.dnsName) {
2867
+ const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
2868
+ if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
2869
+ note = routeNote(route, port);
2870
+ if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
2871
+ }
1633
2872
  if (note) console.error(note);
1634
2873
  const shutdown = () => {
1635
2874
  server.close().then(() => process.exit(0));
@@ -1638,14 +2877,23 @@ async function serve(port) {
1638
2877
  process.on("SIGTERM", shutdown);
1639
2878
  process.on("SIGHUP", shutdown);
1640
2879
  }
1641
- function pair(port, json) {
1642
- const tailscale = tailscaleStatus(findTailscale(process.env), process.env);
1643
- const { token, expiresAt } = createPairingToken();
1644
- const running = tailscale.state === "running";
2880
+ async function pair(port, json, control) {
2881
+ const binary = findTailscale(process.env);
2882
+ const tailscale = tailscaleStatus(binary, process.env);
2883
+ let endpoint = `ws://127.0.0.1:${port}`;
2884
+ let note = tailscaleNote(tailscale);
2885
+ if (note) note = `${note} The endpoint above only works on this Mac.`;
2886
+ if (tailscale.state === "running" && tailscale.dnsName) {
2887
+ const route = await serveRoute(binary, process.env, port, tailscale.ips);
2888
+ if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
2889
+ endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
2890
+ note = routeNote(route, port);
2891
+ }
2892
+ const { token, expiresAt } = createPairingToken(Date.now(), capabilitiesFor(control));
1645
2893
  const payload = {
1646
2894
  v: 1,
1647
2895
  name: macName(tailscale),
1648
- endpoint: running && tailscale.dnsName ? `wss://${tailscale.dnsName}` : `ws://127.0.0.1:${port}`,
2896
+ endpoint,
1649
2897
  pairingToken: token
1650
2898
  };
1651
2899
  if (json) return void console.log(JSON.stringify({
@@ -1653,13 +2901,13 @@ function pair(port, json) {
1653
2901
  expiresAt
1654
2902
  }));
1655
2903
  console.log(JSON.stringify(payload));
1656
- console.error(`The pairing token is single use and expires at ${expiresAt}.`);
1657
- const note = tailscaleNote(tailscale, port);
1658
- if (note) console.error(`${note} The endpoint above only works on this Mac.`);
2904
+ console.error(`The pairing token is single use and expires at ${expiresAt}. The device it pairs can ${control ? "run actions" : "only read"}.`);
2905
+ if (note) console.error(note);
1659
2906
  }
1660
2907
  function describe(device) {
1661
2908
  const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
1662
- 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"}`;
1663
2911
  }
1664
2912
  async function main() {
1665
2913
  const { values, positionals } = parseArgs({
@@ -1667,6 +2915,8 @@ async function main() {
1667
2915
  options: {
1668
2916
  port: { type: "string" },
1669
2917
  json: { type: "boolean" },
2918
+ control: { type: "boolean" },
2919
+ read: { type: "boolean" },
1670
2920
  help: {
1671
2921
  type: "boolean",
1672
2922
  short: "h"
@@ -1683,7 +2933,27 @@ async function main() {
1683
2933
  if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
1684
2934
  const [command, sub, arg, ...rest] = positionals;
1685
2935
  if (command === void 0) return serve(port);
1686
- 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
+ }
1687
2957
  if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
1688
2958
  const devices = readDevices();
1689
2959
  if (values.json) {