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