@stim-cli/server 1.14.0 → 1.15.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.
Files changed (52) hide show
  1. package/README.md +1584 -89
  2. package/dist/protocol-MBMvJpvV.mjs +2715 -0
  3. package/dist/protocol.d.mts +2 -496
  4. package/dist/protocol.mjs +3 -2
  5. package/dist/protocol.schema.json +5074 -549
  6. package/dist/scrcpy/LICENSE +204 -0
  7. package/dist/scrcpy/NOTICE +22 -0
  8. package/dist/scrcpy/scrcpy-server +0 -0
  9. package/dist/stats-read.d.mts +1 -0
  10. package/dist/stats-read.mjs +46 -0
  11. package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
  12. package/dist/stim-frames/DevToolsClient.swift +180 -0
  13. package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
  14. package/dist/stim-frames/DeviceFrameView.swift +167 -0
  15. package/dist/stim-frames/DuoCaptureSource.swift +132 -0
  16. package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
  17. package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
  18. package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
  19. package/dist/stim-frames/DuoMesh.swift +100 -0
  20. package/dist/stim-frames/DuoModelView.swift +275 -0
  21. package/dist/stim-frames/DuoPoseClip.swift +140 -0
  22. package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
  23. package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
  24. package/dist/stim-frames/EmulatorInput.swift +13 -2
  25. package/dist/stim-frames/EmulatorPosture.swift +2 -2
  26. package/dist/stim-frames/FrameArtwork.swift +37 -0
  27. package/dist/stim-frames/H264Decoder.swift +109 -0
  28. package/dist/stim-frames/InputMessages.swift +27 -6
  29. package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
  30. package/dist/stim-frames/MacosSource.swift +677 -0
  31. package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
  32. package/dist/stim-frames/OwnedAppWindows.swift +108 -0
  33. package/dist/stim-frames/PhoneSource.swift +315 -0
  34. package/dist/stim-frames/ProcessStream.swift +155 -0
  35. package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
  36. package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
  37. package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
  38. package/dist/stim-frames/SimulatorHID.swift +70 -29
  39. package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
  40. package/dist/stim-frames/SimulatorPosture.swift +277 -0
  41. package/dist/stim-frames/SimulatorRotation.swift +28 -8
  42. package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
  43. package/dist/stim-frames/StoragePaths.swift +42 -0
  44. package/dist/stim-frames/VideoEncoder.swift +8 -6
  45. package/dist/stim-frames/WebPage.swift +301 -0
  46. package/dist/stim-frames/main.swift +651 -22
  47. package/dist/stim-host/Info.plist +28 -0
  48. package/dist/stim-host/stim-host.c +153 -0
  49. package/dist/stim-server-OiJI9xLU.mjs +13336 -0
  50. package/dist/stim-server.mjs +6 -4421
  51. package/package.json +3 -3
  52. package/dist/protocol-IroIg8Td.mjs +0 -865
