@stim-cli/server 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -31
- package/dist/{protocol-dn2_TUeH.mjs → protocol-CmlG7X7_.mjs} +272 -11
- package/dist/protocol.d.mts +190 -11
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +484 -5
- package/dist/stim-frames/EmulatorDiscovery.swift +49 -0
- package/dist/stim-frames/EmulatorInput.swift +141 -0
- package/dist/stim-frames/GrpcFraming.swift +85 -0
- package/dist/stim-frames/InputMessages.swift +73 -0
- package/dist/stim-frames/ScreenshotMessages.swift +129 -0
- package/dist/stim-frames/ScreenshotStream.swift +130 -0
- package/dist/stim-frames/SimulatorDisplay.swift +116 -0
- package/dist/stim-frames/SimulatorHID.swift +101 -0
- package/dist/stim-frames/VideoEncoder.swift +282 -0
- package/dist/stim-frames/main.swift +389 -0
- package/dist/stim-server.mjs +1394 -124
- package/package.json +4 -4
package/dist/stim-server.mjs
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as MAX_LOG_TAIL, i as
|
|
2
|
+
import { a as FRAME_FPS, c as MAX_LOG_TAIL, i as FRAME_EDGE, n as CAPABILITIES, o as LOG_LEVELS, s as LOG_SOURCES, t as ACTIONS } from "./protocol-CmlG7X7_.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
5
|
-
import { homedir, hostname, tmpdir } from "node:os";
|
|
4
|
+
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, watch, writeFileSync } from "node:fs";
|
|
5
|
+
import { availableParallelism, cpus, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
9
|
+
import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
|
|
9
10
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
11
|
import { configDir, withDirLock } from "@stim-cli/core";
|
|
11
|
-
import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
|
|
12
12
|
import { createServer } from "node:http";
|
|
13
13
|
import { isIP } from "node:net";
|
|
14
14
|
import { WebSocketServer } from "ws";
|
|
15
15
|
import { createInterface } from "node:readline";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
16
17
|
import { connect } from "node:http2";
|
|
17
18
|
//#region src/environment.ts
|
|
18
19
|
function parseEnvironment(output) {
|
|
@@ -90,13 +91,18 @@ function parseIdentity(value) {
|
|
|
90
91
|
};
|
|
91
92
|
return null;
|
|
92
93
|
}
|
|
94
|
+
function parseCapabilities(value) {
|
|
95
|
+
return Array.isArray(value) ? CAPABILITIES.filter((capability) => value.includes(capability)) : [];
|
|
96
|
+
}
|
|
97
|
+
function capabilitiesFor(control) {
|
|
98
|
+
return control ? ["read", "control"] : ["read"];
|
|
99
|
+
}
|
|
93
100
|
function parseDevice(value) {
|
|
94
101
|
if (!isJsonObject(value)) return null;
|
|
95
102
|
const { id, name, tokenHash, pairedAt, lastSeenAt } = value;
|
|
96
103
|
const identity = parseIdentity(value.identity);
|
|
97
104
|
if (typeof id !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || !identity) return null;
|
|
98
105
|
if (typeof pairedAt !== "string") return null;
|
|
99
|
-
const capabilities = Array.isArray(value.capabilities) ? CAPABILITIES.filter((capability) => value.capabilities.includes(capability)) : [];
|
|
100
106
|
return {
|
|
101
107
|
id,
|
|
102
108
|
name,
|
|
@@ -104,7 +110,7 @@ function parseDevice(value) {
|
|
|
104
110
|
identity,
|
|
105
111
|
pairedAt,
|
|
106
112
|
lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
|
|
107
|
-
capabilities
|
|
113
|
+
capabilities: parseCapabilities(value.capabilities)
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
116
|
function readDevices() {
|
|
@@ -116,7 +122,8 @@ function readPairings() {
|
|
|
116
122
|
if (!Array.isArray(tokens)) return [];
|
|
117
123
|
return tokens.flatMap((entry) => isJsonObject(entry) && typeof entry.tokenHash === "string" && typeof entry.expiresAt === "string" ? [{
|
|
118
124
|
tokenHash: entry.tokenHash,
|
|
119
|
-
expiresAt: entry.expiresAt
|
|
125
|
+
expiresAt: entry.expiresAt,
|
|
126
|
+
capabilities: "capabilities" in entry ? parseCapabilities(entry.capabilities) : capabilitiesFor(false)
|
|
120
127
|
}] : []);
|
|
121
128
|
}
|
|
122
129
|
function writeJson(file, value) {
|
|
@@ -138,7 +145,8 @@ function transaction(fn) {
|
|
|
138
145
|
function unexpired(record, now) {
|
|
139
146
|
return Date.parse(record.expiresAt) > now;
|
|
140
147
|
}
|
|
141
|
-
|
|
148
|
+
/** The device that spends the token gets `capabilities`. */
|
|
149
|
+
function createPairingToken(now = Date.now(), capabilities = capabilitiesFor(false)) {
|
|
142
150
|
const token = newToken();
|
|
143
151
|
const expiresAt = new Date(now + PAIRING_TTL_MS).toISOString();
|
|
144
152
|
transaction(() => {
|
|
@@ -147,7 +155,8 @@ function createPairingToken(now = Date.now()) {
|
|
|
147
155
|
version: 1,
|
|
148
156
|
tokens: [...pending, {
|
|
149
157
|
tokenHash: hashToken(token),
|
|
150
|
-
expiresAt
|
|
158
|
+
expiresAt,
|
|
159
|
+
capabilities
|
|
151
160
|
}]
|
|
152
161
|
});
|
|
153
162
|
});
|
|
@@ -187,7 +196,7 @@ function spendPairingToken(token, name, identity, now = Date.now()) {
|
|
|
187
196
|
identity,
|
|
188
197
|
pairedAt: at,
|
|
189
198
|
lastSeenAt: at,
|
|
190
|
-
capabilities:
|
|
199
|
+
capabilities: match.capabilities
|
|
191
200
|
};
|
|
192
201
|
writeJson(devicesFile(), {
|
|
193
202
|
version: 1,
|
|
@@ -224,6 +233,19 @@ function authenticateDevice(token, identity, now = Date.now()) {
|
|
|
224
233
|
};
|
|
225
234
|
});
|
|
226
235
|
}
|
|
236
|
+
function grantDevice(id, capabilities) {
|
|
237
|
+
return transaction(() => {
|
|
238
|
+
const devices = readDevices();
|
|
239
|
+
const device = devices.find((entry) => entry.id === id);
|
|
240
|
+
if (!device) return false;
|
|
241
|
+
device.capabilities = capabilities;
|
|
242
|
+
writeJson(devicesFile(), {
|
|
243
|
+
version: 1,
|
|
244
|
+
devices
|
|
245
|
+
});
|
|
246
|
+
return true;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
227
249
|
function revokeDevice(id) {
|
|
228
250
|
return transaction(() => {
|
|
229
251
|
const devices = readDevices();
|
|
@@ -237,6 +259,113 @@ function revokeDevice(id) {
|
|
|
237
259
|
});
|
|
238
260
|
}
|
|
239
261
|
//#endregion
|
|
262
|
+
//#region src/actions.ts
|
|
263
|
+
function parseAction(params) {
|
|
264
|
+
if (!isJsonObject(params)) return {
|
|
265
|
+
code: "bad-request",
|
|
266
|
+
message: "params must be an object."
|
|
267
|
+
};
|
|
268
|
+
const { action, workspace, platform, ...rest } = params;
|
|
269
|
+
if (typeof action !== "string" || !ACTIONS.includes(action)) return {
|
|
270
|
+
code: "unknown-action",
|
|
271
|
+
message: `Unknown action ${JSON.stringify(action)}. Actions: ${ACTIONS.join(", ")}.`
|
|
272
|
+
};
|
|
273
|
+
if (typeof workspace !== "string") return {
|
|
274
|
+
code: "bad-request",
|
|
275
|
+
message: "params.workspace must be an environment path from a status payload."
|
|
276
|
+
};
|
|
277
|
+
const extra = Object.keys(rest);
|
|
278
|
+
if (action === "stop" && platform !== void 0) extra.push("platform");
|
|
279
|
+
if (extra.length) return {
|
|
280
|
+
code: "bad-request",
|
|
281
|
+
message: `${action} does not take ${extra.join(", ")}.`
|
|
282
|
+
};
|
|
283
|
+
if (action === "stop") return { action: {
|
|
284
|
+
action,
|
|
285
|
+
workspace
|
|
286
|
+
} };
|
|
287
|
+
if (platform !== void 0 && platform !== "ios" && platform !== "android") return {
|
|
288
|
+
code: "bad-request",
|
|
289
|
+
message: "platform must be ios or android."
|
|
290
|
+
};
|
|
291
|
+
return { action: {
|
|
292
|
+
action: "reload",
|
|
293
|
+
workspace,
|
|
294
|
+
...platform ? { platform } : {}
|
|
295
|
+
} };
|
|
296
|
+
}
|
|
297
|
+
function actionArgs(action) {
|
|
298
|
+
if (action.action === "stop") return ["stop", "--json"];
|
|
299
|
+
return [
|
|
300
|
+
"reload",
|
|
301
|
+
...action.platform ? [action.platform] : [],
|
|
302
|
+
"--json"
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
/** The JSON object the command printed, and on failure the message and remedy it printed, if any. */
|
|
306
|
+
function actionOutcome(outcome) {
|
|
307
|
+
let printed = null;
|
|
308
|
+
try {
|
|
309
|
+
printed = JSON.parse(outcome.stdout?.trim() ?? "");
|
|
310
|
+
} catch {
|
|
311
|
+
printed = null;
|
|
312
|
+
}
|
|
313
|
+
if (outcome.ok) return isJsonObject(printed) ? {
|
|
314
|
+
ok: true,
|
|
315
|
+
output: printed
|
|
316
|
+
} : {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: {
|
|
319
|
+
code: "action-failed",
|
|
320
|
+
message: "The command printed output that is not a JSON object."
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
if (isJsonObject(printed) && typeof printed.message === "string") {
|
|
324
|
+
const code = typeof printed.code === "string" ? `${printed.code}: ` : "";
|
|
325
|
+
const remedy = typeof printed.remedy === "string" ? ` ${printed.remedy}` : "";
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
error: {
|
|
329
|
+
code: "action-failed",
|
|
330
|
+
message: `${code}${printed.message}${remedy}`
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
error: {
|
|
337
|
+
code: "action-failed",
|
|
338
|
+
message: outcome.message
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function auditFile() {
|
|
343
|
+
return join(serverDir(), "actions.ndjson");
|
|
344
|
+
}
|
|
345
|
+
function appendAudit(record) {
|
|
346
|
+
mkdirSync(serverDir(), {
|
|
347
|
+
recursive: true,
|
|
348
|
+
mode: 448
|
|
349
|
+
});
|
|
350
|
+
appendFileSync(auditFile(), `${JSON.stringify(record)}\n`, { mode: 384 });
|
|
351
|
+
}
|
|
352
|
+
function readAudit() {
|
|
353
|
+
let text;
|
|
354
|
+
try {
|
|
355
|
+
text = readFileSync(auditFile(), "utf8");
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
return text.split("\n").flatMap((line) => {
|
|
360
|
+
try {
|
|
361
|
+
const value = JSON.parse(line);
|
|
362
|
+
return isJsonObject(value) ? [value] : [];
|
|
363
|
+
} catch {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
//#endregion
|
|
240
369
|
//#region src/stim-command.ts
|
|
241
370
|
const KILL_GRACE_MS = 1e3;
|
|
242
371
|
function terminate(child) {
|
|
@@ -305,7 +434,8 @@ function runStim(stimCli, env, args, cwd, limits) {
|
|
|
305
434
|
const detail = stderr.trim();
|
|
306
435
|
settle({
|
|
307
436
|
ok: false,
|
|
308
|
-
message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}
|
|
437
|
+
message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`,
|
|
438
|
+
stdout: Buffer.concat(chunks).toString("utf8")
|
|
309
439
|
});
|
|
310
440
|
});
|
|
311
441
|
}),
|
|
@@ -406,11 +536,344 @@ var FeedPool = class {
|
|
|
406
536
|
}
|
|
407
537
|
};
|
|
408
538
|
//#endregion
|
|
539
|
+
//#region src/video.ts
|
|
540
|
+
const FIXED_HEADER_BYTES = 21;
|
|
541
|
+
/** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
|
|
542
|
+
function videoPacket(subscription, sequence, unit) {
|
|
543
|
+
const id = Buffer.from(subscription, "ascii");
|
|
544
|
+
const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
|
|
545
|
+
header.writeUInt8(1, 0);
|
|
546
|
+
header.writeUInt8(unit.keyframe ? 1 : 0, 1);
|
|
547
|
+
header.writeUInt16BE(header.length, 2);
|
|
548
|
+
header.writeUInt32BE(sequence >>> 0, 4);
|
|
549
|
+
header.writeDoubleBE(unit.capturedAt, 8);
|
|
550
|
+
header.writeUInt16BE(unit.width, 16);
|
|
551
|
+
header.writeUInt16BE(unit.height, 18);
|
|
552
|
+
header.writeUInt8(id.length, 20);
|
|
553
|
+
id.copy(header, FIXED_HEADER_BYTES);
|
|
554
|
+
return Buffer.concat([header, unit.data]);
|
|
555
|
+
}
|
|
556
|
+
const DEFAULT_VIDEO_LIMITS = {
|
|
557
|
+
congestedBytes: 262144,
|
|
558
|
+
startBitrate: 3e6,
|
|
559
|
+
minBitrate: 25e4,
|
|
560
|
+
maxBitrate: 8e6,
|
|
561
|
+
recoverMs: 2e3
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* Decides, for one subscriber, whether each access unit goes out. A subscriber starts waiting for a keyframe.
|
|
565
|
+
* When its socket backs up it drops everything until the next keyframe, because a P-frame is useless without
|
|
566
|
+
* the frames before it; `congested` is reported once per episode.
|
|
567
|
+
*/
|
|
568
|
+
var VideoGate = class {
|
|
569
|
+
waiting = true;
|
|
570
|
+
congestedBytes;
|
|
571
|
+
constructor(congestedBytes) {
|
|
572
|
+
this.congestedBytes = congestedBytes;
|
|
573
|
+
}
|
|
574
|
+
admit(unit, bufferedBytes) {
|
|
575
|
+
if (bufferedBytes > this.congestedBytes) {
|
|
576
|
+
if (this.waiting) return "drop";
|
|
577
|
+
this.waiting = true;
|
|
578
|
+
return "congested";
|
|
579
|
+
}
|
|
580
|
+
if (this.waiting && !unit.keyframe) return "drop";
|
|
581
|
+
this.waiting = false;
|
|
582
|
+
return "send";
|
|
583
|
+
}
|
|
584
|
+
/** The client lost its decoder state; drop until the keyframe it asked for. */
|
|
585
|
+
reset() {
|
|
586
|
+
this.waiting = true;
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
/**
|
|
590
|
+
* One encoder's bitrate, shared by all subscribers of a device: halved on congestion at most once per `recoverMs`,
|
|
591
|
+
* and raised by a quarter after `recoverMs` without congestion.
|
|
592
|
+
*/
|
|
593
|
+
var Bitrate = class {
|
|
594
|
+
value;
|
|
595
|
+
calmSince;
|
|
596
|
+
cutAt = -Infinity;
|
|
597
|
+
limits;
|
|
598
|
+
constructor(limits, now) {
|
|
599
|
+
this.limits = limits;
|
|
600
|
+
this.value = limits.startBitrate;
|
|
601
|
+
this.calmSince = now;
|
|
602
|
+
}
|
|
603
|
+
get current() {
|
|
604
|
+
return this.value;
|
|
605
|
+
}
|
|
606
|
+
/** Called while any subscriber is congested. Returns the new bitrate when it changed. */
|
|
607
|
+
congested(now) {
|
|
608
|
+
this.calmSince = now;
|
|
609
|
+
if (now - this.cutAt < this.limits.recoverMs) return null;
|
|
610
|
+
this.cutAt = now;
|
|
611
|
+
return this.set(Math.max(this.limits.minBitrate, Math.round(this.value / 2)));
|
|
612
|
+
}
|
|
613
|
+
/** Returns the new bitrate when it changed. */
|
|
614
|
+
tick(now) {
|
|
615
|
+
if (now - this.calmSince < this.limits.recoverMs) return null;
|
|
616
|
+
this.calmSince = now;
|
|
617
|
+
return this.set(Math.min(this.limits.maxBitrate, Math.round(this.value * 1.25)));
|
|
618
|
+
}
|
|
619
|
+
set(next) {
|
|
620
|
+
if (next === this.value) return null;
|
|
621
|
+
this.value = next;
|
|
622
|
+
return next;
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/frame-helper.ts
|
|
627
|
+
const DEFAULT_FRAME_HINT = {
|
|
628
|
+
fps: FRAME_FPS.default,
|
|
629
|
+
maxEdge: FRAME_EDGE.default
|
|
630
|
+
};
|
|
631
|
+
const SOURCES_DIR = fileURLToPath(new URL("./stim-frames/", import.meta.url));
|
|
632
|
+
const BUILD_TIMEOUT_MS = 18e4;
|
|
633
|
+
const VERSION_TIMEOUT_MS = 3e4;
|
|
634
|
+
const MAX_MESSAGE_BYTES = 16777216;
|
|
635
|
+
const FRAME_MESSAGE = 1;
|
|
636
|
+
const NOTICE_MESSAGE = 2;
|
|
637
|
+
const VIDEO_MESSAGE = 3;
|
|
638
|
+
const VIDEO_HEADER_BYTES = 14;
|
|
639
|
+
const KEYFRAME_INTERVAL_MS = 250;
|
|
640
|
+
/** Runs the compiler in its own process group, so a timeout or `signal` also stops `swift-frontend` and `ld`. */
|
|
641
|
+
function run(file, args, env, timeoutMs, signal) {
|
|
642
|
+
return new Promise((resolve, reject) => {
|
|
643
|
+
const child = spawn(file, args, {
|
|
644
|
+
env,
|
|
645
|
+
stdio: [
|
|
646
|
+
"ignore",
|
|
647
|
+
"pipe",
|
|
648
|
+
"pipe"
|
|
649
|
+
],
|
|
650
|
+
detached: true
|
|
651
|
+
});
|
|
652
|
+
let output = "";
|
|
653
|
+
let stopped = null;
|
|
654
|
+
const stop = (reason) => {
|
|
655
|
+
stopped ??= reason;
|
|
656
|
+
try {
|
|
657
|
+
process.kill(-child.pid, "SIGKILL");
|
|
658
|
+
} catch {}
|
|
659
|
+
};
|
|
660
|
+
const timer = setTimeout(() => stop(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`), timeoutMs);
|
|
661
|
+
const abort = () => stop(`${file} ${args[0]} was stopped with the server.`);
|
|
662
|
+
signal?.addEventListener("abort", abort);
|
|
663
|
+
child.stdout.setEncoding("utf8");
|
|
664
|
+
child.stderr.setEncoding("utf8");
|
|
665
|
+
child.stdout.on("data", (chunk) => output = (output + chunk).slice(-4e3));
|
|
666
|
+
child.stderr.on("data", (chunk) => output = (output + chunk).slice(-4e3));
|
|
667
|
+
child.on("error", (error) => {
|
|
668
|
+
clearTimeout(timer);
|
|
669
|
+
signal?.removeEventListener("abort", abort);
|
|
670
|
+
reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
|
|
671
|
+
});
|
|
672
|
+
child.on("close", (code) => {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
signal?.removeEventListener("abort", abort);
|
|
675
|
+
if (stopped) reject(new Error(stopped));
|
|
676
|
+
else if (code === 0) resolve(output);
|
|
677
|
+
else reject(/* @__PURE__ */ new Error(`${file} ${args[0]} exited (code ${code}): ${output.trim()}`));
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Compiles the `stim-frames` helper from the Swift sources shipped in `dist/stim-frames/` into
|
|
683
|
+
* `$STIM_HOME/server/helpers/`, named by a hash of the sources and the compiler version. Resolves to the
|
|
684
|
+
* helper's path, or rejects with the reason it cannot be built.
|
|
685
|
+
*/
|
|
686
|
+
async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
687
|
+
if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
|
|
688
|
+
const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted();
|
|
689
|
+
if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
|
|
690
|
+
const version = await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal);
|
|
691
|
+
const hash = createHash("sha256").update(version);
|
|
692
|
+
for (const name of sources) hash.update(name).update(readFileSync(join(sourcesDir, name)));
|
|
693
|
+
const dir = join(serverDir(), "helpers");
|
|
694
|
+
const helper = join(dir, `stim-frames-${hash.digest("hex").slice(0, 16)}`);
|
|
695
|
+
if (existsSync(helper)) return helper;
|
|
696
|
+
mkdirSync(dir, {
|
|
697
|
+
recursive: true,
|
|
698
|
+
mode: 448
|
|
699
|
+
});
|
|
700
|
+
const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
701
|
+
try {
|
|
702
|
+
await run("xcrun", [
|
|
703
|
+
"swiftc",
|
|
704
|
+
"-O",
|
|
705
|
+
"-swift-version",
|
|
706
|
+
"5",
|
|
707
|
+
"-module-name",
|
|
708
|
+
"StimFrames",
|
|
709
|
+
"-o",
|
|
710
|
+
output,
|
|
711
|
+
...sources.map((source) => join(sourcesDir, source))
|
|
712
|
+
], env, BUILD_TIMEOUT_MS, signal);
|
|
713
|
+
renameSync(output, helper);
|
|
714
|
+
} finally {
|
|
715
|
+
rmSync(output, { force: true });
|
|
716
|
+
}
|
|
717
|
+
return helper;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* One `stim-frames` process per device, shared by its subscribers and stopped with the last of them. It runs
|
|
721
|
+
* at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
|
|
722
|
+
* helper exits when its stdin closes, so it cannot outlive the server.
|
|
723
|
+
*/
|
|
724
|
+
var HelperSource = class {
|
|
725
|
+
listeners = /* @__PURE__ */ new Map();
|
|
726
|
+
child;
|
|
727
|
+
last = null;
|
|
728
|
+
config = "";
|
|
729
|
+
stopped = false;
|
|
730
|
+
notice = null;
|
|
731
|
+
stderr = "";
|
|
732
|
+
ended;
|
|
733
|
+
bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
|
|
734
|
+
keyframeAt = -Infinity;
|
|
735
|
+
keyframeTimer = null;
|
|
736
|
+
constructor(helper, device, env, ended) {
|
|
737
|
+
this.ended = ended;
|
|
738
|
+
const id = device.platform === "ios" ? device.udid : device.serial;
|
|
739
|
+
this.child = spawn(helper, [device.platform, id], {
|
|
740
|
+
env,
|
|
741
|
+
stdio: [
|
|
742
|
+
"pipe",
|
|
743
|
+
"pipe",
|
|
744
|
+
"pipe"
|
|
745
|
+
]
|
|
746
|
+
});
|
|
747
|
+
this.child.stdin.on("error", () => {});
|
|
748
|
+
this.child.stderr.setEncoding("utf8");
|
|
749
|
+
this.child.stderr.on("data", (chunk) => {
|
|
750
|
+
this.stderr = (this.stderr + chunk).slice(-1e3);
|
|
751
|
+
});
|
|
752
|
+
this.readMessages();
|
|
753
|
+
this.child.on("error", (error) => this.fail(`stim-frames could not start (${error.message}).`));
|
|
754
|
+
this.child.on("close", (code, signal) => {
|
|
755
|
+
const detail = this.notice ?? this.stderr.trim();
|
|
756
|
+
this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
add(listener, hint) {
|
|
760
|
+
this.listeners.set(listener, hint);
|
|
761
|
+
this.configure();
|
|
762
|
+
if (listener.video) this.keyframe();
|
|
763
|
+
else if (this.last) listener.frame(this.last);
|
|
764
|
+
return () => {
|
|
765
|
+
if (!this.listeners.delete(listener)) return;
|
|
766
|
+
if (this.listeners.size === 0) this.stop();
|
|
767
|
+
else this.configure();
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
stop() {
|
|
771
|
+
if (this.stopped) return Promise.resolve();
|
|
772
|
+
this.stopped = true;
|
|
773
|
+
if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
|
|
774
|
+
this.listeners.clear();
|
|
775
|
+
this.ended();
|
|
776
|
+
this.child.stdin.end();
|
|
777
|
+
return terminate(this.child);
|
|
778
|
+
}
|
|
779
|
+
/** Asks for at most one keyframe per {@link KEYFRAME_INTERVAL_MS}; a request inside the interval is sent at its end. */
|
|
780
|
+
keyframe() {
|
|
781
|
+
if (this.stopped || this.keyframeTimer) return;
|
|
782
|
+
const wait = this.keyframeAt + KEYFRAME_INTERVAL_MS - Date.now();
|
|
783
|
+
if (wait > 0) {
|
|
784
|
+
this.keyframeTimer = setTimeout(() => {
|
|
785
|
+
this.keyframeTimer = null;
|
|
786
|
+
this.keyframe();
|
|
787
|
+
}, wait);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
this.keyframeAt = Date.now();
|
|
791
|
+
this.child.stdin.write("{\"keyframe\":true}\n");
|
|
792
|
+
}
|
|
793
|
+
congested() {
|
|
794
|
+
if (this.bitrate.congested(Date.now()) !== null) this.configure();
|
|
795
|
+
}
|
|
796
|
+
configure() {
|
|
797
|
+
const hints = [...this.listeners.values()];
|
|
798
|
+
const jpegFps = [...this.listeners].flatMap(([listener, hint]) => listener.video ? [] : [hint.fps]);
|
|
799
|
+
const config = JSON.stringify({
|
|
800
|
+
fps: Math.max(...hints.map((hint) => hint.fps)),
|
|
801
|
+
maxEdge: Math.max(...hints.map((hint) => hint.maxEdge)),
|
|
802
|
+
jpeg: jpegFps.length > 0,
|
|
803
|
+
...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
|
|
804
|
+
video: jpegFps.length < hints.length,
|
|
805
|
+
bitrate: this.bitrate.current
|
|
806
|
+
});
|
|
807
|
+
if (!jpegFps.length) this.last = null;
|
|
808
|
+
if (config === this.config || this.stopped) return;
|
|
809
|
+
this.config = config;
|
|
810
|
+
this.child.stdin.write(`${config}\n`);
|
|
811
|
+
}
|
|
812
|
+
fail(message) {
|
|
813
|
+
if (this.stopped) return;
|
|
814
|
+
const listeners = [...this.listeners.keys()];
|
|
815
|
+
this.stop();
|
|
816
|
+
for (const listener of listeners) listener.failed(message);
|
|
817
|
+
}
|
|
818
|
+
readMessages() {
|
|
819
|
+
let buffer = Buffer.alloc(0);
|
|
820
|
+
this.child.stdout.on("data", (chunk) => {
|
|
821
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
822
|
+
while (buffer.length >= 4) {
|
|
823
|
+
const length = buffer.readUInt32BE(0);
|
|
824
|
+
if (length < 1 || length > MAX_MESSAGE_BYTES) {
|
|
825
|
+
this.child.stdout.removeAllListeners("data");
|
|
826
|
+
return this.fail("stim-frames wrote a malformed message.");
|
|
827
|
+
}
|
|
828
|
+
if (buffer.length < 4 + length) break;
|
|
829
|
+
const body = buffer.subarray(4, 4 + length);
|
|
830
|
+
buffer = buffer.subarray(4 + length);
|
|
831
|
+
if (body[0] === FRAME_MESSAGE && body.length > 5) this.frame(body);
|
|
832
|
+
else if (body[0] === VIDEO_MESSAGE && body.length > VIDEO_HEADER_BYTES) this.video(body);
|
|
833
|
+
else if (body[0] === NOTICE_MESSAGE) this.readNotice(body.subarray(1).toString("utf8"));
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
frame(body) {
|
|
838
|
+
if (this.stopped) return;
|
|
839
|
+
this.last = {
|
|
840
|
+
width: body.readUInt16BE(1),
|
|
841
|
+
height: body.readUInt16BE(3),
|
|
842
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
843
|
+
data: body.subarray(5).toString("base64")
|
|
844
|
+
};
|
|
845
|
+
for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
|
|
846
|
+
}
|
|
847
|
+
video(body) {
|
|
848
|
+
if (this.stopped) return;
|
|
849
|
+
const unit = {
|
|
850
|
+
keyframe: (body[1] & 1) !== 0,
|
|
851
|
+
capturedAt: body.readDoubleBE(2),
|
|
852
|
+
width: body.readUInt16BE(10),
|
|
853
|
+
height: body.readUInt16BE(12),
|
|
854
|
+
data: body.subarray(VIDEO_HEADER_BYTES)
|
|
855
|
+
};
|
|
856
|
+
if (this.bitrate.tick(Date.now()) !== null) this.configure();
|
|
857
|
+
for (const listener of this.listeners.keys()) listener.video?.(unit);
|
|
858
|
+
}
|
|
859
|
+
readNotice(text) {
|
|
860
|
+
try {
|
|
861
|
+
const error = JSON.parse(text)?.error;
|
|
862
|
+
if (typeof error === "string") this.notice = error;
|
|
863
|
+
} catch {}
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
//#endregion
|
|
409
867
|
//#region src/frames.ts
|
|
868
|
+
const DEFAULT_FRAME_LIMITS = {
|
|
869
|
+
toolTimeoutMs: 3e4,
|
|
870
|
+
slowCaptureMs: 3e3,
|
|
871
|
+
failureBackoffMs: 3e3,
|
|
872
|
+
maxConsecutiveFailures: 3
|
|
873
|
+
};
|
|
410
874
|
const MIN_INTERVAL_MS = 200;
|
|
411
875
|
const MAX_INTERVAL_MS = 1e3;
|
|
412
876
|
const MAX_CAPTURES = 2;
|
|
413
|
-
const TOOL_TIMEOUT_MS = 1e4;
|
|
414
877
|
const MAX_EDGE = 1280;
|
|
415
878
|
const JPEG_QUALITY = 70;
|
|
416
879
|
function deviceKey(device) {
|
|
@@ -432,7 +895,8 @@ function ownedDevice(payload, target, attached) {
|
|
|
432
895
|
if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
|
|
433
896
|
return {
|
|
434
897
|
platform: "ios",
|
|
435
|
-
udid: sim.udid
|
|
898
|
+
udid: sim.udid,
|
|
899
|
+
foldable: /\bDuo\b/.test(sim.name ?? "")
|
|
436
900
|
};
|
|
437
901
|
}
|
|
438
902
|
const emulator = devices?.android;
|
|
@@ -469,7 +933,14 @@ function jpegSize(bytes) {
|
|
|
469
933
|
}
|
|
470
934
|
return null;
|
|
471
935
|
}
|
|
472
|
-
|
|
936
|
+
/** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
|
|
937
|
+
function timeoutError(message) {
|
|
938
|
+
return Object.assign(new Error(message), { transient: true });
|
|
939
|
+
}
|
|
940
|
+
function isTransient(error) {
|
|
941
|
+
return error instanceof Error && error.transient === true;
|
|
942
|
+
}
|
|
943
|
+
function runTool(file, args, env, running, timeoutMs) {
|
|
473
944
|
return new Promise((resolve, reject) => {
|
|
474
945
|
const child = spawn(file, args, {
|
|
475
946
|
env,
|
|
@@ -485,8 +956,8 @@ function runTool(file, args, env, running) {
|
|
|
485
956
|
let stderr = "";
|
|
486
957
|
const timer = setTimeout(() => {
|
|
487
958
|
terminate(child);
|
|
488
|
-
reject(
|
|
489
|
-
},
|
|
959
|
+
reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
|
|
960
|
+
}, timeoutMs);
|
|
490
961
|
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
491
962
|
child.stderr.setEncoding("utf8");
|
|
492
963
|
child.stderr.on("data", (chunk) => {
|
|
@@ -511,36 +982,87 @@ async function stopTools(running, tmp) {
|
|
|
511
982
|
});
|
|
512
983
|
}
|
|
513
984
|
/**
|
|
514
|
-
* Without `--display`, simctl captures the first panel it finds
|
|
515
|
-
*
|
|
516
|
-
*
|
|
985
|
+
* Without `--display`, simctl captures the first panel it finds; `primary` is the panel CoreDevice reports as
|
|
986
|
+
* primary. A simctl that rejects `primary` gets the default display instead.
|
|
987
|
+
*
|
|
988
|
+
* An iPhone Duo lights one of two panels: `primary` is the cover, lit when folded, and `primary-1` the inner
|
|
989
|
+
* panel, lit when unfolded; the other one's framebuffer is all black. `litPanels` remembers the lit panel per
|
|
990
|
+
* simulator across subscriptions, and a black capture tries the other panel.
|
|
517
991
|
*/
|
|
518
|
-
function simulatorCapturer(
|
|
992
|
+
function simulatorCapturer(device, env, limits, litPanels) {
|
|
519
993
|
let tmp = null;
|
|
520
994
|
let display = ["--display=primary"];
|
|
521
995
|
let closed = false;
|
|
522
996
|
const running = /* @__PURE__ */ new Set();
|
|
523
|
-
const screenshot = (
|
|
524
|
-
"
|
|
525
|
-
"
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
997
|
+
const screenshot = async (panel) => {
|
|
998
|
+
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
999
|
+
const output = join(tmp, "frame.jpg");
|
|
1000
|
+
await runTool("xcrun", [
|
|
1001
|
+
"simctl",
|
|
1002
|
+
"io",
|
|
1003
|
+
device.udid,
|
|
1004
|
+
"screenshot",
|
|
1005
|
+
"--type=jpeg",
|
|
1006
|
+
...panel,
|
|
1007
|
+
output
|
|
1008
|
+
], env, running, limits.toolTimeoutMs);
|
|
1009
|
+
return readFileSync(output);
|
|
1010
|
+
};
|
|
1011
|
+
const isBlack = async (jpeg) => {
|
|
1012
|
+
const input = join(tmp, "check.jpg");
|
|
1013
|
+
const output = join(tmp, "check.bmp");
|
|
1014
|
+
writeFileSync(input, jpeg);
|
|
1015
|
+
await runTool("sips", [
|
|
1016
|
+
"-s",
|
|
1017
|
+
"format",
|
|
1018
|
+
"bmp",
|
|
1019
|
+
"-z",
|
|
1020
|
+
"24",
|
|
1021
|
+
"24",
|
|
1022
|
+
input,
|
|
1023
|
+
"--out",
|
|
1024
|
+
output
|
|
1025
|
+
], env, running, limits.toolTimeoutMs);
|
|
1026
|
+
return bmpIsBlack(readFileSync(output));
|
|
1027
|
+
};
|
|
1028
|
+
const open = () => {
|
|
1029
|
+
if (closed) throw new Error("The capture was stopped.");
|
|
1030
|
+
};
|
|
1031
|
+
const captureDuo = async () => {
|
|
1032
|
+
const lit = litPanels.get(device.udid) ?? "primary";
|
|
1033
|
+
const jpeg = await screenshot([`--display=${lit}`]);
|
|
1034
|
+
open();
|
|
1035
|
+
if (!await isBlack(jpeg)) return {
|
|
1036
|
+
raw: jpeg,
|
|
1037
|
+
jpeg: async () => jpeg,
|
|
1038
|
+
posture: POSTURES[lit]
|
|
1039
|
+
};
|
|
1040
|
+
const other = lit === "primary" ? "primary-1" : "primary";
|
|
1041
|
+
open();
|
|
1042
|
+
const otherJpeg = await screenshot([`--display=${other}`]);
|
|
1043
|
+
open();
|
|
1044
|
+
if (await isBlack(otherJpeg)) return {
|
|
1045
|
+
raw: jpeg,
|
|
1046
|
+
jpeg: async () => jpeg
|
|
1047
|
+
};
|
|
1048
|
+
litPanels.set(device.udid, other);
|
|
1049
|
+
return {
|
|
1050
|
+
raw: otherJpeg,
|
|
1051
|
+
jpeg: async () => otherJpeg,
|
|
1052
|
+
posture: POSTURES[other]
|
|
1053
|
+
};
|
|
1054
|
+
};
|
|
532
1055
|
return {
|
|
533
1056
|
capture: async () => {
|
|
534
|
-
|
|
535
|
-
|
|
1057
|
+
if (device.foldable) return captureDuo();
|
|
1058
|
+
let jpeg;
|
|
536
1059
|
try {
|
|
537
|
-
await screenshot(
|
|
1060
|
+
jpeg = await screenshot(display);
|
|
538
1061
|
} catch (error) {
|
|
539
1062
|
if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
|
|
540
1063
|
display = [];
|
|
541
|
-
await screenshot(
|
|
1064
|
+
jpeg = await screenshot(display);
|
|
542
1065
|
}
|
|
543
|
-
const jpeg = readFileSync(output);
|
|
544
1066
|
return {
|
|
545
1067
|
raw: jpeg,
|
|
546
1068
|
jpeg: async () => jpeg
|
|
@@ -552,6 +1074,18 @@ function simulatorCapturer(udid, env) {
|
|
|
552
1074
|
}
|
|
553
1075
|
};
|
|
554
1076
|
}
|
|
1077
|
+
const POSTURES = {
|
|
1078
|
+
primary: "folded",
|
|
1079
|
+
"primary-1": "unfolded"
|
|
1080
|
+
};
|
|
1081
|
+
/** Whether every pixel of an uncompressed 24- or 32-bit BMP, as `sips -s format bmp` writes it, is black. */
|
|
1082
|
+
function bmpIsBlack(bmp) {
|
|
1083
|
+
if (bmp.length < 54 || bmp.toString("latin1", 0, 2) !== "BM") throw new Error("sips did not write a BMP image.");
|
|
1084
|
+
const offset = bmp.readUInt32LE(10);
|
|
1085
|
+
const bytesPerPixel = bmp.readUInt16LE(28) / 8;
|
|
1086
|
+
for (let at = offset; at + 3 <= bmp.length; at += bytesPerPixel) if (bmp[at] > 8 || bmp[at + 1] > 8 || bmp[at + 2] > 8) return false;
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
555
1089
|
/**
|
|
556
1090
|
* The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
|
|
557
1091
|
* Stim passes when it boots an owned AVD (#1064).
|
|
@@ -636,36 +1170,61 @@ function* protoFields(bytes) {
|
|
|
636
1170
|
else throw new Error(`unsupported protobuf wire type ${type}`);
|
|
637
1171
|
}
|
|
638
1172
|
}
|
|
639
|
-
|
|
640
|
-
function screenshotRequest() {
|
|
641
|
-
const message = Buffer.from([
|
|
642
|
-
24,
|
|
643
|
-
...varint(MAX_EDGE),
|
|
644
|
-
32,
|
|
645
|
-
...varint(MAX_EDGE)
|
|
646
|
-
]);
|
|
1173
|
+
function grpcMessage(message) {
|
|
647
1174
|
const header = Buffer.alloc(5);
|
|
648
1175
|
header.writeUInt32BE(message.length, 1);
|
|
649
|
-
return Buffer.concat([header, message]);
|
|
1176
|
+
return Buffer.concat([header, Buffer.from(message)]);
|
|
1177
|
+
}
|
|
1178
|
+
function grpcReply(body, method) {
|
|
1179
|
+
if (body.length < 5 || body[0] !== 0) throw new Error(`${method} returned no message.`);
|
|
1180
|
+
return body.subarray(5, 5 + body.readUInt32BE(1));
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. The reply's
|
|
1184
|
+
* format carries `foldedDisplay` (field 7) while a foldable is folded.
|
|
1185
|
+
*/
|
|
1186
|
+
function screenshotReply(body) {
|
|
1187
|
+
let png = null;
|
|
1188
|
+
let folded = false;
|
|
1189
|
+
for (const entry of protoFields(grpcReply(body, "getScreenshot"))) {
|
|
1190
|
+
if (entry.field === 4 && "bytes" in entry) png = Buffer.from(entry.bytes);
|
|
1191
|
+
if (entry.field === 1 && "bytes" in entry) folded = [...protoFields(entry.bytes)].some((format) => format.field === 7);
|
|
1192
|
+
}
|
|
1193
|
+
if (!png) throw new Error("getScreenshot returned no image.");
|
|
1194
|
+
return {
|
|
1195
|
+
png,
|
|
1196
|
+
folded
|
|
1197
|
+
};
|
|
650
1198
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1199
|
+
/**
|
|
1200
|
+
* `PhysicalModelValue` for POSTURE (PhysicalType 16), whose one float is a `Posture.PostureValue`; 1 to 5
|
|
1201
|
+
* are real postures, so any of them means the emulator has a hinge.
|
|
1202
|
+
*/
|
|
1203
|
+
function hasHinge(body) {
|
|
1204
|
+
for (const entry of protoFields(grpcReply(body, "getPhysicalModel"))) {
|
|
1205
|
+
if (entry.field === 2 && "varint" in entry && entry.varint !== 0) return false;
|
|
1206
|
+
if (entry.field !== 3 || !("bytes" in entry)) continue;
|
|
1207
|
+
for (const value of protoFields(entry.bytes)) {
|
|
1208
|
+
if (value.field !== 1 || !("bytes" in value) || value.bytes.length < 4) continue;
|
|
1209
|
+
const posture = Math.round(value.bytes.readFloatLE(0));
|
|
1210
|
+
return posture >= 1 && posture <= 5;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return false;
|
|
656
1214
|
}
|
|
657
|
-
function emulatorCapturer(serial, env) {
|
|
1215
|
+
function emulatorCapturer(serial, env, limits) {
|
|
658
1216
|
let session = null;
|
|
659
1217
|
let tmp = null;
|
|
660
1218
|
const running = /* @__PURE__ */ new Set();
|
|
661
|
-
|
|
1219
|
+
let hinged = null;
|
|
1220
|
+
const call = (endpoint, method, requestBody) => new Promise((resolve, reject) => {
|
|
662
1221
|
if (!session || session.closed || session.destroyed) {
|
|
663
1222
|
session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
|
|
664
1223
|
session.on("error", () => {});
|
|
665
1224
|
}
|
|
666
1225
|
const request = session.request({
|
|
667
1226
|
":method": "POST",
|
|
668
|
-
":path":
|
|
1227
|
+
":path": `/android.emulation.control.EmulatorController/${method}`,
|
|
669
1228
|
"content-type": "application/grpc",
|
|
670
1229
|
te: "trailers",
|
|
671
1230
|
...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
|
|
@@ -673,28 +1232,43 @@ function emulatorCapturer(serial, env) {
|
|
|
673
1232
|
const chunks = [];
|
|
674
1233
|
let status;
|
|
675
1234
|
let message;
|
|
1235
|
+
let timedOut = false;
|
|
676
1236
|
const record = (headers) => {
|
|
677
1237
|
if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
|
|
678
1238
|
if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
|
|
679
1239
|
};
|
|
680
|
-
request.setTimeout(
|
|
1240
|
+
request.setTimeout(limits.toolTimeoutMs, () => {
|
|
1241
|
+
timedOut = true;
|
|
1242
|
+
request.close();
|
|
1243
|
+
});
|
|
681
1244
|
request.on("response", record);
|
|
682
1245
|
request.on("trailers", record);
|
|
683
1246
|
request.on("data", (chunk) => chunks.push(chunk));
|
|
684
|
-
request.on("error", (error) =>
|
|
1247
|
+
request.on("error", (error) => {
|
|
1248
|
+
const text = `${method} failed on ${serial}: ${error.message}`;
|
|
1249
|
+
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
1250
|
+
});
|
|
685
1251
|
request.on("close", () => {
|
|
686
1252
|
if (status === "0") return resolve(Buffer.concat(chunks));
|
|
687
|
-
|
|
1253
|
+
const text = `${method} failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
|
|
1254
|
+
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
688
1255
|
});
|
|
689
|
-
request.end(
|
|
1256
|
+
request.end(requestBody);
|
|
690
1257
|
});
|
|
691
1258
|
return {
|
|
692
1259
|
capture: async () => {
|
|
693
1260
|
const endpoint = emulatorEndpoint(env, serial);
|
|
694
1261
|
if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
|
|
695
|
-
|
|
1262
|
+
hinged ??= await call(endpoint, "getPhysicalModel", grpcMessage([8, 16])).then(hasHinge).catch(() => false);
|
|
1263
|
+
const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
|
|
1264
|
+
24,
|
|
1265
|
+
...varint(MAX_EDGE),
|
|
1266
|
+
32,
|
|
1267
|
+
...varint(MAX_EDGE)
|
|
1268
|
+
])));
|
|
696
1269
|
return {
|
|
697
1270
|
raw: png,
|
|
1271
|
+
...hinged ? { posture: folded ? "folded" : "unfolded" } : {},
|
|
698
1272
|
jpeg: async () => {
|
|
699
1273
|
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
700
1274
|
const input = join(tmp, "frame.png");
|
|
@@ -712,7 +1286,7 @@ function emulatorCapturer(serial, env) {
|
|
|
712
1286
|
input,
|
|
713
1287
|
"--out",
|
|
714
1288
|
output
|
|
715
|
-
], env, running);
|
|
1289
|
+
], env, running, limits.toolTimeoutMs);
|
|
716
1290
|
return readFileSync(output);
|
|
717
1291
|
}
|
|
718
1292
|
};
|
|
@@ -749,18 +1323,23 @@ var FrameSource = class {
|
|
|
749
1323
|
interval = MIN_INTERVAL_MS;
|
|
750
1324
|
timer = null;
|
|
751
1325
|
stopped = false;
|
|
1326
|
+
delayed = false;
|
|
1327
|
+
consecutiveFailures = 0;
|
|
752
1328
|
capturer;
|
|
753
1329
|
limiter;
|
|
1330
|
+
limits;
|
|
754
1331
|
ended;
|
|
755
|
-
constructor(capturer, limiter, ended) {
|
|
1332
|
+
constructor(capturer, limiter, limits, ended) {
|
|
756
1333
|
this.capturer = capturer;
|
|
757
1334
|
this.limiter = limiter;
|
|
1335
|
+
this.limits = limits;
|
|
758
1336
|
this.ended = ended;
|
|
759
1337
|
this.tick();
|
|
760
1338
|
}
|
|
761
1339
|
add(listener) {
|
|
762
1340
|
this.listeners.add(listener);
|
|
763
1341
|
if (this.last) listener.frame(this.last);
|
|
1342
|
+
if (this.delayed) listener.delayed(true);
|
|
764
1343
|
return () => {
|
|
765
1344
|
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
766
1345
|
};
|
|
@@ -773,6 +1352,11 @@ var FrameSource = class {
|
|
|
773
1352
|
this.ended();
|
|
774
1353
|
return this.capturer.close();
|
|
775
1354
|
}
|
|
1355
|
+
setDelayed(delayed) {
|
|
1356
|
+
if (this.delayed === delayed) return;
|
|
1357
|
+
this.delayed = delayed;
|
|
1358
|
+
for (const listener of this.listeners) listener.delayed(delayed);
|
|
1359
|
+
}
|
|
776
1360
|
async tick() {
|
|
777
1361
|
this.timer = null;
|
|
778
1362
|
let took = 0;
|
|
@@ -791,17 +1375,26 @@ var FrameSource = class {
|
|
|
791
1375
|
this.last = {
|
|
792
1376
|
...size,
|
|
793
1377
|
capturedAt: new Date(started).toISOString(),
|
|
794
|
-
data: jpeg.toString("base64")
|
|
1378
|
+
data: jpeg.toString("base64"),
|
|
1379
|
+
...capture.posture ? { posture: capture.posture } : {}
|
|
795
1380
|
};
|
|
796
1381
|
took = Date.now() - started;
|
|
797
1382
|
return true;
|
|
798
1383
|
});
|
|
799
1384
|
if (this.stopped) return;
|
|
1385
|
+
this.consecutiveFailures = 0;
|
|
1386
|
+
this.setDelayed(took > this.limits.slowCaptureMs);
|
|
800
1387
|
if (changed) for (const listener of this.listeners) listener.frame(this.last);
|
|
801
1388
|
this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
|
|
802
1389
|
this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
|
|
803
1390
|
} catch (error) {
|
|
804
1391
|
if (this.stopped) return;
|
|
1392
|
+
if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
|
|
1393
|
+
this.consecutiveFailures++;
|
|
1394
|
+
this.setDelayed(true);
|
|
1395
|
+
this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
805
1398
|
const listeners = [...this.listeners];
|
|
806
1399
|
this.stop();
|
|
807
1400
|
for (const listener of listeners) listener.failed(error.message);
|
|
@@ -809,29 +1402,83 @@ var FrameSource = class {
|
|
|
809
1402
|
}
|
|
810
1403
|
};
|
|
811
1404
|
/**
|
|
812
|
-
* One capture
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
1405
|
+
* One capture per device, shared by its subscribers and stopped with the last of them. With the `stim-frames`
|
|
1406
|
+
* helper, a device streams frames as its screen changes, at the rate its subscribers ask for. Without it, for
|
|
1407
|
+
* an iPhone Duo, and while the helper is still being built, a screenshot loop sends a frame only when the
|
|
1408
|
+
* screen changed: up to 5 times a second while it changes, backing off to once a second while it does not,
|
|
1409
|
+
* spending at most half of its time capturing, with at most two captures at once across all devices. A helper
|
|
1410
|
+
* that fails before its first frame gives way to the screenshot loop.
|
|
816
1411
|
*/
|
|
817
1412
|
var FramePool = class {
|
|
818
1413
|
sources = /* @__PURE__ */ new Map();
|
|
819
1414
|
limiter = new Limiter(MAX_CAPTURES);
|
|
1415
|
+
litPanels = /* @__PURE__ */ new Map();
|
|
820
1416
|
env;
|
|
821
|
-
|
|
1417
|
+
limits;
|
|
1418
|
+
helper;
|
|
1419
|
+
constructor(env, limits = DEFAULT_FRAME_LIMITS, helper = () => null) {
|
|
822
1420
|
this.env = env;
|
|
1421
|
+
this.limits = limits;
|
|
1422
|
+
this.helper = helper;
|
|
1423
|
+
}
|
|
1424
|
+
subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
|
|
1425
|
+
const helper = this.helper();
|
|
1426
|
+
if (helper === null || device.platform === "ios" && device.foldable) return this.screenshots(device).add(listener);
|
|
1427
|
+
let streamed = false;
|
|
1428
|
+
let cancelled = false;
|
|
1429
|
+
const { video } = listener;
|
|
1430
|
+
let detach = this.stream(helper, device).add({
|
|
1431
|
+
frame: (frame) => {
|
|
1432
|
+
streamed = true;
|
|
1433
|
+
listener.frame(frame);
|
|
1434
|
+
},
|
|
1435
|
+
...video ? { video: (unit) => {
|
|
1436
|
+
streamed = true;
|
|
1437
|
+
video(unit);
|
|
1438
|
+
} } : {},
|
|
1439
|
+
delayed: listener.delayed,
|
|
1440
|
+
failed: (message) => {
|
|
1441
|
+
if (streamed || cancelled) return listener.failed(message);
|
|
1442
|
+
console.error(`stim-server: ${message} Falling back to screenshots.`);
|
|
1443
|
+
detach = this.screenshots(device).add(listener);
|
|
1444
|
+
}
|
|
1445
|
+
}, hint);
|
|
1446
|
+
return () => {
|
|
1447
|
+
cancelled = true;
|
|
1448
|
+
detach();
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
/** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
|
|
1452
|
+
keyframe(device) {
|
|
1453
|
+
this.helperSource(device)?.keyframe();
|
|
1454
|
+
}
|
|
1455
|
+
/** A subscriber of `device` is behind; called until its socket drains, it lowers the shared bitrate. */
|
|
1456
|
+
congested(device) {
|
|
1457
|
+
this.helperSource(device)?.congested();
|
|
823
1458
|
}
|
|
824
|
-
|
|
1459
|
+
helperSource(device) {
|
|
1460
|
+
const source = this.sources.get(`helper:${deviceKey(device)}`);
|
|
1461
|
+
return source instanceof HelperSource ? source : null;
|
|
1462
|
+
}
|
|
1463
|
+
stream(helper, device) {
|
|
1464
|
+
const key = `helper:${deviceKey(device)}`;
|
|
1465
|
+
const existing = this.sources.get(key);
|
|
1466
|
+
if (existing instanceof HelperSource) return existing;
|
|
1467
|
+
const created = new HelperSource(helper, device, this.env, () => {
|
|
1468
|
+
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
1469
|
+
});
|
|
1470
|
+
this.sources.set(key, created);
|
|
1471
|
+
return created;
|
|
1472
|
+
}
|
|
1473
|
+
screenshots(device) {
|
|
825
1474
|
const key = deviceKey(device);
|
|
826
|
-
|
|
827
|
-
if (
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
834
|
-
return source.add(listener);
|
|
1475
|
+
const existing = this.sources.get(key);
|
|
1476
|
+
if (existing instanceof FrameSource) return existing;
|
|
1477
|
+
const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device, this.env, this.limits, this.litPanels) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
|
|
1478
|
+
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
1479
|
+
});
|
|
1480
|
+
this.sources.set(key, created);
|
|
1481
|
+
return created;
|
|
835
1482
|
}
|
|
836
1483
|
async close() {
|
|
837
1484
|
await Promise.all([...this.sources.values()].map((source) => source.stop()));
|
|
@@ -911,9 +1558,203 @@ var LogBatcher = class {
|
|
|
911
1558
|
}
|
|
912
1559
|
};
|
|
913
1560
|
//#endregion
|
|
1561
|
+
//#region src/machine.ts
|
|
1562
|
+
function stimDiskLocations() {
|
|
1563
|
+
let workspaces = [];
|
|
1564
|
+
try {
|
|
1565
|
+
workspaces = Object.keys(loadConfig()?.projects ?? {});
|
|
1566
|
+
} catch {}
|
|
1567
|
+
return [
|
|
1568
|
+
...workspaces.map((path) => ({
|
|
1569
|
+
label: "Workspaces",
|
|
1570
|
+
path
|
|
1571
|
+
})),
|
|
1572
|
+
{
|
|
1573
|
+
label: "Stim home",
|
|
1574
|
+
path: configDir()
|
|
1575
|
+
},
|
|
1576
|
+
...process.platform === "darwin" ? [{
|
|
1577
|
+
label: "Simulators",
|
|
1578
|
+
path: join(homedir(), "Library", "Developer", "CoreSimulator")
|
|
1579
|
+
}] : []
|
|
1580
|
+
];
|
|
1581
|
+
}
|
|
1582
|
+
function existingAncestor(path) {
|
|
1583
|
+
let current = path;
|
|
1584
|
+
while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
|
|
1585
|
+
return current;
|
|
1586
|
+
}
|
|
1587
|
+
function mountOf(path) {
|
|
1588
|
+
return /^\/Volumes\/[^/]+/.exec(path)?.[0] ?? "/";
|
|
1589
|
+
}
|
|
1590
|
+
function readVolumes(locations) {
|
|
1591
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
1592
|
+
for (const { label, path } of locations) try {
|
|
1593
|
+
const existing = existingAncestor(path);
|
|
1594
|
+
const device = statSync(existing).dev;
|
|
1595
|
+
const known = byDevice.get(device);
|
|
1596
|
+
if (known) {
|
|
1597
|
+
if (!known.holds.includes(label)) known.holds.push(label);
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
const fs = statfsSync(existing);
|
|
1601
|
+
byDevice.set(device, {
|
|
1602
|
+
mount: mountOf(existing),
|
|
1603
|
+
holds: [label],
|
|
1604
|
+
freeBytes: fs.bavail * fs.bsize,
|
|
1605
|
+
totalBytes: fs.blocks * fs.bsize
|
|
1606
|
+
});
|
|
1607
|
+
} catch {}
|
|
1608
|
+
return [...byDevice.values()];
|
|
1609
|
+
}
|
|
1610
|
+
function readMemoryPressure() {
|
|
1611
|
+
if (process.platform !== "darwin") return Promise.resolve(null);
|
|
1612
|
+
return new Promise((resolve) => {
|
|
1613
|
+
execFile("/usr/sbin/sysctl", ["-n", "kern.memorystatus_vm_pressure_level"], { timeout: 2e3 }, (error, stdout) => {
|
|
1614
|
+
const level = error ? "" : stdout.trim();
|
|
1615
|
+
resolve(level === "1" ? "normal" : level === "2" ? "warning" : level === "4" ? "critical" : null);
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Activity Monitor's "Memory Used" from `vm_stat` output: app memory (anonymous minus purgeable pages),
|
|
1621
|
+
* wired, and compressed pages, times the page size its header names. Null when a count is missing.
|
|
1622
|
+
*/
|
|
1623
|
+
function parseVmStatUsedBytes(output) {
|
|
1624
|
+
const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
|
|
1625
|
+
const count = (label) => {
|
|
1626
|
+
const value = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(output)?.[1];
|
|
1627
|
+
return value === void 0 ? null : Number(value);
|
|
1628
|
+
};
|
|
1629
|
+
const anonymous = count("Anonymous pages");
|
|
1630
|
+
const purgeable = count("Pages purgeable");
|
|
1631
|
+
const wired = count("Pages wired down");
|
|
1632
|
+
const compressed = count("Pages occupied by compressor");
|
|
1633
|
+
if (!pageSize || anonymous === null || purgeable === null || wired === null || compressed === null) return null;
|
|
1634
|
+
return (Math.max(0, anonymous - purgeable) + wired + compressed) * Number(pageSize);
|
|
1635
|
+
}
|
|
1636
|
+
function readMemoryUsed() {
|
|
1637
|
+
if (process.platform !== "darwin") return Promise.resolve(null);
|
|
1638
|
+
return new Promise((resolve) => {
|
|
1639
|
+
execFile("/usr/bin/vm_stat", { timeout: 2e3 }, (error, stdout) => {
|
|
1640
|
+
resolve(error ? null : parseVmStatUsedBytes(stdout));
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
function cpuTicks(list) {
|
|
1645
|
+
let idle = 0;
|
|
1646
|
+
let total = 0;
|
|
1647
|
+
for (const cpu of list) {
|
|
1648
|
+
idle += cpu.times.idle;
|
|
1649
|
+
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
|
|
1650
|
+
}
|
|
1651
|
+
return {
|
|
1652
|
+
idle,
|
|
1653
|
+
total
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
/** The busy fraction (0..1) between two `os.cpus()` tick totals. Null when the totals did not advance. */
|
|
1657
|
+
function cpuUsageFraction(previous, current) {
|
|
1658
|
+
const totalDelta = current.total - previous.total;
|
|
1659
|
+
if (totalDelta <= 0) return null;
|
|
1660
|
+
const idleDelta = current.idle - previous.idle;
|
|
1661
|
+
return Math.min(1, Math.max(0, 1 - idleDelta / totalDelta));
|
|
1662
|
+
}
|
|
1663
|
+
let previousCpuTicks = null;
|
|
1664
|
+
function readCpuUsage() {
|
|
1665
|
+
const current = cpuTicks(cpus());
|
|
1666
|
+
const usage = previousCpuTicks ? cpuUsageFraction(previousCpuTicks, current) : null;
|
|
1667
|
+
previousCpuTicks = current;
|
|
1668
|
+
return {
|
|
1669
|
+
usage,
|
|
1670
|
+
cores: availableParallelism()
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
async function readMachineUsage() {
|
|
1674
|
+
const [avg1 = 0, avg5 = 0, avg15 = 0] = loadavg();
|
|
1675
|
+
const [pressure, usedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
|
|
1676
|
+
return {
|
|
1677
|
+
volumes: readVolumes(stimDiskLocations()),
|
|
1678
|
+
memory: {
|
|
1679
|
+
totalBytes: totalmem(),
|
|
1680
|
+
usedBytes,
|
|
1681
|
+
pressure
|
|
1682
|
+
},
|
|
1683
|
+
load: {
|
|
1684
|
+
avg1,
|
|
1685
|
+
avg5,
|
|
1686
|
+
avg15,
|
|
1687
|
+
cpus: availableParallelism()
|
|
1688
|
+
},
|
|
1689
|
+
cpu: readCpuUsage(),
|
|
1690
|
+
sampledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
const PRESSURE_LEVEL = {
|
|
1694
|
+
normal: 0,
|
|
1695
|
+
warning: 1,
|
|
1696
|
+
critical: 2
|
|
1697
|
+
};
|
|
1698
|
+
const HISTORY_INTERVAL_MS = 5e3;
|
|
1699
|
+
const HISTORY_CAPACITY = 720;
|
|
1700
|
+
var UsageSampler = class {
|
|
1701
|
+
samples = [];
|
|
1702
|
+
previousTicks = null;
|
|
1703
|
+
timer = null;
|
|
1704
|
+
intervalMs;
|
|
1705
|
+
capacity;
|
|
1706
|
+
constructor(intervalMs = HISTORY_INTERVAL_MS, capacity = HISTORY_CAPACITY) {
|
|
1707
|
+
this.intervalMs = intervalMs;
|
|
1708
|
+
this.capacity = capacity;
|
|
1709
|
+
}
|
|
1710
|
+
start() {
|
|
1711
|
+
if (this.timer) return;
|
|
1712
|
+
this.previousTicks = cpuTicks(cpus());
|
|
1713
|
+
this.timer = setInterval(() => void this.sample(), this.intervalMs);
|
|
1714
|
+
}
|
|
1715
|
+
stop() {
|
|
1716
|
+
if (this.timer) clearInterval(this.timer);
|
|
1717
|
+
this.timer = null;
|
|
1718
|
+
this.previousTicks = null;
|
|
1719
|
+
}
|
|
1720
|
+
record(sample) {
|
|
1721
|
+
this.samples.push(sample);
|
|
1722
|
+
if (this.samples.length > this.capacity) this.samples.splice(0, this.samples.length - this.capacity);
|
|
1723
|
+
}
|
|
1724
|
+
history(sinceMs) {
|
|
1725
|
+
const samples = sinceMs === void 0 ? [...this.samples] : this.samples.filter((s) => s.at > sinceMs);
|
|
1726
|
+
return {
|
|
1727
|
+
intervalMs: this.intervalMs,
|
|
1728
|
+
samples
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
async sample() {
|
|
1732
|
+
const at = Date.now();
|
|
1733
|
+
const ticks = cpuTicks(cpus());
|
|
1734
|
+
const cpu = this.previousTicks ? cpuUsageFraction(this.previousTicks, ticks) : null;
|
|
1735
|
+
this.previousTicks = ticks;
|
|
1736
|
+
const [pressure, memoryUsedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
|
|
1737
|
+
let diskFreeBytes = null;
|
|
1738
|
+
try {
|
|
1739
|
+
const fs = statfsSync("/");
|
|
1740
|
+
diskFreeBytes = fs.bavail * fs.bsize;
|
|
1741
|
+
} catch {}
|
|
1742
|
+
if (!this.timer) return;
|
|
1743
|
+
this.record({
|
|
1744
|
+
at,
|
|
1745
|
+
cpu,
|
|
1746
|
+
memoryUsedBytes,
|
|
1747
|
+
memoryPressure: pressure === null ? null : PRESSURE_LEVEL[pressure],
|
|
1748
|
+
diskFreeBytes
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
//#endregion
|
|
914
1753
|
//#region src/tailscale.ts
|
|
915
1754
|
const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
916
1755
|
const TIMEOUT_MS = 3e3;
|
|
1756
|
+
/** The tailnet-only HTTPS port the setup command serves stim-server on. */
|
|
1757
|
+
const SERVE_PORT = 7443;
|
|
917
1758
|
function executable(path) {
|
|
918
1759
|
try {
|
|
919
1760
|
accessSync(path, constants.X_OK);
|
|
@@ -967,6 +1808,124 @@ function tailscaleStatus(binary, env) {
|
|
|
967
1808
|
};
|
|
968
1809
|
}
|
|
969
1810
|
}
|
|
1811
|
+
function reaches(address, target, hosts) {
|
|
1812
|
+
if (typeof address !== "string") return null;
|
|
1813
|
+
let url;
|
|
1814
|
+
try {
|
|
1815
|
+
url = new URL(address.includes("://") ? address : `tcp://${address}`);
|
|
1816
|
+
} catch {
|
|
1817
|
+
return null;
|
|
1818
|
+
}
|
|
1819
|
+
return hosts.has(url.hostname) && url.port === String(target) ? url : null;
|
|
1820
|
+
}
|
|
1821
|
+
function portOf(hostPort) {
|
|
1822
|
+
return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
|
|
1826
|
+
* `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
|
|
1827
|
+
* `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
|
|
1828
|
+
* foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
|
|
1829
|
+
* it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
|
|
1830
|
+
*/
|
|
1831
|
+
function parseServeStatus(value, target, ips) {
|
|
1832
|
+
if (!isJsonObject(value)) return {
|
|
1833
|
+
state: "unknown",
|
|
1834
|
+
reason: "it printed no serve config",
|
|
1835
|
+
port: SERVE_PORT
|
|
1836
|
+
};
|
|
1837
|
+
const hosts = /* @__PURE__ */ new Set([
|
|
1838
|
+
"127.0.0.1",
|
|
1839
|
+
"localhost",
|
|
1840
|
+
"[::1]",
|
|
1841
|
+
"0.0.0.0",
|
|
1842
|
+
"[::]",
|
|
1843
|
+
...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
|
|
1844
|
+
]);
|
|
1845
|
+
const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
|
|
1846
|
+
const used = /* @__PURE__ */ new Set();
|
|
1847
|
+
const funneled = /* @__PURE__ */ new Set();
|
|
1848
|
+
const reaching = /* @__PURE__ */ new Set();
|
|
1849
|
+
const routes = /* @__PURE__ */ new Set();
|
|
1850
|
+
for (const config of configs) {
|
|
1851
|
+
const tcp = isJsonObject(config.TCP) ? config.TCP : {};
|
|
1852
|
+
for (const [port, listener] of Object.entries(tcp)) {
|
|
1853
|
+
used.add(Number(port));
|
|
1854
|
+
if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
|
|
1855
|
+
}
|
|
1856
|
+
for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
|
|
1857
|
+
for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
|
|
1858
|
+
const port = portOf(hostPort);
|
|
1859
|
+
const listener = tcp[String(port)];
|
|
1860
|
+
const https = isJsonObject(listener) && listener.HTTPS === true;
|
|
1861
|
+
const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
|
|
1862
|
+
for (const [mount, handler] of Object.entries(handlers)) {
|
|
1863
|
+
const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
|
|
1864
|
+
if (!url) continue;
|
|
1865
|
+
reaching.add(port);
|
|
1866
|
+
if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
let free = SERVE_PORT;
|
|
1871
|
+
while (used.has(free) || funneled.has(free)) free++;
|
|
1872
|
+
const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
|
|
1873
|
+
if (exposed.length) return {
|
|
1874
|
+
state: "funneled",
|
|
1875
|
+
ports: exposed,
|
|
1876
|
+
port: free
|
|
1877
|
+
};
|
|
1878
|
+
const tailnet = [...routes].toSorted((a, b) => a - b);
|
|
1879
|
+
if (tailnet.length) return {
|
|
1880
|
+
state: "routed",
|
|
1881
|
+
port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
|
|
1882
|
+
};
|
|
1883
|
+
return {
|
|
1884
|
+
state: "missing",
|
|
1885
|
+
port: free
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
|
|
1889
|
+
if (!binary) return Promise.resolve({
|
|
1890
|
+
state: "unknown",
|
|
1891
|
+
reason: "the tailscale command was not found",
|
|
1892
|
+
port: SERVE_PORT
|
|
1893
|
+
});
|
|
1894
|
+
return new Promise((resolve) => {
|
|
1895
|
+
execFile(binary, [
|
|
1896
|
+
"serve",
|
|
1897
|
+
"status",
|
|
1898
|
+
"--json"
|
|
1899
|
+
], {
|
|
1900
|
+
env,
|
|
1901
|
+
timeout: timeoutMs,
|
|
1902
|
+
killSignal: "SIGKILL",
|
|
1903
|
+
encoding: "utf8"
|
|
1904
|
+
}, (error, stdout) => {
|
|
1905
|
+
let route;
|
|
1906
|
+
try {
|
|
1907
|
+
route = error ? {
|
|
1908
|
+
state: "unknown",
|
|
1909
|
+
reason: error.killed ? "it timed out" : error.message.split("\n")[0],
|
|
1910
|
+
port: SERVE_PORT
|
|
1911
|
+
} : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
|
|
1912
|
+
} catch {
|
|
1913
|
+
route = {
|
|
1914
|
+
state: "unknown",
|
|
1915
|
+
reason: "it printed output that is not JSON",
|
|
1916
|
+
port: SERVE_PORT
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
resolve(route);
|
|
1920
|
+
});
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
function tailnetEndpoint(dnsName, port) {
|
|
1924
|
+
return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
|
|
1925
|
+
}
|
|
1926
|
+
function serveCommand(port, target) {
|
|
1927
|
+
return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
|
|
1928
|
+
}
|
|
970
1929
|
/** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
|
|
971
1930
|
function parseWhois(value) {
|
|
972
1931
|
if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
|
|
@@ -1032,8 +1991,9 @@ const LOG_LIMITS = {
|
|
|
1032
1991
|
maxBufferedBytes: 4194304,
|
|
1033
1992
|
maxPendingRecords: 2e4
|
|
1034
1993
|
};
|
|
1035
|
-
const
|
|
1036
|
-
const FRAME_RETRY_MS =
|
|
1994
|
+
const FRAME_BUFFER_FRAMES = 2;
|
|
1995
|
+
const FRAME_RETRY_MS = 50;
|
|
1996
|
+
const HELPER_RETRY_MS = 3e5;
|
|
1037
1997
|
const STATUS_FEED = {
|
|
1038
1998
|
args: [
|
|
1039
1999
|
"status",
|
|
@@ -1044,10 +2004,18 @@ const STATUS_FEED = {
|
|
|
1044
2004
|
keep: 1,
|
|
1045
2005
|
label: "stim status --watch"
|
|
1046
2006
|
};
|
|
2007
|
+
const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
|
|
1047
2008
|
const COMMAND_LIMITS = {
|
|
1048
2009
|
timeoutMs: 6e4,
|
|
1049
2010
|
maxOutputBytes: 33554432
|
|
1050
2011
|
};
|
|
2012
|
+
const PLAN_TIMEOUT_MS = 15e4;
|
|
2013
|
+
const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
2014
|
+
const AUDIT_FIELD_CHARS = 256;
|
|
2015
|
+
const ACTION_LIMITS = {
|
|
2016
|
+
timeoutMs: 12e4,
|
|
2017
|
+
maxOutputBytes: 1048576
|
|
2018
|
+
};
|
|
1051
2019
|
const AUTH_REFUSALS = {
|
|
1052
2020
|
"pairing-unknown": {
|
|
1053
2021
|
code: "pairing-expired",
|
|
@@ -1106,11 +2074,62 @@ function send(socket, message) {
|
|
|
1106
2074
|
function requestId(value) {
|
|
1107
2075
|
return typeof value === "number" || typeof value === "string" ? value : null;
|
|
1108
2076
|
}
|
|
2077
|
+
function registeredWorkspace(workspace) {
|
|
2078
|
+
if (typeof workspace !== "string") return {
|
|
2079
|
+
code: "bad-request",
|
|
2080
|
+
message: "params.workspace must be an environment path from a status payload."
|
|
2081
|
+
};
|
|
2082
|
+
let projects;
|
|
2083
|
+
try {
|
|
2084
|
+
projects = loadConfig()?.projects ?? {};
|
|
2085
|
+
} catch (cause) {
|
|
2086
|
+
return {
|
|
2087
|
+
code: "stim-failed",
|
|
2088
|
+
message: cause.message
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
const dir = Object.keys(projects).find((path) => path === workspace);
|
|
2092
|
+
if (dir === void 0) return {
|
|
2093
|
+
code: "unknown-workspace",
|
|
2094
|
+
message: `${workspace} is not a Stim workspace on this Mac.`
|
|
2095
|
+
};
|
|
2096
|
+
if (!existsSync(dir)) return {
|
|
2097
|
+
code: "unknown-workspace",
|
|
2098
|
+
message: `${dir} is registered but no longer exists on this Mac.`
|
|
2099
|
+
};
|
|
2100
|
+
return { dir };
|
|
2101
|
+
}
|
|
1109
2102
|
async function startServer(options) {
|
|
1110
2103
|
const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
|
|
1111
2104
|
const authTimeoutMs = options.authTimeoutMs ?? 5e3;
|
|
1112
2105
|
const feeds = new FeedPool(options.stimCli, options.env);
|
|
1113
|
-
const
|
|
2106
|
+
const frameLimits = {
|
|
2107
|
+
...DEFAULT_FRAME_LIMITS,
|
|
2108
|
+
...options.frameLimits
|
|
2109
|
+
};
|
|
2110
|
+
let helperPath = options.frameHelper ?? null;
|
|
2111
|
+
let helperBuilding = false;
|
|
2112
|
+
let helperRetryAt = 0;
|
|
2113
|
+
const helperAbort = new AbortController();
|
|
2114
|
+
const buildHelper = () => {
|
|
2115
|
+
helperBuilding = true;
|
|
2116
|
+
(async () => {
|
|
2117
|
+
try {
|
|
2118
|
+
helperPath = await buildFrameHelper(options.env, helperAbort.signal);
|
|
2119
|
+
} catch (cause) {
|
|
2120
|
+
helperRetryAt = Date.now() + HELPER_RETRY_MS;
|
|
2121
|
+
if (!helperAbort.signal.aborted) console.error(`stim-server: frames come from screenshots: ${cause.message}`);
|
|
2122
|
+
} finally {
|
|
2123
|
+
helperBuilding = false;
|
|
2124
|
+
}
|
|
2125
|
+
})();
|
|
2126
|
+
};
|
|
2127
|
+
const frameHelper = () => {
|
|
2128
|
+
if (options.frameHelper === void 0 && !helperPath && !helperBuilding && Date.now() >= helperRetryAt) buildHelper();
|
|
2129
|
+
return helperPath;
|
|
2130
|
+
};
|
|
2131
|
+
if (options.frameHelper === void 0) buildHelper();
|
|
2132
|
+
const frames = new FramePool(options.env, frameLimits, frameHelper);
|
|
1114
2133
|
const running = /* @__PURE__ */ new Set();
|
|
1115
2134
|
const logLimits = {
|
|
1116
2135
|
...LOG_LIMITS,
|
|
@@ -1120,7 +2139,13 @@ async function startServer(options) {
|
|
|
1120
2139
|
...COMMAND_LIMITS,
|
|
1121
2140
|
...options.commandLimits
|
|
1122
2141
|
};
|
|
2142
|
+
const actionLimits = {
|
|
2143
|
+
...ACTION_LIMITS,
|
|
2144
|
+
...options.actionLimits
|
|
2145
|
+
};
|
|
2146
|
+
const busyWorkspaces = /* @__PURE__ */ new Set();
|
|
1123
2147
|
const sessions = /* @__PURE__ */ new Map();
|
|
2148
|
+
const sampler = new UsageSampler();
|
|
1124
2149
|
const wss = new WebSocketServer({
|
|
1125
2150
|
noServer: true,
|
|
1126
2151
|
maxPayload: MAX_PAYLOAD
|
|
@@ -1140,6 +2165,7 @@ async function startServer(options) {
|
|
|
1140
2165
|
function connection(socket, peer) {
|
|
1141
2166
|
const limitKey = peer ?? "local";
|
|
1142
2167
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
2168
|
+
const keyframes = /* @__PURE__ */ new Map();
|
|
1143
2169
|
const commands = /* @__PURE__ */ new Set();
|
|
1144
2170
|
let nextSubscription = 1;
|
|
1145
2171
|
let device = null;
|
|
@@ -1188,6 +2214,7 @@ async function startServer(options) {
|
|
|
1188
2214
|
}
|
|
1189
2215
|
device = outcome.device;
|
|
1190
2216
|
sessions.set(socket, device);
|
|
2217
|
+
sampler.start();
|
|
1191
2218
|
send(socket, {
|
|
1192
2219
|
id,
|
|
1193
2220
|
result: {
|
|
@@ -1195,9 +2222,11 @@ async function startServer(options) {
|
|
|
1195
2222
|
server: {
|
|
1196
2223
|
name: options.name,
|
|
1197
2224
|
version: options.serverVersion,
|
|
1198
|
-
stim: options.stimVersion
|
|
2225
|
+
stim: options.stimVersion,
|
|
2226
|
+
home: homedir()
|
|
1199
2227
|
},
|
|
1200
2228
|
capabilities: device.capabilities,
|
|
2229
|
+
actions: device.capabilities.includes("control") ? [...ACTIONS] : [],
|
|
1201
2230
|
device: {
|
|
1202
2231
|
id: device.id,
|
|
1203
2232
|
name: device.name
|
|
@@ -1217,28 +2246,14 @@ async function startServer(options) {
|
|
|
1217
2246
|
}
|
|
1218
2247
|
function workspaceDir(id, workspace, required) {
|
|
1219
2248
|
if (workspace === void 0 && !required) return homedir();
|
|
1220
|
-
|
|
1221
|
-
|
|
2249
|
+
const resolved = registeredWorkspace(workspace);
|
|
2250
|
+
if ("code" in resolved) {
|
|
2251
|
+
error(id, resolved.code, resolved.message);
|
|
1222
2252
|
return null;
|
|
1223
2253
|
}
|
|
1224
|
-
|
|
1225
|
-
try {
|
|
1226
|
-
registered = Object.hasOwn(loadConfig()?.projects ?? {}, workspace);
|
|
1227
|
-
} catch (cause) {
|
|
1228
|
-
error(id, "stim-failed", cause.message);
|
|
1229
|
-
return null;
|
|
1230
|
-
}
|
|
1231
|
-
if (!registered) {
|
|
1232
|
-
error(id, "unknown-workspace", `${workspace} is not a Stim workspace on this Mac.`);
|
|
1233
|
-
return null;
|
|
1234
|
-
}
|
|
1235
|
-
if (!existsSync(workspace)) {
|
|
1236
|
-
error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
|
|
1237
|
-
return null;
|
|
1238
|
-
}
|
|
1239
|
-
return workspace;
|
|
2254
|
+
return resolved.dir;
|
|
1240
2255
|
}
|
|
1241
|
-
function openSubscription(id) {
|
|
2256
|
+
function openSubscription(id, result = {}) {
|
|
1242
2257
|
if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
|
|
1243
2258
|
error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
|
|
1244
2259
|
return null;
|
|
@@ -1246,7 +2261,10 @@ async function startServer(options) {
|
|
|
1246
2261
|
const subscription = `s${nextSubscription++}`;
|
|
1247
2262
|
send(socket, {
|
|
1248
2263
|
id,
|
|
1249
|
-
result: {
|
|
2264
|
+
result: {
|
|
2265
|
+
subscription,
|
|
2266
|
+
...result
|
|
2267
|
+
}
|
|
1250
2268
|
});
|
|
1251
2269
|
return subscription;
|
|
1252
2270
|
}
|
|
@@ -1330,31 +2348,59 @@ async function startServer(options) {
|
|
|
1330
2348
|
});
|
|
1331
2349
|
}
|
|
1332
2350
|
function subscribeFrames(id, params) {
|
|
1333
|
-
const { workspace, platform, slot } = isJsonObject(params) ? params : {};
|
|
2351
|
+
const { workspace, platform, slot, fps, maxEdge, video } = isJsonObject(params) ? params : {};
|
|
1334
2352
|
if (typeof workspace !== "string" || platform !== "ios" && platform !== "android") return error(id, "bad-request", "frames.subscribe needs params.workspace and params.platform (ios or android).");
|
|
1335
2353
|
if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
|
|
2354
|
+
if (video !== void 0 && (!Array.isArray(video) || !video.every((codec) => typeof codec === "string"))) return error(id, "bad-request", "video must be a list of codec names.");
|
|
2355
|
+
const wantsVideo = video?.includes("h264") === true;
|
|
2356
|
+
const offersVideo = wantsVideo && frameHelper() !== null;
|
|
2357
|
+
const maxFps = wantsVideo ? FRAME_FPS.video : FRAME_FPS.max;
|
|
2358
|
+
if (fps !== void 0 && (!Number.isInteger(fps) || fps < 1 || fps > maxFps)) return error(id, "bad-request", `fps must be a whole number from 1 to ${maxFps}.`);
|
|
2359
|
+
if (maxEdge !== void 0 && (!Number.isInteger(maxEdge) || maxEdge < FRAME_EDGE.min || maxEdge > FRAME_EDGE.max)) return error(id, "bad-request", `maxEdge must be a whole number of pixels from ${FRAME_EDGE.min} to ${FRAME_EDGE.max}.`);
|
|
2360
|
+
const hint = {
|
|
2361
|
+
fps: Math.min(fps ?? FRAME_FPS.default, offersVideo ? FRAME_FPS.video : FRAME_FPS.max),
|
|
2362
|
+
maxEdge: maxEdge ?? FRAME_EDGE.default
|
|
2363
|
+
};
|
|
1336
2364
|
if (!workspaceDir(id, workspace, true)) return;
|
|
1337
|
-
const subscription = openSubscription(id);
|
|
2365
|
+
const subscription = openSubscription(id, offersVideo ? { video: "h264" } : {});
|
|
1338
2366
|
if (!subscription) return;
|
|
1339
2367
|
const frameTarget = {
|
|
1340
2368
|
workspace,
|
|
1341
2369
|
platform,
|
|
1342
2370
|
...slot ? { slot } : {}
|
|
1343
2371
|
};
|
|
2372
|
+
const gate = new VideoGate(DEFAULT_VIDEO_LIMITS.congestedBytes);
|
|
2373
|
+
let sequence = 0;
|
|
2374
|
+
let streamed = null;
|
|
2375
|
+
let draining = null;
|
|
2376
|
+
const drain = () => {
|
|
2377
|
+
draining = null;
|
|
2378
|
+
if (ended || !streamed) return;
|
|
2379
|
+
if (socket.bufferedAmount <= DEFAULT_VIDEO_LIMITS.congestedBytes) return frames.keyframe(streamed);
|
|
2380
|
+
frames.congested(streamed);
|
|
2381
|
+
draining = setTimeout(drain, FRAME_RETRY_MS);
|
|
2382
|
+
};
|
|
1344
2383
|
let attached = null;
|
|
1345
2384
|
let detach = null;
|
|
1346
2385
|
let pending = null;
|
|
1347
2386
|
let retry = null;
|
|
1348
2387
|
let ended = false;
|
|
2388
|
+
let sentAt = 0;
|
|
1349
2389
|
const flush = () => {
|
|
1350
2390
|
retry = null;
|
|
1351
2391
|
if (!pending || ended) return;
|
|
1352
|
-
|
|
2392
|
+
const wait = sentAt + 1e3 / hint.fps - Date.now();
|
|
2393
|
+
if (wait > 0) {
|
|
2394
|
+
retry = setTimeout(flush, wait);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
if (socket.bufferedAmount > FRAME_BUFFER_FRAMES * pending.data.length) {
|
|
1353
2398
|
retry = setTimeout(flush, FRAME_RETRY_MS);
|
|
1354
2399
|
return;
|
|
1355
2400
|
}
|
|
1356
2401
|
const frame = pending;
|
|
1357
2402
|
pending = null;
|
|
2403
|
+
sentAt = Date.now();
|
|
1358
2404
|
send(socket, {
|
|
1359
2405
|
event: "frame",
|
|
1360
2406
|
subscription,
|
|
@@ -1366,7 +2412,9 @@ async function startServer(options) {
|
|
|
1366
2412
|
};
|
|
1367
2413
|
const cleanup = () => {
|
|
1368
2414
|
ended = true;
|
|
2415
|
+
keyframes.delete(subscription);
|
|
1369
2416
|
if (retry) clearTimeout(retry);
|
|
2417
|
+
if (draining) clearTimeout(draining);
|
|
1370
2418
|
detach?.();
|
|
1371
2419
|
detach = null;
|
|
1372
2420
|
unsubscribeStatus?.();
|
|
@@ -1389,8 +2437,25 @@ async function startServer(options) {
|
|
|
1389
2437
|
pending = frame;
|
|
1390
2438
|
if (!retry) flush();
|
|
1391
2439
|
},
|
|
1392
|
-
|
|
2440
|
+
delayed: (delayed) => {
|
|
2441
|
+
if (!ended) send(socket, {
|
|
2442
|
+
event: "frame-delayed",
|
|
2443
|
+
subscription,
|
|
2444
|
+
delayed
|
|
2445
|
+
});
|
|
2446
|
+
},
|
|
2447
|
+
failed: end,
|
|
2448
|
+
...offersVideo ? { video: (unit) => {
|
|
2449
|
+
if (ended || socket.readyState !== socket.OPEN) return;
|
|
2450
|
+
const verdict = gate.admit(unit, socket.bufferedAmount);
|
|
2451
|
+
if (verdict === "send") socket.send(videoPacket(subscription, sequence++, unit));
|
|
2452
|
+
else if (verdict === "congested" && !draining) drain();
|
|
2453
|
+
} } : {}
|
|
1393
2454
|
};
|
|
2455
|
+
if (offersVideo) keyframes.set(subscription, () => {
|
|
2456
|
+
gate.reset();
|
|
2457
|
+
if (streamed) frames.keyframe(streamed);
|
|
2458
|
+
});
|
|
1394
2459
|
let unsubscribeStatus = null;
|
|
1395
2460
|
unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
|
|
1396
2461
|
item: (payload) => {
|
|
@@ -1399,23 +2464,28 @@ async function startServer(options) {
|
|
|
1399
2464
|
if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
|
|
1400
2465
|
if (deviceKey(resolved) === attached) return;
|
|
1401
2466
|
detach?.();
|
|
2467
|
+
gate.reset();
|
|
2468
|
+
streamed = resolved;
|
|
1402
2469
|
attached = deviceKey(resolved);
|
|
1403
|
-
detach = frames.subscribe(resolved, listener);
|
|
2470
|
+
detach = frames.subscribe(resolved, listener, hint);
|
|
1404
2471
|
},
|
|
1405
2472
|
failed: (message) => queueMicrotask(() => end(message))
|
|
1406
2473
|
});
|
|
1407
2474
|
subscriptions.set(subscription, cleanup);
|
|
1408
2475
|
}
|
|
1409
|
-
function command(id, args, cwd, result) {
|
|
2476
|
+
function command(id, args, cwd, result, limits = commandLimits) {
|
|
1410
2477
|
if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
|
|
1411
|
-
const run = runStim(options.stimCli, options.env, args, cwd,
|
|
2478
|
+
const run = runStim(options.stimCli, options.env, args, cwd, limits);
|
|
1412
2479
|
commands.add(run.cancel);
|
|
1413
2480
|
running.add(run.cancel);
|
|
1414
2481
|
(async () => {
|
|
1415
2482
|
const outcome = await run.outcome;
|
|
1416
2483
|
commands.delete(run.cancel);
|
|
1417
2484
|
running.delete(run.cancel);
|
|
1418
|
-
if (!outcome.ok)
|
|
2485
|
+
if (!outcome.ok) {
|
|
2486
|
+
const printed = actionOutcome(outcome);
|
|
2487
|
+
return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
|
|
2488
|
+
}
|
|
1419
2489
|
let value;
|
|
1420
2490
|
try {
|
|
1421
2491
|
value = result(outcome.stdout);
|
|
@@ -1445,6 +2515,118 @@ async function startServer(options) {
|
|
|
1445
2515
|
return value;
|
|
1446
2516
|
});
|
|
1447
2517
|
}
|
|
2518
|
+
function runAction(id, params, session) {
|
|
2519
|
+
const startedAt = Date.now();
|
|
2520
|
+
const raw = isJsonObject(params) ? params : {};
|
|
2521
|
+
const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
|
|
2522
|
+
const record = {
|
|
2523
|
+
at: new Date(startedAt).toISOString(),
|
|
2524
|
+
device: {
|
|
2525
|
+
id: session.id,
|
|
2526
|
+
name: session.name
|
|
2527
|
+
},
|
|
2528
|
+
action: clip(raw.action),
|
|
2529
|
+
workspace: clip(raw.workspace),
|
|
2530
|
+
...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
|
|
2531
|
+
ok: false
|
|
2532
|
+
};
|
|
2533
|
+
const audit = (outcome) => {
|
|
2534
|
+
const logged = outcome.error ? {
|
|
2535
|
+
...outcome.error,
|
|
2536
|
+
message: clip(outcome.error.message)
|
|
2537
|
+
} : void 0;
|
|
2538
|
+
try {
|
|
2539
|
+
appendAudit({
|
|
2540
|
+
...record,
|
|
2541
|
+
...outcome,
|
|
2542
|
+
...logged ? { error: logged } : {}
|
|
2543
|
+
});
|
|
2544
|
+
} catch (cause) {
|
|
2545
|
+
console.error(`stim-server: could not append to the action log: ${cause.message}`);
|
|
2546
|
+
}
|
|
2547
|
+
};
|
|
2548
|
+
const refuseAction = (code, message) => {
|
|
2549
|
+
audit({
|
|
2550
|
+
ok: false,
|
|
2551
|
+
error: {
|
|
2552
|
+
code,
|
|
2553
|
+
message
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
error(id, code, message);
|
|
2557
|
+
};
|
|
2558
|
+
if (!readDevices().find((entry) => entry.id === session.id)?.capabilities.includes("control")) return refuseAction("forbidden", `This device can only read. On the Mac, run \`stim-server devices grant ${session.id} --control\` to let it run actions.`);
|
|
2559
|
+
const parsed = parseAction(params);
|
|
2560
|
+
if ("code" in parsed) return refuseAction(parsed.code, parsed.message);
|
|
2561
|
+
const { action } = parsed;
|
|
2562
|
+
const resolved = registeredWorkspace(action.workspace);
|
|
2563
|
+
if ("code" in resolved) return refuseAction(resolved.code, resolved.message);
|
|
2564
|
+
if (busyWorkspaces.has(resolved.dir)) return refuseAction("action-busy", `An action is already running in ${resolved.dir}. Try again when it finishes.`);
|
|
2565
|
+
let run;
|
|
2566
|
+
try {
|
|
2567
|
+
run = runStim(options.stimCli, options.env, actionArgs(action), resolved.dir, actionLimits);
|
|
2568
|
+
} catch (cause) {
|
|
2569
|
+
return refuseAction("action-failed", `stim ${action.action} could not start (${cause.message}).`);
|
|
2570
|
+
}
|
|
2571
|
+
busyWorkspaces.add(resolved.dir);
|
|
2572
|
+
let finished = false;
|
|
2573
|
+
const finish = (outcome) => {
|
|
2574
|
+
if (finished) return;
|
|
2575
|
+
finished = true;
|
|
2576
|
+
running.delete(cancel);
|
|
2577
|
+
busyWorkspaces.delete(resolved.dir);
|
|
2578
|
+
audit({
|
|
2579
|
+
ok: outcome.ok,
|
|
2580
|
+
...outcome.ok ? {} : { error: outcome.error },
|
|
2581
|
+
durationMs: Date.now() - startedAt
|
|
2582
|
+
});
|
|
2583
|
+
if (outcome.ok) send(socket, {
|
|
2584
|
+
id,
|
|
2585
|
+
result: {
|
|
2586
|
+
action: action.action,
|
|
2587
|
+
workspace: resolved.dir,
|
|
2588
|
+
output: outcome.output
|
|
2589
|
+
}
|
|
2590
|
+
});
|
|
2591
|
+
else send(socket, {
|
|
2592
|
+
id,
|
|
2593
|
+
error: outcome.error
|
|
2594
|
+
});
|
|
2595
|
+
};
|
|
2596
|
+
const cancel = async () => {
|
|
2597
|
+
finish({
|
|
2598
|
+
ok: false,
|
|
2599
|
+
error: {
|
|
2600
|
+
code: "action-failed",
|
|
2601
|
+
message: "stim-server stopped before the action finished."
|
|
2602
|
+
}
|
|
2603
|
+
});
|
|
2604
|
+
await run.cancel();
|
|
2605
|
+
};
|
|
2606
|
+
running.add(cancel);
|
|
2607
|
+
run.outcome.then((outcome) => finish(actionOutcome(outcome)));
|
|
2608
|
+
}
|
|
2609
|
+
function planBuild(id, params) {
|
|
2610
|
+
if (!isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
|
|
2611
|
+
const { platform, slot } = params;
|
|
2612
|
+
if (platform !== "ios" && platform !== "android") return error(id, "bad-request", "params.platform must be ios or android.");
|
|
2613
|
+
if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return error(id, "bad-request", "params.slot must be 1-64 letters, digits, underscores or hyphens.");
|
|
2614
|
+
const cwd = workspaceDir(id, params.workspace, true);
|
|
2615
|
+
if (!cwd) return;
|
|
2616
|
+
command(id, [
|
|
2617
|
+
platform,
|
|
2618
|
+
"--plan",
|
|
2619
|
+
"--json",
|
|
2620
|
+
...slot === void 0 ? [] : [`--slot=${slot}`]
|
|
2621
|
+
], cwd, (stdout) => {
|
|
2622
|
+
const value = JSON.parse(stdout);
|
|
2623
|
+
if (!isJsonObject(value) || value.platform !== platform) throw new Error("not a plan");
|
|
2624
|
+
return value;
|
|
2625
|
+
}, {
|
|
2626
|
+
...commandLimits,
|
|
2627
|
+
timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
1448
2630
|
async function handle(raw) {
|
|
1449
2631
|
if (socket.readyState !== socket.OPEN) return;
|
|
1450
2632
|
let message;
|
|
@@ -1470,7 +2652,38 @@ async function startServer(options) {
|
|
|
1470
2652
|
if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
|
|
1471
2653
|
if (message.method === "logs.query") return queryLogs(id, message.params);
|
|
1472
2654
|
if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
|
|
2655
|
+
if (message.method === "frames.keyframe") {
|
|
2656
|
+
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
2657
|
+
const keyframe = typeof name === "string" ? keyframes.get(name) : void 0;
|
|
2658
|
+
if (!keyframe) return send(socket, {
|
|
2659
|
+
id,
|
|
2660
|
+
error: {
|
|
2661
|
+
code: "unknown-subscription",
|
|
2662
|
+
message: `No video subscription ${String(name)}.`
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
keyframe();
|
|
2666
|
+
return send(socket, {
|
|
2667
|
+
id,
|
|
2668
|
+
result: {}
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
if (message.method === "build.plan") return planBuild(id, message.params);
|
|
2672
|
+
if (message.method === "machine.get") return send(socket, {
|
|
2673
|
+
id,
|
|
2674
|
+
result: await readMachineUsage()
|
|
2675
|
+
});
|
|
2676
|
+
if (message.method === "machine.history") {
|
|
2677
|
+
const params = message.params ?? {};
|
|
2678
|
+
const sinceMs = isJsonObject(params) ? params.sinceMs : void 0;
|
|
2679
|
+
if (!isJsonObject(params) || sinceMs !== void 0 && typeof sinceMs !== "number") return error(id, "bad-request", "machine.history takes an optional numeric sinceMs.");
|
|
2680
|
+
return send(socket, {
|
|
2681
|
+
id,
|
|
2682
|
+
result: sampler.history(sinceMs)
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
1473
2685
|
if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
|
|
2686
|
+
if (message.method === "action") return runAction(id, message.params, device);
|
|
1474
2687
|
if (message.method === "unsubscribe") {
|
|
1475
2688
|
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
1476
2689
|
const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
|
|
@@ -1503,6 +2716,7 @@ async function startServer(options) {
|
|
|
1503
2716
|
socket.on("close", () => {
|
|
1504
2717
|
clearTimeout(timer);
|
|
1505
2718
|
sessions.delete(socket);
|
|
2719
|
+
if (sessions.size === 0) sampler.stop();
|
|
1506
2720
|
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
1507
2721
|
subscriptions.clear();
|
|
1508
2722
|
for (const cancel of commands) {
|
|
@@ -1529,10 +2743,20 @@ async function startServer(options) {
|
|
|
1529
2743
|
stimHome: configDir(),
|
|
1530
2744
|
tailscale: healthTailscale(options.tailscaleState)
|
|
1531
2745
|
};
|
|
2746
|
+
const answerHealth = async (response) => {
|
|
2747
|
+
const tailscale = options.tailscaleState;
|
|
2748
|
+
const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
|
|
2749
|
+
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
|
2750
|
+
...health,
|
|
2751
|
+
route
|
|
2752
|
+
}));
|
|
2753
|
+
};
|
|
1532
2754
|
const servers = [];
|
|
1533
2755
|
const addresses = [];
|
|
1534
2756
|
const close = async () => {
|
|
1535
2757
|
watcher.close();
|
|
2758
|
+
helperAbort.abort();
|
|
2759
|
+
sampler.stop();
|
|
1536
2760
|
if (revocationCheck) clearTimeout(revocationCheck);
|
|
1537
2761
|
for (const client of wss.clients) client.terminate();
|
|
1538
2762
|
await Promise.all([
|
|
@@ -1547,7 +2771,7 @@ async function startServer(options) {
|
|
|
1547
2771
|
for (const host of options.hosts) {
|
|
1548
2772
|
const server = createServer((request, response) => {
|
|
1549
2773
|
if (localHealthRequest(request)) {
|
|
1550
|
-
response
|
|
2774
|
+
answerHealth(response);
|
|
1551
2775
|
return;
|
|
1552
2776
|
}
|
|
1553
2777
|
response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
|
|
@@ -1580,22 +2804,32 @@ async function startServer(options) {
|
|
|
1580
2804
|
const DEFAULT_PORT = 7787;
|
|
1581
2805
|
const USAGE = `Usage:
|
|
1582
2806
|
stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
|
|
1583
|
-
stim-server pair [--port <n>] [--json]
|
|
1584
|
-
print a single-use pairing payload for the QR code
|
|
2807
|
+
stim-server pair [--port <n>] [--control] [--json]
|
|
2808
|
+
print a single-use pairing payload for the QR code;
|
|
2809
|
+
--control lets the paired device run actions
|
|
1585
2810
|
stim-server devices [list] [--json]
|
|
1586
2811
|
list paired devices
|
|
1587
|
-
stim-server devices
|
|
2812
|
+
stim-server devices grant <id> --control|--read
|
|
2813
|
+
let a paired device run actions, or only read
|
|
2814
|
+
stim-server devices revoke <id> revoke a paired device
|
|
2815
|
+
stim-server log [--json] list the actions paired devices ran`;
|
|
1588
2816
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1589
2817
|
function fail(message) {
|
|
1590
2818
|
console.error(`stim-server: ${message}`);
|
|
1591
2819
|
process.exit(1);
|
|
1592
2820
|
}
|
|
1593
|
-
function tailscaleNote(tailscale
|
|
1594
|
-
const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server
|
|
2821
|
+
function tailscaleNote(tailscale) {
|
|
2822
|
+
const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
|
|
1595
2823
|
if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
|
|
1596
2824
|
if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
|
|
1597
2825
|
return null;
|
|
1598
2826
|
}
|
|
2827
|
+
function routeNote(route, port) {
|
|
2828
|
+
if (route.state === "missing") return `No \`tailscale serve\` route reaches port ${port}, so phones cannot connect yet. Run this once to serve it on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
|
|
2829
|
+
if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
|
|
2830
|
+
if (route.state === "funneled") return `Tailscale Funnel is on for port ${route.ports.join(", ")}, which proxies to stim-server, so the server is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then serve stim-server on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
1599
2833
|
function macName(tailscale) {
|
|
1600
2834
|
return tailscale.state === "running" && tailscale.hostName || hostname();
|
|
1601
2835
|
}
|
|
@@ -1628,8 +2862,13 @@ async function serve(port) {
|
|
|
1628
2862
|
}
|
|
1629
2863
|
console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
|
|
1630
2864
|
for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
|
|
1631
|
-
|
|
1632
|
-
|
|
2865
|
+
let note = tailscaleNote(tailscale);
|
|
2866
|
+
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
2867
|
+
const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
|
|
2868
|
+
if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
|
|
2869
|
+
note = routeNote(route, port);
|
|
2870
|
+
if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
|
|
2871
|
+
}
|
|
1633
2872
|
if (note) console.error(note);
|
|
1634
2873
|
const shutdown = () => {
|
|
1635
2874
|
server.close().then(() => process.exit(0));
|
|
@@ -1638,14 +2877,23 @@ async function serve(port) {
|
|
|
1638
2877
|
process.on("SIGTERM", shutdown);
|
|
1639
2878
|
process.on("SIGHUP", shutdown);
|
|
1640
2879
|
}
|
|
1641
|
-
function pair(port, json) {
|
|
1642
|
-
const
|
|
1643
|
-
const
|
|
1644
|
-
|
|
2880
|
+
async function pair(port, json, control) {
|
|
2881
|
+
const binary = findTailscale(process.env);
|
|
2882
|
+
const tailscale = tailscaleStatus(binary, process.env);
|
|
2883
|
+
let endpoint = `ws://127.0.0.1:${port}`;
|
|
2884
|
+
let note = tailscaleNote(tailscale);
|
|
2885
|
+
if (note) note = `${note} The endpoint above only works on this Mac.`;
|
|
2886
|
+
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
2887
|
+
const route = await serveRoute(binary, process.env, port, tailscale.ips);
|
|
2888
|
+
if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
|
|
2889
|
+
endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
|
|
2890
|
+
note = routeNote(route, port);
|
|
2891
|
+
}
|
|
2892
|
+
const { token, expiresAt } = createPairingToken(Date.now(), capabilitiesFor(control));
|
|
1645
2893
|
const payload = {
|
|
1646
2894
|
v: 1,
|
|
1647
2895
|
name: macName(tailscale),
|
|
1648
|
-
endpoint
|
|
2896
|
+
endpoint,
|
|
1649
2897
|
pairingToken: token
|
|
1650
2898
|
};
|
|
1651
2899
|
if (json) return void console.log(JSON.stringify({
|
|
@@ -1653,13 +2901,13 @@ function pair(port, json) {
|
|
|
1653
2901
|
expiresAt
|
|
1654
2902
|
}));
|
|
1655
2903
|
console.log(JSON.stringify(payload));
|
|
1656
|
-
console.error(`The pairing token is single use and expires at ${expiresAt}.`);
|
|
1657
|
-
|
|
1658
|
-
if (note) console.error(`${note} The endpoint above only works on this Mac.`);
|
|
2904
|
+
console.error(`The pairing token is single use and expires at ${expiresAt}. The device it pairs can ${control ? "run actions" : "only read"}.`);
|
|
2905
|
+
if (note) console.error(note);
|
|
1659
2906
|
}
|
|
1660
2907
|
function describe(device) {
|
|
1661
2908
|
const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
|
|
1662
|
-
|
|
2909
|
+
const scope = device.capabilities.includes("control") ? "control" : "read";
|
|
2910
|
+
return `${device.id} ${device.name} ${scope} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
|
|
1663
2911
|
}
|
|
1664
2912
|
async function main() {
|
|
1665
2913
|
const { values, positionals } = parseArgs({
|
|
@@ -1667,6 +2915,8 @@ async function main() {
|
|
|
1667
2915
|
options: {
|
|
1668
2916
|
port: { type: "string" },
|
|
1669
2917
|
json: { type: "boolean" },
|
|
2918
|
+
control: { type: "boolean" },
|
|
2919
|
+
read: { type: "boolean" },
|
|
1670
2920
|
help: {
|
|
1671
2921
|
type: "boolean",
|
|
1672
2922
|
short: "h"
|
|
@@ -1683,7 +2933,27 @@ async function main() {
|
|
|
1683
2933
|
if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
|
|
1684
2934
|
const [command, sub, arg, ...rest] = positionals;
|
|
1685
2935
|
if (command === void 0) return serve(port);
|
|
1686
|
-
|
|
2936
|
+
const grant = command === "devices" && sub === "grant";
|
|
2937
|
+
if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
|
|
2938
|
+
if (values.control && !grant && command !== "pair") fail(`--control applies only to \`pair\` and \`devices grant\`.\n${USAGE}`);
|
|
2939
|
+
if (command === "pair" && sub === void 0) return pair(port, values.json === true, values.control === true);
|
|
2940
|
+
if (grant && arg !== void 0 && rest.length === 0) {
|
|
2941
|
+
if (values.control === values.read) fail("devices grant takes exactly one of --control or --read.");
|
|
2942
|
+
if (!grantDevice(arg, capabilitiesFor(values.control === true))) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
|
|
2943
|
+
console.log(`${arg} can now ${values.control ? "run actions" : "only read"}.`);
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
if (command === "log" && sub === void 0) {
|
|
2947
|
+
const records = readAudit();
|
|
2948
|
+
if (values.json) return void console.log(JSON.stringify({ actions: records }));
|
|
2949
|
+
if (!records.length) console.log("No actions.");
|
|
2950
|
+
for (const record of records) {
|
|
2951
|
+
const outcome = record.ok ? "ok" : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
|
|
2952
|
+
const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
|
|
2953
|
+
console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
|
|
2954
|
+
}
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
1687
2957
|
if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
|
|
1688
2958
|
const devices = readDevices();
|
|
1689
2959
|
if (values.json) {
|