@stim-cli/server 0.0.0 → 1.10.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.
@@ -0,0 +1,1714 @@
1
+ #!/usr/bin/env node
2
+ import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-dn2_TUeH.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
+ import { homedir, hostname, tmpdir } from "node:os";
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 { createHash, randomBytes } from "node:crypto";
10
+ import { configDir, withDirLock } from "@stim-cli/core";
11
+ import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
12
+ import { createServer } from "node:http";
13
+ import { isIP } from "node:net";
14
+ import { WebSocketServer } from "ws";
15
+ import { createInterface } from "node:readline";
16
+ import { connect } from "node:http2";
17
+ //#region src/environment.ts
18
+ function parseEnvironment(output) {
19
+ const environment = {};
20
+ for (const entry of output.split("\0")) {
21
+ const equals = entry.indexOf("=");
22
+ if (equals > 0) environment[entry.slice(0, equals)] = entry.slice(equals + 1);
23
+ }
24
+ return environment;
25
+ }
26
+ /**
27
+ * The login shell's environment, as Stim Desktop captures it: a process started by launchd lacks the
28
+ * PATH entries and variables such as ANDROID_HOME that the shell profile sets. The shell writes to a
29
+ * file because a background process started by a profile can keep a pipe open after the shell exits.
30
+ */
31
+ function loginShellEnvironment() {
32
+ const dir = mkdtempSync(join(tmpdir(), "stim-server-env-"));
33
+ const file = join(dir, "env");
34
+ try {
35
+ spawnSync(process.env.SHELL || "/bin/zsh", [
36
+ "-lic",
37
+ "command env -0 > \"$1\"",
38
+ "stim-server",
39
+ file
40
+ ], {
41
+ stdio: "ignore",
42
+ timeout: 15e3
43
+ });
44
+ const environment = parseEnvironment(readFileSync(file, "utf8"));
45
+ return Object.keys(environment).length ? environment : null;
46
+ } catch {
47
+ return null;
48
+ } finally {
49
+ rmSync(dir, {
50
+ recursive: true,
51
+ force: true
52
+ });
53
+ }
54
+ }
55
+ function bundledStim() {
56
+ const manifest = createRequire(import.meta.url).resolve("stim/package.json");
57
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
58
+ return {
59
+ cli: join(dirname(manifest), pkg.bin.stim),
60
+ version: pkg.version
61
+ };
62
+ }
63
+ //#endregion
64
+ //#region src/registry.ts
65
+ const PAIRING_TTL_MS = 3e5;
66
+ function serverDir() {
67
+ return join(configDir(), "server");
68
+ }
69
+ function devicesFile() {
70
+ return join(serverDir(), "devices.json");
71
+ }
72
+ function pairingFile() {
73
+ return join(serverDir(), "pairing.json");
74
+ }
75
+ function hashToken(token) {
76
+ return createHash("sha256").update(token).digest("hex");
77
+ }
78
+ function newToken() {
79
+ return randomBytes(32).toString("base64url");
80
+ }
81
+ function parseIdentity(value) {
82
+ if (!isJsonObject(value)) return null;
83
+ if (value.kind === "local") return { kind: "local" };
84
+ const { nodeId, nodeName, user } = value;
85
+ if (value.kind === "tailnet" && typeof nodeId === "string" && typeof nodeName === "string" && typeof user === "string") return {
86
+ kind: "tailnet",
87
+ nodeId,
88
+ nodeName,
89
+ user
90
+ };
91
+ return null;
92
+ }
93
+ function parseDevice(value) {
94
+ if (!isJsonObject(value)) return null;
95
+ const { id, name, tokenHash, pairedAt, lastSeenAt } = value;
96
+ const identity = parseIdentity(value.identity);
97
+ if (typeof id !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || !identity) return null;
98
+ if (typeof pairedAt !== "string") return null;
99
+ const capabilities = Array.isArray(value.capabilities) ? CAPABILITIES.filter((capability) => value.capabilities.includes(capability)) : [];
100
+ return {
101
+ id,
102
+ name,
103
+ tokenHash,
104
+ identity,
105
+ pairedAt,
106
+ lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
107
+ capabilities
108
+ };
109
+ }
110
+ function readDevices() {
111
+ const devices = readJsonObject(devicesFile())?.devices;
112
+ return Array.isArray(devices) ? devices.flatMap((entry) => parseDevice(entry) ?? []) : [];
113
+ }
114
+ function readPairings() {
115
+ const tokens = readJsonObject(pairingFile())?.tokens;
116
+ if (!Array.isArray(tokens)) return [];
117
+ return tokens.flatMap((entry) => isJsonObject(entry) && typeof entry.tokenHash === "string" && typeof entry.expiresAt === "string" ? [{
118
+ tokenHash: entry.tokenHash,
119
+ expiresAt: entry.expiresAt
120
+ }] : []);
121
+ }
122
+ function writeJson(file, value) {
123
+ const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
124
+ try {
125
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
126
+ renameSync(temporary, file);
127
+ } catch (error) {
128
+ rmSync(temporary, { force: true });
129
+ throw error;
130
+ }
131
+ }
132
+ function transaction(fn) {
133
+ return withDirLock(join(serverDir(), "registry.lock"), fn, { ensureParent: () => mkdirSync(serverDir(), {
134
+ recursive: true,
135
+ mode: 448
136
+ }) });
137
+ }
138
+ function unexpired(record, now) {
139
+ return Date.parse(record.expiresAt) > now;
140
+ }
141
+ function createPairingToken(now = Date.now()) {
142
+ const token = newToken();
143
+ const expiresAt = new Date(now + PAIRING_TTL_MS).toISOString();
144
+ transaction(() => {
145
+ const pending = readPairings().filter((record) => unexpired(record, now));
146
+ writeJson(pairingFile(), {
147
+ version: 1,
148
+ tokens: [...pending, {
149
+ tokenHash: hashToken(token),
150
+ expiresAt
151
+ }]
152
+ });
153
+ });
154
+ return {
155
+ token,
156
+ expiresAt
157
+ };
158
+ }
159
+ function sameNode(a, b) {
160
+ if (a.kind === "local" || b.kind === "local") return a.kind === b.kind;
161
+ return a.nodeId === b.nodeId;
162
+ }
163
+ function spendPairingToken(token, name, identity, now = Date.now()) {
164
+ return transaction(() => {
165
+ const pairings = readPairings();
166
+ const tokenHash = hashToken(token);
167
+ const match = pairings.find((record) => record.tokenHash === tokenHash);
168
+ const pending = pairings.filter((record) => record !== match && unexpired(record, now));
169
+ if (pending.length !== pairings.length) writeJson(pairingFile(), {
170
+ version: 1,
171
+ tokens: pending
172
+ });
173
+ if (!match) return {
174
+ ok: false,
175
+ reason: "pairing-unknown"
176
+ };
177
+ if (!unexpired(match, now)) return {
178
+ ok: false,
179
+ reason: "pairing-expired"
180
+ };
181
+ const deviceToken = newToken();
182
+ const at = new Date(now).toISOString();
183
+ const device = {
184
+ id: randomBytes(4).toString("hex"),
185
+ name,
186
+ tokenHash: hashToken(deviceToken),
187
+ identity,
188
+ pairedAt: at,
189
+ lastSeenAt: at,
190
+ capabilities: ["read"]
191
+ };
192
+ writeJson(devicesFile(), {
193
+ version: 1,
194
+ devices: [...readDevices(), device]
195
+ });
196
+ return {
197
+ ok: true,
198
+ device,
199
+ deviceToken
200
+ };
201
+ });
202
+ }
203
+ function authenticateDevice(token, identity, now = Date.now()) {
204
+ return transaction(() => {
205
+ const devices = readDevices();
206
+ const tokenHash = hashToken(token);
207
+ const device = devices.find((entry) => entry.tokenHash === tokenHash);
208
+ if (!device) return {
209
+ ok: false,
210
+ reason: "device-unknown"
211
+ };
212
+ if (!sameNode(device.identity, identity)) return {
213
+ ok: false,
214
+ reason: "node-mismatch"
215
+ };
216
+ device.lastSeenAt = new Date(now).toISOString();
217
+ writeJson(devicesFile(), {
218
+ version: 1,
219
+ devices
220
+ });
221
+ return {
222
+ ok: true,
223
+ device
224
+ };
225
+ });
226
+ }
227
+ function revokeDevice(id) {
228
+ return transaction(() => {
229
+ const devices = readDevices();
230
+ const remaining = devices.filter((device) => device.id !== id);
231
+ if (remaining.length === devices.length) return false;
232
+ writeJson(devicesFile(), {
233
+ version: 1,
234
+ devices: remaining
235
+ });
236
+ return true;
237
+ });
238
+ }
239
+ //#endregion
240
+ //#region src/stim-command.ts
241
+ const KILL_GRACE_MS = 1e3;
242
+ function terminate(child) {
243
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
244
+ child.kill("SIGTERM");
245
+ const timer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
246
+ return new Promise((resolve) => {
247
+ const done = () => {
248
+ clearTimeout(timer);
249
+ resolve();
250
+ };
251
+ child.once("exit", done);
252
+ child.once("close", done);
253
+ });
254
+ }
255
+ function runStim(stimCli, env, args, cwd, limits) {
256
+ const child = spawn(process.execPath, [stimCli, ...args], {
257
+ cwd,
258
+ env,
259
+ stdio: [
260
+ "ignore",
261
+ "pipe",
262
+ "pipe"
263
+ ]
264
+ });
265
+ const label = `stim ${args[0]}`;
266
+ let cancelled = false;
267
+ let failure = null;
268
+ const chunks = [];
269
+ let size = 0;
270
+ let stderr = "";
271
+ const fail = (message) => {
272
+ if (failure) return;
273
+ failure = message;
274
+ terminate(child);
275
+ };
276
+ const timer = setTimeout(() => fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`), limits.timeoutMs);
277
+ child.stdout.on("data", (chunk) => {
278
+ size += chunk.length;
279
+ if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
280
+ chunks.push(chunk);
281
+ });
282
+ child.stderr.setEncoding("utf8");
283
+ child.stderr.on("data", (chunk) => {
284
+ stderr = (stderr + chunk).slice(-2e3);
285
+ });
286
+ return {
287
+ outcome: new Promise((resolve) => {
288
+ const settle = (result) => {
289
+ clearTimeout(timer);
290
+ if (!cancelled) resolve(result);
291
+ };
292
+ child.on("error", (error) => settle({
293
+ ok: false,
294
+ message: `${label} could not start (${error.message}).`
295
+ }));
296
+ child.on("close", (code, signal) => {
297
+ if (failure) return settle({
298
+ ok: false,
299
+ message: failure
300
+ });
301
+ if (code === 0) return settle({
302
+ ok: true,
303
+ stdout: Buffer.concat(chunks).toString("utf8")
304
+ });
305
+ const detail = stderr.trim();
306
+ settle({
307
+ ok: false,
308
+ message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`
309
+ });
310
+ });
311
+ }),
312
+ cancel: () => {
313
+ cancelled = true;
314
+ clearTimeout(timer);
315
+ return terminate(child);
316
+ }
317
+ };
318
+ }
319
+ //#endregion
320
+ //#region src/feed.ts
321
+ var Feed = class {
322
+ listeners = /* @__PURE__ */ new Set();
323
+ kept = [];
324
+ child;
325
+ ended;
326
+ constructor(spec, stimCli, env, ended) {
327
+ this.ended = ended;
328
+ const child = spawn(process.execPath, [stimCli, ...spec.args], {
329
+ cwd: spec.cwd,
330
+ env,
331
+ stdio: [
332
+ "ignore",
333
+ "pipe",
334
+ "pipe"
335
+ ]
336
+ });
337
+ this.child = child;
338
+ let stderr = "";
339
+ child.stderr?.setEncoding("utf8");
340
+ child.stderr?.on("data", (chunk) => {
341
+ stderr = (stderr + chunk).slice(-2e3);
342
+ });
343
+ createInterface({ input: child.stdout }).on("line", (line) => {
344
+ if (this.child !== child) return;
345
+ let value;
346
+ try {
347
+ value = JSON.parse(line);
348
+ } catch {
349
+ return;
350
+ }
351
+ if (!isJsonObject(value)) return;
352
+ this.kept.push(value);
353
+ if (this.kept.length > spec.keep) this.kept.shift();
354
+ for (const listener of this.listeners) listener.item(value);
355
+ });
356
+ const exited = (reason) => {
357
+ if (this.child !== child) return;
358
+ this.child = null;
359
+ this.ended();
360
+ const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
361
+ const listeners = [...this.listeners];
362
+ this.listeners.clear();
363
+ for (const listener of listeners) listener.failed(message);
364
+ };
365
+ child.on("error", (error) => exited(`could not start (${error.message})`));
366
+ child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
367
+ }
368
+ add(listener) {
369
+ this.listeners.add(listener);
370
+ for (const value of this.kept) listener.item(value);
371
+ return () => {
372
+ if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
373
+ };
374
+ }
375
+ stop() {
376
+ const child = this.child;
377
+ if (!child) return Promise.resolve();
378
+ this.child = null;
379
+ this.listeners.clear();
380
+ this.ended();
381
+ return terminate(child);
382
+ }
383
+ };
384
+ var FeedPool = class {
385
+ feeds = /* @__PURE__ */ new Map();
386
+ stimCli;
387
+ env;
388
+ constructor(stimCli, env) {
389
+ this.stimCli = stimCli;
390
+ this.env = env;
391
+ }
392
+ subscribe(spec, listener) {
393
+ const key = JSON.stringify([spec.cwd, spec.args]);
394
+ let feed = this.feeds.get(key);
395
+ if (!feed) {
396
+ const created = new Feed(spec, this.stimCli, this.env, () => {
397
+ if (this.feeds.get(key) === created) this.feeds.delete(key);
398
+ });
399
+ this.feeds.set(key, created);
400
+ feed = created;
401
+ }
402
+ return feed.add(listener);
403
+ }
404
+ async close() {
405
+ await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
406
+ }
407
+ };
408
+ //#endregion
409
+ //#region src/frames.ts
410
+ const MIN_INTERVAL_MS = 200;
411
+ const MAX_INTERVAL_MS = 1e3;
412
+ const MAX_CAPTURES = 2;
413
+ const TOOL_TIMEOUT_MS = 1e4;
414
+ const MAX_EDGE = 1280;
415
+ const JPEG_QUALITY = 70;
416
+ function deviceKey(device) {
417
+ return device.platform === "ios" ? `ios:${device.udid}` : `android:${device.serial}`;
418
+ }
419
+ function ownedDevice(payload, target, attached) {
420
+ const slot = target.slot ?? "default";
421
+ if (!Array.isArray(payload.environments)) return "stim status printed a payload without environments.";
422
+ const environment = payload.environments.find((candidate) => candidate.path === target.workspace);
423
+ if (!environment) return `${target.workspace} is not a Stim workspace on this Mac.`;
424
+ const devices = slot === "default" ? {
425
+ ios: environment.ios,
426
+ android: environment.android
427
+ } : environment.slots?.find((candidate) => candidate.slot === slot);
428
+ const where = `${target.platform} in slot ${slot} of ${target.workspace}`;
429
+ if (target.platform === "ios") {
430
+ const sim = devices?.ios;
431
+ if (!sim?.owned) return `No simulator Stim owns runs ${where}.`;
432
+ if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
433
+ return {
434
+ platform: "ios",
435
+ udid: sim.udid
436
+ };
437
+ }
438
+ const emulator = devices?.android;
439
+ if (!emulator?.owned || emulator.physical) return `No emulator Stim owns runs ${where}.`;
440
+ if (emulator.state === "unknown" && attached?.startsWith("android:")) return {
441
+ platform: "android",
442
+ serial: attached.slice(8)
443
+ };
444
+ if (emulator.state !== "detected" || !emulator.serial) return `The emulator for ${where} is not running.`;
445
+ return {
446
+ platform: "android",
447
+ serial: emulator.serial
448
+ };
449
+ }
450
+ function jpegSize(bytes) {
451
+ if (bytes[0] !== 255 || bytes[1] !== 216) return null;
452
+ let offset = 2;
453
+ while (offset + 9 <= bytes.length) {
454
+ if (bytes[offset] !== 255) return null;
455
+ const marker = bytes[offset + 1];
456
+ if (marker === 255) {
457
+ offset += 1;
458
+ continue;
459
+ }
460
+ if (marker === 1 || marker >= 208 && marker <= 216) {
461
+ offset += 2;
462
+ continue;
463
+ }
464
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
465
+ height: bytes.readUInt16BE(offset + 5),
466
+ width: bytes.readUInt16BE(offset + 7)
467
+ };
468
+ offset += 2 + bytes.readUInt16BE(offset + 2);
469
+ }
470
+ return null;
471
+ }
472
+ function runTool(file, args, env, running) {
473
+ return new Promise((resolve, reject) => {
474
+ const child = spawn(file, args, {
475
+ env,
476
+ stdio: [
477
+ "ignore",
478
+ "pipe",
479
+ "pipe"
480
+ ]
481
+ });
482
+ running.add(child);
483
+ child.once("close", () => running.delete(child));
484
+ const chunks = [];
485
+ let stderr = "";
486
+ const timer = setTimeout(() => {
487
+ terminate(child);
488
+ reject(/* @__PURE__ */ new Error(`${file} ${args[0]} did not finish within ${TOOL_TIMEOUT_MS / 1e3} s.`));
489
+ }, TOOL_TIMEOUT_MS);
490
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
491
+ child.stderr.setEncoding("utf8");
492
+ child.stderr.on("data", (chunk) => {
493
+ stderr = (stderr + chunk).slice(-1e3);
494
+ });
495
+ child.on("error", (error) => {
496
+ clearTimeout(timer);
497
+ reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
498
+ });
499
+ child.on("close", (code) => {
500
+ clearTimeout(timer);
501
+ if (code === 0) resolve(Buffer.concat(chunks));
502
+ else reject(Object.assign(/* @__PURE__ */ new Error(`${file} ${args.join(" ")} exited (code ${code}): ${stderr.trim()}`), { stderr }));
503
+ });
504
+ });
505
+ }
506
+ async function stopTools(running, tmp) {
507
+ await Promise.all([...running].map((child) => terminate(child)));
508
+ if (tmp) rmSync(tmp, {
509
+ recursive: true,
510
+ force: true
511
+ });
512
+ }
513
+ /**
514
+ * Without `--display`, simctl captures the first panel it finds, which on a foldable simulator (iPhone Duo)
515
+ * can be the unlit one and comes back black; `primary` is the panel CoreDevice reports as primary. A simctl
516
+ * that rejects `primary` gets the default display instead.
517
+ */
518
+ function simulatorCapturer(udid, env) {
519
+ let tmp = null;
520
+ let display = ["--display=primary"];
521
+ let closed = false;
522
+ const running = /* @__PURE__ */ new Set();
523
+ const screenshot = (output) => runTool("xcrun", [
524
+ "simctl",
525
+ "io",
526
+ udid,
527
+ "screenshot",
528
+ "--type=jpeg",
529
+ ...display,
530
+ output
531
+ ], env, running);
532
+ return {
533
+ capture: async () => {
534
+ tmp ??= mkdtempSync(join(serverDir(), "frames-"));
535
+ const output = join(tmp, "frame.jpg");
536
+ try {
537
+ await screenshot(output);
538
+ } catch (error) {
539
+ if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
540
+ display = [];
541
+ await screenshot(output);
542
+ }
543
+ const jpeg = readFileSync(output);
544
+ return {
545
+ raw: jpeg,
546
+ jpeg: async () => jpeg
547
+ };
548
+ },
549
+ close: () => {
550
+ closed = true;
551
+ return stopTools(running, tmp);
552
+ }
553
+ };
554
+ }
555
+ /**
556
+ * The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
557
+ * Stim passes when it boots an owned AVD (#1064).
558
+ */
559
+ function emulatorEndpoint(env, serial) {
560
+ const console = /^emulator-(\d+)$/.exec(serial)?.[1];
561
+ if (!console || !env.HOME) return null;
562
+ const dir = join(env.HOME, "Library/Caches/TemporaryItems/avd/running");
563
+ let names;
564
+ try {
565
+ names = readdirSync(dir);
566
+ } catch {
567
+ return null;
568
+ }
569
+ for (const name of names) {
570
+ const pid = /^pid_(\d+)\.ini$/.exec(name)?.[1];
571
+ if (!pid) continue;
572
+ try {
573
+ process.kill(Number(pid), 0);
574
+ } catch {
575
+ continue;
576
+ }
577
+ let text;
578
+ try {
579
+ text = readFileSync(join(dir, name), "utf8");
580
+ } catch {
581
+ continue;
582
+ }
583
+ const values = new Map(text.split(/\r?\n/).flatMap((line) => {
584
+ const at = line.indexOf("=");
585
+ return at > 0 ? [[line.slice(0, at), line.slice(at + 1)]] : [];
586
+ }));
587
+ const grpcPort = Number(values.get("grpc.port"));
588
+ if (values.get("port.serial") === console && Number.isInteger(grpcPort) && grpcPort > 0) return {
589
+ grpcPort,
590
+ token: values.get("grpc.token") ?? null
591
+ };
592
+ }
593
+ return null;
594
+ }
595
+ function varint(value) {
596
+ const out = [];
597
+ let rest = value;
598
+ while (rest >= 128) {
599
+ out.push(rest & 127 | 128);
600
+ rest = Math.floor(rest / 128);
601
+ }
602
+ out.push(rest);
603
+ return out;
604
+ }
605
+ function* protoFields(bytes) {
606
+ let offset = 0;
607
+ const readVarint = () => {
608
+ let result = 0;
609
+ let scale = 1;
610
+ for (;;) {
611
+ if (offset >= bytes.length) throw new Error("truncated protobuf");
612
+ const byte = bytes[offset++];
613
+ result += (byte & 127) * scale;
614
+ if (byte < 128) return result;
615
+ scale *= 128;
616
+ }
617
+ };
618
+ while (offset < bytes.length) {
619
+ const key = readVarint();
620
+ const field = Math.floor(key / 8);
621
+ const type = key % 8;
622
+ if (type === 0) yield {
623
+ field,
624
+ varint: readVarint()
625
+ };
626
+ else if (type === 2) {
627
+ const length = readVarint();
628
+ if (offset + length > bytes.length) throw new Error("truncated protobuf");
629
+ yield {
630
+ field,
631
+ bytes: bytes.subarray(offset, offset + length)
632
+ };
633
+ offset += length;
634
+ } else if (type === 1) offset += 8;
635
+ else if (type === 5) offset += 4;
636
+ else throw new Error(`unsupported protobuf wire type ${type}`);
637
+ }
638
+ }
639
+ /** `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. */
640
+ function screenshotRequest() {
641
+ const message = Buffer.from([
642
+ 24,
643
+ ...varint(MAX_EDGE),
644
+ 32,
645
+ ...varint(MAX_EDGE)
646
+ ]);
647
+ const header = Buffer.alloc(5);
648
+ header.writeUInt32BE(message.length, 1);
649
+ return Buffer.concat([header, message]);
650
+ }
651
+ function screenshotImage(body) {
652
+ if (body.length < 5 || body[0] !== 0) throw new Error("getScreenshot returned no message.");
653
+ const message = body.subarray(5, 5 + body.readUInt32BE(1));
654
+ for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
655
+ throw new Error("getScreenshot returned no image.");
656
+ }
657
+ function emulatorCapturer(serial, env) {
658
+ let session = null;
659
+ let tmp = null;
660
+ const running = /* @__PURE__ */ new Set();
661
+ const call = (endpoint) => new Promise((resolve, reject) => {
662
+ if (!session || session.closed || session.destroyed) {
663
+ session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
664
+ session.on("error", () => {});
665
+ }
666
+ const request = session.request({
667
+ ":method": "POST",
668
+ ":path": "/android.emulation.control.EmulatorController/getScreenshot",
669
+ "content-type": "application/grpc",
670
+ te: "trailers",
671
+ ...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
672
+ });
673
+ const chunks = [];
674
+ let status;
675
+ let message;
676
+ const record = (headers) => {
677
+ if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
678
+ if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
679
+ };
680
+ request.setTimeout(TOOL_TIMEOUT_MS, () => request.close());
681
+ request.on("response", record);
682
+ request.on("trailers", record);
683
+ request.on("data", (chunk) => chunks.push(chunk));
684
+ request.on("error", (error) => reject(/* @__PURE__ */ new Error(`getScreenshot failed on ${serial}: ${error.message}`)));
685
+ request.on("close", () => {
686
+ if (status === "0") return resolve(Buffer.concat(chunks));
687
+ reject(/* @__PURE__ */ new Error(`getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`));
688
+ });
689
+ request.end(screenshotRequest());
690
+ });
691
+ return {
692
+ capture: async () => {
693
+ const endpoint = emulatorEndpoint(env, serial);
694
+ if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
695
+ const png = screenshotImage(await call(endpoint));
696
+ return {
697
+ raw: png,
698
+ jpeg: async () => {
699
+ tmp ??= mkdtempSync(join(serverDir(), "frames-"));
700
+ const input = join(tmp, "frame.png");
701
+ const output = join(tmp, "frame.jpg");
702
+ writeFileSync(input, png);
703
+ await runTool("sips", [
704
+ ...[
705
+ "-s",
706
+ "format",
707
+ "jpeg",
708
+ "-s",
709
+ "formatOptions",
710
+ String(JPEG_QUALITY)
711
+ ],
712
+ input,
713
+ "--out",
714
+ output
715
+ ], env, running);
716
+ return readFileSync(output);
717
+ }
718
+ };
719
+ },
720
+ close: () => {
721
+ session?.close();
722
+ return stopTools(running, tmp);
723
+ }
724
+ };
725
+ }
726
+ var Limiter = class {
727
+ active = 0;
728
+ waiting = [];
729
+ max;
730
+ constructor(max) {
731
+ this.max = max;
732
+ }
733
+ async run(task) {
734
+ if (this.active >= this.max) await new Promise((resolve) => this.waiting.push(resolve));
735
+ else this.active++;
736
+ try {
737
+ return await task();
738
+ } finally {
739
+ const next = this.waiting.shift();
740
+ if (next) next();
741
+ else this.active--;
742
+ }
743
+ }
744
+ };
745
+ var FrameSource = class {
746
+ listeners = /* @__PURE__ */ new Set();
747
+ last = null;
748
+ lastHash = null;
749
+ interval = MIN_INTERVAL_MS;
750
+ timer = null;
751
+ stopped = false;
752
+ capturer;
753
+ limiter;
754
+ ended;
755
+ constructor(capturer, limiter, ended) {
756
+ this.capturer = capturer;
757
+ this.limiter = limiter;
758
+ this.ended = ended;
759
+ this.tick();
760
+ }
761
+ add(listener) {
762
+ this.listeners.add(listener);
763
+ if (this.last) listener.frame(this.last);
764
+ return () => {
765
+ if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
766
+ };
767
+ }
768
+ stop() {
769
+ if (this.stopped) return Promise.resolve();
770
+ this.stopped = true;
771
+ if (this.timer) clearTimeout(this.timer);
772
+ this.listeners.clear();
773
+ this.ended();
774
+ return this.capturer.close();
775
+ }
776
+ async tick() {
777
+ this.timer = null;
778
+ let took = 0;
779
+ try {
780
+ const changed = await this.limiter.run(async () => {
781
+ if (this.stopped) return false;
782
+ const started = Date.now();
783
+ const capture = await this.capturer.capture();
784
+ const hash = createHash("sha256").update(capture.raw).digest("hex");
785
+ took = Date.now() - started;
786
+ if (hash === this.lastHash || this.stopped) return false;
787
+ const jpeg = await capture.jpeg();
788
+ const size = jpegSize(jpeg);
789
+ if (!size) throw new Error("The screenshot is not a JPEG image.");
790
+ this.lastHash = hash;
791
+ this.last = {
792
+ ...size,
793
+ capturedAt: new Date(started).toISOString(),
794
+ data: jpeg.toString("base64")
795
+ };
796
+ took = Date.now() - started;
797
+ return true;
798
+ });
799
+ if (this.stopped) return;
800
+ if (changed) for (const listener of this.listeners) listener.frame(this.last);
801
+ this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
802
+ this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
803
+ } catch (error) {
804
+ if (this.stopped) return;
805
+ const listeners = [...this.listeners];
806
+ this.stop();
807
+ for (const listener of listeners) listener.failed(error.message);
808
+ }
809
+ }
810
+ };
811
+ /**
812
+ * One capture loop per device, shared by its subscribers and stopped with the last of them. A loop sends a
813
+ * frame only when the screen changed; it captures up to 5 times a second while the screen changes and backs
814
+ * off to once a second while it does not, and spends at most half of its time capturing. At most two
815
+ * captures run at once across all devices.
816
+ */
817
+ var FramePool = class {
818
+ sources = /* @__PURE__ */ new Map();
819
+ limiter = new Limiter(MAX_CAPTURES);
820
+ env;
821
+ constructor(env) {
822
+ this.env = env;
823
+ }
824
+ subscribe(device, listener) {
825
+ const key = deviceKey(device);
826
+ let source = this.sources.get(key);
827
+ if (!source) {
828
+ const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env) : emulatorCapturer(device.serial, this.env), this.limiter, () => {
829
+ if (this.sources.get(key) === created) this.sources.delete(key);
830
+ });
831
+ this.sources.set(key, created);
832
+ source = created;
833
+ }
834
+ return source.add(listener);
835
+ }
836
+ async close() {
837
+ await Promise.all([...this.sources.values()].map((source) => source.stop()));
838
+ }
839
+ };
840
+ //#endregion
841
+ //#region src/logs.ts
842
+ function parseLogFilter(params) {
843
+ if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
844
+ const { sources, level, slot, grep, errors, tail } = params;
845
+ 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(", ")}.` };
846
+ if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
847
+ if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return { error: "slot must be a slot name." };
848
+ if (grep !== void 0 && (typeof grep !== "string" || compileGrep(grep).error)) return { error: "grep must be a valid regular expression." };
849
+ if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
850
+ if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > 5e3)) return { error: `tail must be an integer from 1 to ${MAX_LOG_TAIL}.` };
851
+ return { filter: {
852
+ workspace: params.workspace,
853
+ ...sources ? { sources } : {},
854
+ ...level && level !== "debug" ? { level } : {},
855
+ ...slot ? { slot } : {},
856
+ ...grep ? { grep } : {},
857
+ ...errors ? { errors } : {},
858
+ tail: tail ?? 5e3
859
+ } };
860
+ }
861
+ function logArgs(filter, follow) {
862
+ const args = [
863
+ "logs",
864
+ "--json",
865
+ ...follow ? ["--follow"] : [],
866
+ `--tail=${filter.tail ?? 5e3}`
867
+ ];
868
+ if (filter.sources) args.push("--source", ...LOG_SOURCES.filter((source) => filter.sources.includes(source)));
869
+ if (filter.slot) args.push(`--slot=${filter.slot}`);
870
+ if (filter.level) args.push(`--level=${filter.level}`);
871
+ if (filter.grep) args.push(`--grep=${filter.grep}`);
872
+ if (filter.errors) args.push("--errors");
873
+ return args;
874
+ }
875
+ const BATCH_MS = 100;
876
+ const MAX_BATCH = 500;
877
+ var LogBatcher = class {
878
+ pending = [];
879
+ timer = null;
880
+ stopped = false;
881
+ sink;
882
+ limits;
883
+ constructor(sink, limits) {
884
+ this.sink = sink;
885
+ this.limits = limits;
886
+ }
887
+ push(record) {
888
+ if (this.stopped) return;
889
+ this.pending.push(record);
890
+ if (this.pending.length > this.limits.maxPendingRecords) {
891
+ this.stop();
892
+ queueMicrotask(this.sink.overflow);
893
+ return;
894
+ }
895
+ this.timer ??= setTimeout(() => this.flush(false), BATCH_MS);
896
+ }
897
+ flush(force) {
898
+ if (this.timer) clearTimeout(this.timer);
899
+ this.timer = null;
900
+ while (this.pending.length) {
901
+ if (!force && this.sink.bufferedBytes() >= this.limits.maxBufferedBytes) break;
902
+ this.sink.send(this.pending.splice(0, MAX_BATCH));
903
+ }
904
+ if (this.pending.length && !this.stopped) this.timer = setTimeout(() => this.flush(false), BATCH_MS);
905
+ }
906
+ stop() {
907
+ this.stopped = true;
908
+ if (this.timer) clearTimeout(this.timer);
909
+ this.timer = null;
910
+ this.pending = [];
911
+ }
912
+ };
913
+ //#endregion
914
+ //#region src/tailscale.ts
915
+ const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
916
+ const TIMEOUT_MS = 3e3;
917
+ function executable(path) {
918
+ try {
919
+ accessSync(path, constants.X_OK);
920
+ return true;
921
+ } catch {
922
+ return false;
923
+ }
924
+ }
925
+ function findTailscale(env) {
926
+ for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "tailscale"))) return join(dir, "tailscale");
927
+ return executable(MAC_APP_BINARY) ? MAC_APP_BINARY : null;
928
+ }
929
+ function parseTailscaleStatus(value) {
930
+ if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
931
+ state: "unavailable",
932
+ reason: "`tailscale status --json` printed no BackendState"
933
+ };
934
+ if (value.BackendState !== "Running") return {
935
+ state: "not-running",
936
+ backendState: value.BackendState
937
+ };
938
+ const self = isJsonObject(value.Self) ? value.Self : {};
939
+ return {
940
+ state: "running",
941
+ ips: Array.isArray(value.TailscaleIPs) ? value.TailscaleIPs.filter((ip) => typeof ip === "string") : [],
942
+ dnsName: typeof self.DNSName === "string" && self.DNSName ? self.DNSName.replace(/\.$/, "") : null,
943
+ hostName: typeof self.HostName === "string" && self.HostName ? self.HostName : null
944
+ };
945
+ }
946
+ function tailscaleStatus(binary, env) {
947
+ if (!binary) return {
948
+ state: "unavailable",
949
+ reason: "the tailscale command was not found"
950
+ };
951
+ try {
952
+ const output = execFileSync(binary, ["status", "--json"], {
953
+ env,
954
+ timeout: TIMEOUT_MS,
955
+ encoding: "utf8",
956
+ stdio: [
957
+ "ignore",
958
+ "pipe",
959
+ "ignore"
960
+ ]
961
+ });
962
+ return parseTailscaleStatus(JSON.parse(output));
963
+ } catch (error) {
964
+ return {
965
+ state: "unavailable",
966
+ reason: `\`tailscale status --json\` failed: ${error.message}`
967
+ };
968
+ }
969
+ }
970
+ /** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
971
+ function parseWhois(value) {
972
+ if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
973
+ const { StableID, Name } = value.Node;
974
+ const login = isJsonObject(value.UserProfile) ? value.UserProfile.LoginName : void 0;
975
+ if (typeof StableID !== "string" || !StableID) return null;
976
+ return {
977
+ kind: "tailnet",
978
+ nodeId: StableID,
979
+ nodeName: typeof Name === "string" ? Name.replace(/\.$/, "") : "",
980
+ user: typeof login === "string" ? login : ""
981
+ };
982
+ }
983
+ function whois(binary, env, ip) {
984
+ if (!binary) return Promise.resolve(null);
985
+ return new Promise((resolve) => {
986
+ execFile(binary, [
987
+ "whois",
988
+ "--json",
989
+ ip
990
+ ], {
991
+ env,
992
+ timeout: TIMEOUT_MS,
993
+ encoding: "utf8"
994
+ }, (error, stdout) => {
995
+ if (error) return resolve(null);
996
+ try {
997
+ resolve(parseWhois(JSON.parse(stdout)));
998
+ } catch {
999
+ resolve(null);
1000
+ }
1001
+ });
1002
+ });
1003
+ }
1004
+ //#endregion
1005
+ //#region src/server.ts
1006
+ function healthTailscale(tailscale) {
1007
+ if (tailscale.state === "running") return {
1008
+ state: "running",
1009
+ dnsName: tailscale.dnsName
1010
+ };
1011
+ if (tailscale.state === "not-running") return {
1012
+ state: "not-running",
1013
+ backendState: tailscale.backendState
1014
+ };
1015
+ return {
1016
+ state: "unavailable",
1017
+ reason: tailscale.reason
1018
+ };
1019
+ }
1020
+ /** Only a request made to this Mac's loopback name, so a DNS-rebound web page cannot read the health payload. */
1021
+ function localHealthRequest(request) {
1022
+ const host = request.headers.host?.replace(/:\d+$/, "");
1023
+ return request.method === "GET" && request.url === "/health" && (host === "127.0.0.1" || host === "localhost") && peerAddress(request) === null;
1024
+ }
1025
+ const CLOSE_UNAUTHORIZED = 4401;
1026
+ const CLOSE_BAD_REQUEST = 4400;
1027
+ const CLOSE_AUTH_TIMEOUT = 4408;
1028
+ const MAX_PAYLOAD = 65536;
1029
+ const MAX_SUBSCRIPTIONS = 32;
1030
+ const MAX_COMMANDS = 4;
1031
+ const LOG_LIMITS = {
1032
+ maxBufferedBytes: 4194304,
1033
+ maxPendingRecords: 2e4
1034
+ };
1035
+ const FRAME_BUFFER_BYTES = 1048576;
1036
+ const FRAME_RETRY_MS = 100;
1037
+ const STATUS_FEED = {
1038
+ args: [
1039
+ "status",
1040
+ "--watch",
1041
+ "--json"
1042
+ ],
1043
+ cwd: homedir(),
1044
+ keep: 1,
1045
+ label: "stim status --watch"
1046
+ };
1047
+ const COMMAND_LIMITS = {
1048
+ timeoutMs: 6e4,
1049
+ maxOutputBytes: 33554432
1050
+ };
1051
+ const AUTH_REFUSALS = {
1052
+ "pairing-unknown": {
1053
+ code: "pairing-expired",
1054
+ message: "This pairing code is unknown or was already used. Pair again."
1055
+ },
1056
+ "pairing-expired": {
1057
+ code: "pairing-expired",
1058
+ message: "This pairing code expired. Pair again."
1059
+ },
1060
+ "device-unknown": {
1061
+ code: "unauthorized",
1062
+ message: "This Mac does not recognize this device token. Pair again."
1063
+ },
1064
+ "node-mismatch": {
1065
+ code: "unauthorized",
1066
+ message: "This device token was paired from a different tailnet node."
1067
+ }
1068
+ };
1069
+ function isLoopback(address) {
1070
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
1071
+ }
1072
+ /**
1073
+ * The tailnet address of the peer. `tailscale serve` proxies from loopback and names the peer in
1074
+ * X-Forwarded-For; a loopback connection without that header comes from this Mac.
1075
+ */
1076
+ function peerAddress(request) {
1077
+ const remote = request.socket.remoteAddress;
1078
+ if (!isLoopback(remote)) return remote ?? null;
1079
+ const forwarded = request.headers["x-forwarded-for"];
1080
+ return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
1081
+ }
1082
+ var FailureLimiter = class {
1083
+ failures = /* @__PURE__ */ new Map();
1084
+ max;
1085
+ windowMs;
1086
+ constructor(max, windowMs) {
1087
+ this.max = max;
1088
+ this.windowMs = windowMs;
1089
+ }
1090
+ recent(key, now) {
1091
+ const kept = (this.failures.get(key) ?? []).filter((at) => now - at < this.windowMs);
1092
+ if (kept.length) this.failures.set(key, kept);
1093
+ else this.failures.delete(key);
1094
+ return kept;
1095
+ }
1096
+ blocked(key, now = Date.now()) {
1097
+ return this.recent(key, now).length >= this.max;
1098
+ }
1099
+ record(key, now = Date.now()) {
1100
+ this.failures.set(key, [...this.recent(key, now), now]);
1101
+ }
1102
+ };
1103
+ function send(socket, message) {
1104
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
1105
+ }
1106
+ function requestId(value) {
1107
+ return typeof value === "number" || typeof value === "string" ? value : null;
1108
+ }
1109
+ async function startServer(options) {
1110
+ const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
1111
+ const authTimeoutMs = options.authTimeoutMs ?? 5e3;
1112
+ const feeds = new FeedPool(options.stimCli, options.env);
1113
+ const frames = new FramePool(options.env);
1114
+ const running = /* @__PURE__ */ new Set();
1115
+ const logLimits = {
1116
+ ...LOG_LIMITS,
1117
+ ...options.logLimits
1118
+ };
1119
+ const commandLimits = {
1120
+ ...COMMAND_LIMITS,
1121
+ ...options.commandLimits
1122
+ };
1123
+ const sessions = /* @__PURE__ */ new Map();
1124
+ const wss = new WebSocketServer({
1125
+ noServer: true,
1126
+ maxPayload: MAX_PAYLOAD
1127
+ });
1128
+ mkdirSync(serverDir(), {
1129
+ recursive: true,
1130
+ mode: 448
1131
+ });
1132
+ let revocationCheck = null;
1133
+ const watcher = watch(serverDir(), () => {
1134
+ revocationCheck ??= setTimeout(() => {
1135
+ revocationCheck = null;
1136
+ const paired = new Set(readDevices().map((device) => device.id));
1137
+ for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
1138
+ }, 50);
1139
+ });
1140
+ function connection(socket, peer) {
1141
+ const limitKey = peer ?? "local";
1142
+ const subscriptions = /* @__PURE__ */ new Map();
1143
+ const commands = /* @__PURE__ */ new Set();
1144
+ let nextSubscription = 1;
1145
+ let device = null;
1146
+ let queue = Promise.resolve();
1147
+ const refuse = (id, code, message, closeCode) => {
1148
+ if (!device) limiter.record(limitKey);
1149
+ send(socket, {
1150
+ id,
1151
+ error: {
1152
+ code,
1153
+ message
1154
+ }
1155
+ });
1156
+ socket.close(closeCode, code);
1157
+ };
1158
+ const timer = setTimeout(() => {
1159
+ limiter.record(limitKey);
1160
+ socket.close(CLOSE_AUTH_TIMEOUT, "authentication timeout");
1161
+ }, authTimeoutMs);
1162
+ async function identify() {
1163
+ if (peer === null) return { kind: "local" };
1164
+ return isIP(peer) ? whois(options.tailscale, options.env, peer) : null;
1165
+ }
1166
+ async function hello(id, params) {
1167
+ if (device) return send(socket, {
1168
+ id,
1169
+ error: {
1170
+ code: "already-authenticated",
1171
+ message: "hello was already accepted."
1172
+ }
1173
+ });
1174
+ const auth = isJsonObject(params) && isJsonObject(params.auth) ? params.auth : null;
1175
+ if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
1176
+ if (params.protocol !== 1) return refuse(id, "protocol-unsupported", `This server speaks protocol 1.`, CLOSE_BAD_REQUEST);
1177
+ clearTimeout(timer);
1178
+ const identity = await identify();
1179
+ if (socket.readyState !== socket.OPEN) return;
1180
+ if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
1181
+ let outcome;
1182
+ if (typeof auth.pairingToken === "string" && typeof auth.deviceName === "string" && auth.deviceName.trim()) outcome = spendPairingToken(auth.pairingToken, auth.deviceName.trim(), identity);
1183
+ else if (typeof auth.deviceToken === "string") outcome = authenticateDevice(auth.deviceToken, identity);
1184
+ else return refuse(id, "bad-request", "auth needs pairingToken and deviceName, or deviceToken.", CLOSE_BAD_REQUEST);
1185
+ if (!outcome.ok) {
1186
+ const { code, message } = AUTH_REFUSALS[outcome.reason];
1187
+ return refuse(id, code, message, CLOSE_UNAUTHORIZED);
1188
+ }
1189
+ device = outcome.device;
1190
+ sessions.set(socket, device);
1191
+ send(socket, {
1192
+ id,
1193
+ result: {
1194
+ protocol: 1,
1195
+ server: {
1196
+ name: options.name,
1197
+ version: options.serverVersion,
1198
+ stim: options.stimVersion
1199
+ },
1200
+ capabilities: device.capabilities,
1201
+ device: {
1202
+ id: device.id,
1203
+ name: device.name
1204
+ },
1205
+ ...outcome.deviceToken ? { deviceToken: outcome.deviceToken } : {}
1206
+ }
1207
+ });
1208
+ }
1209
+ function error(id, code, message) {
1210
+ send(socket, {
1211
+ id,
1212
+ error: {
1213
+ code,
1214
+ message
1215
+ }
1216
+ });
1217
+ }
1218
+ function workspaceDir(id, workspace, required) {
1219
+ if (workspace === void 0 && !required) return homedir();
1220
+ if (typeof workspace !== "string") {
1221
+ error(id, "bad-request", "params.workspace must be an environment path from a status payload.");
1222
+ return null;
1223
+ }
1224
+ let registered;
1225
+ try {
1226
+ registered = Object.hasOwn(loadConfig()?.projects ?? {}, workspace);
1227
+ } catch (cause) {
1228
+ error(id, "stim-failed", cause.message);
1229
+ return null;
1230
+ }
1231
+ if (!registered) {
1232
+ error(id, "unknown-workspace", `${workspace} is not a Stim workspace on this Mac.`);
1233
+ return null;
1234
+ }
1235
+ if (!existsSync(workspace)) {
1236
+ error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
1237
+ return null;
1238
+ }
1239
+ return workspace;
1240
+ }
1241
+ function openSubscription(id) {
1242
+ if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
1243
+ error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
1244
+ return null;
1245
+ }
1246
+ const subscription = `s${nextSubscription++}`;
1247
+ send(socket, {
1248
+ id,
1249
+ result: { subscription }
1250
+ });
1251
+ return subscription;
1252
+ }
1253
+ function subscribeStatus(id) {
1254
+ const subscription = openSubscription(id);
1255
+ if (!subscription) return;
1256
+ const unsubscribe = feeds.subscribe(STATUS_FEED, {
1257
+ item: (payload) => send(socket, {
1258
+ event: "status",
1259
+ subscription,
1260
+ payload
1261
+ }),
1262
+ failed: (message) => {
1263
+ subscriptions.delete(subscription);
1264
+ send(socket, {
1265
+ event: "error",
1266
+ subscription,
1267
+ error: {
1268
+ code: "status-failed",
1269
+ message
1270
+ }
1271
+ });
1272
+ }
1273
+ });
1274
+ subscriptions.set(subscription, unsubscribe);
1275
+ }
1276
+ function subscribeLogs(id, params) {
1277
+ const parsed = parseLogFilter(params);
1278
+ if ("error" in parsed) return error(id, "bad-request", parsed.error);
1279
+ const cwd = workspaceDir(id, parsed.filter.workspace, true);
1280
+ if (!cwd) return;
1281
+ const subscription = openSubscription(id);
1282
+ if (!subscription) return;
1283
+ const end = () => {
1284
+ subscriptions.get(subscription)?.();
1285
+ subscriptions.delete(subscription);
1286
+ };
1287
+ const batcher = new LogBatcher({
1288
+ send: (records) => send(socket, {
1289
+ event: "logs",
1290
+ subscription,
1291
+ records
1292
+ }),
1293
+ bufferedBytes: () => socket.bufferedAmount,
1294
+ overflow: () => {
1295
+ end();
1296
+ send(socket, {
1297
+ event: "error",
1298
+ subscription,
1299
+ error: {
1300
+ code: "slow-client",
1301
+ message: "This client fell behind the log stream. Subscribe again."
1302
+ }
1303
+ });
1304
+ }
1305
+ }, logLimits);
1306
+ const unsubscribe = feeds.subscribe({
1307
+ args: logArgs(parsed.filter, true),
1308
+ cwd,
1309
+ keep: parsed.filter.tail,
1310
+ label: "stim logs --follow"
1311
+ }, {
1312
+ item: (record) => batcher.push(record),
1313
+ failed: (message) => {
1314
+ batcher.flush(true);
1315
+ batcher.stop();
1316
+ subscriptions.delete(subscription);
1317
+ send(socket, {
1318
+ event: "error",
1319
+ subscription,
1320
+ error: {
1321
+ code: "logs-failed",
1322
+ message
1323
+ }
1324
+ });
1325
+ }
1326
+ });
1327
+ subscriptions.set(subscription, () => {
1328
+ batcher.stop();
1329
+ unsubscribe();
1330
+ });
1331
+ }
1332
+ function subscribeFrames(id, params) {
1333
+ const { workspace, platform, slot } = isJsonObject(params) ? params : {};
1334
+ if (typeof workspace !== "string" || platform !== "ios" && platform !== "android") return error(id, "bad-request", "frames.subscribe needs params.workspace and params.platform (ios or android).");
1335
+ if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
1336
+ if (!workspaceDir(id, workspace, true)) return;
1337
+ const subscription = openSubscription(id);
1338
+ if (!subscription) return;
1339
+ const frameTarget = {
1340
+ workspace,
1341
+ platform,
1342
+ ...slot ? { slot } : {}
1343
+ };
1344
+ let attached = null;
1345
+ let detach = null;
1346
+ let pending = null;
1347
+ let retry = null;
1348
+ let ended = false;
1349
+ const flush = () => {
1350
+ retry = null;
1351
+ if (!pending || ended) return;
1352
+ if (socket.bufferedAmount > FRAME_BUFFER_BYTES) {
1353
+ retry = setTimeout(flush, FRAME_RETRY_MS);
1354
+ return;
1355
+ }
1356
+ const frame = pending;
1357
+ pending = null;
1358
+ send(socket, {
1359
+ event: "frame",
1360
+ subscription,
1361
+ platform,
1362
+ slot: slot ?? "default",
1363
+ mime: "image/jpeg",
1364
+ ...frame
1365
+ });
1366
+ };
1367
+ const cleanup = () => {
1368
+ ended = true;
1369
+ if (retry) clearTimeout(retry);
1370
+ detach?.();
1371
+ detach = null;
1372
+ unsubscribeStatus?.();
1373
+ };
1374
+ const end = (message) => {
1375
+ if (ended) return;
1376
+ cleanup();
1377
+ subscriptions.delete(subscription);
1378
+ send(socket, {
1379
+ event: "error",
1380
+ subscription,
1381
+ error: {
1382
+ code: "frames-failed",
1383
+ message
1384
+ }
1385
+ });
1386
+ };
1387
+ const listener = {
1388
+ frame: (frame) => {
1389
+ pending = frame;
1390
+ if (!retry) flush();
1391
+ },
1392
+ failed: end
1393
+ };
1394
+ let unsubscribeStatus = null;
1395
+ unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
1396
+ item: (payload) => {
1397
+ if (ended) return;
1398
+ const resolved = ownedDevice(payload, frameTarget, attached);
1399
+ if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
1400
+ if (deviceKey(resolved) === attached) return;
1401
+ detach?.();
1402
+ attached = deviceKey(resolved);
1403
+ detach = frames.subscribe(resolved, listener);
1404
+ },
1405
+ failed: (message) => queueMicrotask(() => end(message))
1406
+ });
1407
+ subscriptions.set(subscription, cleanup);
1408
+ }
1409
+ function command(id, args, cwd, result) {
1410
+ if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
1411
+ const run = runStim(options.stimCli, options.env, args, cwd, commandLimits);
1412
+ commands.add(run.cancel);
1413
+ running.add(run.cancel);
1414
+ (async () => {
1415
+ const outcome = await run.outcome;
1416
+ commands.delete(run.cancel);
1417
+ running.delete(run.cancel);
1418
+ if (!outcome.ok) return error(id, "stim-failed", outcome.message);
1419
+ let value;
1420
+ try {
1421
+ value = result(outcome.stdout);
1422
+ } catch {
1423
+ return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
1424
+ }
1425
+ send(socket, {
1426
+ id,
1427
+ result: value
1428
+ });
1429
+ })();
1430
+ }
1431
+ function queryLogs(id, params) {
1432
+ const parsed = parseLogFilter(params);
1433
+ if ("error" in parsed) return error(id, "bad-request", parsed.error);
1434
+ const cwd = workspaceDir(id, parsed.filter.workspace, true);
1435
+ if (!cwd) return;
1436
+ command(id, logArgs(parsed.filter, false), cwd, (stdout) => ({ records: stdout.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)) }));
1437
+ }
1438
+ function workspaceCommand(id, method, params) {
1439
+ if (params !== void 0 && !isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
1440
+ const cwd = workspaceDir(id, params?.workspace, false);
1441
+ if (!cwd) return;
1442
+ command(id, [method === "stats.get" ? "stats" : "settings", "--json"], cwd, (stdout) => {
1443
+ const value = JSON.parse(stdout);
1444
+ if (!isJsonObject(value)) throw new Error("not an object");
1445
+ return value;
1446
+ });
1447
+ }
1448
+ async function handle(raw) {
1449
+ if (socket.readyState !== socket.OPEN) return;
1450
+ let message;
1451
+ try {
1452
+ message = JSON.parse(raw);
1453
+ } catch {
1454
+ message = null;
1455
+ }
1456
+ const id = isJsonObject(message) ? requestId(message.id) : null;
1457
+ if (!isJsonObject(message) || id === null || typeof message.method !== "string") {
1458
+ if (device) return send(socket, {
1459
+ id,
1460
+ error: {
1461
+ code: "bad-request",
1462
+ message: "Expected {id, method, params}."
1463
+ }
1464
+ });
1465
+ return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
1466
+ }
1467
+ if (message.method === "hello") return hello(id, message.params);
1468
+ if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
1469
+ if (message.method === "status.subscribe") return subscribeStatus(id);
1470
+ if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
1471
+ if (message.method === "logs.query") return queryLogs(id, message.params);
1472
+ if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
1473
+ if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
1474
+ if (message.method === "unsubscribe") {
1475
+ const name = isJsonObject(message.params) ? message.params.subscription : void 0;
1476
+ const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
1477
+ if (!unsubscribe) return send(socket, {
1478
+ id,
1479
+ error: {
1480
+ code: "unknown-subscription",
1481
+ message: `No subscription ${String(name)}.`
1482
+ }
1483
+ });
1484
+ subscriptions.delete(name);
1485
+ unsubscribe();
1486
+ return send(socket, {
1487
+ id,
1488
+ result: {}
1489
+ });
1490
+ }
1491
+ send(socket, {
1492
+ id,
1493
+ error: {
1494
+ code: "unknown-method",
1495
+ message: `Unknown method ${message.method}.`
1496
+ }
1497
+ });
1498
+ }
1499
+ socket.on("message", (data) => {
1500
+ const raw = data.toString();
1501
+ queue = queue.then(() => handle(raw)).catch(() => socket.close(1011, "internal error"));
1502
+ });
1503
+ socket.on("close", () => {
1504
+ clearTimeout(timer);
1505
+ sessions.delete(socket);
1506
+ for (const unsubscribe of subscriptions.values()) unsubscribe();
1507
+ subscriptions.clear();
1508
+ for (const cancel of commands) {
1509
+ running.delete(cancel);
1510
+ cancel();
1511
+ }
1512
+ commands.clear();
1513
+ });
1514
+ }
1515
+ function upgrade(request, socket, head) {
1516
+ const peer = peerAddress(request);
1517
+ if (limiter.blocked(peer ?? "local")) {
1518
+ socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
1519
+ return;
1520
+ }
1521
+ wss.handleUpgrade(request, socket, head, (ws) => connection(ws, peer));
1522
+ }
1523
+ const health = {
1524
+ server: "stim-server",
1525
+ name: options.name,
1526
+ version: options.serverVersion,
1527
+ stim: options.stimVersion,
1528
+ protocol: 1,
1529
+ stimHome: configDir(),
1530
+ tailscale: healthTailscale(options.tailscaleState)
1531
+ };
1532
+ const servers = [];
1533
+ const addresses = [];
1534
+ const close = async () => {
1535
+ watcher.close();
1536
+ if (revocationCheck) clearTimeout(revocationCheck);
1537
+ for (const client of wss.clients) client.terminate();
1538
+ await Promise.all([
1539
+ frames.close(),
1540
+ feeds.close(),
1541
+ ...[...running].map((cancel) => cancel())
1542
+ ]);
1543
+ wss.close();
1544
+ await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));
1545
+ };
1546
+ try {
1547
+ for (const host of options.hosts) {
1548
+ const server = createServer((request, response) => {
1549
+ if (localHealthRequest(request)) {
1550
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(health));
1551
+ return;
1552
+ }
1553
+ response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
1554
+ });
1555
+ server.on("upgrade", upgrade);
1556
+ servers.push(server);
1557
+ await new Promise((resolve, reject) => {
1558
+ server.once("error", reject);
1559
+ server.listen(options.port, host, () => {
1560
+ server.off("error", reject);
1561
+ resolve();
1562
+ });
1563
+ });
1564
+ addresses.push({
1565
+ host,
1566
+ port: server.address().port
1567
+ });
1568
+ }
1569
+ } catch (error) {
1570
+ await close();
1571
+ throw error;
1572
+ }
1573
+ return {
1574
+ addresses,
1575
+ close
1576
+ };
1577
+ }
1578
+ //#endregion
1579
+ //#region bin/stim-server.ts
1580
+ const DEFAULT_PORT = 7787;
1581
+ const USAGE = `Usage:
1582
+ stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
1583
+ stim-server pair [--port <n>] [--json]
1584
+ print a single-use pairing payload for the QR code
1585
+ stim-server devices [list] [--json]
1586
+ list paired devices
1587
+ stim-server devices revoke <id> revoke a paired device`;
1588
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1589
+ function fail(message) {
1590
+ console.error(`stim-server: ${message}`);
1591
+ process.exit(1);
1592
+ }
1593
+ function tailscaleNote(tailscale, port) {
1594
+ const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server and run \`tailscale serve --bg http://127.0.0.1:${port}\` once.`;
1595
+ if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
1596
+ if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
1597
+ return null;
1598
+ }
1599
+ function macName(tailscale) {
1600
+ return tailscale.state === "running" && tailscale.hostName || hostname();
1601
+ }
1602
+ async function serve(port) {
1603
+ const login = loginShellEnvironment();
1604
+ if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
1605
+ const env = { ...login ?? process.env };
1606
+ if (!process.env.STIM_HOME && env.STIM_HOME) process.env.STIM_HOME = env.STIM_HOME;
1607
+ if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
1608
+ const tailscaleBinary = findTailscale(env);
1609
+ const tailscale = tailscaleStatus(tailscaleBinary, env);
1610
+ const stim = bundledStim();
1611
+ const hosts = ["127.0.0.1", ...tailscale.state === "running" ? tailscale.ips : []];
1612
+ let server;
1613
+ try {
1614
+ server = await startServer({
1615
+ name: macName(tailscale),
1616
+ hosts,
1617
+ port,
1618
+ stimCli: stim.cli,
1619
+ stimVersion: stim.version,
1620
+ serverVersion: pkg.version,
1621
+ env,
1622
+ tailscale: tailscaleBinary,
1623
+ tailscaleState: tailscale
1624
+ });
1625
+ } catch (error) {
1626
+ if (error.code === "EADDRINUSE") fail(`port ${port} is in use; another stim-server may already be running. Pass --port to use another port.`);
1627
+ throw error;
1628
+ }
1629
+ console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
1630
+ for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
1631
+ if (tailscale.state === "running" && tailscale.dnsName) console.log(`tailnet endpoint: wss://${tailscale.dnsName}`);
1632
+ const note = tailscaleNote(tailscale, port);
1633
+ if (note) console.error(note);
1634
+ const shutdown = () => {
1635
+ server.close().then(() => process.exit(0));
1636
+ };
1637
+ process.on("SIGINT", shutdown);
1638
+ process.on("SIGTERM", shutdown);
1639
+ process.on("SIGHUP", shutdown);
1640
+ }
1641
+ function pair(port, json) {
1642
+ const tailscale = tailscaleStatus(findTailscale(process.env), process.env);
1643
+ const { token, expiresAt } = createPairingToken();
1644
+ const running = tailscale.state === "running";
1645
+ const payload = {
1646
+ v: 1,
1647
+ name: macName(tailscale),
1648
+ endpoint: running && tailscale.dnsName ? `wss://${tailscale.dnsName}` : `ws://127.0.0.1:${port}`,
1649
+ pairingToken: token
1650
+ };
1651
+ if (json) return void console.log(JSON.stringify({
1652
+ qr: payload,
1653
+ expiresAt
1654
+ }));
1655
+ console.log(JSON.stringify(payload));
1656
+ console.error(`The pairing token is single use and expires at ${expiresAt}.`);
1657
+ const note = tailscaleNote(tailscale, port);
1658
+ if (note) console.error(`${note} The endpoint above only works on this Mac.`);
1659
+ }
1660
+ function describe(device) {
1661
+ const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
1662
+ return `${device.id} ${device.name} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
1663
+ }
1664
+ async function main() {
1665
+ const { values, positionals } = parseArgs({
1666
+ allowPositionals: true,
1667
+ options: {
1668
+ port: { type: "string" },
1669
+ json: { type: "boolean" },
1670
+ help: {
1671
+ type: "boolean",
1672
+ short: "h"
1673
+ },
1674
+ version: {
1675
+ type: "boolean",
1676
+ short: "V"
1677
+ }
1678
+ }
1679
+ });
1680
+ if (values.help) return void console.log(USAGE);
1681
+ if (values.version) return void console.log(pkg.version);
1682
+ const port = values.port === void 0 ? DEFAULT_PORT : Number(values.port);
1683
+ if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
1684
+ const [command, sub, arg, ...rest] = positionals;
1685
+ if (command === void 0) return serve(port);
1686
+ if (command === "pair" && sub === void 0) return pair(port, values.json === true);
1687
+ if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
1688
+ const devices = readDevices();
1689
+ if (values.json) {
1690
+ const listed = devices.map(({ id, name, identity, pairedAt, lastSeenAt, capabilities }) => ({
1691
+ id,
1692
+ name,
1693
+ identity,
1694
+ pairedAt,
1695
+ lastSeenAt,
1696
+ capabilities
1697
+ }));
1698
+ console.log(JSON.stringify({ devices: listed }));
1699
+ return;
1700
+ }
1701
+ if (!devices.length) console.log("No paired devices.");
1702
+ for (const device of devices) console.log(describe(device));
1703
+ return;
1704
+ }
1705
+ if (command === "devices" && sub === "revoke" && arg !== void 0 && rest.length === 0) {
1706
+ if (!revokeDevice(arg)) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
1707
+ console.log(`Revoked ${arg}.`);
1708
+ return;
1709
+ }
1710
+ fail(`unknown command.\n${USAGE}`);
1711
+ }
1712
+ await main();
1713
+ //#endregion
1714
+ export {};