@@ -1,4424 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { _ as PUSH_TOKEN_PATTERN, b as TOUCH_PHASES, c as INPUT_BUTTONS, f as MAX_LOG_TAIL, g as PUSH_EVENTS, l as LOG_LEVELS, n as CAPABILITIES, o as FRAME_EDGE, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES, v as RELOAD_PLATFORMS, y as ROTATE_DIRECTIONS } from "./protocol-IroIg8Td.mjs";
3
- import { createRequire } from "node:module";
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
- import { parseArgs } from "node:util";
7
- import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
8
- import { basename, delimiter, dirname, join } from "node:path";
9
- import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
10
- import { createHash, randomBytes } from "node:crypto";
11
- import { configDir, withDirLock } from "@stim-cli/core";
12
- import { createServer } from "node:http";
13
- import { isIP } from "node:net";
14
- import { WebSocketServer } from "ws";
15
- import { connect } from "node:http2";
16
- import { fileURLToPath } from "node:url";
17
- import { createInterface } from "node:readline";
18
- //#region src/environment.ts
19
- function parseEnvironment(output) {
20
- const environment = {};
21
- for (const entry of output.split("\0")) {
22
- const equals = entry.indexOf("=");
23
- if (equals > 0) environment[entry.slice(0, equals)] = entry.slice(equals + 1);
24
- }
25
- return environment;
26
- }
27
- /**
28
- * The login shell's environment, as Stim Desktop captures it: a process started by launchd lacks the
29
- * PATH entries and variables such as ANDROID_HOME that the shell profile sets. The shell writes to a
30
- * file because a background process started by a profile can keep a pipe open after the shell exits.
31
- */
32
- function loginShellEnvironment() {
33
- const dir = mkdtempSync(join(tmpdir(), "stim-server-env-"));
34
- const file = join(dir, "env");
35
- try {
36
- spawnSync(process.env.SHELL || "/bin/zsh", [
37
- "-lic",
38
- "command env -0 > \"$1\"",
39
- "stim-server",
40
- file
41
- ], {
42
- stdio: "ignore",
43
- timeout: 15e3
44
- });
45
- const environment = parseEnvironment(readFileSync(file, "utf8"));
46
- return Object.keys(environment).length ? environment : null;
47
- } catch {
48
- return null;
49
- } finally {
50
- rmSync(dir, {
51
- recursive: true,
52
- force: true
53
- });
54
- }
55
- }
56
- function bundledStim() {
57
- const manifest = createRequire(import.meta.url).resolve("stim/package.json");
58
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
59
- return {
60
- cli: join(dirname(manifest), pkg.bin.stim),
61
- version: pkg.version
62
- };
63
- }
64
- //#endregion
65
- //#region src/registry.ts
66
- const PAIRING_TTL_MS = 3e5;
67
- function serverDir() {
68
- return join(configDir(), "server");
69
- }
70
- function devicesFile() {
71
- return join(serverDir(), "devices.json");
72
- }
73
- function pairingFile() {
74
- return join(serverDir(), "pairing.json");
75
- }
76
- function hashToken(token) {
77
- return createHash("sha256").update(token).digest("hex");
78
- }
79
- function newToken() {
80
- return randomBytes(32).toString("base64url");
81
- }
82
- function parseIdentity(value) {
83
- if (!isJsonObject(value)) return null;
84
- if (value.kind === "local") return { kind: "local" };
85
- const { nodeId, nodeName, user } = value;
86
- if (value.kind === "tailnet" && typeof nodeId === "string" && typeof nodeName === "string" && typeof user === "string") return {
87
- kind: "tailnet",
88
- nodeId,
89
- nodeName,
90
- user
91
- };
92
- return null;
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
- }
100
- const pushToken$1 = new RegExp(PUSH_TOKEN_PATTERN);
101
- function parsePush(value) {
102
- if (!isJsonObject(value)) return null;
103
- const { token, events, agentOnly, ref, registeredAt } = value;
104
- if (typeof token !== "string" || !pushToken$1.test(token) || typeof ref !== "string") return null;
105
- if (!Array.isArray(events) || typeof registeredAt !== "string") return null;
106
- return {
107
- token,
108
- events: PUSH_EVENTS.filter((event) => events.includes(event)),
109
- agentOnly: agentOnly === true,
110
- ref,
111
- registeredAt
112
- };
113
- }
114
- function parseDevice(value) {
115
- if (!isJsonObject(value)) return null;
116
- const { id, name, tokenHash, pairedAt, lastSeenAt } = value;
117
- const identity = parseIdentity(value.identity);
118
- if (typeof id !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || !identity) return null;
119
- if (typeof pairedAt !== "string") return null;
120
- const push = parsePush(value.push);
121
- return {
122
- id,
123
- name,
124
- tokenHash,
125
- identity,
126
- pairedAt,
127
- lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
128
- capabilities: parseCapabilities(value.capabilities),
129
- ...push ? { push } : {}
130
- };
131
- }
132
- function readDevices() {
133
- const devices = readJsonObject(devicesFile())?.devices;
134
- return Array.isArray(devices) ? devices.flatMap((entry) => parseDevice(entry) ?? []) : [];
135
- }
136
- function readPairings() {
137
- const tokens = readJsonObject(pairingFile())?.tokens;
138
- if (!Array.isArray(tokens)) return [];
139
- return tokens.flatMap((entry) => isJsonObject(entry) && typeof entry.tokenHash === "string" && typeof entry.expiresAt === "string" ? [{
140
- tokenHash: entry.tokenHash,
141
- expiresAt: entry.expiresAt,
142
- capabilities: "capabilities" in entry ? parseCapabilities(entry.capabilities) : capabilitiesFor(false)
143
- }] : []);
144
- }
145
- function writeJson(file, value) {
146
- const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
147
- try {
148
- writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
149
- renameSync(temporary, file);
150
- } catch (error) {
151
- rmSync(temporary, { force: true });
152
- throw error;
153
- }
154
- }
155
- function transaction(fn) {
156
- return withDirLock(join(serverDir(), "registry.lock"), fn, { ensureParent: () => mkdirSync(serverDir(), {
157
- recursive: true,
158
- mode: 448
159
- }) });
160
- }
161
- function unexpired(record, now) {
162
- return Date.parse(record.expiresAt) > now;
163
- }
164
- /** The device that spends the token gets `capabilities`. */
165
- function createPairingToken(now = Date.now(), capabilities = capabilitiesFor(false)) {
166
- const token = newToken();
167
- const expiresAt = new Date(now + PAIRING_TTL_MS).toISOString();
168
- transaction(() => {
169
- const pending = readPairings().filter((record) => unexpired(record, now));
170
- writeJson(pairingFile(), {
171
- version: 1,
172
- tokens: [...pending, {
173
- tokenHash: hashToken(token),
174
- expiresAt,
175
- capabilities
176
- }]
177
- });
178
- });
179
- return {
180
- token,
181
- expiresAt
182
- };
183
- }
184
- function sameNode(a, b) {
185
- if (a.kind === "local" || b.kind === "local") return a.kind === b.kind;
186
- return a.nodeId === b.nodeId;
187
- }
188
- function spendPairingToken(token, name, identity, now = Date.now()) {
189
- return transaction(() => {
190
- const pairings = readPairings();
191
- const tokenHash = hashToken(token);
192
- const match = pairings.find((record) => record.tokenHash === tokenHash);
193
- const pending = pairings.filter((record) => record !== match && unexpired(record, now));
194
- if (pending.length !== pairings.length) writeJson(pairingFile(), {
195
- version: 1,
196
- tokens: pending
197
- });
198
- if (!match) return {
199
- ok: false,
200
- reason: "pairing-unknown"
201
- };
202
- if (!unexpired(match, now)) return {
203
- ok: false,
204
- reason: "pairing-expired"
205
- };
206
- const deviceToken = newToken();
207
- const at = new Date(now).toISOString();
208
- const device = {
209
- id: randomBytes(4).toString("hex"),
210
- name,
211
- tokenHash: hashToken(deviceToken),
212
- identity,
213
- pairedAt: at,
214
- lastSeenAt: at,
215
- capabilities: match.capabilities
216
- };
217
- writeJson(devicesFile(), {
218
- version: 1,
219
- devices: [...readDevices(), device]
220
- });
221
- return {
222
- ok: true,
223
- device,
224
- deviceToken
225
- };
226
- });
227
- }
228
- function authenticateDevice(token, identity, now = Date.now()) {
229
- return transaction(() => {
230
- const devices = readDevices();
231
- const tokenHash = hashToken(token);
232
- const device = devices.find((entry) => entry.tokenHash === tokenHash);
233
- if (!device) return {
234
- ok: false,
235
- reason: "device-unknown"
236
- };
237
- if (!sameNode(device.identity, identity)) return {
238
- ok: false,
239
- reason: "node-mismatch"
240
- };
241
- device.lastSeenAt = new Date(now).toISOString();
242
- writeJson(devicesFile(), {
243
- version: 1,
244
- devices
245
- });
246
- return {
247
- ok: true,
248
- device
249
- };
250
- });
251
- }
252
- function grantDevice(id, capabilities) {
253
- return transaction(() => {
254
- const devices = readDevices();
255
- const device = devices.find((entry) => entry.id === id);
256
- if (!device) return false;
257
- device.capabilities = capabilities;
258
- writeJson(devicesFile(), {
259
- version: 1,
260
- devices
261
- });
262
- return true;
263
- });
264
- }
265
- /**
266
- * Sets or, with null, removes a device's push registration, taking the token from any other pairing, such as the
267
- * same phone's previous one. False when the device is no longer paired.
268
- */
269
- function setDevicePush(id, push) {
270
- return transaction(() => {
271
- const devices = readDevices();
272
- const device = devices.find((entry) => entry.id === id);
273
- if (!device) return false;
274
- for (const other of devices) if (push && other.push?.token === push.token) delete other.push;
275
- if (push) device.push = push;
276
- else delete device.push;
277
- writeJson(devicesFile(), {
278
- version: 1,
279
- devices
280
- });
281
- return true;
282
- });
283
- }
284
- /** Removes every registration of a push token the push service no longer delivers to. */
285
- function dropPushToken(token) {
286
- transaction(() => {
287
- const devices = readDevices();
288
- let changed = false;
289
- for (const device of devices) {
290
- if (device.push?.token !== token) continue;
291
- delete device.push;
292
- changed = true;
293
- }
294
- if (changed) writeJson(devicesFile(), {
295
- version: 1,
296
- devices
297
- });
298
- });
299
- }
300
- function revokeDevice(id) {
301
- return transaction(() => {
302
- const devices = readDevices();
303
- const remaining = devices.filter((device) => device.id !== id);
304
- if (remaining.length === devices.length) return false;
305
- writeJson(devicesFile(), {
306
- version: 1,
307
- devices: remaining
308
- });
309
- return true;
310
- });
311
- }
312
- //#endregion
313
- //#region src/actions.ts
314
- function parseAction(params) {
315
- if (!isJsonObject(params)) return {
316
- code: "bad-request",
317
- message: "params must be an object."
318
- };
319
- const { action, workspace, platform, ...rest } = params;
320
- if (typeof action !== "string" || !ACTIONS.includes(action)) return {
321
- code: "unknown-action",
322
- message: `Unknown action ${JSON.stringify(action)}. Actions: ${ACTIONS.join(", ")}.`
323
- };
324
- if (typeof workspace !== "string") return {
325
- code: "bad-request",
326
- message: "params.workspace must be an environment path from a status payload."
327
- };
328
- const extra = Object.keys(rest);
329
- if (action === "stop" && platform !== void 0) extra.push("platform");
330
- if (extra.length) return {
331
- code: "bad-request",
332
- message: `${action} does not take ${extra.join(", ")}.`
333
- };
334
- if (action === "stop") return { action: {
335
- action,
336
- workspace
337
- } };
338
- if (platform !== void 0 && !RELOAD_PLATFORMS.includes(platform)) return {
339
- code: "bad-request",
340
- message: `platform must be one of ${RELOAD_PLATFORMS.join(", ")}.`
341
- };
342
- return { action: {
343
- action: "reload",
344
- workspace,
345
- ...platform ? { platform } : {}
346
- } };
347
- }
348
- function actionArgs(action) {
349
- if (action.action === "stop") return ["stop", "--json"];
350
- return [
351
- "reload",
352
- ...action.platform ? [action.platform] : [],
353
- "--json"
354
- ];
355
- }
356
- /** The JSON object the command printed, and on failure the message and remedy it printed, if any. */
357
- function actionOutcome(outcome) {
358
- let printed = null;
359
- try {
360
- printed = JSON.parse(outcome.stdout?.trim() ?? "");
361
- } catch {
362
- printed = null;
363
- }
364
- if (outcome.ok) return isJsonObject(printed) ? {
365
- ok: true,
366
- output: printed
367
- } : {
368
- ok: false,
369
- error: {
370
- code: "action-failed",
371
- message: "The command printed output that is not a JSON object."
372
- }
373
- };
374
- if (isJsonObject(printed) && typeof printed.message === "string") {
375
- const code = typeof printed.code === "string" ? `${printed.code}: ` : "";
376
- const remedy = typeof printed.remedy === "string" ? ` ${printed.remedy}` : "";
377
- return {
378
- ok: false,
379
- error: {
380
- code: "action-failed",
381
- message: `${code}${printed.message}${remedy}`
382
- }
383
- };
384
- }
385
- return {
386
- ok: false,
387
- error: {
388
- code: "action-failed",
389
- message: outcome.message
390
- }
391
- };
392
- }
393
- function auditFile() {
394
- return join(serverDir(), "actions.ndjson");
395
- }
396
- function appendAudit(record) {
397
- mkdirSync(serverDir(), {
398
- recursive: true,
399
- mode: 448
400
- });
401
- appendFileSync(auditFile(), `${JSON.stringify(record)}\n`, { mode: 384 });
402
- }
403
- function readAudit() {
404
- let text;
405
- try {
406
- text = readFileSync(auditFile(), "utf8");
407
- } catch {
408
- return [];
409
- }
410
- return text.split("\n").flatMap((line) => {
411
- try {
412
- const value = JSON.parse(line);
413
- return isJsonObject(value) ? [value] : [];
414
- } catch {
415
- return [];
416
- }
417
- });
418
- }
419
- //#endregion
420
- //#region src/stim-command.ts
421
- const KILL_GRACE_MS = 1e3;
422
- function terminate(child) {
423
- if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
424
- child.kill("SIGTERM");
425
- const timer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
426
- return new Promise((resolve) => {
427
- const done = () => {
428
- clearTimeout(timer);
429
- resolve();
430
- };
431
- child.once("exit", done);
432
- child.once("close", done);
433
- });
434
- }
435
- function runStim(stimCli, env, args, cwd, limits) {
436
- const child = spawn(process.execPath, [stimCli, ...args], {
437
- cwd,
438
- env,
439
- stdio: [
440
- "ignore",
441
- "pipe",
442
- "pipe"
443
- ]
444
- });
445
- const label = `stim ${args[0]}`;
446
- let cancelled = false;
447
- let failure = null;
448
- const chunks = [];
449
- let size = 0;
450
- let stderr = "";
451
- const release = () => {
452
- child.stdout.destroy();
453
- child.stderr.destroy();
454
- };
455
- const fail = (message) => {
456
- if (failure) return;
457
- failure = message;
458
- release();
459
- terminate(child);
460
- };
461
- const timer = setTimeout(() => {
462
- if (child.exitCode !== null || child.signalCode !== null) return release();
463
- fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`);
464
- }, limits.timeoutMs);
465
- child.stdout.on("data", (chunk) => {
466
- size += chunk.length;
467
- if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
468
- chunks.push(chunk);
469
- });
470
- child.stderr.setEncoding("utf8");
471
- child.stderr.on("data", (chunk) => {
472
- stderr = (stderr + chunk).slice(-2e3);
473
- });
474
- return {
475
- outcome: new Promise((resolve) => {
476
- const settle = (result) => {
477
- clearTimeout(timer);
478
- if (!cancelled) resolve(result);
479
- };
480
- child.on("error", (error) => settle({
481
- ok: false,
482
- message: `${label} could not start (${error.message}).`
483
- }));
484
- child.on("close", (code, signal) => {
485
- if (failure) return settle({
486
- ok: false,
487
- message: failure
488
- });
489
- if (code === 0) return settle({
490
- ok: true,
491
- stdout: Buffer.concat(chunks).toString("utf8")
492
- });
493
- const detail = stderr.trim();
494
- settle({
495
- ok: false,
496
- message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`,
497
- stdout: Buffer.concat(chunks).toString("utf8")
498
- });
499
- });
500
- }),
501
- cancel: () => {
502
- cancelled = true;
503
- clearTimeout(timer);
504
- return terminate(child);
505
- }
506
- };
507
- }
508
- //#endregion
509
- //#region src/video.ts
510
- const FIXED_HEADER_BYTES = 21;
511
- /** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
512
- function videoPacket(subscription, sequence, unit) {
513
- const id = Buffer.from(subscription, "ascii");
514
- const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
515
- header.writeUInt8(1, 0);
516
- const posture = unit.posture === "folded" ? 2 : unit.posture === "unfolded" ? 4 : 0;
517
- header.writeUInt8((unit.keyframe ? 1 : 0) | posture, 1);
518
- header.writeUInt16BE(header.length, 2);
519
- header.writeUInt32BE(sequence >>> 0, 4);
520
- header.writeDoubleBE(unit.capturedAt, 8);
521
- header.writeUInt16BE(unit.width, 16);
522
- header.writeUInt16BE(unit.height, 18);
523
- header.writeUInt8(id.length, 20);
524
- id.copy(header, FIXED_HEADER_BYTES);
525
- return Buffer.concat([header, unit.data]);
526
- }
527
- const DEFAULT_VIDEO_LIMITS = {
528
- congestedBytes: 262144,
529
- startBitrate: 3e6,
530
- minBitrate: 25e4,
531
- maxBitrate: 8e6,
532
- recoverMs: 2e3
533
- };
534
- /**
535
- * Decides, for one subscriber, whether each access unit goes out. A subscriber starts waiting for a keyframe.
536
- * When its socket backs up it drops everything until the next keyframe, because a P-frame is useless without
537
- * the frames before it; `congested` is reported once per episode.
538
- */
539
- var VideoGate = class {
540
- waiting = true;
541
- congestedBytes;
542
- constructor(congestedBytes) {
543
- this.congestedBytes = congestedBytes;
544
- }
545
- admit(unit, bufferedBytes) {
546
- if (bufferedBytes > this.congestedBytes) {
547
- if (this.waiting) return "drop";
548
- this.waiting = true;
549
- return "congested";
550
- }
551
- if (this.waiting && !unit.keyframe) return "drop";
552
- this.waiting = false;
553
- return "send";
554
- }
555
- /** The client lost its decoder state; drop until the keyframe it asked for. */
556
- reset() {
557
- this.waiting = true;
558
- }
559
- };
560
- /**
561
- * One encoder's bitrate, shared by all subscribers of a device: halved on congestion at most once per `recoverMs`,
562
- * and raised by a quarter after `recoverMs` without congestion.
563
- */
564
- var Bitrate = class {
565
- value;
566
- calmSince;
567
- cutAt = -Infinity;
568
- limits;
569
- constructor(limits, now) {
570
- this.limits = limits;
571
- this.value = limits.startBitrate;
572
- this.calmSince = now;
573
- }
574
- get current() {
575
- return this.value;
576
- }
577
- /** Called while any subscriber is congested. Returns the new bitrate when it changed. */
578
- congested(now) {
579
- this.calmSince = now;
580
- if (now - this.cutAt < this.limits.recoverMs) return null;
581
- this.cutAt = now;
582
- return this.set(Math.max(this.limits.minBitrate, Math.round(this.value / 2)));
583
- }
584
- /** Returns the new bitrate when it changed. */
585
- tick(now) {
586
- if (now - this.calmSince < this.limits.recoverMs) return null;
587
- this.calmSince = now;
588
- return this.set(Math.min(this.limits.maxBitrate, Math.round(this.value * 1.25)));
589
- }
590
- set(next) {
591
- if (next === this.value) return null;
592
- this.value = next;
593
- return next;
594
- }
595
- };
596
- //#endregion
597
- //#region src/frame-helper.ts
598
- const DEFAULT_FRAME_HINT = {
599
- fps: FRAME_FPS.default,
600
- maxEdge: FRAME_EDGE.default
601
- };
602
- const SOURCES_DIR = fileURLToPath(new URL("./stim-frames/", import.meta.url));
603
- const BUILD_TIMEOUT_MS = 18e4;
604
- const VERSION_TIMEOUT_MS = 3e4;
605
- const MAX_MESSAGE_BYTES = 16777216;
606
- const FRAME_MESSAGE = 1;
607
- const NOTICE_MESSAGE = 2;
608
- const VIDEO_MESSAGE = 3;
609
- const VIDEO_HEADER_BYTES = 14;
610
- const KEYFRAME_INTERVAL_MS = 250;
611
- /** Runs the compiler in its own process group, so a timeout or `signal` also stops `swift-frontend` and `ld`. */
612
- function run(file, args, env, timeoutMs, signal) {
613
- return new Promise((resolve, reject) => {
614
- const child = spawn(file, args, {
615
- env,
616
- stdio: [
617
- "ignore",
618
- "pipe",
619
- "pipe"
620
- ],
621
- detached: true
622
- });
623
- let output = "";
624
- let stopped = null;
625
- const stop = (reason) => {
626
- stopped ??= reason;
627
- try {
628
- process.kill(-child.pid, "SIGKILL");
629
- } catch {}
630
- };
631
- const timer = setTimeout(() => stop(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`), timeoutMs);
632
- const abort = () => stop(`${file} ${args[0]} was stopped with the server.`);
633
- signal?.addEventListener("abort", abort);
634
- child.stdout.setEncoding("utf8");
635
- child.stderr.setEncoding("utf8");
636
- child.stdout.on("data", (chunk) => output = (output + chunk).slice(-4e3));
637
- child.stderr.on("data", (chunk) => output = (output + chunk).slice(-4e3));
638
- child.on("error", (error) => {
639
- clearTimeout(timer);
640
- signal?.removeEventListener("abort", abort);
641
- reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
642
- });
643
- child.on("close", (code) => {
644
- clearTimeout(timer);
645
- signal?.removeEventListener("abort", abort);
646
- if (stopped) reject(new Error(stopped));
647
- else if (code === 0) resolve(output);
648
- else reject(/* @__PURE__ */ new Error(`${file} ${args[0]} exited (code ${code}): ${output.trim()}`));
649
- });
650
- });
651
- }
652
- /**
653
- * Compiles the `stim-frames` helper from the Swift sources shipped in `dist/stim-frames/` into
654
- * `$STIM_HOME/server/helpers/`, named by a hash of the sources and the compiler version. Resolves to the
655
- * helper's path, or rejects with the reason it cannot be built.
656
- */
657
- async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
658
- if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
659
- const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
660
- if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
661
- return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
662
- await run("xcrun", [
663
- "swiftc",
664
- "-O",
665
- "-swift-version",
666
- "5",
667
- "-module-name",
668
- "StimFrames",
669
- "-o",
670
- output,
671
- ...sources
672
- ], env, BUILD_TIMEOUT_MS, signal);
673
- });
674
- }
675
- /**
676
- * Compiles `sim-fold`, which folds or unfolds an iPhone Duo from inside the simulator, from the Stim Desktop
677
- * sources shipped in `dist/stim-frames/`, the way Stim Desktop's bundle script builds it.
678
- */
679
- async function buildFoldHelper(env, signal, sourcesDir = SOURCES_DIR) {
680
- if (process.platform !== "darwin") throw new Error("sim-fold runs only on macOS.");
681
- const source = join(sourcesDir, "sim-fold.m");
682
- const entitlements = join(sourcesDir, "sim-fold.entitlements");
683
- const clang = [
684
- "-sdk",
685
- "iphonesimulator",
686
- "clang"
687
- ];
688
- const version = await run("xcrun", [...clang, "--version"], env, VERSION_TIMEOUT_MS, signal);
689
- return compiled("sim-fold", [source, entitlements], version, async (output) => {
690
- await run("xcrun", [
691
- ...clang,
692
- "-fobjc-arc",
693
- "-arch",
694
- "arm64",
695
- "-arch",
696
- "x86_64",
697
- "-mios-simulator-version-min=18.0",
698
- "-framework",
699
- "Foundation",
700
- source,
701
- "-o",
702
- output,
703
- `-Wl,-sectcreate,__TEXT,__entitlements,${entitlements}`
704
- ], env, BUILD_TIMEOUT_MS, signal);
705
- await run("codesign", [
706
- "--force",
707
- "--sign",
708
- "-",
709
- output
710
- ], env, VERSION_TIMEOUT_MS, signal);
711
- });
712
- }
713
- async function compiled(name, inputs, version, compile) {
714
- const hash = createHash("sha256").update(version);
715
- for (const input of inputs) hash.update(basename(input)).update(readFileSync(input));
716
- const dir = join(serverDir(), "helpers");
717
- const helper = join(dir, `${name}-${hash.digest("hex").slice(0, 16)}`);
718
- if (existsSync(helper)) return helper;
719
- mkdirSync(dir, {
720
- recursive: true,
721
- mode: 448
722
- });
723
- const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
724
- try {
725
- await compile(output);
726
- renameSync(output, helper);
727
- } finally {
728
- rmSync(output, { force: true });
729
- }
730
- return helper;
731
- }
732
- /**
733
- * One `stim-frames` process per device, shared by its subscribers and stopped `lingerMs` after the last of them
734
- * leaves, unchanged meanwhile, so a subscriber that comes back within that time gets the latest frame at once. It runs
735
- * at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
736
- * helper exits when its stdin closes, so it cannot outlive the server. `lit` turns the display the helper
737
- * reports it streams into a posture, which the frames and access units after it carry.
738
- */
739
- var HelperSource = class {
740
- listeners = /* @__PURE__ */ new Map();
741
- child;
742
- last = null;
743
- config = "";
744
- stopped = false;
745
- notice = null;
746
- keyboard = null;
747
- stderr = "";
748
- ended;
749
- bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
750
- keyframeAt = -Infinity;
751
- keyframeTimer = null;
752
- lit;
753
- posture;
754
- lingerMs;
755
- lingerTimer = null;
756
- constructor(helper, device, env, ended, lingerMs, lit) {
757
- this.ended = ended;
758
- this.lingerMs = lingerMs;
759
- this.lit = lit;
760
- const id = device.platform === "ios" ? device.udid : device.serial;
761
- this.child = spawn(helper, [device.platform, id], {
762
- env,
763
- stdio: [
764
- "pipe",
765
- "pipe",
766
- "pipe"
767
- ]
768
- });
769
- this.child.stdin.on("error", () => {});
770
- this.child.stderr.setEncoding("utf8");
771
- this.child.stderr.on("data", (chunk) => {
772
- this.stderr = (this.stderr + chunk).slice(-1e3);
773
- });
774
- this.readMessages();
775
- this.child.on("error", (error) => this.fail(`stim-frames could not start (${error.message}).`));
776
- this.child.on("close", (code, signal) => {
777
- const detail = this.notice ?? this.stderr.trim();
778
- this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
779
- });
780
- }
781
- /** A null `hint` keeps the helper running for input without asking for frames. */
782
- add(listener, hint) {
783
- if (this.lingerTimer) clearTimeout(this.lingerTimer);
784
- this.lingerTimer = null;
785
- this.listeners.set(listener, hint);
786
- this.configure();
787
- if (listener.video) this.keyframe();
788
- else if (this.last) listener.frame(this.last);
789
- return () => {
790
- if (!this.listeners.delete(listener)) return;
791
- if (this.listeners.size > 0) this.configure();
792
- else this.lingerTimer = setTimeout(() => void this.stop(), this.lingerMs);
793
- };
794
- }
795
- stop() {
796
- if (this.stopped) return Promise.resolve();
797
- this.stopped = true;
798
- if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
799
- if (this.lingerTimer) clearTimeout(this.lingerTimer);
800
- this.listeners.clear();
801
- this.ended();
802
- this.child.stdin.end();
803
- return terminate(this.child);
804
- }
805
- /** Asks for at most one keyframe per {@link KEYFRAME_INTERVAL_MS}; a request inside the interval is sent at its end. */
806
- keyframe() {
807
- if (this.stopped || this.keyframeTimer) return;
808
- const wait = this.keyframeAt + KEYFRAME_INTERVAL_MS - Date.now();
809
- if (wait > 0) {
810
- this.keyframeTimer = setTimeout(() => {
811
- this.keyframeTimer = null;
812
- this.keyframe();
813
- }, wait);
814
- return;
815
- }
816
- this.keyframeAt = Date.now();
817
- this.child.stdin.write("{\"keyframe\":true}\n");
818
- }
819
- congested() {
820
- if (this.bitrate.congested(Date.now()) !== null) this.configure();
821
- }
822
- send(command) {
823
- if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
824
- }
825
- configure() {
826
- if (this.listeners.size === 0) return;
827
- const watching = [...this.listeners].flatMap(([listener, hint]) => hint ? [{
828
- listener,
829
- hint
830
- }] : []);
831
- const hints = watching.map(({ hint }) => hint);
832
- const jpegFps = watching.flatMap(({ listener, hint }) => listener.video ? [] : [hint.fps]);
833
- const config = JSON.stringify({
834
- fps: Math.max(0, ...hints.map((hint) => hint.fps)),
835
- maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
836
- jpeg: jpegFps.length > 0,
837
- ...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
838
- video: jpegFps.length < hints.length,
839
- bitrate: this.bitrate.current
840
- });
841
- if (!jpegFps.length) this.last = null;
842
- if (config === this.config || this.stopped) return;
843
- this.config = config;
844
- this.child.stdin.write(`${config}\n`);
845
- }
846
- fail(message) {
847
- if (this.stopped) return;
848
- const listeners = [...this.listeners.keys()];
849
- this.stop();
850
- for (const listener of listeners) listener.failed(message);
851
- }
852
- readMessages() {
853
- let buffer = Buffer.alloc(0);
854
- this.child.stdout.on("data", (chunk) => {
855
- buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
856
- while (buffer.length >= 4) {
857
- const length = buffer.readUInt32BE(0);
858
- if (length < 1 || length > MAX_MESSAGE_BYTES) {
859
- this.child.stdout.removeAllListeners("data");
860
- return this.fail("stim-frames wrote a malformed message.");
861
- }
862
- if (buffer.length < 4 + length) break;
863
- const body = buffer.subarray(4, 4 + length);
864
- buffer = buffer.subarray(4 + length);
865
- if (body[0] === FRAME_MESSAGE && body.length > 5) this.frame(body);
866
- else if (body[0] === VIDEO_MESSAGE && body.length > VIDEO_HEADER_BYTES) this.video(body);
867
- else if (body[0] === NOTICE_MESSAGE) this.readNotice(body.subarray(1).toString("utf8"));
868
- }
869
- });
870
- }
871
- frame(body) {
872
- if (this.stopped) return;
873
- this.last = {
874
- width: body.readUInt16BE(1),
875
- height: body.readUInt16BE(3),
876
- capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
877
- data: body.subarray(5).toString("base64"),
878
- ...this.posture ? { posture: this.posture } : {}
879
- };
880
- for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
881
- }
882
- video(body) {
883
- if (this.stopped) return;
884
- const unit = {
885
- keyframe: (body[1] & 1) !== 0,
886
- capturedAt: body.readDoubleBE(2),
887
- width: body.readUInt16BE(10),
888
- height: body.readUInt16BE(12),
889
- data: body.subarray(VIDEO_HEADER_BYTES),
890
- ...this.posture ? { posture: this.posture } : {}
891
- };
892
- if (this.bitrate.tick(Date.now()) !== null) this.configure();
893
- for (const listener of this.listeners.keys()) listener.video?.(unit);
894
- }
895
- readNotice(text) {
896
- try {
897
- const notice = JSON.parse(text);
898
- const error = notice?.error;
899
- if (typeof error === "string") this.notice = error;
900
- const keyboard = notice?.keyboard;
901
- if (keyboard === "yes" || keyboard === "no") this.keyboard = keyboard === "yes";
902
- const inputError = notice?.inputError;
903
- if (typeof inputError === "string") console.error(`stim-server: stim-frames: ${inputError}`);
904
- const display = notice?.display;
905
- if (this.lit && typeof display === "number") this.posture = this.lit(display);
906
- } catch {}
907
- }
908
- };
909
- //#endregion
910
- //#region src/frames.ts
911
- const DEFAULT_FRAME_LIMITS = {
912
- toolTimeoutMs: 3e4,
913
- slowCaptureMs: 3e3,
914
- failureBackoffMs: 3e3,
915
- maxConsecutiveFailures: 3,
916
- lingerMs: 1e4
917
- };
918
- const MIN_INTERVAL_MS = 200;
919
- const MAX_INTERVAL_MS = 1e3;
920
- const MAX_CAPTURES = 2;
921
- const MAX_EDGE = 1280;
922
- const JPEG_QUALITY = 70;
923
- function deviceKey(device) {
924
- return device.platform === "ios" ? `ios:${device.udid}` : `android:${device.serial}`;
925
- }
926
- function ownedDevice(payload, target, attached) {
927
- const slot = target.slot ?? "default";
928
- if (!Array.isArray(payload.environments)) return "stim status printed a payload without environments.";
929
- const environment = payload.environments.find((candidate) => candidate.path === target.workspace);
930
- if (!environment) return `${target.workspace} is not a Stim workspace on this Mac.`;
931
- const devices = slot === "default" ? {
932
- ios: environment.ios,
933
- android: environment.android
934
- } : environment.slots?.find((candidate) => candidate.slot === slot);
935
- const where = `${target.platform} in slot ${slot} of ${target.workspace}`;
936
- if (target.platform === "ios") {
937
- const sim = devices?.ios;
938
- if (!sim?.owned) return `No simulator Stim owns runs ${where}.`;
939
- if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
940
- return {
941
- platform: "ios",
942
- udid: sim.udid,
943
- foldable: /\bDuo\b/.test(sim.name ?? "")
944
- };
945
- }
946
- const emulator = devices?.android;
947
- if (!emulator?.owned || emulator.physical) return `No emulator Stim owns runs ${where}.`;
948
- if (emulator.state === "unknown" && attached?.startsWith("android:")) return {
949
- platform: "android",
950
- serial: attached.slice(8)
951
- };
952
- if (emulator.state !== "detected" || !emulator.serial) return `The emulator for ${where} is not running.`;
953
- return {
954
- platform: "android",
955
- serial: emulator.serial
956
- };
957
- }
958
- function jpegSize(bytes) {
959
- if (bytes[0] !== 255 || bytes[1] !== 216) return null;
960
- let offset = 2;
961
- while (offset + 9 <= bytes.length) {
962
- if (bytes[offset] !== 255) return null;
963
- const marker = bytes[offset + 1];
964
- if (marker === 255) {
965
- offset += 1;
966
- continue;
967
- }
968
- if (marker === 1 || marker >= 208 && marker <= 216) {
969
- offset += 2;
970
- continue;
971
- }
972
- if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
973
- height: bytes.readUInt16BE(offset + 5),
974
- width: bytes.readUInt16BE(offset + 7)
975
- };
976
- offset += 2 + bytes.readUInt16BE(offset + 2);
977
- }
978
- return null;
979
- }
980
- /** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
981
- function timeoutError(message) {
982
- return Object.assign(new Error(message), { transient: true });
983
- }
984
- function isTransient(error) {
985
- return error instanceof Error && error.transient === true;
986
- }
987
- function runTool(file, args, env, running, timeoutMs) {
988
- return new Promise((resolve, reject) => {
989
- const child = spawn(file, args, {
990
- env,
991
- stdio: [
992
- "ignore",
993
- "pipe",
994
- "pipe"
995
- ]
996
- });
997
- running.add(child);
998
- child.once("close", () => running.delete(child));
999
- const chunks = [];
1000
- let stderr = "";
1001
- const timer = setTimeout(() => {
1002
- terminate(child);
1003
- reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
1004
- }, timeoutMs);
1005
- child.stdout.on("data", (chunk) => chunks.push(chunk));
1006
- child.stderr.setEncoding("utf8");
1007
- child.stderr.on("data", (chunk) => {
1008
- stderr = (stderr + chunk).slice(-1e3);
1009
- });
1010
- child.on("error", (error) => {
1011
- clearTimeout(timer);
1012
- reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
1013
- });
1014
- child.on("close", (code) => {
1015
- clearTimeout(timer);
1016
- if (code === 0) resolve(Buffer.concat(chunks));
1017
- else reject(Object.assign(/* @__PURE__ */ new Error(`${file} ${args.join(" ")} exited (code ${code}): ${stderr.trim()}`), { stderr }));
1018
- });
1019
- });
1020
- }
1021
- async function stopTools(running, tmp) {
1022
- await Promise.all([...running].map((child) => terminate(child)));
1023
- if (tmp) rmSync(tmp, {
1024
- recursive: true,
1025
- force: true
1026
- });
1027
- }
1028
- /**
1029
- * Without `--display`, simctl captures the first panel it finds; `primary` is the panel CoreDevice reports as
1030
- * primary. A simctl that rejects `primary` gets the default display instead.
1031
- *
1032
- * An iPhone Duo lights one of two panels: `primary` is the cover, lit when folded, and `primary-1` the inner
1033
- * panel, lit when unfolded; the other one's framebuffer is all black. `litPanels` remembers the lit panel per
1034
- * simulator across subscriptions, and a black capture tries the other panel.
1035
- */
1036
- function simulatorCapturer(device, env, limits, litPanels) {
1037
- let tmp = null;
1038
- let display = ["--display=primary"];
1039
- let closed = false;
1040
- const running = /* @__PURE__ */ new Set();
1041
- const screenshot = async (panel) => {
1042
- tmp ??= mkdtempSync(join(serverDir(), "frames-"));
1043
- const output = join(tmp, "frame.jpg");
1044
- await runTool("xcrun", [
1045
- "simctl",
1046
- "io",
1047
- device.udid,
1048
- "screenshot",
1049
- "--type=jpeg",
1050
- ...panel,
1051
- output
1052
- ], env, running, limits.toolTimeoutMs);
1053
- return readFileSync(output);
1054
- };
1055
- const isBlack = async (jpeg) => {
1056
- const input = join(tmp, "check.jpg");
1057
- const output = join(tmp, "check.bmp");
1058
- writeFileSync(input, jpeg);
1059
- await runTool("sips", [
1060
- "-s",
1061
- "format",
1062
- "bmp",
1063
- "-z",
1064
- "24",
1065
- "24",
1066
- input,
1067
- "--out",
1068
- output
1069
- ], env, running, limits.toolTimeoutMs);
1070
- return bmpIsBlack(readFileSync(output));
1071
- };
1072
- const open = () => {
1073
- if (closed) throw new Error("The capture was stopped.");
1074
- };
1075
- const captureDuo = async () => {
1076
- const lit = litPanels.get(device.udid) ?? "primary";
1077
- const jpeg = await screenshot([`--display=${lit}`]);
1078
- open();
1079
- if (!await isBlack(jpeg)) {
1080
- litPanels.set(device.udid, lit);
1081
- return {
1082
- raw: jpeg,
1083
- jpeg: async () => jpeg,
1084
- posture: POSTURES[lit]
1085
- };
1086
- }
1087
- const other = lit === "primary" ? "primary-1" : "primary";
1088
- open();
1089
- const otherJpeg = await screenshot([`--display=${other}`]);
1090
- open();
1091
- if (await isBlack(otherJpeg)) return {
1092
- raw: jpeg,
1093
- jpeg: async () => jpeg
1094
- };
1095
- litPanels.set(device.udid, other);
1096
- return {
1097
- raw: otherJpeg,
1098
- jpeg: async () => otherJpeg,
1099
- posture: POSTURES[other]
1100
- };
1101
- };
1102
- return {
1103
- capture: async () => {
1104
- if (device.foldable) return captureDuo();
1105
- let jpeg;
1106
- try {
1107
- jpeg = await screenshot(display);
1108
- } catch (error) {
1109
- if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
1110
- display = [];
1111
- jpeg = await screenshot(display);
1112
- }
1113
- return {
1114
- raw: jpeg,
1115
- jpeg: async () => jpeg
1116
- };
1117
- },
1118
- close: () => {
1119
- closed = true;
1120
- return stopTools(running, tmp);
1121
- }
1122
- };
1123
- }
1124
- /** The Duo's panels in the order the helper indexes CoreSimulator's displays, by screen ID. */
1125
- const DUO_PANELS = ["primary", "primary-1"];
1126
- const POSTURES = {
1127
- primary: "folded",
1128
- "primary-1": "unfolded"
1129
- };
1130
- /** Whether every pixel of an uncompressed 24- or 32-bit BMP, as `sips -s format bmp` writes it, is black. */
1131
- function bmpIsBlack(bmp) {
1132
- if (bmp.length < 54 || bmp.toString("latin1", 0, 2) !== "BM") throw new Error("sips did not write a BMP image.");
1133
- const offset = bmp.readUInt32LE(10);
1134
- const bytesPerPixel = bmp.readUInt16LE(28) / 8;
1135
- for (let at = offset; at + 3 <= bmp.length; at += bytesPerPixel) if (bmp[at] > 8 || bmp[at + 1] > 8 || bmp[at + 2] > 8) return false;
1136
- return true;
1137
- }
1138
- /**
1139
- * The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
1140
- * Stim passes when it boots an owned AVD (#1064).
1141
- */
1142
- function emulatorEndpoint(env, serial) {
1143
- const console = /^emulator-(\d+)$/.exec(serial)?.[1];
1144
- if (!console || !env.HOME) return null;
1145
- const dir = join(env.HOME, "Library/Caches/TemporaryItems/avd/running");
1146
- let names;
1147
- try {
1148
- names = readdirSync(dir);
1149
- } catch {
1150
- return null;
1151
- }
1152
- for (const name of names) {
1153
- const pid = /^pid_(\d+)\.ini$/.exec(name)?.[1];
1154
- if (!pid) continue;
1155
- try {
1156
- process.kill(Number(pid), 0);
1157
- } catch {
1158
- continue;
1159
- }
1160
- let text;
1161
- try {
1162
- text = readFileSync(join(dir, name), "utf8");
1163
- } catch {
1164
- continue;
1165
- }
1166
- const values = new Map(text.split(/\r?\n/).flatMap((line) => {
1167
- const at = line.indexOf("=");
1168
- return at > 0 ? [[line.slice(0, at), line.slice(at + 1)]] : [];
1169
- }));
1170
- const grpcPort = Number(values.get("grpc.port"));
1171
- if (values.get("port.serial") === console && Number.isInteger(grpcPort) && grpcPort > 0) return {
1172
- grpcPort,
1173
- token: values.get("grpc.token") ?? null
1174
- };
1175
- }
1176
- return null;
1177
- }
1178
- function varint(value) {
1179
- const out = [];
1180
- let rest = value;
1181
- while (rest >= 128) {
1182
- out.push(rest & 127 | 128);
1183
- rest = Math.floor(rest / 128);
1184
- }
1185
- out.push(rest);
1186
- return out;
1187
- }
1188
- function* protoFields(bytes) {
1189
- let offset = 0;
1190
- const readVarint = () => {
1191
- let result = 0;
1192
- let scale = 1;
1193
- for (;;) {
1194
- if (offset >= bytes.length) throw new Error("truncated protobuf");
1195
- const byte = bytes[offset++];
1196
- result += (byte & 127) * scale;
1197
- if (byte < 128) return result;
1198
- scale *= 128;
1199
- }
1200
- };
1201
- while (offset < bytes.length) {
1202
- const key = readVarint();
1203
- const field = Math.floor(key / 8);
1204
- const type = key % 8;
1205
- if (type === 0) yield {
1206
- field,
1207
- varint: readVarint()
1208
- };
1209
- else if (type === 2) {
1210
- const length = readVarint();
1211
- if (offset + length > bytes.length) throw new Error("truncated protobuf");
1212
- yield {
1213
- field,
1214
- bytes: bytes.subarray(offset, offset + length)
1215
- };
1216
- offset += length;
1217
- } else if (type === 1) offset += 8;
1218
- else if (type === 5) offset += 4;
1219
- else throw new Error(`unsupported protobuf wire type ${type}`);
1220
- }
1221
- }
1222
- function grpcMessage(message) {
1223
- const header = Buffer.alloc(5);
1224
- header.writeUInt32BE(message.length, 1);
1225
- return Buffer.concat([header, Buffer.from(message)]);
1226
- }
1227
- function grpcReply(body, method) {
1228
- if (body.length < 5 || body[0] !== 0) throw new Error(`${method} returned no message.`);
1229
- return body.subarray(5, 5 + body.readUInt32BE(1));
1230
- }
1231
- /**
1232
- * `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. The reply's
1233
- * format carries `foldedDisplay` (field 7) while a foldable is folded.
1234
- */
1235
- function screenshotReply(body) {
1236
- let png = null;
1237
- let folded = false;
1238
- for (const entry of protoFields(grpcReply(body, "getScreenshot"))) {
1239
- if (entry.field === 4 && "bytes" in entry) png = Buffer.from(entry.bytes);
1240
- if (entry.field === 1 && "bytes" in entry) folded = [...protoFields(entry.bytes)].some((format) => format.field === 7);
1241
- }
1242
- if (!png) throw new Error("getScreenshot returned no image.");
1243
- return {
1244
- png,
1245
- folded
1246
- };
1247
- }
1248
- /**
1249
- * `PhysicalModelValue` for POSTURE (PhysicalType 16), whose one float is a `Posture.PostureValue`; 1 to 5
1250
- * are real postures, so any of them means the emulator has a hinge.
1251
- */
1252
- function hasHinge(body) {
1253
- for (const entry of protoFields(grpcReply(body, "getPhysicalModel"))) {
1254
- if (entry.field === 2 && "varint" in entry && entry.varint !== 0) return false;
1255
- if (entry.field !== 3 || !("bytes" in entry)) continue;
1256
- for (const value of protoFields(entry.bytes)) {
1257
- if (value.field !== 1 || !("bytes" in value) || value.bytes.length < 4) continue;
1258
- const posture = Math.round(value.bytes.readFloatLE(0));
1259
- return posture >= 1 && posture <= 5;
1260
- }
1261
- }
1262
- return false;
1263
- }
1264
- function grpcSession(endpoint) {
1265
- const session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
1266
- session.on("error", () => {});
1267
- return session;
1268
- }
1269
- function grpcCall(session, endpoint, serial, method, requestBody, timeoutMs) {
1270
- return new Promise((resolve, reject) => {
1271
- const request = session.request({
1272
- ":method": "POST",
1273
- ":path": `/android.emulation.control.EmulatorController/${method}`,
1274
- "content-type": "application/grpc",
1275
- te: "trailers",
1276
- ...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
1277
- });
1278
- const chunks = [];
1279
- let status;
1280
- let message;
1281
- let timedOut = false;
1282
- const record = (headers) => {
1283
- if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
1284
- if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
1285
- };
1286
- request.setTimeout(timeoutMs, () => {
1287
- timedOut = true;
1288
- request.close();
1289
- });
1290
- request.on("response", record);
1291
- request.on("trailers", record);
1292
- request.on("data", (chunk) => chunks.push(chunk));
1293
- request.on("error", (error) => {
1294
- const text = `${method} failed on ${serial}: ${error.message}`;
1295
- reject(timedOut ? timeoutError(text) : new Error(text));
1296
- });
1297
- request.on("close", () => {
1298
- if (status === "0") return resolve(Buffer.concat(chunks));
1299
- const text = `${method} failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
1300
- reject(timedOut ? timeoutError(text) : new Error(text));
1301
- });
1302
- request.end(requestBody);
1303
- });
1304
- }
1305
- const POSTURE_MODEL = grpcMessage([8, 16]);
1306
- /**
1307
- * The postures `input.posture` accepts for an owned device: the two panels of an iPhone Duo, and the hinge
1308
- * positions of an emulator whose physical model has a posture.
1309
- */
1310
- async function devicePostures(device, env, timeoutMs) {
1311
- if (device.platform === "ios") return device.foldable ? ["folded", "unfolded"] : [];
1312
- const endpoint = emulatorEndpoint(env, device.serial);
1313
- if (!endpoint) return [];
1314
- const session = grpcSession(endpoint);
1315
- try {
1316
- return hasHinge(await grpcCall(session, endpoint, device.serial, "getPhysicalModel", POSTURE_MODEL, timeoutMs)) ? [
1317
- "folded",
1318
- "half-open",
1319
- "unfolded"
1320
- ] : [];
1321
- } catch {
1322
- return [];
1323
- } finally {
1324
- session.close();
1325
- }
1326
- }
1327
- function emulatorCapturer(serial, env, limits) {
1328
- let session = null;
1329
- let tmp = null;
1330
- const running = /* @__PURE__ */ new Set();
1331
- let hinged = null;
1332
- const call = (endpoint, method, requestBody) => {
1333
- if (!session || session.closed || session.destroyed) session = grpcSession(endpoint);
1334
- return grpcCall(session, endpoint, serial, method, requestBody, limits.toolTimeoutMs);
1335
- };
1336
- return {
1337
- capture: async () => {
1338
- const endpoint = emulatorEndpoint(env, serial);
1339
- if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
1340
- hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
1341
- const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
1342
- 24,
1343
- ...varint(MAX_EDGE),
1344
- 32,
1345
- ...varint(MAX_EDGE)
1346
- ])));
1347
- return {
1348
- raw: png,
1349
- ...hinged ? { posture: folded ? "folded" : "unfolded" } : {},
1350
- jpeg: async () => {
1351
- tmp ??= mkdtempSync(join(serverDir(), "frames-"));
1352
- const input = join(tmp, "frame.png");
1353
- const output = join(tmp, "frame.jpg");
1354
- writeFileSync(input, png);
1355
- await runTool("sips", [
1356
- ...[
1357
- "-s",
1358
- "format",
1359
- "jpeg",
1360
- "-s",
1361
- "formatOptions",
1362
- String(JPEG_QUALITY)
1363
- ],
1364
- input,
1365
- "--out",
1366
- output
1367
- ], env, running, limits.toolTimeoutMs);
1368
- return readFileSync(output);
1369
- }
1370
- };
1371
- },
1372
- close: () => {
1373
- session?.close();
1374
- return stopTools(running, tmp);
1375
- }
1376
- };
1377
- }
1378
- var Limiter = class {
1379
- active = 0;
1380
- waiting = [];
1381
- max;
1382
- constructor(max) {
1383
- this.max = max;
1384
- }
1385
- async run(task) {
1386
- if (this.active >= this.max) await new Promise((resolve) => this.waiting.push(resolve));
1387
- else this.active++;
1388
- try {
1389
- return await task();
1390
- } finally {
1391
- const next = this.waiting.shift();
1392
- if (next) next();
1393
- else this.active--;
1394
- }
1395
- }
1396
- };
1397
- var FrameSource = class {
1398
- listeners = /* @__PURE__ */ new Set();
1399
- last = null;
1400
- lastHash = null;
1401
- interval = MIN_INTERVAL_MS;
1402
- timer = null;
1403
- stopped = false;
1404
- delayed = false;
1405
- consecutiveFailures = 0;
1406
- capturer;
1407
- limiter;
1408
- limits;
1409
- ended;
1410
- constructor(capturer, limiter, limits, ended) {
1411
- this.capturer = capturer;
1412
- this.limiter = limiter;
1413
- this.limits = limits;
1414
- this.ended = ended;
1415
- this.tick();
1416
- }
1417
- add(listener) {
1418
- this.listeners.add(listener);
1419
- if (this.last) listener.frame(this.last);
1420
- if (this.delayed) listener.delayed(true);
1421
- return () => {
1422
- if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
1423
- };
1424
- }
1425
- stop() {
1426
- if (this.stopped) return Promise.resolve();
1427
- this.stopped = true;
1428
- if (this.timer) clearTimeout(this.timer);
1429
- this.listeners.clear();
1430
- this.ended();
1431
- return this.capturer.close();
1432
- }
1433
- setDelayed(delayed) {
1434
- if (this.delayed === delayed) return;
1435
- this.delayed = delayed;
1436
- for (const listener of this.listeners) listener.delayed(delayed);
1437
- }
1438
- async tick() {
1439
- this.timer = null;
1440
- let took = 0;
1441
- try {
1442
- const changed = await this.limiter.run(async () => {
1443
- if (this.stopped) return false;
1444
- const started = Date.now();
1445
- const capture = await this.capturer.capture();
1446
- const hash = createHash("sha256").update(capture.raw).digest("hex");
1447
- took = Date.now() - started;
1448
- if (hash === this.lastHash || this.stopped) return false;
1449
- const jpeg = await capture.jpeg();
1450
- const size = jpegSize(jpeg);
1451
- if (!size) throw new Error("The screenshot is not a JPEG image.");
1452
- this.lastHash = hash;
1453
- this.last = {
1454
- ...size,
1455
- capturedAt: new Date(started).toISOString(),
1456
- data: jpeg.toString("base64"),
1457
- ...capture.posture ? { posture: capture.posture } : {}
1458
- };
1459
- took = Date.now() - started;
1460
- return true;
1461
- });
1462
- if (this.stopped) return;
1463
- this.consecutiveFailures = 0;
1464
- this.setDelayed(took > this.limits.slowCaptureMs);
1465
- if (changed) for (const listener of this.listeners) listener.frame(this.last);
1466
- this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
1467
- this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
1468
- } catch (error) {
1469
- if (this.stopped) return;
1470
- if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
1471
- this.consecutiveFailures++;
1472
- this.setDelayed(true);
1473
- this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
1474
- return;
1475
- }
1476
- const listeners = [...this.listeners];
1477
- this.stop();
1478
- for (const listener of listeners) listener.failed(error.message);
1479
- }
1480
- }
1481
- };
1482
- /**
1483
- * One capture per device, shared by its subscribers. A `stim-frames` helper stops `lingerMs` after the last of
1484
- * them leaves, so a client that takes one frame at a time reuses it; a screenshot loop stops with the last of
1485
- * them. With the `stim-frames`
1486
- * helper, a device streams frames as its screen changes, at the rate its subscribers ask for; an iPhone Duo
1487
- * streams its lit panel, which is its posture. Without the helper, and while it is still being built, a
1488
- * screenshot loop sends a frame only when the screen changed: up to 5 times a second while it changes, backing
1489
- * off to once a second while it does not, spending at most half of its time capturing, with at most two
1490
- * captures at once across all devices. A helper that fails before its first frame gives way to the screenshot
1491
- * loop.
1492
- */
1493
- var FramePool = class {
1494
- sources = /* @__PURE__ */ new Map();
1495
- limiter = new Limiter(MAX_CAPTURES);
1496
- litPanels = /* @__PURE__ */ new Map();
1497
- env;
1498
- limits;
1499
- helper;
1500
- constructor(env, limits = DEFAULT_FRAME_LIMITS, helper = () => null) {
1501
- this.env = env;
1502
- this.limits = limits;
1503
- this.helper = helper;
1504
- }
1505
- subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
1506
- const helper = this.helper();
1507
- if (helper === null) return this.screenshots(device).add(listener);
1508
- let streamed = false;
1509
- let cancelled = false;
1510
- const { video } = listener;
1511
- let detach = this.stream(helper, device).add({
1512
- frame: (frame) => {
1513
- streamed = true;
1514
- listener.frame(frame);
1515
- },
1516
- ...video ? { video: (unit) => {
1517
- streamed = true;
1518
- video(unit);
1519
- } } : {},
1520
- delayed: listener.delayed,
1521
- failed: (message) => {
1522
- if (streamed || cancelled) return listener.failed(message);
1523
- console.error(`stim-server: ${message} Falling back to screenshots.`);
1524
- detach = this.screenshots(device).add(listener);
1525
- }
1526
- }, hint);
1527
- return () => {
1528
- cancelled = true;
1529
- detach();
1530
- };
1531
- }
1532
- /** The posture of an iPhone Duo as its last frame showed it; null before a frame and for other devices. */
1533
- litPosture(device) {
1534
- const lit = device.platform === "ios" && device.foldable ? this.litPanels.get(device.udid) : void 0;
1535
- return lit ? POSTURES[lit] : null;
1536
- }
1537
- /** Records the panel a finished fold lit, so a request before the next frame sees the new posture. */
1538
- folded(udid, posture) {
1539
- this.litPanels.set(udid, posture === "folded" ? "primary" : "primary-1");
1540
- }
1541
- /** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
1542
- keyframe(device) {
1543
- this.helperSource(device)?.keyframe();
1544
- }
1545
- /** A subscriber of `device` is behind; called until its socket drains, it lowers the shared bitrate. */
1546
- congested(device) {
1547
- this.helperSource(device)?.congested();
1548
- }
1549
- helperSource(device) {
1550
- const source = this.sources.get(`helper:${deviceKey(device)}`);
1551
- return source instanceof HelperSource ? source : null;
1552
- }
1553
- /**
1554
- * Keeps the device's helper running to send it input, or returns null without a helper. `failed` runs when
1555
- * the helper exits.
1556
- */
1557
- control(device, failed) {
1558
- const helper = this.helper();
1559
- if (helper === null) return null;
1560
- const source = this.stream(helper, device);
1561
- return {
1562
- send: (command) => source.send(command),
1563
- keys: () => source.keyboard === true,
1564
- detach: source.add({
1565
- frame: () => {},
1566
- delayed: () => {},
1567
- failed
1568
- }, null)
1569
- };
1570
- }
1571
- stream(helper, device) {
1572
- const key = `helper:${deviceKey(device)}`;
1573
- const existing = this.sources.get(key);
1574
- if (existing instanceof HelperSource) return existing;
1575
- const lit = device.platform === "ios" && device.foldable ? (display) => {
1576
- const panel = DUO_PANELS[display];
1577
- if (!panel) return void 0;
1578
- this.litPanels.set(device.udid, panel);
1579
- return POSTURES[panel];
1580
- } : void 0;
1581
- const created = new HelperSource(helper, device, this.env, () => {
1582
- if (this.sources.get(key) === created) this.sources.delete(key);
1583
- }, this.limits.lingerMs, lit);
1584
- this.sources.set(key, created);
1585
- return created;
1586
- }
1587
- screenshots(device) {
1588
- const key = deviceKey(device);
1589
- const existing = this.sources.get(key);
1590
- if (existing instanceof FrameSource) return existing;
1591
- 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, () => {
1592
- if (this.sources.get(key) === created) this.sources.delete(key);
1593
- });
1594
- this.sources.set(key, created);
1595
- return created;
1596
- }
1597
- async close() {
1598
- await Promise.all([...this.sources.values()].map((source) => source.stop()));
1599
- }
1600
- };
1601
- //#endregion
1602
- //#region src/control.ts
1603
- const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
1604
- const TEXT = /^[\x20-\x7e\n\t\b]+$/;
1605
- function parseControlBegin(params) {
1606
- if (!isJsonObject(params)) return {
1607
- code: "bad-request",
1608
- message: "params must be an object."
1609
- };
1610
- const { workspace, platform, slot, takeOver, ...rest } = params;
1611
- if (Object.keys(rest).length) return {
1612
- code: "bad-request",
1613
- message: `control.begin does not take ${Object.keys(rest).join(", ")}.`
1614
- };
1615
- if (typeof workspace !== "string") return {
1616
- code: "bad-request",
1617
- message: "params.workspace must be an environment path from a status payload."
1618
- };
1619
- if (platform !== "ios" && platform !== "android") return {
1620
- code: "bad-request",
1621
- message: "params.platform must be ios or android."
1622
- };
1623
- if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return {
1624
- code: "bad-request",
1625
- message: "params.slot must be 1-64 letters, digits, underscores or hyphens."
1626
- };
1627
- if (takeOver !== void 0 && typeof takeOver !== "boolean") return {
1628
- code: "bad-request",
1629
- message: "params.takeOver must be true or false."
1630
- };
1631
- return { value: {
1632
- workspace,
1633
- platform,
1634
- ...slot ? { slot } : {},
1635
- ...takeOver ? { takeOver } : {}
1636
- } };
1637
- }
1638
- const IOS_BUTTONS = ["home", "lock"];
1639
- function fraction(value) {
1640
- return typeof value === "number" && value >= 0 && value <= 1;
1641
- }
1642
- /** Validates an `input.*` request for one of the connection's sessions; returns its session id and command. */
1643
- function parseInput(method, params, targetOf) {
1644
- if (!isJsonObject(params) || typeof params.session !== "string") return {
1645
- code: "bad-request",
1646
- message: `${method} needs params.session from control.begin.`
1647
- };
1648
- const target = targetOf(params.session);
1649
- if (!target) return {
1650
- code: "unknown-session",
1651
- message: `No control session ${params.session} on this connection.`
1652
- };
1653
- const { platform, postures } = target;
1654
- const session = params.session;
1655
- if (method === "input.rotate") {
1656
- const { direction } = params;
1657
- if (!ROTATE_DIRECTIONS.includes(direction)) return {
1658
- code: "bad-request",
1659
- message: "input.rotate needs direction left or right."
1660
- };
1661
- return { value: {
1662
- session,
1663
- command: {
1664
- input: "rotate",
1665
- direction
1666
- }
1667
- } };
1668
- }
1669
- if (method === "input.posture") {
1670
- const { posture } = params;
1671
- if (!postures.includes(posture)) return {
1672
- code: "bad-request",
1673
- message: `This device ${postures.length ? `takes these postures: ${postures.join(", ")}` : "has no hinge"}.`
1674
- };
1675
- return { value: {
1676
- session,
1677
- command: {
1678
- input: "posture",
1679
- posture
1680
- }
1681
- } };
1682
- }
1683
- if (method === "input.touch") {
1684
- const { phase, x, y, display = 0 } = params;
1685
- if (!TOUCH_PHASES.includes(phase) || !fraction(x) || !fraction(y)) return {
1686
- code: "bad-request",
1687
- message: "input.touch needs phase (down, move or up), and x and y from 0 to 1."
1688
- };
1689
- if (!Number.isInteger(display) || display < 0 || display > 3) return {
1690
- code: "bad-request",
1691
- message: "display must be a display index from 0 to 3."
1692
- };
1693
- if (platform === "android" && display !== 0) return {
1694
- code: "bad-request",
1695
- message: "An emulator takes input on its main display (0) only."
1696
- };
1697
- return { value: {
1698
- session,
1699
- command: {
1700
- input: "touch",
1701
- phase,
1702
- x,
1703
- y,
1704
- display
1705
- }
1706
- } };
1707
- }
1708
- if (method === "input.text") {
1709
- const { text } = params;
1710
- if (typeof text !== "string" || !text.length || text.length > 256 || !TEXT.test(text)) return {
1711
- code: "bad-request",
1712
- message: `input.text takes 1 to 256 printable ASCII characters, with \\n, \\t and \\b.`
1713
- };
1714
- return { value: {
1715
- session,
1716
- command: {
1717
- input: "text",
1718
- text
1719
- }
1720
- } };
1721
- }
1722
- const { button } = params;
1723
- const allowed = platform === "ios" ? IOS_BUTTONS : INPUT_BUTTONS;
1724
- if (!allowed.includes(button)) return {
1725
- code: "bad-request",
1726
- message: `An ${platform} device takes these buttons: ${allowed.join(", ")}.`
1727
- };
1728
- return { value: {
1729
- session,
1730
- command: {
1731
- input: "button",
1732
- button
1733
- }
1734
- } };
1735
- }
1736
- const ANDROID_KEYS = {
1737
- home: "KEYCODE_HOME",
1738
- back: "KEYCODE_BACK",
1739
- "app-switch": "KEYCODE_APP_SWITCH",
1740
- lock: "KEYCODE_POWER",
1741
- "\n": "KEYCODE_ENTER",
1742
- " ": "KEYCODE_TAB",
1743
- "\b": "KEYCODE_DEL"
1744
- };
1745
- /**
1746
- * The `adb shell` argument lists that type `text` or press `button` on an emulator without a hardware keyboard
1747
- * (`hw.keyboard=no`), which drops gRPC key events. `adb shell` joins its arguments into one device shell
1748
- * command, so text goes single-quoted; `input text` reads `%s` as a space.
1749
- */
1750
- function adbInputArgs(command) {
1751
- if (command.input === "button") return [[
1752
- "shell",
1753
- "input",
1754
- "keyevent",
1755
- ANDROID_KEYS[command.button]
1756
- ]];
1757
- const calls = [];
1758
- for (const part of command.text.split(/([\n\t\b]+)/)) {
1759
- if (!part) continue;
1760
- if (/^[\n\t\b]+$/.test(part)) {
1761
- calls.push([
1762
- "shell",
1763
- "input",
1764
- "keyevent",
1765
- ...Array.from(part, (key) => ANDROID_KEYS[key])
1766
- ]);
1767
- continue;
1768
- }
1769
- calls.push([
1770
- "shell",
1771
- "input",
1772
- "text",
1773
- `'${part.replaceAll(" ", "%s").replaceAll("'", "'\\''")}'`
1774
- ]);
1775
- }
1776
- return calls;
1777
- }
1778
- function adbPath(env) {
1779
- for (const sdk of [
1780
- env.ANDROID_HOME,
1781
- env.ANDROID_SDK_ROOT,
1782
- join(env.HOME ?? homedir(), "Library/Android/sdk")
1783
- ]) if (sdk && existsSync(join(sdk, "platform-tools/adb"))) return join(sdk, "platform-tools/adb");
1784
- return "adb";
1785
- }
1786
- const ADB_TIMEOUT_MS = 1e4;
1787
- const POSTURE_TIMEOUT_MS = 5e3;
1788
- function runQuietly(env, file, args, label, timeoutMs) {
1789
- return new Promise((resolve, reject) => {
1790
- const child = spawn(file, args, {
1791
- env,
1792
- stdio: [
1793
- "ignore",
1794
- "ignore",
1795
- "pipe"
1796
- ]
1797
- });
1798
- let stderr = "";
1799
- const finish = (code) => {
1800
- clearTimeout(timer);
1801
- if (code === 0) resolve();
1802
- else reject(/* @__PURE__ */ new Error(`${label} failed (code ${code}): ${stderr.trim()}`));
1803
- };
1804
- const timer = setTimeout(() => {
1805
- child.stderr.destroy();
1806
- if (child.exitCode !== null || child.signalCode !== null) return finish(child.exitCode);
1807
- terminate(child);
1808
- reject(/* @__PURE__ */ new Error(`${label} did not finish within ${timeoutMs / 1e3} s.`));
1809
- }, timeoutMs);
1810
- child.stderr.setEncoding("utf8");
1811
- child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-500));
1812
- child.on("error", (error) => {
1813
- clearTimeout(timer);
1814
- reject(/* @__PURE__ */ new Error(`${label} could not start (${error.message}).`));
1815
- });
1816
- child.on("close", finish);
1817
- });
1818
- }
1819
- function runAdb(env, serial, args) {
1820
- return runQuietly(env, adbPath(env), [
1821
- "-s",
1822
- serial,
1823
- ...args
1824
- ], `adb ${args.slice(0, 3).join(" ")}`, ADB_TIMEOUT_MS);
1825
- }
1826
- function activityOf(payload, target) {
1827
- const environment = payload.environments?.find((candidate) => candidate.path === target.workspace);
1828
- const slot = target.slot ?? "default";
1829
- const devices = slot === "default" ? environment : environment?.slots?.find((candidate) => candidate.slot === slot);
1830
- return target.platform === "ios" ? devices?.ios?.activity : devices?.android?.activity;
1831
- }
1832
- function otherDriver(activity, ownLeases) {
1833
- if (activity?.state !== "driven" || !activity.driver) return null;
1834
- const { tool, since } = activity.driver;
1835
- if (tool === "stim device lock" && since && ownLeases.has(since)) return null;
1836
- return since ? `${tool} (since ${since})` : tool;
1837
- }
1838
- const STATUS_WAIT_MS = 6e4;
1839
- /**
1840
- * Control sessions across all connections: at most one per device. A session holds the device's `stim-frames`
1841
- * helper for input and a `stim device lock` lease, renewed while it lasts and released when it ends if the
1842
- * session took it.
1843
- */
1844
- var ControlHub = class {
1845
- options;
1846
- sessions = /* @__PURE__ */ new Map();
1847
- byDevice = /* @__PURE__ */ new Map();
1848
- ownLeases = /* @__PURE__ */ new Set();
1849
- starting = /* @__PURE__ */ new Set();
1850
- folding = /* @__PURE__ */ new Set();
1851
- closing = false;
1852
- next = 1;
1853
- constructor(options) {
1854
- this.options = options;
1855
- }
1856
- targetOf(owner, session) {
1857
- const found = this.sessions.get(session);
1858
- return found && found.owner === owner && !found.ended ? {
1859
- platform: found.target.platform,
1860
- postures: found.postures
1861
- } : null;
1862
- }
1863
- /**
1864
- * `stillAllowed` is checked again after the status read and the lock, which can take seconds: the client may
1865
- * have disconnected or lost `control` meanwhile.
1866
- */
1867
- async begin(owner, target, cwd, stillAllowed) {
1868
- const beganAt = Date.now();
1869
- if (this.closing) return {
1870
- code: "action-failed",
1871
- message: "stim-server is stopping."
1872
- };
1873
- const status = await this.status();
1874
- if ("code" in status) return status;
1875
- const device = ownedDevice(status, target, null);
1876
- if (typeof device === "string") return {
1877
- code: "action-failed",
1878
- message: device
1879
- };
1880
- const key = deviceKey(device);
1881
- if (this.starting.has(key)) return {
1882
- code: "device-busy",
1883
- message: "Another client is starting to control this device. Try again."
1884
- };
1885
- const earlier = this.byDevice.get(key);
1886
- const driver = earlier ? `${earlier.owner.device.name} through stim-server` : otherDriver(activityOf(status, target), this.ownLeases);
1887
- if (driver && !target.takeOver) return {
1888
- code: "device-busy",
1889
- message: `This device is driven by ${driver}. Take over to control it anyway.`
1890
- };
1891
- this.starting.add(key);
1892
- let lease;
1893
- let postures;
1894
- try {
1895
- [lease, postures] = await Promise.all([this.lock(device, target, cwd, beganAt), devicePostures(device, this.options.env, POSTURE_TIMEOUT_MS)]);
1896
- } finally {
1897
- this.starting.delete(key);
1898
- }
1899
- const granted = "code" in lease ? null : lease;
1900
- if (!granted && !target.takeOver) return lease;
1901
- const current = this.byDevice.get(key);
1902
- const refuse = (refusal) => {
1903
- if (granted?.mine && !current?.lease?.mine) this.unlock(target, cwd);
1904
- return refusal;
1905
- };
1906
- if (this.closing || !stillAllowed()) return refuse({
1907
- code: "forbidden",
1908
- message: "This device can no longer control devices."
1909
- });
1910
- if (current && current !== earlier && !target.takeOver) return refuse({
1911
- code: "device-busy",
1912
- message: `${current.owner.device.name} started controlling this device.`
1913
- });
1914
- let session;
1915
- const input = this.options.frames.control(device, (message) => queueMicrotask(() => void this.end(session, "failed", message)));
1916
- if (!input) return refuse({
1917
- code: "action-failed",
1918
- message: "Input needs the stim-frames helper, which this Mac has not built."
1919
- });
1920
- const inherited = granted !== null && current?.lease?.mine === true;
1921
- if (current) this.end(current, "taken-over", `${owner.device.name} took over this device.`, !inherited);
1922
- const id = `c${this.next++}`;
1923
- session = {
1924
- id,
1925
- owner,
1926
- device,
1927
- key,
1928
- target,
1929
- cwd,
1930
- lease: granted ? {
1931
- ...granted,
1932
- mine: granted.mine || inherited
1933
- } : null,
1934
- input,
1935
- postures,
1936
- startedAt: Date.now(),
1937
- idle: setTimeout(() => void this.end(session, "idle", "No input for 5 minutes."), this.options.idleMs),
1938
- renew: setInterval(() => this.renew(session, beganAt), this.options.renewMs),
1939
- renewing: Promise.resolve(),
1940
- unwatch: () => {},
1941
- adb: Promise.resolve(),
1942
- ended: false
1943
- };
1944
- this.sessions.set(id, session);
1945
- this.byDevice.set(key, session);
1946
- session.unwatch = this.options.feeds.subscribe(this.options.statusFeed, {
1947
- item: (payload) => {
1948
- const resolved = ownedDevice(payload, target, key);
1949
- if (typeof resolved === "string" || deviceKey(resolved) !== key) {
1950
- const message = typeof resolved === "string" ? resolved : "The device changed.";
1951
- queueMicrotask(() => void this.end(session, "device-gone", message));
1952
- }
1953
- },
1954
- failed: () => {}
1955
- });
1956
- this.audit(owner, target, driver || current ? "control.take-over" : "control.begin", {
1957
- ok: true,
1958
- ...driver || current ? { reason: `took over from ${driver ?? current.owner.device.name}` } : {}
1959
- });
1960
- return {
1961
- session: id,
1962
- platform: target.platform,
1963
- lease: session.lease ? {
1964
- grantedAt: session.lease.grantedAt,
1965
- expiresAt: session.lease.expiresAt
1966
- } : null,
1967
- postures
1968
- };
1969
- }
1970
- renew(session, beganAt) {
1971
- if (session.ended || !session.lease?.mine) return;
1972
- session.renewing = (async () => {
1973
- const renewed = await this.lock(session.device, session.target, session.cwd, beganAt);
1974
- if ("code" in renewed) console.error(`stim-server: could not renew the device lease: ${renewed.message}`);
1975
- else if (session.lease) session.lease.expiresAt = renewed.expiresAt;
1976
- })();
1977
- }
1978
- input(owner, id, command) {
1979
- const session = this.sessions.get(id);
1980
- if (!session || session.owner !== owner || session.ended) return Promise.resolve({
1981
- code: "unknown-session",
1982
- message: `No control session ${id} on this connection.`
1983
- });
1984
- session.idle.refresh();
1985
- if (command.input === "posture" && session.device.platform === "ios") return this.fold(session, session.device.udid, command.posture);
1986
- if (session.device.platform === "ios" || command.input === "touch" || command.input === "rotate" || command.input === "posture" || session.input.keys()) {
1987
- session.input.send(command);
1988
- return Promise.resolve(null);
1989
- }
1990
- const serial = session.device.serial;
1991
- const previous = session.adb;
1992
- const run = (async () => {
1993
- await previous;
1994
- try {
1995
- for (const args of adbInputArgs(command)) {
1996
- if (session.ended) return null;
1997
- await runAdb(this.options.env, serial, args);
1998
- }
1999
- return null;
2000
- } catch (cause) {
2001
- return {
2002
- code: "action-failed",
2003
- message: cause.message
2004
- };
2005
- }
2006
- })();
2007
- session.adb = run.then(() => void 0);
2008
- return run;
2009
- }
2010
- /**
2011
- * `sim-fold` sweeps the hinge to the other posture, so it runs only when the Duo's last frame shows the
2012
- * other one.
2013
- */
2014
- async fold(session, udid, posture) {
2015
- if (this.folding.has(udid)) return {
2016
- code: "device-busy",
2017
- message: "The device is still folding."
2018
- };
2019
- const current = this.options.frames.litPosture(session.device);
2020
- if (!current) return {
2021
- code: "action-failed",
2022
- message: "Subscribe to frames of this device to learn its posture first."
2023
- };
2024
- if (current === posture) return null;
2025
- this.folding.add(udid);
2026
- try {
2027
- const helper = await this.options.foldHelper();
2028
- if (session.ended) return null;
2029
- await runQuietly(this.options.env, "xcrun", [
2030
- "simctl",
2031
- "spawn",
2032
- udid,
2033
- helper
2034
- ], "sim-fold", this.options.foldTimeoutMs);
2035
- this.options.frames.folded(udid, posture === "folded" ? "folded" : "unfolded");
2036
- return null;
2037
- } catch (cause) {
2038
- return {
2039
- code: "action-failed",
2040
- message: cause.message
2041
- };
2042
- } finally {
2043
- this.folding.delete(udid);
2044
- }
2045
- }
2046
- endById(owner, id) {
2047
- const session = this.sessions.get(id);
2048
- if (!session || session.owner !== owner) return false;
2049
- this.end(session, null, "The client ended the session.");
2050
- return true;
2051
- }
2052
- endFor(owner, reason, message) {
2053
- for (const session of this.sessions.values()) if (session.owner === owner) this.end(session, reason, message);
2054
- }
2055
- async close() {
2056
- this.closing = true;
2057
- const releases = [...this.sessions.values()].map((session) => this.end(session, null, "stim-server stopped."));
2058
- await Promise.all(releases);
2059
- }
2060
- end(session, reason, message, release = true) {
2061
- if (session.ended) return Promise.resolve();
2062
- session.ended = true;
2063
- clearTimeout(session.idle);
2064
- clearInterval(session.renew);
2065
- session.unwatch();
2066
- session.input.detach();
2067
- this.sessions.delete(session.id);
2068
- if (this.byDevice.get(session.key) === session) this.byDevice.delete(session.key);
2069
- if (reason) session.owner.send({
2070
- event: "control-ended",
2071
- session: session.id,
2072
- reason,
2073
- message
2074
- });
2075
- this.audit(session.owner, session.target, "control.end", {
2076
- ok: true,
2077
- durationMs: Date.now() - session.startedAt,
2078
- reason: `${reason ?? "ended"}: ${message}`
2079
- });
2080
- if (!release || !session.lease?.mine) return Promise.resolve();
2081
- return session.renewing.then(() => this.unlock(session.target, session.cwd));
2082
- }
2083
- status() {
2084
- return new Promise((resolve) => {
2085
- let settled = false;
2086
- let unsubscribe = null;
2087
- const finish = (value) => {
2088
- if (settled) return;
2089
- settled = true;
2090
- clearTimeout(timer);
2091
- unsubscribe?.();
2092
- resolve(value);
2093
- };
2094
- const timer = setTimeout(() => finish({
2095
- code: "status-failed",
2096
- message: "stim status did not report the device in time."
2097
- }), STATUS_WAIT_MS);
2098
- unsubscribe = this.options.feeds.subscribe(this.options.statusFeed, {
2099
- item: (payload) => finish(payload),
2100
- failed: (message) => finish({
2101
- code: "status-failed",
2102
- message
2103
- })
2104
- });
2105
- if (settled) unsubscribe();
2106
- });
2107
- }
2108
- async lock(device, target, cwd, beganAt) {
2109
- const id = device.platform === "ios" ? device.udid : device.serial;
2110
- const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
2111
- const args = [
2112
- "device",
2113
- "lock",
2114
- target.platform,
2115
- id,
2116
- "--for",
2117
- this.options.leaseFor,
2118
- "--wait",
2119
- "0",
2120
- "--json",
2121
- ...slot
2122
- ];
2123
- const printed = actionOutcome(await runStim(this.options.stimCli, this.options.env, args, cwd, this.options.lockLimits).outcome);
2124
- if (!printed.ok) return {
2125
- code: printed.error.message.startsWith("STIM_DEVICE_BUSY") ? "device-busy" : "action-failed",
2126
- message: `stim device lock: ${printed.error.message}`
2127
- };
2128
- const grantedAt = typeof printed.output.grantedAt === "string" ? printed.output.grantedAt : null;
2129
- const expiresAt = typeof printed.output.expiresAt === "string" ? printed.output.expiresAt : "";
2130
- const mine = grantedAt !== null && Date.parse(grantedAt) >= beganAt - 1e3;
2131
- if (mine) this.ownLeases.add(grantedAt);
2132
- return {
2133
- grantedAt,
2134
- expiresAt,
2135
- mine
2136
- };
2137
- }
2138
- async unlock(target, cwd) {
2139
- const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
2140
- const outcome = await runStim(this.options.stimCli, this.options.env, [
2141
- "device",
2142
- "unlock",
2143
- target.platform,
2144
- "--json",
2145
- ...slot
2146
- ], cwd, this.options.lockLimits).outcome;
2147
- if (!outcome.ok) console.error(`stim-server: could not release the device lease: ${outcome.message}`);
2148
- }
2149
- audit(owner, target, action, outcome) {
2150
- this.options.audit({
2151
- at: (/* @__PURE__ */ new Date()).toISOString(),
2152
- device: {
2153
- id: owner.device.id,
2154
- name: owner.device.name
2155
- },
2156
- action,
2157
- workspace: target.workspace,
2158
- platform: target.platform,
2159
- ...outcome
2160
- });
2161
- }
2162
- };
2163
- //#endregion
2164
- //#region src/feed.ts
2165
- var Feed = class {
2166
- listeners = /* @__PURE__ */ new Set();
2167
- kept = [];
2168
- child;
2169
- ended;
2170
- constructor(spec, stimCli, env, ended) {
2171
- this.ended = ended;
2172
- const child = spawn(process.execPath, [stimCli, ...spec.args], {
2173
- cwd: spec.cwd,
2174
- env,
2175
- stdio: [
2176
- "ignore",
2177
- "pipe",
2178
- "pipe"
2179
- ]
2180
- });
2181
- this.child = child;
2182
- let stderr = "";
2183
- child.stderr?.setEncoding("utf8");
2184
- child.stderr?.on("data", (chunk) => {
2185
- stderr = (stderr + chunk).slice(-2e3);
2186
- });
2187
- createInterface({ input: child.stdout }).on("line", (line) => {
2188
- if (this.child !== child) return;
2189
- let value;
2190
- try {
2191
- value = JSON.parse(line);
2192
- } catch {
2193
- return;
2194
- }
2195
- if (!isJsonObject(value)) return;
2196
- this.kept.push({
2197
- value,
2198
- text: line
2199
- });
2200
- if (this.kept.length > spec.keep) this.kept.shift();
2201
- for (const listener of this.listeners) listener.item(value, line);
2202
- });
2203
- const exited = (reason) => {
2204
- if (this.child !== child) return;
2205
- this.child = null;
2206
- this.ended();
2207
- const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
2208
- const listeners = [...this.listeners];
2209
- this.listeners.clear();
2210
- for (const listener of listeners) listener.failed(message);
2211
- };
2212
- child.on("error", (error) => exited(`could not start (${error.message})`));
2213
- child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
2214
- }
2215
- add(listener) {
2216
- this.listeners.add(listener);
2217
- for (const { value, text } of this.kept) listener.item(value, text);
2218
- return () => {
2219
- if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
2220
- };
2221
- }
2222
- stop() {
2223
- const child = this.child;
2224
- if (!child) return Promise.resolve();
2225
- this.child = null;
2226
- this.listeners.clear();
2227
- this.ended();
2228
- return terminate(child);
2229
- }
2230
- };
2231
- var FeedPool = class {
2232
- feeds = /* @__PURE__ */ new Map();
2233
- stimCli;
2234
- env;
2235
- constructor(stimCli, env) {
2236
- this.stimCli = stimCli;
2237
- this.env = env;
2238
- }
2239
- subscribe(spec, listener) {
2240
- const key = JSON.stringify([spec.cwd, spec.args]);
2241
- let feed = this.feeds.get(key);
2242
- if (!feed) {
2243
- const created = new Feed(spec, this.stimCli, this.env, () => {
2244
- if (this.feeds.get(key) === created) this.feeds.delete(key);
2245
- });
2246
- this.feeds.set(key, created);
2247
- feed = created;
2248
- }
2249
- return feed.add(listener);
2250
- }
2251
- async close() {
2252
- await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
2253
- }
2254
- };
2255
- //#endregion
2256
- //#region src/logs.ts
2257
- function parseLogFilter(params) {
2258
- if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
2259
- const { sources, level, slot, grep, errors, tail } = params;
2260
- if (sources !== void 0 && (!Array.isArray(sources) || sources.length === 0 || !sources.every((source) => LOG_SOURCES.includes(source)))) return { error: `sources must be a non-empty list of ${LOG_SOURCES.join(", ")}.` };
2261
- if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
2262
- if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return { error: "slot must be 1-64 letters, digits, underscores or hyphens." };
2263
- if (grep !== void 0 && (typeof grep !== "string" || grep.includes("\0") || compileGrep(grep).error)) return { error: "grep must be a valid regular expression without NUL characters." };
2264
- if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
2265
- if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > 5e3)) return { error: `tail must be an integer from 1 to ${MAX_LOG_TAIL}.` };
2266
- return { filter: {
2267
- workspace: params.workspace,
2268
- ...sources ? { sources } : {},
2269
- ...level && level !== "debug" ? { level } : {},
2270
- ...slot ? { slot } : {},
2271
- ...grep ? { grep } : {},
2272
- ...errors ? { errors } : {},
2273
- tail: tail ?? 5e3
2274
- } };
2275
- }
2276
- function logArgs(filter, follow) {
2277
- const args = [
2278
- "logs",
2279
- "--json",
2280
- ...follow ? ["--follow"] : [],
2281
- `--tail=${filter.tail ?? 5e3}`
2282
- ];
2283
- if (filter.sources) args.push("--source", ...LOG_SOURCES.filter((source) => filter.sources.includes(source)));
2284
- if (filter.slot) args.push(`--slot=${filter.slot}`);
2285
- if (filter.level) args.push(`--level=${filter.level}`);
2286
- if (filter.grep) args.push(`--grep=${filter.grep}`);
2287
- if (filter.errors) args.push("--errors");
2288
- return args;
2289
- }
2290
- const BATCH_MS = 100;
2291
- const MAX_BATCH = 500;
2292
- var LogBatcher = class {
2293
- pending = [];
2294
- timer = null;
2295
- stopped = false;
2296
- sink;
2297
- limits;
2298
- constructor(sink, limits) {
2299
- this.sink = sink;
2300
- this.limits = limits;
2301
- }
2302
- push(record) {
2303
- if (this.stopped) return;
2304
- this.pending.push(record);
2305
- if (this.pending.length > this.limits.maxPendingRecords) {
2306
- this.stop();
2307
- queueMicrotask(this.sink.overflow);
2308
- return;
2309
- }
2310
- this.timer ??= setTimeout(() => this.flush(false), BATCH_MS);
2311
- }
2312
- flush(force) {
2313
- if (this.timer) clearTimeout(this.timer);
2314
- this.timer = null;
2315
- while (this.pending.length) {
2316
- if (!force && this.sink.bufferedBytes() >= this.limits.maxBufferedBytes) break;
2317
- this.sink.send(this.pending.splice(0, MAX_BATCH));
2318
- }
2319
- if (this.pending.length && !this.stopped) this.timer = setTimeout(() => this.flush(false), BATCH_MS);
2320
- }
2321
- stop() {
2322
- this.stopped = true;
2323
- if (this.timer) clearTimeout(this.timer);
2324
- this.timer = null;
2325
- this.pending = [];
2326
- }
2327
- };
2328
- //#endregion
2329
- //#region src/machine.ts
2330
- function stimDiskLocations() {
2331
- let workspaces = [];
2332
- try {
2333
- workspaces = Object.keys(loadConfig()?.projects ?? {});
2334
- } catch {}
2335
- return [
2336
- ...workspaces.map((path) => ({
2337
- label: "Workspaces",
2338
- path
2339
- })),
2340
- {
2341
- label: "Stim home",
2342
- path: configDir()
2343
- },
2344
- ...process.platform === "darwin" ? [{
2345
- label: "Simulators",
2346
- path: join(homedir(), "Library", "Developer", "CoreSimulator")
2347
- }] : []
2348
- ];
2349
- }
2350
- function existingAncestor(path) {
2351
- let current = path;
2352
- while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
2353
- return current;
2354
- }
2355
- function mountOf(path) {
2356
- return /^\/Volumes\/[^/]+/.exec(path)?.[0] ?? "/";
2357
- }
2358
- function readVolumes(locations) {
2359
- const byDevice = /* @__PURE__ */ new Map();
2360
- for (const { label, path } of locations) try {
2361
- const existing = existingAncestor(path);
2362
- const device = statSync(existing).dev;
2363
- const known = byDevice.get(device);
2364
- if (known) {
2365
- if (!known.holds.includes(label)) known.holds.push(label);
2366
- continue;
2367
- }
2368
- const fs = statfsSync(existing);
2369
- byDevice.set(device, {
2370
- mount: mountOf(existing),
2371
- holds: [label],
2372
- freeBytes: fs.bavail * fs.bsize,
2373
- totalBytes: fs.blocks * fs.bsize
2374
- });
2375
- } catch {}
2376
- return [...byDevice.values()];
2377
- }
2378
- function readMemoryPressure() {
2379
- if (process.platform !== "darwin") return Promise.resolve(null);
2380
- return new Promise((resolve) => {
2381
- execFile("/usr/sbin/sysctl", ["-n", "kern.memorystatus_vm_pressure_level"], { timeout: 2e3 }, (error, stdout) => {
2382
- const level = error ? "" : stdout.trim();
2383
- resolve(level === "1" ? "normal" : level === "2" ? "warning" : level === "4" ? "critical" : null);
2384
- });
2385
- });
2386
- }
2387
- /**
2388
- * Activity Monitor's "Memory Used" from `vm_stat` output: app memory (anonymous minus purgeable pages),
2389
- * wired, and compressed pages, times the page size its header names. Null when a count is missing.
2390
- */
2391
- function parseVmStatUsedBytes(output) {
2392
- const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
2393
- const count = (label) => {
2394
- const value = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(output)?.[1];
2395
- return value === void 0 ? null : Number(value);
2396
- };
2397
- const anonymous = count("Anonymous pages");
2398
- const purgeable = count("Pages purgeable");
2399
- const wired = count("Pages wired down");
2400
- const compressed = count("Pages occupied by compressor");
2401
- if (!pageSize || anonymous === null || purgeable === null || wired === null || compressed === null) return null;
2402
- return (Math.max(0, anonymous - purgeable) + wired + compressed) * Number(pageSize);
2403
- }
2404
- function readMemoryUsed() {
2405
- if (process.platform !== "darwin") return Promise.resolve(null);
2406
- return new Promise((resolve) => {
2407
- execFile("/usr/bin/vm_stat", { timeout: 2e3 }, (error, stdout) => {
2408
- resolve(error ? null : parseVmStatUsedBytes(stdout));
2409
- });
2410
- });
2411
- }
2412
- function cpuTicks(list) {
2413
- let idle = 0;
2414
- let total = 0;
2415
- for (const cpu of list) {
2416
- idle += cpu.times.idle;
2417
- total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
2418
- }
2419
- return {
2420
- idle,
2421
- total
2422
- };
2423
- }
2424
- /** The busy fraction (0..1) between two `os.cpus()` tick totals. Null when the totals did not advance. */
2425
- function cpuUsageFraction(previous, current) {
2426
- const totalDelta = current.total - previous.total;
2427
- if (totalDelta <= 0) return null;
2428
- const idleDelta = current.idle - previous.idle;
2429
- return Math.min(1, Math.max(0, 1 - idleDelta / totalDelta));
2430
- }
2431
- let previousCpuTicks = null;
2432
- function readCpuUsage() {
2433
- const current = cpuTicks(cpus());
2434
- const usage = previousCpuTicks ? cpuUsageFraction(previousCpuTicks, current) : null;
2435
- previousCpuTicks = current;
2436
- return {
2437
- usage,
2438
- cores: availableParallelism()
2439
- };
2440
- }
2441
- /** The volumes `machine.get` reports, without its CPU and memory readings. */
2442
- function readDiskVolumes() {
2443
- return readVolumes(stimDiskLocations());
2444
- }
2445
- async function readMachineUsage() {
2446
- const [avg1 = 0, avg5 = 0, avg15 = 0] = loadavg();
2447
- const [pressure, usedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
2448
- return {
2449
- volumes: readVolumes(stimDiskLocations()),
2450
- memory: {
2451
- totalBytes: totalmem(),
2452
- usedBytes,
2453
- pressure
2454
- },
2455
- load: {
2456
- avg1,
2457
- avg5,
2458
- avg15,
2459
- cpus: availableParallelism()
2460
- },
2461
- cpu: readCpuUsage(),
2462
- sampledAt: (/* @__PURE__ */ new Date()).toISOString()
2463
- };
2464
- }
2465
- const PRESSURE_LEVEL = {
2466
- normal: 0,
2467
- warning: 1,
2468
- critical: 2
2469
- };
2470
- const HISTORY_INTERVAL_MS = 5e3;
2471
- const HISTORY_CAPACITY = 720;
2472
- var UsageSampler = class {
2473
- samples = [];
2474
- previousTicks = null;
2475
- timer = null;
2476
- intervalMs;
2477
- capacity;
2478
- constructor(intervalMs = HISTORY_INTERVAL_MS, capacity = HISTORY_CAPACITY) {
2479
- this.intervalMs = intervalMs;
2480
- this.capacity = capacity;
2481
- }
2482
- start() {
2483
- if (this.timer) return;
2484
- this.previousTicks = cpuTicks(cpus());
2485
- this.timer = setInterval(() => void this.sample(), this.intervalMs);
2486
- }
2487
- stop() {
2488
- if (this.timer) clearInterval(this.timer);
2489
- this.timer = null;
2490
- this.previousTicks = null;
2491
- }
2492
- record(sample) {
2493
- this.samples.push(sample);
2494
- if (this.samples.length > this.capacity) this.samples.splice(0, this.samples.length - this.capacity);
2495
- }
2496
- history(sinceMs) {
2497
- const samples = sinceMs === void 0 ? [...this.samples] : this.samples.filter((s) => s.at > sinceMs);
2498
- return {
2499
- intervalMs: this.intervalMs,
2500
- samples
2501
- };
2502
- }
2503
- async sample() {
2504
- const at = Date.now();
2505
- const ticks = cpuTicks(cpus());
2506
- const cpu = this.previousTicks ? cpuUsageFraction(this.previousTicks, ticks) : null;
2507
- this.previousTicks = ticks;
2508
- const [pressure, memoryUsedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
2509
- let diskFreeBytes = null;
2510
- try {
2511
- const fs = statfsSync("/");
2512
- diskFreeBytes = fs.bavail * fs.bsize;
2513
- } catch {}
2514
- if (!this.timer) return;
2515
- this.record({
2516
- at,
2517
- cpu,
2518
- memoryUsedBytes,
2519
- memoryPressure: pressure === null ? null : PRESSURE_LEVEL[pressure],
2520
- diskFreeBytes
2521
- });
2522
- }
2523
- };
2524
- //#endregion
2525
- //#region src/attention.ts
2526
- const OVERRUN_FACTOR = 2;
2527
- const RECENT_FAILURE_MS = 864e5;
2528
- const DISK_CRITICAL_BYTES = 5e9;
2529
- const platformName = (platform) => platform === "ios" ? "iOS" : "Android";
2530
- const basename$1 = (path) => path.split("/").findLast(Boolean) ?? path;
2531
- function clockDuration(ms) {
2532
- const seconds = Math.max(0, Math.floor(ms / 1e3));
2533
- return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
2534
- }
2535
- function formatBytes(bytes) {
2536
- if (bytes >= 0xe8d4a51000) return `${(bytes / 0xe8d4a51000).toFixed(1)} TB`;
2537
- const gb = bytes / 1e9;
2538
- return gb >= 100 ? `${Math.round(gb)} GB` : `${gb.toFixed(1)} GB`;
2539
- }
2540
- function worktreeRoot(path) {
2541
- const parts = path.split("/");
2542
- for (let i = parts.length - 2; i > 0; i--) {
2543
- if (parts[i] === ".worktrees") return parts.slice(0, i).join("/");
2544
- if (parts[i] === "worktrees" && parts[i - 1] === ".claude") return parts.slice(0, i - 1).join("/");
2545
- }
2546
- return null;
2547
- }
2548
- function markedCheckout(path) {
2549
- const parts = path.split("/");
2550
- for (let i = parts.length - 2; i > 0; i--) if (parts[i] === ".worktrees" || parts[i] === "worktrees" && parts[i - 1] === ".claude") return parts.slice(0, i + 2).join("/");
2551
- return null;
2552
- }
2553
- function repositoryRoots(payload) {
2554
- const roots = /* @__PURE__ */ new Set();
2555
- for (const { path, worktree } of payload.environments) roots.add(worktreeRoot(path) ?? worktree?.repository ?? path);
2556
- for (const { path, repository } of payload.unprovisionedWorktrees ?? []) {
2557
- const root = worktreeRoot(path) ?? repository;
2558
- if (root) roots.add(root);
2559
- }
2560
- return [...roots];
2561
- }
2562
- function workspaceTitle(env, roots) {
2563
- if (env.worktree?.branch) return env.worktree.branch;
2564
- const checkout = env.worktree?.path ?? markedCheckout(env.path);
2565
- if (checkout) return basename$1(checkout);
2566
- const root = worktreeRoot(env.path) ?? env.worktree?.repository ?? roots.filter((r) => env.path === r || env.path.startsWith(`${r}/`)).reduce((best, r) => best === null || r.length < best.length ? r : best, null) ?? env.path;
2567
- return basename$1(root);
2568
- }
2569
- function devicesOf(env) {
2570
- const out = [];
2571
- const add = (slot, ios, android) => {
2572
- if (ios) out.push({
2573
- platform: "ios",
2574
- slot,
2575
- model: /\(([^()]*(?:\([^()]*\)[^()]*)*)\)\s*$/.exec(ios.name ?? "")?.[1] ?? "iOS Simulator",
2576
- running: ios.state === "Booted",
2577
- driven: ios.activity?.state === "driven",
2578
- appStopped: ios.app?.state === "stopped"
2579
- });
2580
- if (android) out.push({
2581
- platform: "android",
2582
- slot,
2583
- model: android.physical ? "Android device" : "Android Emulator",
2584
- running: android.state === "detected",
2585
- driven: android.activity?.state === "driven",
2586
- appStopped: android.app?.state === "stopped"
2587
- });
2588
- };
2589
- add("default", env.ios, env.android);
2590
- for (const slot of env.slots ?? []) add(slot.slot, slot.ios, slot.android);
2591
- return out;
2592
- }
2593
- function runningBuild(env, device) {
2594
- const build = env.build;
2595
- if (!build || build.state !== "running") return null;
2596
- if (device && (build.platform !== device.platform || build.slot !== device.slot)) return null;
2597
- return build;
2598
- }
2599
- const isActive = (env) => env.live || env.build?.state === "running" || (env.remoteDevices?.length ?? 0) > 0;
2600
- function workspaceCandidates(env, title, now) {
2601
- const items = [];
2602
- const active = isActive(env);
2603
- const devices = devicesOf(env);
2604
- const driven = devices.some((device) => device.driven);
2605
- const add = (id, event, occurrence, reason, extra) => items.push({
2606
- key: `${env.path}\n${id}`,
2607
- event,
2608
- occurrence,
2609
- title,
2610
- reason,
2611
- target: {
2612
- kind: "workspace",
2613
- path: env.path
2614
- },
2615
- driven,
2616
- ...extra
2617
- });
2618
- for (const platform of ["ios", "android"]) {
2619
- const last = env.lastBuilds?.[platform];
2620
- if (!last || last.status !== "failed" || runningBuild(env)?.platform === platform) continue;
2621
- const endedAt = last.finishedAt ?? last.startedAt;
2622
- if (!active && !(now - Date.parse(endedAt) < RECENT_FAILURE_MS)) continue;
2623
- const code = last.errorCode ? ` (${last.errorCode})` : "";
2624
- add(`build-${platform}`, "build-failed", endedAt, `${platformName(platform)} build failed${code}`, {});
2625
- }
2626
- const errors = env.logs?.errorsSinceMarker ?? 0;
2627
- if (active && errors > 0) add("logs", "log-errors", String(errors), `${errors === 1 ? "1 error" : `${errors} errors`} in the logs`, {
2628
- target: {
2629
- kind: "logs",
2630
- path: env.path
2631
- },
2632
- count: errors
2633
- });
2634
- const build = runningBuild(env);
2635
- const started = build ? Date.parse(build.startedAt) : NaN;
2636
- if (build?.expectedMs && Number.isFinite(started) && now - started > OVERRUN_FACTOR * build.expectedMs) add("overrun", "slow-build", build.startedAt, `${platformName(build.platform)} build at ${clockDuration(now - started)}, usually ~${clockDuration(build.expectedMs)}`, {});
2637
- if (env.live) for (const device of devices) {
2638
- if (!device.running || !device.appStopped || runningBuild(env, device)) continue;
2639
- add(`app-${device.platform}-${device.slot}`, "app-stopped", "", `App not running on ${device.model}`, {});
2640
- }
2641
- return items;
2642
- }
2643
- /**
2644
- * The machine's disk item and each workspace's failed builds, log errors, build overrun and stopped apps, with
2645
- * the phone strip's rules: an idle workspace reports only a build that failed in the last day.
2646
- */
2647
- function attentionCandidates(status, volumes, name, now) {
2648
- const candidates = [];
2649
- const lowest = volumes?.reduce((min, v) => min === null ? v.freeBytes : Math.min(min, v.freeBytes), null);
2650
- if (lowest !== null && lowest !== void 0 && lowest < DISK_CRITICAL_BYTES) candidates.push({
2651
- key: "disk",
2652
- event: "disk",
2653
- occurrence: "",
2654
- title: name,
2655
- reason: `${formatBytes(lowest)} free, below Stim's floor`,
2656
- target: { kind: "machine" },
2657
- driven: null
2658
- });
2659
- const roots = repositoryRoots(status);
2660
- for (const env of status.environments) candidates.push(...workspaceCandidates(env, workspaceTitle(env, roots), now));
2661
- return candidates;
2662
- }
2663
- //#endregion
2664
- //#region src/notify.ts
2665
- /** How long an item must hold before it notifies: log errors settle, and an offline machine may reconnect. */
2666
- const SETTLE_MS = {
2667
- "log-errors": 1e4,
2668
- offline: 6e4
2669
- };
2670
- /** Log errors that keep arriving still notify this long after the first one. */
2671
- const MAX_SETTLE_MS = 6e4;
2672
- const LOG_COOLDOWN_MS = 3e5;
2673
- /** An item absent this long is forgotten, so a later one notifies again; a shorter gap is the same problem. */
2674
- const FORGET_MS = 12e4;
2675
- /**
2676
- * The notifications `candidates` owe since `entries`, the state the previous call returned. Null `entries`
2677
- * records every candidate without notifying, so what is already wrong at the first check stays quiet. An item
2678
- * notifies once per occurrence. `awakeSince` restarts the settle time, for a checker that was not running.
2679
- */
2680
- function diffAttention(entries, candidates, choice, now, awakeSince = 0) {
2681
- const next = {};
2682
- const notify = [];
2683
- let wakeAt = null;
2684
- for (const candidate of candidates) {
2685
- const prev = entries?.[candidate.key];
2686
- let entry = prev && prev.occurrence === candidate.occurrence ? {
2687
- ...prev,
2688
- seenAt: now
2689
- } : {
2690
- occurrence: candidate.occurrence,
2691
- since: now,
2692
- heldSince: prev && !prev.done ? prev.heldSince : now,
2693
- seenAt: now,
2694
- notifiedAt: prev?.notifiedAt ?? null,
2695
- done: entries === null,
2696
- ...prev?.count !== void 0 ? { count: prev.count } : {}
2697
- };
2698
- if (entries === null) entry = {
2699
- ...entry,
2700
- count: candidate.count
2701
- };
2702
- next[candidate.key] = entry;
2703
- if (entry.done) continue;
2704
- if (!(choice.events.includes(candidate.event) && !(choice.agentOnly && candidate.driven === false))) {
2705
- next[candidate.key] = {
2706
- ...entry,
2707
- done: true,
2708
- count: candidate.count
2709
- };
2710
- continue;
2711
- }
2712
- const settle = SETTLE_MS[candidate.event] ?? 0;
2713
- let dueAt = Math.max(Math.min(entry.since + settle, entry.heldSince + MAX_SETTLE_MS), awakeSince + settle);
2714
- if (candidate.event === "log-errors" && entry.notifiedAt !== null) dueAt = Math.max(dueAt, entry.notifiedAt + LOG_COOLDOWN_MS);
2715
- if (now < dueAt) {
2716
- wakeAt = wakeAt === null ? dueAt : Math.min(wakeAt, dueAt);
2717
- continue;
2718
- }
2719
- next[candidate.key] = {
2720
- ...entry,
2721
- done: true,
2722
- notifiedAt: now,
2723
- count: candidate.count
2724
- };
2725
- notify.push(candidate.event === "log-errors" ? {
2726
- ...candidate,
2727
- reason: newErrors(candidate, entry.count)
2728
- } : candidate);
2729
- }
2730
- for (const [key, entry] of Object.entries(entries ?? {})) if (!(key in next) && now - entry.seenAt < FORGET_MS) next[key] = entry;
2731
- return {
2732
- entries: next,
2733
- notify,
2734
- wakeAt
2735
- };
2736
- }
2737
- function newErrors(candidate, before) {
2738
- const count = candidate.count ?? 0;
2739
- const added = before !== void 0 && count > before ? count - before : count;
2740
- const noun = added === 1 ? "error" : "errors";
2741
- return added === count ? `${count} ${noun} in the logs` : `${added} new ${noun} in the logs`;
2742
- }
2743
- const DEFAULT_PUSH_LIMITS = {
2744
- tickMs: 3e4,
2745
- diskMs: 6e4,
2746
- receiptDelayMs: 9e5,
2747
- perHour: 20,
2748
- summarizeAbove: 3,
2749
- resubscribeMs: 3e4
2750
- };
2751
- /**
2752
- * Pushes attention notifications to the devices that asked with `push.register`. While any device is
2753
- * registered it keeps its own `stim status --watch --json` subscription and reads the disks every minute.
2754
- */
2755
- var PushNotifier = class {
2756
- options;
2757
- limits;
2758
- now;
2759
- registered = /* @__PURE__ */ new Map();
2760
- unsubscribe = null;
2761
- status = null;
2762
- volumes = null;
2763
- timers = /* @__PURE__ */ new Set();
2764
- tick = null;
2765
- disk = null;
2766
- wake = null;
2767
- resubscribe = null;
2768
- closed = false;
2769
- constructor(options) {
2770
- this.options = options;
2771
- this.limits = {
2772
- ...DEFAULT_PUSH_LIMITS,
2773
- ...options.limits
2774
- };
2775
- this.now = options.now ?? Date.now;
2776
- }
2777
- /** Rereads the registrations; a device whose token is new starts from what is already wrong. */
2778
- refresh() {
2779
- if (this.closed) return;
2780
- const current = new Map(this.options.devices().flatMap((d) => d.push ? [[d.id, d.push]] : []));
2781
- for (const id of this.registered.keys()) if (!current.has(id)) this.registered.delete(id);
2782
- let added = false;
2783
- for (const [id, push] of current) {
2784
- const known = this.registered.get(id);
2785
- if (known && known.push.token === push.token) {
2786
- known.push = push;
2787
- continue;
2788
- }
2789
- this.registered.set(id, {
2790
- push,
2791
- entries: null,
2792
- bucket: {
2793
- tokens: this.limits.perHour,
2794
- at: this.now()
2795
- }
2796
- });
2797
- added = true;
2798
- }
2799
- if (this.registered.size > 0) this.watch();
2800
- else this.unwatch();
2801
- if (added) this.evaluate();
2802
- }
2803
- close() {
2804
- this.closed = true;
2805
- this.unwatch();
2806
- for (const timer of this.timers) clearTimeout(timer);
2807
- this.timers.clear();
2808
- }
2809
- watch() {
2810
- if (!this.disk) {
2811
- this.readDisk();
2812
- this.disk = setInterval(() => {
2813
- this.readDisk();
2814
- this.evaluate();
2815
- }, this.limits.diskMs);
2816
- }
2817
- if (!this.tick) this.tick = setInterval(() => this.evaluate(), this.limits.tickMs);
2818
- if (!this.unsubscribe && !this.resubscribe) this.subscribe();
2819
- }
2820
- unwatch() {
2821
- this.unsubscribe?.();
2822
- this.unsubscribe = null;
2823
- for (const timer of [this.tick, this.disk]) if (timer) clearInterval(timer);
2824
- for (const timer of [this.wake, this.resubscribe]) if (timer) clearTimeout(timer);
2825
- this.tick = this.disk = this.wake = this.resubscribe = null;
2826
- this.status = null;
2827
- this.volumes = null;
2828
- }
2829
- subscribe() {
2830
- this.unsubscribe = this.options.subscribeStatus({
2831
- item: (value) => {
2832
- this.status = value;
2833
- this.evaluate();
2834
- },
2835
- failed: (message) => {
2836
- console.error(`stim-server: push notifications paused: ${message}`);
2837
- this.unsubscribe = null;
2838
- this.status = null;
2839
- this.resubscribe = setTimeout(() => {
2840
- this.resubscribe = null;
2841
- if (this.registered.size > 0 && !this.closed) this.subscribe();
2842
- }, this.limits.resubscribeMs);
2843
- }
2844
- });
2845
- }
2846
- readDisk() {
2847
- try {
2848
- this.volumes = this.options.readVolumes();
2849
- } catch {
2850
- this.volumes = null;
2851
- }
2852
- }
2853
- evaluate() {
2854
- const status = this.status;
2855
- if (!status || this.closed) return;
2856
- const now = this.now();
2857
- const candidates = attentionCandidates(status, this.volumes, this.options.name, now);
2858
- const messages = [];
2859
- let wakeAt = null;
2860
- for (const device of this.registered.values()) {
2861
- const diff = diffAttention(device.entries, candidates, device.push, now);
2862
- device.entries = diff.entries;
2863
- if (diff.wakeAt !== null) wakeAt = wakeAt === null ? diff.wakeAt : Math.min(wakeAt, diff.wakeAt);
2864
- const due = diff.notify.length > this.limits.summarizeAbove ? [this.summary(device, diff.notify)] : diff.notify.map((c) => this.message(device.push, c));
2865
- for (const message of due) if (this.take(device, now)) messages.push(message);
2866
- }
2867
- if (this.wake) clearTimeout(this.wake);
2868
- this.wake = wakeAt === null ? null : setTimeout(() => this.evaluate(), Math.max(0, wakeAt - now));
2869
- if (messages.length > 0) this.send(messages);
2870
- }
2871
- take(device, now) {
2872
- const { bucket } = device;
2873
- bucket.tokens = Math.min(this.limits.perHour, bucket.tokens + (now - bucket.at) * this.limits.perHour / 36e5);
2874
- bucket.at = now;
2875
- if (bucket.tokens < 1) return false;
2876
- bucket.tokens -= 1;
2877
- return true;
2878
- }
2879
- message(push, candidate) {
2880
- const machine = candidate.target.kind === "machine";
2881
- return {
2882
- to: push.token,
2883
- title: candidate.title,
2884
- ...machine ? {} : { subtitle: this.options.name },
2885
- body: candidate.reason,
2886
- sound: "default",
2887
- data: {
2888
- ref: push.ref,
2889
- target: candidate.target.kind,
2890
- ...candidate.target.kind === "machine" ? {} : { path: candidate.target.path }
2891
- }
2892
- };
2893
- }
2894
- summary(device, candidates) {
2895
- return {
2896
- to: device.push.token,
2897
- title: this.options.name,
2898
- body: `${candidates.length} problems need attention`,
2899
- sound: "default",
2900
- data: {
2901
- ref: device.push.ref,
2902
- target: "home"
2903
- }
2904
- };
2905
- }
2906
- later(fn, ms) {
2907
- if (this.closed) return;
2908
- const timer = setTimeout(() => {
2909
- this.timers.delete(timer);
2910
- if (!this.closed) fn();
2911
- }, ms);
2912
- timer.unref();
2913
- this.timers.add(timer);
2914
- }
2915
- async post(path, body) {
2916
- const response = await fetch(`${this.options.endpoint}/${path}`, {
2917
- method: "POST",
2918
- headers: {
2919
- accept: "application/json",
2920
- "content-type": "application/json"
2921
- },
2922
- body: JSON.stringify(body),
2923
- signal: AbortSignal.timeout(3e4)
2924
- });
2925
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
2926
- return (await response.json()).data;
2927
- }
2928
- async send(messages) {
2929
- let tickets;
2930
- try {
2931
- tickets = await this.post("send", messages);
2932
- } catch (cause) {
2933
- console.error(`stim-server: could not send ${messages.length} push notifications: ${cause.message}`);
2934
- return;
2935
- }
2936
- if (!Array.isArray(tickets) || this.closed) return;
2937
- const receipts = /* @__PURE__ */ new Map();
2938
- tickets.forEach((ticket, index) => {
2939
- const to = messages[index]?.to;
2940
- if (!to) return;
2941
- if (ticket?.status === "ok" && typeof ticket.id === "string") receipts.set(ticket.id, to);
2942
- else if (ticket?.details?.error === "DeviceNotRegistered") this.drop(to);
2943
- else if (ticket?.status === "error") console.error(`stim-server: Expo refused a push: ${String(ticket.message)}`);
2944
- });
2945
- if (receipts.size > 0) this.later(() => void this.checkReceipts(receipts), this.limits.receiptDelayMs);
2946
- }
2947
- drop(token) {
2948
- try {
2949
- this.options.dropToken(token);
2950
- } catch (cause) {
2951
- console.error(`stim-server: could not drop an unregistered push token: ${cause.message}`);
2952
- }
2953
- }
2954
- async checkReceipts(receipts) {
2955
- let data;
2956
- try {
2957
- data = await this.post("getReceipts", { ids: [...receipts.keys()] });
2958
- } catch {
2959
- return;
2960
- }
2961
- if (!data || typeof data !== "object" || this.closed) return;
2962
- for (const [id, receipt] of Object.entries(data)) {
2963
- const to = receipts.get(id);
2964
- if (to && receipt?.details?.error === "DeviceNotRegistered") this.drop(to);
2965
- else if (receipt?.status === "error") console.error(`stim-server: a push failed: ${String(receipt.message)}`);
2966
- }
2967
- }
2968
- };
2969
- //#endregion
2970
- //#region src/tailscale.ts
2971
- const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
2972
- const TIMEOUT_MS = 3e3;
2973
- /** The tailnet-only HTTPS port the setup command serves stim-server on. */
2974
- const SERVE_PORT = 7443;
2975
- function executable(path) {
2976
- try {
2977
- accessSync(path, constants.X_OK);
2978
- return true;
2979
- } catch {
2980
- return false;
2981
- }
2982
- }
2983
- function findTailscale(env) {
2984
- for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "tailscale"))) return join(dir, "tailscale");
2985
- return executable(MAC_APP_BINARY) ? MAC_APP_BINARY : null;
2986
- }
2987
- function parseTailscaleStatus(value) {
2988
- if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
2989
- state: "unavailable",
2990
- reason: "`tailscale status --json` printed no BackendState"
2991
- };
2992
- if (value.BackendState !== "Running") return {
2993
- state: "not-running",
2994
- backendState: value.BackendState
2995
- };
2996
- const self = isJsonObject(value.Self) ? value.Self : {};
2997
- return {
2998
- state: "running",
2999
- ips: Array.isArray(value.TailscaleIPs) ? value.TailscaleIPs.filter((ip) => typeof ip === "string") : [],
3000
- dnsName: typeof self.DNSName === "string" && self.DNSName ? self.DNSName.replace(/\.$/, "") : null,
3001
- hostName: typeof self.HostName === "string" && self.HostName ? self.HostName : null
3002
- };
3003
- }
3004
- function tailscaleStatus(binary, env) {
3005
- if (!binary) return {
3006
- state: "unavailable",
3007
- reason: "the tailscale command was not found"
3008
- };
3009
- try {
3010
- const output = execFileSync(binary, ["status", "--json"], {
3011
- env,
3012
- timeout: TIMEOUT_MS,
3013
- encoding: "utf8",
3014
- stdio: [
3015
- "ignore",
3016
- "pipe",
3017
- "ignore"
3018
- ]
3019
- });
3020
- return parseTailscaleStatus(JSON.parse(output));
3021
- } catch (error) {
3022
- return {
3023
- state: "unavailable",
3024
- reason: `\`tailscale status --json\` failed: ${error.message}`
3025
- };
3026
- }
3027
- }
3028
- function reaches(address, target, hosts) {
3029
- if (typeof address !== "string") return null;
3030
- let url;
3031
- try {
3032
- url = new URL(address.includes("://") ? address : `tcp://${address}`);
3033
- } catch {
3034
- return null;
3035
- }
3036
- return hosts.has(url.hostname) && url.port === String(target) ? url : null;
3037
- }
3038
- function portOf(hostPort) {
3039
- return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
3040
- }
3041
- /**
3042
- * Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
3043
- * `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
3044
- * `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
3045
- * foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
3046
- * it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
3047
- */
3048
- function parseServeStatus(value, target, ips) {
3049
- if (!isJsonObject(value)) return {
3050
- state: "unknown",
3051
- reason: "it printed no serve config",
3052
- port: SERVE_PORT
3053
- };
3054
- const hosts = /* @__PURE__ */ new Set([
3055
- "127.0.0.1",
3056
- "localhost",
3057
- "[::1]",
3058
- "0.0.0.0",
3059
- "[::]",
3060
- ...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
3061
- ]);
3062
- const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
3063
- const used = /* @__PURE__ */ new Set();
3064
- const funneled = /* @__PURE__ */ new Set();
3065
- const reaching = /* @__PURE__ */ new Set();
3066
- const routes = /* @__PURE__ */ new Set();
3067
- for (const config of configs) {
3068
- const tcp = isJsonObject(config.TCP) ? config.TCP : {};
3069
- for (const [port, listener] of Object.entries(tcp)) {
3070
- used.add(Number(port));
3071
- if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
3072
- }
3073
- for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
3074
- for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
3075
- const port = portOf(hostPort);
3076
- const listener = tcp[String(port)];
3077
- const https = isJsonObject(listener) && listener.HTTPS === true;
3078
- const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
3079
- for (const [mount, handler] of Object.entries(handlers)) {
3080
- const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
3081
- if (!url) continue;
3082
- reaching.add(port);
3083
- if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
3084
- }
3085
- }
3086
- }
3087
- let free = SERVE_PORT;
3088
- while (used.has(free) || funneled.has(free)) free++;
3089
- const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
3090
- if (exposed.length) return {
3091
- state: "funneled",
3092
- ports: exposed,
3093
- port: free
3094
- };
3095
- const tailnet = [...routes].toSorted((a, b) => a - b);
3096
- if (tailnet.length) return {
3097
- state: "routed",
3098
- port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
3099
- };
3100
- return {
3101
- state: "missing",
3102
- port: free
3103
- };
3104
- }
3105
- function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
3106
- if (!binary) return Promise.resolve({
3107
- state: "unknown",
3108
- reason: "the tailscale command was not found",
3109
- port: SERVE_PORT
3110
- });
3111
- return new Promise((resolve) => {
3112
- execFile(binary, [
3113
- "serve",
3114
- "status",
3115
- "--json"
3116
- ], {
3117
- env,
3118
- timeout: timeoutMs,
3119
- killSignal: "SIGKILL",
3120
- encoding: "utf8"
3121
- }, (error, stdout) => {
3122
- let route;
3123
- try {
3124
- route = error ? {
3125
- state: "unknown",
3126
- reason: error.killed ? "it timed out" : error.message.split("\n")[0],
3127
- port: SERVE_PORT
3128
- } : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
3129
- } catch {
3130
- route = {
3131
- state: "unknown",
3132
- reason: "it printed output that is not JSON",
3133
- port: SERVE_PORT
3134
- };
3135
- }
3136
- resolve(route);
3137
- });
3138
- });
3139
- }
3140
- function tailnetEndpoint(dnsName, port) {
3141
- return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
3142
- }
3143
- function serveCommand(port, target) {
3144
- return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
3145
- }
3146
- /** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
3147
- function parseWhois(value) {
3148
- if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
3149
- const { StableID, Name } = value.Node;
3150
- const login = isJsonObject(value.UserProfile) ? value.UserProfile.LoginName : void 0;
3151
- if (typeof StableID !== "string" || !StableID) return null;
3152
- return {
3153
- kind: "tailnet",
3154
- nodeId: StableID,
3155
- nodeName: typeof Name === "string" ? Name.replace(/\.$/, "") : "",
3156
- user: typeof login === "string" ? login : ""
3157
- };
3158
- }
3159
- function whois(binary, env, ip) {
3160
- if (!binary) return Promise.resolve(null);
3161
- return new Promise((resolve) => {
3162
- execFile(binary, [
3163
- "whois",
3164
- "--json",
3165
- ip
3166
- ], {
3167
- env,
3168
- timeout: TIMEOUT_MS,
3169
- encoding: "utf8"
3170
- }, (error, stdout) => {
3171
- if (error) return resolve(null);
3172
- try {
3173
- resolve(parseWhois(JSON.parse(stdout)));
3174
- } catch {
3175
- resolve(null);
3176
- }
3177
- });
3178
- });
3179
- }
3180
- //#endregion
3181
- //#region src/server.ts
3182
- function healthTailscale(tailscale) {
3183
- if (tailscale.state === "running") return {
3184
- state: "running",
3185
- dnsName: tailscale.dnsName
3186
- };
3187
- if (tailscale.state === "not-running") return {
3188
- state: "not-running",
3189
- backendState: tailscale.backendState
3190
- };
3191
- return {
3192
- state: "unavailable",
3193
- reason: tailscale.reason
3194
- };
3195
- }
3196
- /** Only a request made to this Mac's loopback name, so a DNS-rebound web page cannot read the health payload. */
3197
- function localHealthRequest(request) {
3198
- const host = request.headers.host?.replace(/:\d+$/, "");
3199
- return request.method === "GET" && request.url === "/health" && (host === "127.0.0.1" || host === "localhost") && peerAddress(request) === null;
3200
- }
3201
- const CLOSE_UNAUTHORIZED = 4401;
3202
- const CLOSE_BAD_REQUEST = 4400;
3203
- const CLOSE_AUTH_TIMEOUT = 4408;
3204
- const MAX_PAYLOAD = 65536;
3205
- const MAX_SUBSCRIPTIONS = 32;
3206
- const MAX_COMMANDS = 4;
3207
- const LOG_LIMITS = {
3208
- maxBufferedBytes: 4194304,
3209
- maxPendingRecords: 2e4
3210
- };
3211
- const FRAME_BUFFER_FRAMES = 2;
3212
- const FRAME_RETRY_MS = 50;
3213
- const HELPER_RETRY_MS = 3e5;
3214
- const CONTROL_LIMITS = {
3215
- idleMs: 3e5,
3216
- renewMs: 6e4,
3217
- leaseFor: "2m",
3218
- inputPerSecond: 120,
3219
- textCharsPerSecond: 40,
3220
- shapeChangesPerSecond: 2,
3221
- foldTimeoutMs: 4e4
3222
- };
3223
- const LOCK_LIMITS = {
3224
- timeoutMs: 3e4,
3225
- maxOutputBytes: 65536
3226
- };
3227
- const pushToken = new RegExp(PUSH_TOKEN_PATTERN);
3228
- const STATUS_FEED = {
3229
- args: [
3230
- "status",
3231
- "--watch",
3232
- "--json"
3233
- ],
3234
- cwd: homedir(),
3235
- keep: 1,
3236
- label: "stim status --watch"
3237
- };
3238
- const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
3239
- const COMMAND_LIMITS = {
3240
- timeoutMs: 6e4,
3241
- maxOutputBytes: 33554432
3242
- };
3243
- const PLAN_TIMEOUT_MS = 15e4;
3244
- const AUDIT_FIELD_CHARS = 256;
3245
- const ACTION_LIMITS = {
3246
- timeoutMs: 12e4,
3247
- maxOutputBytes: 1048576
3248
- };
3249
- const AUTH_REFUSALS = {
3250
- "pairing-unknown": {
3251
- code: "pairing-expired",
3252
- message: "This pairing code is unknown or was already used. Pair again."
3253
- },
3254
- "pairing-expired": {
3255
- code: "pairing-expired",
3256
- message: "This pairing code expired. Pair again."
3257
- },
3258
- "device-unknown": {
3259
- code: "unauthorized",
3260
- message: "This Mac does not recognize this device token. Pair again."
3261
- },
3262
- "node-mismatch": {
3263
- code: "unauthorized",
3264
- message: "This device token was paired from a different tailnet node."
3265
- }
3266
- };
3267
- function isLoopback(address) {
3268
- return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
3269
- }
3270
- /**
3271
- * The tailnet address of the peer. `tailscale serve` proxies from loopback and names the peer in
3272
- * X-Forwarded-For; a loopback connection without that header comes from this Mac.
3273
- */
3274
- function peerAddress(request) {
3275
- const remote = request.socket.remoteAddress;
3276
- if (!isLoopback(remote)) return remote ?? null;
3277
- const forwarded = request.headers["x-forwarded-for"];
3278
- return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
3279
- }
3280
- var FailureLimiter = class {
3281
- failures = /* @__PURE__ */ new Map();
3282
- max;
3283
- windowMs;
3284
- constructor(max, windowMs) {
3285
- this.max = max;
3286
- this.windowMs = windowMs;
3287
- }
3288
- recent(key, now) {
3289
- const kept = (this.failures.get(key) ?? []).filter((at) => now - at < this.windowMs);
3290
- if (kept.length) this.failures.set(key, kept);
3291
- else this.failures.delete(key);
3292
- return kept;
3293
- }
3294
- blocked(key, now = Date.now()) {
3295
- return this.recent(key, now).length >= this.max;
3296
- }
3297
- record(key, now = Date.now()) {
3298
- this.failures.set(key, [...this.recent(key, now), now]);
3299
- }
3300
- };
3301
- function auditSafely(record) {
3302
- try {
3303
- appendAudit(record);
3304
- } catch (cause) {
3305
- console.error(`stim-server: could not append to the action log: ${cause.message}`);
3306
- }
3307
- }
3308
- function take(bucket, cost, perSecond, capacity) {
3309
- const now = Date.now();
3310
- bucket.tokens = Math.min(capacity, bucket.tokens + (now - bucket.at) / 1e3 * perSecond);
3311
- bucket.at = now;
3312
- if (bucket.tokens < cost) return false;
3313
- bucket.tokens -= cost;
3314
- return true;
3315
- }
3316
- function send(socket, message) {
3317
- if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
3318
- }
3319
- function requestId(value) {
3320
- return typeof value === "number" || typeof value === "string" ? value : null;
3321
- }
3322
- function registeredWorkspace(workspace) {
3323
- if (typeof workspace !== "string") return {
3324
- code: "bad-request",
3325
- message: "params.workspace must be an environment path from a status payload."
3326
- };
3327
- let projects;
3328
- try {
3329
- projects = loadConfig()?.projects ?? {};
3330
- } catch (cause) {
3331
- return {
3332
- code: "stim-failed",
3333
- message: cause.message
3334
- };
3335
- }
3336
- const dir = Object.keys(projects).find((path) => path === workspace);
3337
- if (dir === void 0) return {
3338
- code: "unknown-workspace",
3339
- message: `${workspace} is not a Stim workspace on this Mac.`
3340
- };
3341
- if (!existsSync(dir)) return {
3342
- code: "unknown-workspace",
3343
- message: `${dir} is registered but no longer exists on this Mac.`
3344
- };
3345
- return { dir };
3346
- }
3347
- async function startServer(options) {
3348
- const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
3349
- const authTimeoutMs = options.authTimeoutMs ?? 5e3;
3350
- const feeds = new FeedPool(options.stimCli, options.env);
3351
- const frameLimits = {
3352
- ...DEFAULT_FRAME_LIMITS,
3353
- ...options.frameLimits
3354
- };
3355
- let helperPath = options.frameHelper ?? null;
3356
- let helperBuilding = false;
3357
- let helperRetryAt = 0;
3358
- const helperAbort = new AbortController();
3359
- const buildHelper = () => {
3360
- helperBuilding = true;
3361
- (async () => {
3362
- try {
3363
- helperPath = await buildFrameHelper(options.env, helperAbort.signal);
3364
- } catch (cause) {
3365
- helperRetryAt = Date.now() + HELPER_RETRY_MS;
3366
- if (!helperAbort.signal.aborted) console.error(`stim-server: frames come from screenshots: ${cause.message}`);
3367
- } finally {
3368
- helperBuilding = false;
3369
- }
3370
- })();
3371
- };
3372
- const frameHelper = () => {
3373
- if (options.frameHelper === void 0 && !helperPath && !helperBuilding && Date.now() >= helperRetryAt) buildHelper();
3374
- return helperPath;
3375
- };
3376
- if (options.frameHelper === void 0) buildHelper();
3377
- const frames = new FramePool(options.env, frameLimits, frameHelper);
3378
- let foldBuild = null;
3379
- const foldHelper = () => {
3380
- if (options.foldHelper !== void 0) return Promise.resolve(options.foldHelper);
3381
- foldBuild ??= buildFoldHelper(options.env, helperAbort.signal).catch((cause) => {
3382
- foldBuild = null;
3383
- throw new Error(`sim-fold could not be built: ${cause.message}`);
3384
- });
3385
- return foldBuild;
3386
- };
3387
- const running = /* @__PURE__ */ new Set();
3388
- const logLimits = {
3389
- ...LOG_LIMITS,
3390
- ...options.logLimits
3391
- };
3392
- const commandLimits = {
3393
- ...COMMAND_LIMITS,
3394
- ...options.commandLimits
3395
- };
3396
- const actionLimits = {
3397
- ...ACTION_LIMITS,
3398
- ...options.actionLimits
3399
- };
3400
- const busyWorkspaces = /* @__PURE__ */ new Set();
3401
- const planQueues = /* @__PURE__ */ new Map();
3402
- const sessions = /* @__PURE__ */ new Map();
3403
- const sampler = new UsageSampler();
3404
- const controllers = /* @__PURE__ */ new Map();
3405
- const controlLimits = {
3406
- ...CONTROL_LIMITS,
3407
- ...options.controlLimits
3408
- };
3409
- const control = new ControlHub({
3410
- env: options.env,
3411
- stimCli: options.stimCli,
3412
- feeds,
3413
- frames,
3414
- statusFeed: STATUS_FEED,
3415
- audit: auditSafely,
3416
- lockLimits: LOCK_LIMITS,
3417
- idleMs: controlLimits.idleMs,
3418
- renewMs: controlLimits.renewMs,
3419
- leaseFor: controlLimits.leaseFor,
3420
- foldHelper,
3421
- foldTimeoutMs: controlLimits.foldTimeoutMs
3422
- });
3423
- const wss = new WebSocketServer({
3424
- noServer: true,
3425
- maxPayload: MAX_PAYLOAD
3426
- });
3427
- const push = new PushNotifier({
3428
- name: options.name,
3429
- endpoint: options.pushEndpoint ?? "https://exp.host/--/api/v2/push",
3430
- subscribeStatus: (listener) => feeds.subscribe(STATUS_FEED, listener),
3431
- readVolumes: readDiskVolumes,
3432
- devices: readDevices,
3433
- dropToken: dropPushToken,
3434
- limits: options.pushLimits
3435
- });
3436
- mkdirSync(serverDir(), {
3437
- recursive: true,
3438
- mode: 448
3439
- });
3440
- let revocationCheck = null;
3441
- const watcher = watch(serverDir(), () => {
3442
- revocationCheck ??= setTimeout(() => {
3443
- revocationCheck = null;
3444
- const paired = new Map(readDevices().map((device) => [device.id, device]));
3445
- push.refresh();
3446
- for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
3447
- for (const [socket, controller] of controllers) if (!paired.get(controller.device.id)?.capabilities.includes("control")) {
3448
- control.endFor(controller, "forbidden", "This device can no longer control devices.");
3449
- controllers.delete(socket);
3450
- }
3451
- }, 50);
3452
- });
3453
- function connection(socket, peer) {
3454
- const limitKey = peer ?? "local";
3455
- const subscriptions = /* @__PURE__ */ new Map();
3456
- const keyframes = /* @__PURE__ */ new Map();
3457
- const commands = /* @__PURE__ */ new Set();
3458
- let nextSubscription = 1;
3459
- let device = null;
3460
- let queue = Promise.resolve();
3461
- const refuse = (id, code, message, closeCode) => {
3462
- if (!device) limiter.record(limitKey);
3463
- send(socket, {
3464
- id,
3465
- error: {
3466
- code,
3467
- message
3468
- }
3469
- });
3470
- socket.close(closeCode, code);
3471
- };
3472
- const timer = setTimeout(() => {
3473
- limiter.record(limitKey);
3474
- socket.close(CLOSE_AUTH_TIMEOUT, "authentication timeout");
3475
- }, authTimeoutMs);
3476
- async function identify() {
3477
- if (peer === null) return { kind: "local" };
3478
- return isIP(peer) ? whois(options.tailscale, options.env, peer) : null;
3479
- }
3480
- async function hello(id, params) {
3481
- if (device) return send(socket, {
3482
- id,
3483
- error: {
3484
- code: "already-authenticated",
3485
- message: "hello was already accepted."
3486
- }
3487
- });
3488
- const auth = isJsonObject(params) && isJsonObject(params.auth) ? params.auth : null;
3489
- if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
3490
- if (params.protocol !== 1) return refuse(id, "protocol-unsupported", `This server speaks protocol 1.`, CLOSE_BAD_REQUEST);
3491
- clearTimeout(timer);
3492
- const identity = await identify();
3493
- if (socket.readyState !== socket.OPEN) return;
3494
- if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
3495
- let outcome;
3496
- if (typeof auth.pairingToken === "string" && typeof auth.deviceName === "string" && auth.deviceName.trim()) outcome = spendPairingToken(auth.pairingToken, auth.deviceName.trim(), identity);
3497
- else if (typeof auth.deviceToken === "string") outcome = authenticateDevice(auth.deviceToken, identity);
3498
- else return refuse(id, "bad-request", "auth needs pairingToken and deviceName, or deviceToken.", CLOSE_BAD_REQUEST);
3499
- if (!outcome.ok) {
3500
- const { code, message } = AUTH_REFUSALS[outcome.reason];
3501
- return refuse(id, code, message, CLOSE_UNAUTHORIZED);
3502
- }
3503
- device = outcome.device;
3504
- sessions.set(socket, device);
3505
- sampler.start();
3506
- send(socket, {
3507
- id,
3508
- result: {
3509
- protocol: 1,
3510
- server: {
3511
- name: options.name,
3512
- version: options.serverVersion,
3513
- stim: options.stimVersion,
3514
- home: homedir()
3515
- },
3516
- capabilities: device.capabilities,
3517
- actions: device.capabilities.includes("control") ? [...ACTIONS] : [],
3518
- device: {
3519
- id: device.id,
3520
- name: device.name
3521
- },
3522
- ...outcome.deviceToken ? { deviceToken: outcome.deviceToken } : {}
3523
- }
3524
- });
3525
- }
3526
- function error(id, code, message) {
3527
- send(socket, {
3528
- id,
3529
- error: {
3530
- code,
3531
- message
3532
- }
3533
- });
3534
- }
3535
- const inputs = {
3536
- tokens: controlLimits.inputPerSecond,
3537
- at: Date.now()
3538
- };
3539
- const characters = {
3540
- tokens: 256,
3541
- at: Date.now()
3542
- };
3543
- const shapeChanges = {
3544
- tokens: controlLimits.shapeChangesPerSecond,
3545
- at: Date.now()
3546
- };
3547
- function controller(session) {
3548
- let found = controllers.get(socket);
3549
- if (!found) {
3550
- found = {
3551
- device: session,
3552
- send: (message) => send(socket, message)
3553
- };
3554
- controllers.set(socket, found);
3555
- }
3556
- return found;
3557
- }
3558
- async function beginControl(id, params, session) {
3559
- const raw = isJsonObject(params) ? params : {};
3560
- const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
3561
- const refuseControl = (code, message) => {
3562
- auditSafely({
3563
- at: (/* @__PURE__ */ new Date()).toISOString(),
3564
- device: {
3565
- id: session.id,
3566
- name: session.name
3567
- },
3568
- action: raw.takeOver === true ? "control.take-over" : "control.begin",
3569
- workspace: clip(raw.workspace),
3570
- ...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
3571
- ok: false,
3572
- error: {
3573
- code,
3574
- message: clip(message)
3575
- }
3576
- });
3577
- error(id, code, message);
3578
- };
3579
- if (!readDevices().find((entry) => entry.id === session.id)?.capabilities.includes("control")) return refuseControl("forbidden", `This device can only read. On the Mac, run \`stim-server devices grant ${session.id} --control\` to let it control devices.`);
3580
- const parsed = parseControlBegin(params);
3581
- if ("code" in parsed) return refuseControl(parsed.code, parsed.message);
3582
- const resolved = registeredWorkspace(parsed.value.workspace);
3583
- if ("code" in resolved) return refuseControl(resolved.code, resolved.message);
3584
- const owner = controller(session);
3585
- const outcome = await control.begin(owner, {
3586
- ...parsed.value,
3587
- workspace: resolved.dir
3588
- }, resolved.dir, () => socket.readyState === socket.OPEN && controllers.get(socket) === owner && readDevices().some((entry) => entry.id === session.id && entry.capabilities.includes("control")));
3589
- if ("code" in outcome) return refuseControl(outcome.code, outcome.message);
3590
- send(socket, {
3591
- id,
3592
- result: outcome
3593
- });
3594
- }
3595
- async function input(id, method, params, session) {
3596
- const owner = controller(session);
3597
- const parsed = parseInput(method, params, (name) => control.targetOf(owner, name));
3598
- if ("code" in parsed) return error(id, parsed.code, parsed.message);
3599
- if (!take(inputs, 1, controlLimits.inputPerSecond, controlLimits.inputPerSecond)) return error(id, "limit-exceeded", `A connection can send ${controlLimits.inputPerSecond} inputs a second.`);
3600
- const { command: sent } = parsed.value;
3601
- if (sent.input === "text" && !take(characters, sent.text.length, controlLimits.textCharsPerSecond, 256)) return error(id, "limit-exceeded", `A connection can type ${controlLimits.textCharsPerSecond} characters a second. Send the rest shortly.`);
3602
- const { shapeChangesPerSecond } = controlLimits;
3603
- if ((sent.input === "rotate" || sent.input === "posture") && !take(shapeChanges, 1, shapeChangesPerSecond, shapeChangesPerSecond)) return error(id, "limit-exceeded", `A connection can rotate or fold ${shapeChangesPerSecond} times a second.`);
3604
- const refused = await control.input(owner, parsed.value.session, parsed.value.command);
3605
- if (refused) return error(id, refused.code, refused.message);
3606
- send(socket, {
3607
- id,
3608
- result: {}
3609
- });
3610
- }
3611
- function workspaceDir(id, workspace, required) {
3612
- if (workspace === void 0 && !required) return homedir();
3613
- const resolved = registeredWorkspace(workspace);
3614
- if ("code" in resolved) {
3615
- error(id, resolved.code, resolved.message);
3616
- return null;
3617
- }
3618
- return resolved.dir;
3619
- }
3620
- function openSubscription(id, result = {}) {
3621
- if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
3622
- error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
3623
- return null;
3624
- }
3625
- const subscription = `s${nextSubscription++}`;
3626
- send(socket, {
3627
- id,
3628
- result: {
3629
- subscription,
3630
- ...result
3631
- }
3632
- });
3633
- return subscription;
3634
- }
3635
- function subscribeStatus(id) {
3636
- const subscription = openSubscription(id);
3637
- if (!subscription) return;
3638
- const envelope = `{"event":"status","subscription":${JSON.stringify(subscription)},"payload":`;
3639
- const unsubscribe = feeds.subscribe(STATUS_FEED, {
3640
- item: (_payload, text) => send(socket, `${envelope}${text}}`),
3641
- failed: (message) => {
3642
- subscriptions.delete(subscription);
3643
- send(socket, {
3644
- event: "error",
3645
- subscription,
3646
- error: {
3647
- code: "status-failed",
3648
- message
3649
- }
3650
- });
3651
- }
3652
- });
3653
- subscriptions.set(subscription, unsubscribe);
3654
- }
3655
- function subscribeLogs(id, params) {
3656
- const parsed = parseLogFilter(params);
3657
- if ("error" in parsed) return error(id, "bad-request", parsed.error);
3658
- const cwd = workspaceDir(id, parsed.filter.workspace, true);
3659
- if (!cwd) return;
3660
- const subscription = openSubscription(id);
3661
- if (!subscription) return;
3662
- const end = () => {
3663
- subscriptions.get(subscription)?.();
3664
- subscriptions.delete(subscription);
3665
- };
3666
- const batcher = new LogBatcher({
3667
- send: (records) => send(socket, {
3668
- event: "logs",
3669
- subscription,
3670
- records
3671
- }),
3672
- bufferedBytes: () => socket.bufferedAmount,
3673
- overflow: () => {
3674
- end();
3675
- send(socket, {
3676
- event: "error",
3677
- subscription,
3678
- error: {
3679
- code: "slow-client",
3680
- message: "This client fell behind the log stream. Subscribe again."
3681
- }
3682
- });
3683
- }
3684
- }, logLimits);
3685
- const unsubscribe = feeds.subscribe({
3686
- args: logArgs(parsed.filter, true),
3687
- cwd,
3688
- keep: parsed.filter.tail,
3689
- label: "stim logs --follow"
3690
- }, {
3691
- item: (record) => batcher.push(record),
3692
- failed: (message) => {
3693
- batcher.flush(true);
3694
- batcher.stop();
3695
- subscriptions.delete(subscription);
3696
- send(socket, {
3697
- event: "error",
3698
- subscription,
3699
- error: {
3700
- code: "logs-failed",
3701
- message
3702
- }
3703
- });
3704
- }
3705
- });
3706
- subscriptions.set(subscription, () => {
3707
- batcher.stop();
3708
- unsubscribe();
3709
- });
3710
- }
3711
- function subscribeFrames(id, params) {
3712
- const { workspace, platform, slot, fps, maxEdge, video } = isJsonObject(params) ? params : {};
3713
- if (typeof workspace !== "string" || platform !== "ios" && platform !== "android") return error(id, "bad-request", "frames.subscribe needs params.workspace and params.platform (ios or android).");
3714
- if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
3715
- 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.");
3716
- const wantsVideo = video?.includes("h264") === true;
3717
- const offersVideo = wantsVideo && frameHelper() !== null;
3718
- const maxFps = wantsVideo ? FRAME_FPS.video : FRAME_FPS.max;
3719
- 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}.`);
3720
- 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}.`);
3721
- const hint = {
3722
- fps: Math.min(fps ?? FRAME_FPS.default, offersVideo ? FRAME_FPS.video : FRAME_FPS.max),
3723
- maxEdge: maxEdge ?? FRAME_EDGE.default
3724
- };
3725
- if (!workspaceDir(id, workspace, true)) return;
3726
- const subscription = openSubscription(id, offersVideo ? { video: "h264" } : {});
3727
- if (!subscription) return;
3728
- const frameTarget = {
3729
- workspace,
3730
- platform,
3731
- ...slot ? { slot } : {}
3732
- };
3733
- const gate = new VideoGate(DEFAULT_VIDEO_LIMITS.congestedBytes);
3734
- let sequence = 0;
3735
- let streamed = null;
3736
- let draining = null;
3737
- const drain = () => {
3738
- draining = null;
3739
- if (ended || !streamed) return;
3740
- if (socket.bufferedAmount <= DEFAULT_VIDEO_LIMITS.congestedBytes) return frames.keyframe(streamed);
3741
- frames.congested(streamed);
3742
- draining = setTimeout(drain, FRAME_RETRY_MS);
3743
- };
3744
- let attached = null;
3745
- let detach = null;
3746
- let pending = null;
3747
- let retry = null;
3748
- let ended = false;
3749
- let sentAt = 0;
3750
- const flush = () => {
3751
- retry = null;
3752
- if (!pending || ended) return;
3753
- const wait = sentAt + 1e3 / hint.fps - Date.now();
3754
- if (wait > 0) {
3755
- retry = setTimeout(flush, wait);
3756
- return;
3757
- }
3758
- if (socket.bufferedAmount > FRAME_BUFFER_FRAMES * pending.data.length) {
3759
- retry = setTimeout(flush, FRAME_RETRY_MS);
3760
- return;
3761
- }
3762
- const frame = pending;
3763
- pending = null;
3764
- sentAt = Date.now();
3765
- send(socket, {
3766
- event: "frame",
3767
- subscription,
3768
- platform,
3769
- slot: slot ?? "default",
3770
- mime: "image/jpeg",
3771
- ...frame
3772
- });
3773
- };
3774
- const cleanup = () => {
3775
- ended = true;
3776
- keyframes.delete(subscription);
3777
- if (retry) clearTimeout(retry);
3778
- if (draining) clearTimeout(draining);
3779
- detach?.();
3780
- detach = null;
3781
- unsubscribeStatus?.();
3782
- };
3783
- const end = (message) => {
3784
- if (ended) return;
3785
- cleanup();
3786
- subscriptions.delete(subscription);
3787
- send(socket, {
3788
- event: "error",
3789
- subscription,
3790
- error: {
3791
- code: "frames-failed",
3792
- message
3793
- }
3794
- });
3795
- };
3796
- const listener = {
3797
- frame: (frame) => {
3798
- pending = frame;
3799
- if (!retry) flush();
3800
- },
3801
- delayed: (delayed) => {
3802
- if (!ended) send(socket, {
3803
- event: "frame-delayed",
3804
- subscription,
3805
- delayed
3806
- });
3807
- },
3808
- failed: end,
3809
- ...offersVideo ? { video: (unit) => {
3810
- if (ended || socket.readyState !== socket.OPEN) return;
3811
- const verdict = gate.admit(unit, socket.bufferedAmount);
3812
- if (verdict === "send") socket.send(videoPacket(subscription, sequence++, unit));
3813
- else if (verdict === "congested" && !draining) drain();
3814
- } } : {}
3815
- };
3816
- if (offersVideo) keyframes.set(subscription, () => {
3817
- gate.reset();
3818
- if (streamed) frames.keyframe(streamed);
3819
- });
3820
- let unsubscribeStatus = null;
3821
- unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
3822
- item: (payload) => {
3823
- if (ended) return;
3824
- const resolved = ownedDevice(payload, frameTarget, attached);
3825
- if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
3826
- if (deviceKey(resolved) === attached) return;
3827
- detach?.();
3828
- gate.reset();
3829
- streamed = resolved;
3830
- attached = deviceKey(resolved);
3831
- detach = frames.subscribe(resolved, listener, hint);
3832
- },
3833
- failed: (message) => queueMicrotask(() => end(message))
3834
- });
3835
- subscriptions.set(subscription, cleanup);
3836
- }
3837
- function command(id, args, cwd, result, limits = commandLimits, turn = null) {
3838
- if (commands.size >= MAX_COMMANDS) {
3839
- error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
3840
- return null;
3841
- }
3842
- let run = null;
3843
- let dropped = false;
3844
- let release;
3845
- const released = new Promise((resolve) => {
3846
- release = resolve;
3847
- });
3848
- const cancel = async () => {
3849
- dropped = true;
3850
- if (!run) return void (turn ?? Promise.resolve()).then(release);
3851
- await run.cancel();
3852
- release();
3853
- };
3854
- const start = async () => {
3855
- if (dropped) return;
3856
- run = runStim(options.stimCli, options.env, args, cwd, limits);
3857
- const outcome = await run.outcome;
3858
- commands.delete(cancel);
3859
- running.delete(cancel);
3860
- try {
3861
- if (!outcome.ok) {
3862
- const printed = actionOutcome(outcome);
3863
- return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
3864
- }
3865
- let value;
3866
- try {
3867
- value = result(outcome.stdout);
3868
- } catch {
3869
- return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
3870
- }
3871
- send(socket, {
3872
- id,
3873
- result: value
3874
- });
3875
- } finally {
3876
- release();
3877
- }
3878
- };
3879
- commands.add(cancel);
3880
- running.add(cancel);
3881
- if (turn) turn.then(start);
3882
- else start();
3883
- return released;
3884
- }
3885
- function queryLogs(id, params) {
3886
- const parsed = parseLogFilter(params);
3887
- if ("error" in parsed) return error(id, "bad-request", parsed.error);
3888
- const cwd = workspaceDir(id, parsed.filter.workspace, true);
3889
- if (!cwd) return;
3890
- command(id, logArgs(parsed.filter, false), cwd, (stdout) => ({ records: stdout.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)) }));
3891
- }
3892
- function workspaceCommand(id, method, params) {
3893
- if (params !== void 0 && !isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
3894
- const cwd = workspaceDir(id, params?.workspace, false);
3895
- if (!cwd) return;
3896
- command(id, [method === "stats.get" ? "stats" : "settings", "--json"], cwd, (stdout) => {
3897
- const value = JSON.parse(stdout);
3898
- if (!isJsonObject(value)) throw new Error("not an object");
3899
- return value;
3900
- });
3901
- }
3902
- function runAction(id, params, session) {
3903
- const startedAt = Date.now();
3904
- const raw = isJsonObject(params) ? params : {};
3905
- const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
3906
- const record = {
3907
- at: new Date(startedAt).toISOString(),
3908
- device: {
3909
- id: session.id,
3910
- name: session.name
3911
- },
3912
- action: clip(raw.action),
3913
- workspace: clip(raw.workspace),
3914
- ...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
3915
- ok: false
3916
- };
3917
- const audit = (outcome) => {
3918
- const logged = outcome.error ? {
3919
- ...outcome.error,
3920
- message: clip(outcome.error.message)
3921
- } : void 0;
3922
- try {
3923
- appendAudit({
3924
- ...record,
3925
- ...outcome,
3926
- ...logged ? { error: logged } : {}
3927
- });
3928
- } catch (cause) {
3929
- console.error(`stim-server: could not append to the action log: ${cause.message}`);
3930
- }
3931
- };
3932
- const refuseAction = (code, message) => {
3933
- audit({
3934
- ok: false,
3935
- error: {
3936
- code,
3937
- message
3938
- }
3939
- });
3940
- error(id, code, message);
3941
- };
3942
- 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.`);
3943
- const parsed = parseAction(params);
3944
- if ("code" in parsed) return refuseAction(parsed.code, parsed.message);
3945
- const { action } = parsed;
3946
- const resolved = registeredWorkspace(action.workspace);
3947
- if ("code" in resolved) return refuseAction(resolved.code, resolved.message);
3948
- if (busyWorkspaces.has(resolved.dir)) return refuseAction("action-busy", `An action is already running in ${resolved.dir}. Try again when it finishes.`);
3949
- let run;
3950
- try {
3951
- run = runStim(options.stimCli, options.env, actionArgs(action), resolved.dir, actionLimits);
3952
- } catch (cause) {
3953
- return refuseAction("action-failed", `stim ${action.action} could not start (${cause.message}).`);
3954
- }
3955
- busyWorkspaces.add(resolved.dir);
3956
- let finished = false;
3957
- const finish = (outcome) => {
3958
- if (finished) return;
3959
- finished = true;
3960
- running.delete(cancel);
3961
- busyWorkspaces.delete(resolved.dir);
3962
- audit({
3963
- ok: outcome.ok,
3964
- ...outcome.ok ? {} : { error: outcome.error },
3965
- durationMs: Date.now() - startedAt
3966
- });
3967
- if (outcome.ok) send(socket, {
3968
- id,
3969
- result: {
3970
- action: action.action,
3971
- workspace: resolved.dir,
3972
- output: outcome.output
3973
- }
3974
- });
3975
- else send(socket, {
3976
- id,
3977
- error: outcome.error
3978
- });
3979
- };
3980
- const cancel = async () => {
3981
- finish({
3982
- ok: false,
3983
- error: {
3984
- code: "action-failed",
3985
- message: "stim-server stopped before the action finished."
3986
- }
3987
- });
3988
- await run.cancel();
3989
- };
3990
- running.add(cancel);
3991
- run.outcome.then((outcome) => finish(actionOutcome(outcome)));
3992
- }
3993
- function planBuild(id, params) {
3994
- if (!isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
3995
- const { platform, slot } = params;
3996
- if (platform !== "ios" && platform !== "android") return error(id, "bad-request", "params.platform must be ios or android.");
3997
- 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.");
3998
- const cwd = workspaceDir(id, params.workspace, true);
3999
- if (!cwd) return;
4000
- const finished = command(id, [
4001
- platform,
4002
- "--plan",
4003
- "--json",
4004
- ...slot === void 0 ? [] : [`--slot=${slot}`]
4005
- ], cwd, (stdout) => {
4006
- const value = JSON.parse(stdout);
4007
- if (!isJsonObject(value) || value.platform !== platform) throw new Error("not a plan");
4008
- return value;
4009
- }, {
4010
- ...commandLimits,
4011
- timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
4012
- }, planQueues.get(cwd) ?? null);
4013
- if (!finished) return;
4014
- planQueues.set(cwd, finished);
4015
- finished.finally(() => {
4016
- if (planQueues.get(cwd) === finished) planQueues.delete(cwd);
4017
- });
4018
- }
4019
- function registerPush(id, params, session) {
4020
- const token = isJsonObject(params) ? params.token : void 0;
4021
- const events = isJsonObject(params) ? params.events : void 0;
4022
- const agentOnly = isJsonObject(params) ? params.agentOnly === void 0 ? false : params.agentOnly : void 0;
4023
- const ref = isJsonObject(params) ? params.ref : void 0;
4024
- if (typeof token !== "string" || !pushToken.test(token) || !Array.isArray(events) || events.length === 0 || !events.every((event) => PUSH_EVENTS.includes(event)) || typeof agentOnly !== "boolean" || typeof ref !== "string" || ref.length === 0 || ref.length > 128) return error(id, "bad-request", `push.register takes an Expo push token, one or more events from ${PUSH_EVENTS.join(", ")}, an optional boolean agentOnly and a ref of 1 to 128 characters.`);
4025
- if (!setDevicePush(session.id, {
4026
- token,
4027
- events: PUSH_EVENTS.filter((event) => events.includes(event)),
4028
- agentOnly,
4029
- ref,
4030
- registeredAt: (/* @__PURE__ */ new Date()).toISOString()
4031
- })) return error(id, "unauthorized", "This device is no longer paired.");
4032
- push.refresh();
4033
- send(socket, {
4034
- id,
4035
- result: {}
4036
- });
4037
- }
4038
- async function handle(raw) {
4039
- if (socket.readyState !== socket.OPEN) return;
4040
- let message;
4041
- try {
4042
- message = JSON.parse(raw);
4043
- } catch {
4044
- message = null;
4045
- }
4046
- const id = isJsonObject(message) ? requestId(message.id) : null;
4047
- if (!isJsonObject(message) || id === null || typeof message.method !== "string") {
4048
- if (device) return send(socket, {
4049
- id,
4050
- error: {
4051
- code: "bad-request",
4052
- message: "Expected {id, method, params}."
4053
- }
4054
- });
4055
- return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
4056
- }
4057
- if (message.method === "hello") return hello(id, message.params);
4058
- if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
4059
- if (message.method === "status.subscribe") return subscribeStatus(id);
4060
- if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
4061
- if (message.method === "logs.query") return queryLogs(id, message.params);
4062
- if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
4063
- if (message.method === "frames.keyframe") {
4064
- const name = isJsonObject(message.params) ? message.params.subscription : void 0;
4065
- const keyframe = typeof name === "string" ? keyframes.get(name) : void 0;
4066
- if (!keyframe) return send(socket, {
4067
- id,
4068
- error: {
4069
- code: "unknown-subscription",
4070
- message: `No video subscription ${String(name)}.`
4071
- }
4072
- });
4073
- keyframe();
4074
- return send(socket, {
4075
- id,
4076
- result: {}
4077
- });
4078
- }
4079
- if (message.method === "build.plan") return planBuild(id, message.params);
4080
- if (message.method === "machine.get") return send(socket, {
4081
- id,
4082
- result: await readMachineUsage()
4083
- });
4084
- if (message.method === "machine.history") {
4085
- const params = message.params ?? {};
4086
- const sinceMs = isJsonObject(params) ? params.sinceMs : void 0;
4087
- if (!isJsonObject(params) || sinceMs !== void 0 && typeof sinceMs !== "number") return error(id, "bad-request", "machine.history takes an optional numeric sinceMs.");
4088
- return send(socket, {
4089
- id,
4090
- result: sampler.history(sinceMs)
4091
- });
4092
- }
4093
- if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
4094
- if (message.method === "action") return runAction(id, message.params, device);
4095
- if (message.method === "control.begin") {
4096
- beginControl(id, message.params, device);
4097
- return;
4098
- }
4099
- if (message.method === "control.end") {
4100
- const name = isJsonObject(message.params) ? message.params.session : void 0;
4101
- if (typeof name !== "string" || !control.endById(controller(device), name)) return error(id, "unknown-session", `No control session ${String(name)} on this connection.`);
4102
- return send(socket, {
4103
- id,
4104
- result: {}
4105
- });
4106
- }
4107
- if (message.method === "input.touch" || message.method === "input.text" || message.method === "input.button" || message.method === "input.rotate" || message.method === "input.posture") {
4108
- const method = message.method;
4109
- input(id, method, message.params, device);
4110
- return;
4111
- }
4112
- if (message.method === "push.register") return registerPush(id, message.params, device);
4113
- if (message.method === "push.unregister") {
4114
- setDevicePush(device.id, null);
4115
- push.refresh();
4116
- return send(socket, {
4117
- id,
4118
- result: {}
4119
- });
4120
- }
4121
- if (message.method === "unsubscribe") {
4122
- const name = isJsonObject(message.params) ? message.params.subscription : void 0;
4123
- const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
4124
- if (!unsubscribe) return send(socket, {
4125
- id,
4126
- error: {
4127
- code: "unknown-subscription",
4128
- message: `No subscription ${String(name)}.`
4129
- }
4130
- });
4131
- subscriptions.delete(name);
4132
- unsubscribe();
4133
- return send(socket, {
4134
- id,
4135
- result: {}
4136
- });
4137
- }
4138
- send(socket, {
4139
- id,
4140
- error: {
4141
- code: "unknown-method",
4142
- message: `Unknown method ${message.method}.`
4143
- }
4144
- });
4145
- }
4146
- socket.on("message", (data) => {
4147
- const raw = data.toString();
4148
- queue = queue.then(() => handle(raw)).catch(() => socket.close(1011, "internal error"));
4149
- });
4150
- socket.on("close", () => {
4151
- clearTimeout(timer);
4152
- sessions.delete(socket);
4153
- if (sessions.size === 0) sampler.stop();
4154
- const owner = controllers.get(socket);
4155
- if (owner) control.endFor(owner, null, "The client disconnected.");
4156
- controllers.delete(socket);
4157
- for (const unsubscribe of subscriptions.values()) unsubscribe();
4158
- subscriptions.clear();
4159
- for (const cancel of commands) {
4160
- running.delete(cancel);
4161
- cancel();
4162
- }
4163
- commands.clear();
4164
- });
4165
- }
4166
- function upgrade(request, socket, head) {
4167
- const peer = peerAddress(request);
4168
- if (limiter.blocked(peer ?? "local")) {
4169
- socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
4170
- return;
4171
- }
4172
- wss.handleUpgrade(request, socket, head, (ws) => connection(ws, peer));
4173
- }
4174
- const health = {
4175
- server: "stim-server",
4176
- name: options.name,
4177
- version: options.serverVersion,
4178
- stim: options.stimVersion,
4179
- protocol: 1,
4180
- stimHome: configDir(),
4181
- tailscale: healthTailscale(options.tailscaleState)
4182
- };
4183
- const answerHealth = async (response) => {
4184
- const tailscale = options.tailscaleState;
4185
- const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
4186
- response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
4187
- ...health,
4188
- route
4189
- }));
4190
- };
4191
- const servers = [];
4192
- const addresses = [];
4193
- push.refresh();
4194
- const close = async () => {
4195
- push.close();
4196
- watcher.close();
4197
- helperAbort.abort();
4198
- sampler.stop();
4199
- if (revocationCheck) clearTimeout(revocationCheck);
4200
- for (const client of wss.clients) client.terminate();
4201
- await control.close();
4202
- await Promise.all([
4203
- frames.close(),
4204
- feeds.close(),
4205
- ...[...running].map((cancel) => cancel())
4206
- ]);
4207
- wss.close();
4208
- await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));
4209
- };
4210
- try {
4211
- for (const host of options.hosts) {
4212
- const server = createServer((request, response) => {
4213
- if (localHealthRequest(request)) {
4214
- answerHealth(response);
4215
- return;
4216
- }
4217
- response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
4218
- });
4219
- server.on("upgrade", upgrade);
4220
- servers.push(server);
4221
- await new Promise((resolve, reject) => {
4222
- server.once("error", reject);
4223
- server.listen(options.port, host, () => {
4224
- server.off("error", reject);
4225
- resolve();
4226
- });
4227
- });
4228
- addresses.push({
4229
- host,
4230
- port: server.address().port
4231
- });
4232
- }
4233
- } catch (error) {
4234
- await close();
4235
- throw error;
4236
- }
4237
- return {
4238
- addresses,
4239
- close
4240
- };
4241
- }
4242
- //#endregion
4243
- //#region bin/stim-server.ts
4244
- const DEFAULT_PORT = 7787;
4245
- const USAGE = `Usage:
4246
- stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
4247
- stim-server pair [--port <n>] [--control] [--json]
4248
- print a single-use pairing payload for the QR code;
4249
- --control lets the paired device run actions
4250
- stim-server devices [list] [--json]
4251
- list paired devices
4252
- stim-server devices grant <id> --control|--read
4253
- let a paired device run actions, or only read
4254
- stim-server devices revoke <id> revoke a paired device
4255
- stim-server log [--json] list the actions paired devices ran`;
4256
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
4257
- function fail(message) {
4258
- console.error(`stim-server: ${message}`);
4259
- process.exit(1);
4260
- }
4261
- function tailscaleNote(tailscale) {
4262
- const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
4263
- if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
4264
- if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
4265
- return null;
4266
- }
4267
- function routeNote(route, port) {
4268
- 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)}\``;
4269
- if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
4270
- 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)}\``;
4271
- return null;
4272
- }
4273
- function macName(tailscale) {
4274
- return tailscale.state === "running" && tailscale.hostName || hostname();
4275
- }
4276
- async function serve(port) {
4277
- const login = loginShellEnvironment();
4278
- if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
4279
- const env = { ...login ?? process.env };
4280
- if (!process.env.STIM_HOME && env.STIM_HOME) process.env.STIM_HOME = env.STIM_HOME;
4281
- if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
4282
- const tailscaleBinary = findTailscale(env);
4283
- const tailscale = tailscaleStatus(tailscaleBinary, env);
4284
- const stim = bundledStim();
4285
- const hosts = ["127.0.0.1", ...tailscale.state === "running" ? tailscale.ips : []];
4286
- let server;
4287
- try {
4288
- server = await startServer({
4289
- name: macName(tailscale),
4290
- hosts,
4291
- port,
4292
- stimCli: stim.cli,
4293
- stimVersion: stim.version,
4294
- serverVersion: pkg.version,
4295
- env,
4296
- tailscale: tailscaleBinary,
4297
- tailscaleState: tailscale
4298
- });
4299
- } catch (error) {
4300
- if (error.code === "EADDRINUSE") fail(`port ${port} is in use; another stim-server may already be running. Pass --port to use another port.`);
4301
- throw error;
4302
- }
4303
- console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
4304
- for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
4305
- let note = tailscaleNote(tailscale);
4306
- if (tailscale.state === "running" && tailscale.dnsName) {
4307
- const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
4308
- if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
4309
- note = routeNote(route, port);
4310
- if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
4311
- }
4312
- if (note) console.error(note);
4313
- const shutdown = () => {
4314
- server.close().then(() => process.exit(0));
4315
- };
4316
- process.on("SIGINT", shutdown);
4317
- process.on("SIGTERM", shutdown);
4318
- process.on("SIGHUP", shutdown);
4319
- }
4320
- async function pair(port, json, control) {
4321
- const binary = findTailscale(process.env);
4322
- const tailscale = tailscaleStatus(binary, process.env);
4323
- let endpoint = `ws://127.0.0.1:${port}`;
4324
- let note = tailscaleNote(tailscale);
4325
- if (note) note = `${note} The endpoint above only works on this Mac.`;
4326
- if (tailscale.state === "running" && tailscale.dnsName) {
4327
- const route = await serveRoute(binary, process.env, port, tailscale.ips);
4328
- if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
4329
- endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
4330
- note = routeNote(route, port);
4331
- }
4332
- const { token, expiresAt } = createPairingToken(Date.now(), capabilitiesFor(control));
4333
- const payload = {
4334
- v: 1,
4335
- name: macName(tailscale),
4336
- endpoint,
4337
- pairingToken: token
4338
- };
4339
- if (json) return void console.log(JSON.stringify({
4340
- qr: payload,
4341
- expiresAt
4342
- }));
4343
- console.log(JSON.stringify(payload));
4344
- console.error(`The pairing token is single use and expires at ${expiresAt}. The device it pairs can ${control ? "run actions" : "only read"}.`);
4345
- if (note) console.error(note);
4346
- }
4347
- function describe(device) {
4348
- const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
4349
- const scope = device.capabilities.includes("control") ? "control" : "read";
4350
- return `${device.id} ${device.name} ${scope} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
4351
- }
4352
- async function main() {
4353
- const { values, positionals } = parseArgs({
4354
- allowPositionals: true,
4355
- options: {
4356
- port: { type: "string" },
4357
- json: { type: "boolean" },
4358
- control: { type: "boolean" },
4359
- read: { type: "boolean" },
4360
- help: {
4361
- type: "boolean",
4362
- short: "h"
4363
- },
4364
- version: {
4365
- type: "boolean",
4366
- short: "V"
4367
- }
4368
- }
4369
- });
4370
- if (values.help) return void console.log(USAGE);
4371
- if (values.version) return void console.log(pkg.version);
4372
- const port = values.port === void 0 ? DEFAULT_PORT : Number(values.port);
4373
- if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
4374
- const [command, sub, arg, ...rest] = positionals;
4375
- if (command === void 0) return serve(port);
4376
- const grant = command === "devices" && sub === "grant";
4377
- if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
4378
- if (values.control && !grant && command !== "pair") fail(`--control applies only to \`pair\` and \`devices grant\`.\n${USAGE}`);
4379
- if (command === "pair" && sub === void 0) return pair(port, values.json === true, values.control === true);
4380
- if (grant && arg !== void 0 && rest.length === 0) {
4381
- if (values.control === values.read) fail("devices grant takes exactly one of --control or --read.");
4382
- if (!grantDevice(arg, capabilitiesFor(values.control === true))) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
4383
- console.log(`${arg} can now ${values.control ? "run actions" : "only read"}.`);
4384
- return;
4385
- }
4386
- if (command === "log" && sub === void 0) {
4387
- const records = readAudit();
4388
- if (values.json) return void console.log(JSON.stringify({ actions: records }));
4389
- if (!records.length) console.log("No actions.");
4390
- for (const record of records) {
4391
- const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
4392
- const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
4393
- console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
4394
- }
4395
- return;
4396
- }
4397
- if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
4398
- const devices = readDevices();
4399
- if (values.json) {
4400
- const listed = devices.map(({ id, name, identity, pairedAt, lastSeenAt, capabilities }) => ({
4401
- id,
4402
- name,
4403
- identity,
4404
- pairedAt,
4405
- lastSeenAt,
4406
- capabilities
4407
- }));
4408
- console.log(JSON.stringify({ devices: listed }));
4409
- return;
4410
- }
4411
- if (!devices.length) console.log("No paired devices.");
4412
- for (const device of devices) console.log(describe(device));
4413
- return;
4414
- }
4415
- if (command === "devices" && sub === "revoke" && arg !== void 0 && rest.length === 0) {
4416
- if (!revokeDevice(arg)) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
4417
- console.log(`Revoked ${arg}.`);
4418
- return;
4419
- }
4420
- fail(`unknown command.\n${USAGE}`);
4421
- }
4422
- await main();
2
+ //#region bin/node-check.ts
3
+ const encode = (version) => version.split(".", 3).reduce((sum, part) => sum * 1e3 + parseInt(part, 10), 0);
4
+ if (encode(process.versions.node) < encode("22.12.0")) {
5
+ process.stderr.write(`STIM_NODE_UNSUPPORTED: stim-server needs Node 22.12.0 or later; this is Node ${process.versions.node} at ${process.execPath}. To run stim-server where a project pins an older Node, see https://stim.appandflow.com/docs/requirements#older-node-pins\n`);
6
+ process.exitCode = 1;
7
+ } else import("./stim-server-OiJI9xLU.mjs");
4423
8
  //#endregion
4424
9
  export {};