@stim-cli/server 1.10.1 → 1.12.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 +247 -27
- package/dist/protocol-BElQc2vm.mjs +808 -0
- package/dist/protocol.d.mts +286 -11
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +1004 -148
- package/dist/stim-frames/EmulatorDiscovery.swift +49 -0
- package/dist/stim-frames/EmulatorInput.swift +141 -0
- package/dist/stim-frames/EmulatorPosture.swift +73 -0
- package/dist/stim-frames/EmulatorRotation.swift +59 -0
- package/dist/stim-frames/GrpcFraming.swift +85 -0
- package/dist/stim-frames/InputMessages.swift +123 -0
- package/dist/stim-frames/ScreenshotMessages.swift +152 -0
- package/dist/stim-frames/ScreenshotStream.swift +130 -0
- package/dist/stim-frames/SimulatorDisplay.swift +116 -0
- package/dist/stim-frames/SimulatorHID.swift +336 -0
- package/dist/stim-frames/SimulatorRotation.swift +64 -0
- package/dist/stim-frames/VideoEncoder.swift +282 -0
- package/dist/stim-frames/main.swift +623 -0
- package/dist/stim-frames/sim-fold.entitlements +8 -0
- package/dist/stim-frames/sim-fold.m +47 -0
- package/dist/stim-server.mjs +2135 -208
- package/package.json +4 -4
- package/dist/protocol-B3k9Ykuu.mjs +0 -407
package/dist/stim-server.mjs
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { _ as TOUCH_PHASES, c as INPUT_BUTTONS, f as MAX_LOG_TAIL, g as ROTATE_DIRECTIONS, l as LOG_LEVELS, n as CAPABILITIES, o as FRAME_EDGE, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES } from "./protocol-BElQc2vm.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
|
-
import { createInterface } from "node:readline";
|
|
16
15
|
import { connect } from "node:http2";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
17
18
|
//#region src/environment.ts
|
|
18
19
|
function parseEnvironment(output) {
|
|
19
20
|
const environment = {};
|
|
@@ -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
|
}),
|
|
@@ -317,92 +447,387 @@ function runStim(stimCli, env, args, cwd, limits) {
|
|
|
317
447
|
};
|
|
318
448
|
}
|
|
319
449
|
//#endregion
|
|
320
|
-
//#region src/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
450
|
+
//#region src/video.ts
|
|
451
|
+
const FIXED_HEADER_BYTES = 21;
|
|
452
|
+
/** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
|
|
453
|
+
function videoPacket(subscription, sequence, unit) {
|
|
454
|
+
const id = Buffer.from(subscription, "ascii");
|
|
455
|
+
const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
|
|
456
|
+
header.writeUInt8(1, 0);
|
|
457
|
+
header.writeUInt8(unit.keyframe ? 1 : 0, 1);
|
|
458
|
+
header.writeUInt16BE(header.length, 2);
|
|
459
|
+
header.writeUInt32BE(sequence >>> 0, 4);
|
|
460
|
+
header.writeDoubleBE(unit.capturedAt, 8);
|
|
461
|
+
header.writeUInt16BE(unit.width, 16);
|
|
462
|
+
header.writeUInt16BE(unit.height, 18);
|
|
463
|
+
header.writeUInt8(id.length, 20);
|
|
464
|
+
id.copy(header, FIXED_HEADER_BYTES);
|
|
465
|
+
return Buffer.concat([header, unit.data]);
|
|
466
|
+
}
|
|
467
|
+
const DEFAULT_VIDEO_LIMITS = {
|
|
468
|
+
congestedBytes: 262144,
|
|
469
|
+
startBitrate: 3e6,
|
|
470
|
+
minBitrate: 25e4,
|
|
471
|
+
maxBitrate: 8e6,
|
|
472
|
+
recoverMs: 2e3
|
|
473
|
+
};
|
|
474
|
+
/**
|
|
475
|
+
* Decides, for one subscriber, whether each access unit goes out. A subscriber starts waiting for a keyframe.
|
|
476
|
+
* When its socket backs up it drops everything until the next keyframe, because a P-frame is useless without
|
|
477
|
+
* the frames before it; `congested` is reported once per episode.
|
|
478
|
+
*/
|
|
479
|
+
var VideoGate = class {
|
|
480
|
+
waiting = true;
|
|
481
|
+
congestedBytes;
|
|
482
|
+
constructor(congestedBytes) {
|
|
483
|
+
this.congestedBytes = congestedBytes;
|
|
484
|
+
}
|
|
485
|
+
admit(unit, bufferedBytes) {
|
|
486
|
+
if (bufferedBytes > this.congestedBytes) {
|
|
487
|
+
if (this.waiting) return "drop";
|
|
488
|
+
this.waiting = true;
|
|
489
|
+
return "congested";
|
|
490
|
+
}
|
|
491
|
+
if (this.waiting && !unit.keyframe) return "drop";
|
|
492
|
+
this.waiting = false;
|
|
493
|
+
return "send";
|
|
494
|
+
}
|
|
495
|
+
/** The client lost its decoder state; drop until the keyframe it asked for. */
|
|
496
|
+
reset() {
|
|
497
|
+
this.waiting = true;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
/**
|
|
501
|
+
* One encoder's bitrate, shared by all subscribers of a device: halved on congestion at most once per `recoverMs`,
|
|
502
|
+
* and raised by a quarter after `recoverMs` without congestion.
|
|
503
|
+
*/
|
|
504
|
+
var Bitrate = class {
|
|
505
|
+
value;
|
|
506
|
+
calmSince;
|
|
507
|
+
cutAt = -Infinity;
|
|
508
|
+
limits;
|
|
509
|
+
constructor(limits, now) {
|
|
510
|
+
this.limits = limits;
|
|
511
|
+
this.value = limits.startBitrate;
|
|
512
|
+
this.calmSince = now;
|
|
513
|
+
}
|
|
514
|
+
get current() {
|
|
515
|
+
return this.value;
|
|
516
|
+
}
|
|
517
|
+
/** Called while any subscriber is congested. Returns the new bitrate when it changed. */
|
|
518
|
+
congested(now) {
|
|
519
|
+
this.calmSince = now;
|
|
520
|
+
if (now - this.cutAt < this.limits.recoverMs) return null;
|
|
521
|
+
this.cutAt = now;
|
|
522
|
+
return this.set(Math.max(this.limits.minBitrate, Math.round(this.value / 2)));
|
|
523
|
+
}
|
|
524
|
+
/** Returns the new bitrate when it changed. */
|
|
525
|
+
tick(now) {
|
|
526
|
+
if (now - this.calmSince < this.limits.recoverMs) return null;
|
|
527
|
+
this.calmSince = now;
|
|
528
|
+
return this.set(Math.min(this.limits.maxBitrate, Math.round(this.value * 1.25)));
|
|
529
|
+
}
|
|
530
|
+
set(next) {
|
|
531
|
+
if (next === this.value) return null;
|
|
532
|
+
this.value = next;
|
|
533
|
+
return next;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/frame-helper.ts
|
|
538
|
+
const DEFAULT_FRAME_HINT = {
|
|
539
|
+
fps: FRAME_FPS.default,
|
|
540
|
+
maxEdge: FRAME_EDGE.default
|
|
541
|
+
};
|
|
542
|
+
const SOURCES_DIR = fileURLToPath(new URL("./stim-frames/", import.meta.url));
|
|
543
|
+
const BUILD_TIMEOUT_MS = 18e4;
|
|
544
|
+
const VERSION_TIMEOUT_MS = 3e4;
|
|
545
|
+
const MAX_MESSAGE_BYTES = 16777216;
|
|
546
|
+
const FRAME_MESSAGE = 1;
|
|
547
|
+
const NOTICE_MESSAGE = 2;
|
|
548
|
+
const VIDEO_MESSAGE = 3;
|
|
549
|
+
const VIDEO_HEADER_BYTES = 14;
|
|
550
|
+
const KEYFRAME_INTERVAL_MS = 250;
|
|
551
|
+
/** Runs the compiler in its own process group, so a timeout or `signal` also stops `swift-frontend` and `ld`. */
|
|
552
|
+
function run(file, args, env, timeoutMs, signal) {
|
|
553
|
+
return new Promise((resolve, reject) => {
|
|
554
|
+
const child = spawn(file, args, {
|
|
555
|
+
env,
|
|
556
|
+
stdio: [
|
|
557
|
+
"ignore",
|
|
558
|
+
"pipe",
|
|
559
|
+
"pipe"
|
|
560
|
+
],
|
|
561
|
+
detached: true
|
|
562
|
+
});
|
|
563
|
+
let output = "";
|
|
564
|
+
let stopped = null;
|
|
565
|
+
const stop = (reason) => {
|
|
566
|
+
stopped ??= reason;
|
|
567
|
+
try {
|
|
568
|
+
process.kill(-child.pid, "SIGKILL");
|
|
569
|
+
} catch {}
|
|
570
|
+
};
|
|
571
|
+
const timer = setTimeout(() => stop(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`), timeoutMs);
|
|
572
|
+
const abort = () => stop(`${file} ${args[0]} was stopped with the server.`);
|
|
573
|
+
signal?.addEventListener("abort", abort);
|
|
574
|
+
child.stdout.setEncoding("utf8");
|
|
575
|
+
child.stderr.setEncoding("utf8");
|
|
576
|
+
child.stdout.on("data", (chunk) => output = (output + chunk).slice(-4e3));
|
|
577
|
+
child.stderr.on("data", (chunk) => output = (output + chunk).slice(-4e3));
|
|
578
|
+
child.on("error", (error) => {
|
|
579
|
+
clearTimeout(timer);
|
|
580
|
+
signal?.removeEventListener("abort", abort);
|
|
581
|
+
reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
|
|
582
|
+
});
|
|
583
|
+
child.on("close", (code) => {
|
|
584
|
+
clearTimeout(timer);
|
|
585
|
+
signal?.removeEventListener("abort", abort);
|
|
586
|
+
if (stopped) reject(new Error(stopped));
|
|
587
|
+
else if (code === 0) resolve(output);
|
|
588
|
+
else reject(/* @__PURE__ */ new Error(`${file} ${args[0]} exited (code ${code}): ${output.trim()}`));
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Compiles the `stim-frames` helper from the Swift sources shipped in `dist/stim-frames/` into
|
|
594
|
+
* `$STIM_HOME/server/helpers/`, named by a hash of the sources and the compiler version. Resolves to the
|
|
595
|
+
* helper's path, or rejects with the reason it cannot be built.
|
|
596
|
+
*/
|
|
597
|
+
async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
598
|
+
if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
|
|
599
|
+
const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
|
|
600
|
+
if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
|
|
601
|
+
return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
|
|
602
|
+
await run("xcrun", [
|
|
603
|
+
"swiftc",
|
|
604
|
+
"-O",
|
|
605
|
+
"-swift-version",
|
|
606
|
+
"5",
|
|
607
|
+
"-module-name",
|
|
608
|
+
"StimFrames",
|
|
609
|
+
"-o",
|
|
610
|
+
output,
|
|
611
|
+
...sources
|
|
612
|
+
], env, BUILD_TIMEOUT_MS, signal);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Compiles `sim-fold`, which folds or unfolds an iPhone Duo from inside the simulator, from the Stim Desktop
|
|
617
|
+
* sources shipped in `dist/stim-frames/`, the way Stim Desktop's bundle script builds it.
|
|
618
|
+
*/
|
|
619
|
+
async function buildFoldHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
620
|
+
if (process.platform !== "darwin") throw new Error("sim-fold runs only on macOS.");
|
|
621
|
+
const source = join(sourcesDir, "sim-fold.m");
|
|
622
|
+
const entitlements = join(sourcesDir, "sim-fold.entitlements");
|
|
623
|
+
const clang = [
|
|
624
|
+
"-sdk",
|
|
625
|
+
"iphonesimulator",
|
|
626
|
+
"clang"
|
|
627
|
+
];
|
|
628
|
+
const version = await run("xcrun", [...clang, "--version"], env, VERSION_TIMEOUT_MS, signal);
|
|
629
|
+
return compiled("sim-fold", [source, entitlements], version, async (output) => {
|
|
630
|
+
await run("xcrun", [
|
|
631
|
+
...clang,
|
|
632
|
+
"-fobjc-arc",
|
|
633
|
+
"-arch",
|
|
634
|
+
"arm64",
|
|
635
|
+
"-arch",
|
|
636
|
+
"x86_64",
|
|
637
|
+
"-mios-simulator-version-min=18.0",
|
|
638
|
+
"-framework",
|
|
639
|
+
"Foundation",
|
|
640
|
+
source,
|
|
641
|
+
"-o",
|
|
642
|
+
output,
|
|
643
|
+
`-Wl,-sectcreate,__TEXT,__entitlements,${entitlements}`
|
|
644
|
+
], env, BUILD_TIMEOUT_MS, signal);
|
|
645
|
+
await run("codesign", [
|
|
646
|
+
"--force",
|
|
647
|
+
"--sign",
|
|
648
|
+
"-",
|
|
649
|
+
output
|
|
650
|
+
], env, VERSION_TIMEOUT_MS, signal);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
async function compiled(name, inputs, version, compile) {
|
|
654
|
+
const hash = createHash("sha256").update(version);
|
|
655
|
+
for (const input of inputs) hash.update(basename(input)).update(readFileSync(input));
|
|
656
|
+
const dir = join(serverDir(), "helpers");
|
|
657
|
+
const helper = join(dir, `${name}-${hash.digest("hex").slice(0, 16)}`);
|
|
658
|
+
if (existsSync(helper)) return helper;
|
|
659
|
+
mkdirSync(dir, {
|
|
660
|
+
recursive: true,
|
|
661
|
+
mode: 448
|
|
662
|
+
});
|
|
663
|
+
const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
664
|
+
try {
|
|
665
|
+
await compile(output);
|
|
666
|
+
renameSync(output, helper);
|
|
667
|
+
} finally {
|
|
668
|
+
rmSync(output, { force: true });
|
|
669
|
+
}
|
|
670
|
+
return helper;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* One `stim-frames` process per device, shared by its subscribers and stopped with the last of them. It runs
|
|
674
|
+
* at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
|
|
675
|
+
* helper exits when its stdin closes, so it cannot outlive the server.
|
|
676
|
+
*/
|
|
677
|
+
var HelperSource = class {
|
|
678
|
+
listeners = /* @__PURE__ */ new Map();
|
|
324
679
|
child;
|
|
680
|
+
last = null;
|
|
681
|
+
config = "";
|
|
682
|
+
stopped = false;
|
|
683
|
+
notice = null;
|
|
684
|
+
keyboard = null;
|
|
685
|
+
stderr = "";
|
|
325
686
|
ended;
|
|
326
|
-
|
|
687
|
+
bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
|
|
688
|
+
keyframeAt = -Infinity;
|
|
689
|
+
keyframeTimer = null;
|
|
690
|
+
constructor(helper, device, env, ended) {
|
|
327
691
|
this.ended = ended;
|
|
328
|
-
const
|
|
329
|
-
|
|
692
|
+
const id = device.platform === "ios" ? device.udid : device.serial;
|
|
693
|
+
this.child = spawn(helper, [device.platform, id], {
|
|
330
694
|
env,
|
|
331
695
|
stdio: [
|
|
332
|
-
"
|
|
696
|
+
"pipe",
|
|
333
697
|
"pipe",
|
|
334
698
|
"pipe"
|
|
335
699
|
]
|
|
336
700
|
});
|
|
337
|
-
this.child
|
|
338
|
-
|
|
339
|
-
child.stderr
|
|
340
|
-
|
|
341
|
-
stderr = (stderr + chunk).slice(-2e3);
|
|
701
|
+
this.child.stdin.on("error", () => {});
|
|
702
|
+
this.child.stderr.setEncoding("utf8");
|
|
703
|
+
this.child.stderr.on("data", (chunk) => {
|
|
704
|
+
this.stderr = (this.stderr + chunk).slice(-1e3);
|
|
342
705
|
});
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
} catch {
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
if (!isJsonObject(value)) return;
|
|
352
|
-
this.kept.push(value);
|
|
353
|
-
if (this.kept.length > spec.keep) this.kept.shift();
|
|
354
|
-
for (const listener of this.listeners) listener.item(value);
|
|
706
|
+
this.readMessages();
|
|
707
|
+
this.child.on("error", (error) => this.fail(`stim-frames could not start (${error.message}).`));
|
|
708
|
+
this.child.on("close", (code, signal) => {
|
|
709
|
+
const detail = this.notice ?? this.stderr.trim();
|
|
710
|
+
this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
|
|
355
711
|
});
|
|
356
|
-
const exited = (reason) => {
|
|
357
|
-
if (this.child !== child) return;
|
|
358
|
-
this.child = null;
|
|
359
|
-
this.ended();
|
|
360
|
-
const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
|
|
361
|
-
const listeners = [...this.listeners];
|
|
362
|
-
this.listeners.clear();
|
|
363
|
-
for (const listener of listeners) listener.failed(message);
|
|
364
|
-
};
|
|
365
|
-
child.on("error", (error) => exited(`could not start (${error.message})`));
|
|
366
|
-
child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
|
|
367
712
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
713
|
+
/** A null `hint` keeps the helper running for input without asking for frames. */
|
|
714
|
+
add(listener, hint) {
|
|
715
|
+
this.listeners.set(listener, hint);
|
|
716
|
+
this.configure();
|
|
717
|
+
if (listener.video) this.keyframe();
|
|
718
|
+
else if (this.last) listener.frame(this.last);
|
|
371
719
|
return () => {
|
|
372
|
-
if (this.listeners.delete(listener)
|
|
720
|
+
if (!this.listeners.delete(listener)) return;
|
|
721
|
+
if (this.listeners.size === 0) this.stop();
|
|
722
|
+
else this.configure();
|
|
373
723
|
};
|
|
374
724
|
}
|
|
375
725
|
stop() {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
this.
|
|
726
|
+
if (this.stopped) return Promise.resolve();
|
|
727
|
+
this.stopped = true;
|
|
728
|
+
if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
|
|
379
729
|
this.listeners.clear();
|
|
380
730
|
this.ended();
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
};
|
|
384
|
-
var FeedPool = class {
|
|
385
|
-
feeds = /* @__PURE__ */ new Map();
|
|
386
|
-
stimCli;
|
|
387
|
-
env;
|
|
388
|
-
constructor(stimCli, env) {
|
|
389
|
-
this.stimCli = stimCli;
|
|
390
|
-
this.env = env;
|
|
731
|
+
this.child.stdin.end();
|
|
732
|
+
return terminate(this.child);
|
|
391
733
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
734
|
+
/** Asks for at most one keyframe per {@link KEYFRAME_INTERVAL_MS}; a request inside the interval is sent at its end. */
|
|
735
|
+
keyframe() {
|
|
736
|
+
if (this.stopped || this.keyframeTimer) return;
|
|
737
|
+
const wait = this.keyframeAt + KEYFRAME_INTERVAL_MS - Date.now();
|
|
738
|
+
if (wait > 0) {
|
|
739
|
+
this.keyframeTimer = setTimeout(() => {
|
|
740
|
+
this.keyframeTimer = null;
|
|
741
|
+
this.keyframe();
|
|
742
|
+
}, wait);
|
|
743
|
+
return;
|
|
401
744
|
}
|
|
402
|
-
|
|
745
|
+
this.keyframeAt = Date.now();
|
|
746
|
+
this.child.stdin.write("{\"keyframe\":true}\n");
|
|
403
747
|
}
|
|
404
|
-
|
|
405
|
-
|
|
748
|
+
congested() {
|
|
749
|
+
if (this.bitrate.congested(Date.now()) !== null) this.configure();
|
|
750
|
+
}
|
|
751
|
+
send(command) {
|
|
752
|
+
if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
753
|
+
}
|
|
754
|
+
configure() {
|
|
755
|
+
const watching = [...this.listeners].flatMap(([listener, hint]) => hint ? [{
|
|
756
|
+
listener,
|
|
757
|
+
hint
|
|
758
|
+
}] : []);
|
|
759
|
+
const hints = watching.map(({ hint }) => hint);
|
|
760
|
+
const jpegFps = watching.flatMap(({ listener, hint }) => listener.video ? [] : [hint.fps]);
|
|
761
|
+
const config = JSON.stringify({
|
|
762
|
+
fps: Math.max(0, ...hints.map((hint) => hint.fps)),
|
|
763
|
+
maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
|
|
764
|
+
jpeg: jpegFps.length > 0,
|
|
765
|
+
...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
|
|
766
|
+
video: jpegFps.length < hints.length,
|
|
767
|
+
bitrate: this.bitrate.current
|
|
768
|
+
});
|
|
769
|
+
if (!jpegFps.length) this.last = null;
|
|
770
|
+
if (config === this.config || this.stopped) return;
|
|
771
|
+
this.config = config;
|
|
772
|
+
this.child.stdin.write(`${config}\n`);
|
|
773
|
+
}
|
|
774
|
+
fail(message) {
|
|
775
|
+
if (this.stopped) return;
|
|
776
|
+
const listeners = [...this.listeners.keys()];
|
|
777
|
+
this.stop();
|
|
778
|
+
for (const listener of listeners) listener.failed(message);
|
|
779
|
+
}
|
|
780
|
+
readMessages() {
|
|
781
|
+
let buffer = Buffer.alloc(0);
|
|
782
|
+
this.child.stdout.on("data", (chunk) => {
|
|
783
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
784
|
+
while (buffer.length >= 4) {
|
|
785
|
+
const length = buffer.readUInt32BE(0);
|
|
786
|
+
if (length < 1 || length > MAX_MESSAGE_BYTES) {
|
|
787
|
+
this.child.stdout.removeAllListeners("data");
|
|
788
|
+
return this.fail("stim-frames wrote a malformed message.");
|
|
789
|
+
}
|
|
790
|
+
if (buffer.length < 4 + length) break;
|
|
791
|
+
const body = buffer.subarray(4, 4 + length);
|
|
792
|
+
buffer = buffer.subarray(4 + length);
|
|
793
|
+
if (body[0] === FRAME_MESSAGE && body.length > 5) this.frame(body);
|
|
794
|
+
else if (body[0] === VIDEO_MESSAGE && body.length > VIDEO_HEADER_BYTES) this.video(body);
|
|
795
|
+
else if (body[0] === NOTICE_MESSAGE) this.readNotice(body.subarray(1).toString("utf8"));
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
frame(body) {
|
|
800
|
+
if (this.stopped) return;
|
|
801
|
+
this.last = {
|
|
802
|
+
width: body.readUInt16BE(1),
|
|
803
|
+
height: body.readUInt16BE(3),
|
|
804
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
805
|
+
data: body.subarray(5).toString("base64")
|
|
806
|
+
};
|
|
807
|
+
for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
|
|
808
|
+
}
|
|
809
|
+
video(body) {
|
|
810
|
+
if (this.stopped) return;
|
|
811
|
+
const unit = {
|
|
812
|
+
keyframe: (body[1] & 1) !== 0,
|
|
813
|
+
capturedAt: body.readDoubleBE(2),
|
|
814
|
+
width: body.readUInt16BE(10),
|
|
815
|
+
height: body.readUInt16BE(12),
|
|
816
|
+
data: body.subarray(VIDEO_HEADER_BYTES)
|
|
817
|
+
};
|
|
818
|
+
if (this.bitrate.tick(Date.now()) !== null) this.configure();
|
|
819
|
+
for (const listener of this.listeners.keys()) listener.video?.(unit);
|
|
820
|
+
}
|
|
821
|
+
readNotice(text) {
|
|
822
|
+
try {
|
|
823
|
+
const notice = JSON.parse(text);
|
|
824
|
+
const error = notice?.error;
|
|
825
|
+
if (typeof error === "string") this.notice = error;
|
|
826
|
+
const keyboard = notice?.keyboard;
|
|
827
|
+
if (keyboard === "yes" || keyboard === "no") this.keyboard = keyboard === "yes";
|
|
828
|
+
const inputError = notice?.inputError;
|
|
829
|
+
if (typeof inputError === "string") console.error(`stim-server: stim-frames: ${inputError}`);
|
|
830
|
+
} catch {}
|
|
406
831
|
}
|
|
407
832
|
};
|
|
408
833
|
//#endregion
|
|
@@ -437,7 +862,8 @@ function ownedDevice(payload, target, attached) {
|
|
|
437
862
|
if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
|
|
438
863
|
return {
|
|
439
864
|
platform: "ios",
|
|
440
|
-
udid: sim.udid
|
|
865
|
+
udid: sim.udid,
|
|
866
|
+
foldable: /\bDuo\b/.test(sim.name ?? "")
|
|
441
867
|
};
|
|
442
868
|
}
|
|
443
869
|
const emulator = devices?.android;
|
|
@@ -523,36 +949,90 @@ async function stopTools(running, tmp) {
|
|
|
523
949
|
});
|
|
524
950
|
}
|
|
525
951
|
/**
|
|
526
|
-
* Without `--display`, simctl captures the first panel it finds
|
|
527
|
-
*
|
|
528
|
-
*
|
|
952
|
+
* Without `--display`, simctl captures the first panel it finds; `primary` is the panel CoreDevice reports as
|
|
953
|
+
* primary. A simctl that rejects `primary` gets the default display instead.
|
|
954
|
+
*
|
|
955
|
+
* An iPhone Duo lights one of two panels: `primary` is the cover, lit when folded, and `primary-1` the inner
|
|
956
|
+
* panel, lit when unfolded; the other one's framebuffer is all black. `litPanels` remembers the lit panel per
|
|
957
|
+
* simulator across subscriptions, and a black capture tries the other panel.
|
|
529
958
|
*/
|
|
530
|
-
function simulatorCapturer(
|
|
959
|
+
function simulatorCapturer(device, env, limits, litPanels) {
|
|
531
960
|
let tmp = null;
|
|
532
961
|
let display = ["--display=primary"];
|
|
533
962
|
let closed = false;
|
|
534
963
|
const running = /* @__PURE__ */ new Set();
|
|
535
|
-
const screenshot = (
|
|
536
|
-
"
|
|
537
|
-
"
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
964
|
+
const screenshot = async (panel) => {
|
|
965
|
+
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
966
|
+
const output = join(tmp, "frame.jpg");
|
|
967
|
+
await runTool("xcrun", [
|
|
968
|
+
"simctl",
|
|
969
|
+
"io",
|
|
970
|
+
device.udid,
|
|
971
|
+
"screenshot",
|
|
972
|
+
"--type=jpeg",
|
|
973
|
+
...panel,
|
|
974
|
+
output
|
|
975
|
+
], env, running, limits.toolTimeoutMs);
|
|
976
|
+
return readFileSync(output);
|
|
977
|
+
};
|
|
978
|
+
const isBlack = async (jpeg) => {
|
|
979
|
+
const input = join(tmp, "check.jpg");
|
|
980
|
+
const output = join(tmp, "check.bmp");
|
|
981
|
+
writeFileSync(input, jpeg);
|
|
982
|
+
await runTool("sips", [
|
|
983
|
+
"-s",
|
|
984
|
+
"format",
|
|
985
|
+
"bmp",
|
|
986
|
+
"-z",
|
|
987
|
+
"24",
|
|
988
|
+
"24",
|
|
989
|
+
input,
|
|
990
|
+
"--out",
|
|
991
|
+
output
|
|
992
|
+
], env, running, limits.toolTimeoutMs);
|
|
993
|
+
return bmpIsBlack(readFileSync(output));
|
|
994
|
+
};
|
|
995
|
+
const open = () => {
|
|
996
|
+
if (closed) throw new Error("The capture was stopped.");
|
|
997
|
+
};
|
|
998
|
+
const captureDuo = async () => {
|
|
999
|
+
const lit = litPanels.get(device.udid) ?? "primary";
|
|
1000
|
+
const jpeg = await screenshot([`--display=${lit}`]);
|
|
1001
|
+
open();
|
|
1002
|
+
if (!await isBlack(jpeg)) {
|
|
1003
|
+
litPanels.set(device.udid, lit);
|
|
1004
|
+
return {
|
|
1005
|
+
raw: jpeg,
|
|
1006
|
+
jpeg: async () => jpeg,
|
|
1007
|
+
posture: POSTURES[lit]
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
const other = lit === "primary" ? "primary-1" : "primary";
|
|
1011
|
+
open();
|
|
1012
|
+
const otherJpeg = await screenshot([`--display=${other}`]);
|
|
1013
|
+
open();
|
|
1014
|
+
if (await isBlack(otherJpeg)) return {
|
|
1015
|
+
raw: jpeg,
|
|
1016
|
+
jpeg: async () => jpeg
|
|
1017
|
+
};
|
|
1018
|
+
litPanels.set(device.udid, other);
|
|
1019
|
+
return {
|
|
1020
|
+
raw: otherJpeg,
|
|
1021
|
+
jpeg: async () => otherJpeg,
|
|
1022
|
+
posture: POSTURES[other]
|
|
1023
|
+
};
|
|
1024
|
+
};
|
|
544
1025
|
return {
|
|
545
1026
|
capture: async () => {
|
|
546
|
-
|
|
547
|
-
|
|
1027
|
+
if (device.foldable) return captureDuo();
|
|
1028
|
+
let jpeg;
|
|
548
1029
|
try {
|
|
549
|
-
await screenshot(
|
|
1030
|
+
jpeg = await screenshot(display);
|
|
550
1031
|
} catch (error) {
|
|
551
1032
|
if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
|
|
552
1033
|
display = [];
|
|
553
|
-
await screenshot(
|
|
1034
|
+
jpeg = await screenshot(display);
|
|
554
1035
|
}
|
|
555
|
-
const jpeg = readFileSync(output);
|
|
556
1036
|
return {
|
|
557
1037
|
raw: jpeg,
|
|
558
1038
|
jpeg: async () => jpeg
|
|
@@ -564,6 +1044,18 @@ function simulatorCapturer(udid, env, limits) {
|
|
|
564
1044
|
}
|
|
565
1045
|
};
|
|
566
1046
|
}
|
|
1047
|
+
const POSTURES = {
|
|
1048
|
+
primary: "folded",
|
|
1049
|
+
"primary-1": "unfolded"
|
|
1050
|
+
};
|
|
1051
|
+
/** Whether every pixel of an uncompressed 24- or 32-bit BMP, as `sips -s format bmp` writes it, is black. */
|
|
1052
|
+
function bmpIsBlack(bmp) {
|
|
1053
|
+
if (bmp.length < 54 || bmp.toString("latin1", 0, 2) !== "BM") throw new Error("sips did not write a BMP image.");
|
|
1054
|
+
const offset = bmp.readUInt32LE(10);
|
|
1055
|
+
const bytesPerPixel = bmp.readUInt16LE(28) / 8;
|
|
1056
|
+
for (let at = offset; at + 3 <= bmp.length; at += bytesPerPixel) if (bmp[at] > 8 || bmp[at + 1] > 8 || bmp[at + 2] > 8) return false;
|
|
1057
|
+
return true;
|
|
1058
|
+
}
|
|
567
1059
|
/**
|
|
568
1060
|
* The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
|
|
569
1061
|
* Stim passes when it boots an owned AVD (#1064).
|
|
@@ -648,36 +1140,58 @@ function* protoFields(bytes) {
|
|
|
648
1140
|
else throw new Error(`unsupported protobuf wire type ${type}`);
|
|
649
1141
|
}
|
|
650
1142
|
}
|
|
651
|
-
|
|
652
|
-
function screenshotRequest() {
|
|
653
|
-
const message = Buffer.from([
|
|
654
|
-
24,
|
|
655
|
-
...varint(MAX_EDGE),
|
|
656
|
-
32,
|
|
657
|
-
...varint(MAX_EDGE)
|
|
658
|
-
]);
|
|
1143
|
+
function grpcMessage(message) {
|
|
659
1144
|
const header = Buffer.alloc(5);
|
|
660
1145
|
header.writeUInt32BE(message.length, 1);
|
|
661
|
-
return Buffer.concat([header, message]);
|
|
1146
|
+
return Buffer.concat([header, Buffer.from(message)]);
|
|
662
1147
|
}
|
|
663
|
-
function
|
|
664
|
-
if (body.length < 5 || body[0] !== 0) throw new Error(
|
|
665
|
-
|
|
666
|
-
for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
|
|
667
|
-
throw new Error("getScreenshot returned no image.");
|
|
1148
|
+
function grpcReply(body, method) {
|
|
1149
|
+
if (body.length < 5 || body[0] !== 0) throw new Error(`${method} returned no message.`);
|
|
1150
|
+
return body.subarray(5, 5 + body.readUInt32BE(1));
|
|
668
1151
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1152
|
+
/**
|
|
1153
|
+
* `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. The reply's
|
|
1154
|
+
* format carries `foldedDisplay` (field 7) while a foldable is folded.
|
|
1155
|
+
*/
|
|
1156
|
+
function screenshotReply(body) {
|
|
1157
|
+
let png = null;
|
|
1158
|
+
let folded = false;
|
|
1159
|
+
for (const entry of protoFields(grpcReply(body, "getScreenshot"))) {
|
|
1160
|
+
if (entry.field === 4 && "bytes" in entry) png = Buffer.from(entry.bytes);
|
|
1161
|
+
if (entry.field === 1 && "bytes" in entry) folded = [...protoFields(entry.bytes)].some((format) => format.field === 7);
|
|
1162
|
+
}
|
|
1163
|
+
if (!png) throw new Error("getScreenshot returned no image.");
|
|
1164
|
+
return {
|
|
1165
|
+
png,
|
|
1166
|
+
folded
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* `PhysicalModelValue` for POSTURE (PhysicalType 16), whose one float is a `Posture.PostureValue`; 1 to 5
|
|
1171
|
+
* are real postures, so any of them means the emulator has a hinge.
|
|
1172
|
+
*/
|
|
1173
|
+
function hasHinge(body) {
|
|
1174
|
+
for (const entry of protoFields(grpcReply(body, "getPhysicalModel"))) {
|
|
1175
|
+
if (entry.field === 2 && "varint" in entry && entry.varint !== 0) return false;
|
|
1176
|
+
if (entry.field !== 3 || !("bytes" in entry)) continue;
|
|
1177
|
+
for (const value of protoFields(entry.bytes)) {
|
|
1178
|
+
if (value.field !== 1 || !("bytes" in value) || value.bytes.length < 4) continue;
|
|
1179
|
+
const posture = Math.round(value.bytes.readFloatLE(0));
|
|
1180
|
+
return posture >= 1 && posture <= 5;
|
|
677
1181
|
}
|
|
1182
|
+
}
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
function grpcSession(endpoint) {
|
|
1186
|
+
const session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
|
|
1187
|
+
session.on("error", () => {});
|
|
1188
|
+
return session;
|
|
1189
|
+
}
|
|
1190
|
+
function grpcCall(session, endpoint, serial, method, requestBody, timeoutMs) {
|
|
1191
|
+
return new Promise((resolve, reject) => {
|
|
678
1192
|
const request = session.request({
|
|
679
1193
|
":method": "POST",
|
|
680
|
-
":path":
|
|
1194
|
+
":path": `/android.emulation.control.EmulatorController/${method}`,
|
|
681
1195
|
"content-type": "application/grpc",
|
|
682
1196
|
te: "trailers",
|
|
683
1197
|
...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
|
|
@@ -690,7 +1204,7 @@ function emulatorCapturer(serial, env, limits) {
|
|
|
690
1204
|
if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
|
|
691
1205
|
if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
|
|
692
1206
|
};
|
|
693
|
-
request.setTimeout(
|
|
1207
|
+
request.setTimeout(timeoutMs, () => {
|
|
694
1208
|
timedOut = true;
|
|
695
1209
|
request.close();
|
|
696
1210
|
});
|
|
@@ -698,23 +1212,62 @@ function emulatorCapturer(serial, env, limits) {
|
|
|
698
1212
|
request.on("trailers", record);
|
|
699
1213
|
request.on("data", (chunk) => chunks.push(chunk));
|
|
700
1214
|
request.on("error", (error) => {
|
|
701
|
-
const text =
|
|
1215
|
+
const text = `${method} failed on ${serial}: ${error.message}`;
|
|
702
1216
|
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
703
1217
|
});
|
|
704
1218
|
request.on("close", () => {
|
|
705
1219
|
if (status === "0") return resolve(Buffer.concat(chunks));
|
|
706
|
-
const text =
|
|
1220
|
+
const text = `${method} failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
|
|
707
1221
|
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
708
1222
|
});
|
|
709
|
-
request.end(
|
|
1223
|
+
request.end(requestBody);
|
|
710
1224
|
});
|
|
1225
|
+
}
|
|
1226
|
+
const POSTURE_MODEL = grpcMessage([8, 16]);
|
|
1227
|
+
/**
|
|
1228
|
+
* The postures `input.posture` accepts for an owned device: the two panels of an iPhone Duo, and the hinge
|
|
1229
|
+
* positions of an emulator whose physical model has a posture.
|
|
1230
|
+
*/
|
|
1231
|
+
async function devicePostures(device, env, timeoutMs) {
|
|
1232
|
+
if (device.platform === "ios") return device.foldable ? ["folded", "unfolded"] : [];
|
|
1233
|
+
const endpoint = emulatorEndpoint(env, device.serial);
|
|
1234
|
+
if (!endpoint) return [];
|
|
1235
|
+
const session = grpcSession(endpoint);
|
|
1236
|
+
try {
|
|
1237
|
+
return hasHinge(await grpcCall(session, endpoint, device.serial, "getPhysicalModel", POSTURE_MODEL, timeoutMs)) ? [
|
|
1238
|
+
"folded",
|
|
1239
|
+
"half-open",
|
|
1240
|
+
"unfolded"
|
|
1241
|
+
] : [];
|
|
1242
|
+
} catch {
|
|
1243
|
+
return [];
|
|
1244
|
+
} finally {
|
|
1245
|
+
session.close();
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
function emulatorCapturer(serial, env, limits) {
|
|
1249
|
+
let session = null;
|
|
1250
|
+
let tmp = null;
|
|
1251
|
+
const running = /* @__PURE__ */ new Set();
|
|
1252
|
+
let hinged = null;
|
|
1253
|
+
const call = (endpoint, method, requestBody) => {
|
|
1254
|
+
if (!session || session.closed || session.destroyed) session = grpcSession(endpoint);
|
|
1255
|
+
return grpcCall(session, endpoint, serial, method, requestBody, limits.toolTimeoutMs);
|
|
1256
|
+
};
|
|
711
1257
|
return {
|
|
712
1258
|
capture: async () => {
|
|
713
1259
|
const endpoint = emulatorEndpoint(env, serial);
|
|
714
1260
|
if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
|
|
715
|
-
|
|
1261
|
+
hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
|
|
1262
|
+
const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
|
|
1263
|
+
24,
|
|
1264
|
+
...varint(MAX_EDGE),
|
|
1265
|
+
32,
|
|
1266
|
+
...varint(MAX_EDGE)
|
|
1267
|
+
])));
|
|
716
1268
|
return {
|
|
717
1269
|
raw: png,
|
|
1270
|
+
...hinged ? { posture: folded ? "folded" : "unfolded" } : {},
|
|
718
1271
|
jpeg: async () => {
|
|
719
1272
|
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
720
1273
|
const input = join(tmp, "frame.png");
|
|
@@ -821,7 +1374,8 @@ var FrameSource = class {
|
|
|
821
1374
|
this.last = {
|
|
822
1375
|
...size,
|
|
823
1376
|
capturedAt: new Date(started).toISOString(),
|
|
824
|
-
data: jpeg.toString("base64")
|
|
1377
|
+
data: jpeg.toString("base64"),
|
|
1378
|
+
...capture.posture ? { posture: capture.posture } : {}
|
|
825
1379
|
};
|
|
826
1380
|
took = Date.now() - started;
|
|
827
1381
|
return true;
|
|
@@ -841,40 +1395,766 @@ var FrameSource = class {
|
|
|
841
1395
|
return;
|
|
842
1396
|
}
|
|
843
1397
|
const listeners = [...this.listeners];
|
|
844
|
-
this.stop();
|
|
845
|
-
for (const listener of listeners) listener.failed(error.message);
|
|
846
|
-
}
|
|
1398
|
+
this.stop();
|
|
1399
|
+
for (const listener of listeners) listener.failed(error.message);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
/**
|
|
1404
|
+
* One capture per device, shared by its subscribers and stopped with the last of them. With the `stim-frames`
|
|
1405
|
+
* helper, a device streams frames as its screen changes, at the rate its subscribers ask for. Without it, for
|
|
1406
|
+
* an iPhone Duo, and while the helper is still being built, a screenshot loop sends a frame only when the
|
|
1407
|
+
* screen changed: up to 5 times a second while it changes, backing off to once a second while it does not,
|
|
1408
|
+
* spending at most half of its time capturing, with at most two captures at once across all devices. A helper
|
|
1409
|
+
* that fails before its first frame gives way to the screenshot loop.
|
|
1410
|
+
*/
|
|
1411
|
+
var FramePool = class {
|
|
1412
|
+
sources = /* @__PURE__ */ new Map();
|
|
1413
|
+
limiter = new Limiter(MAX_CAPTURES);
|
|
1414
|
+
litPanels = /* @__PURE__ */ new Map();
|
|
1415
|
+
env;
|
|
1416
|
+
limits;
|
|
1417
|
+
helper;
|
|
1418
|
+
constructor(env, limits = DEFAULT_FRAME_LIMITS, helper = () => null) {
|
|
1419
|
+
this.env = env;
|
|
1420
|
+
this.limits = limits;
|
|
1421
|
+
this.helper = helper;
|
|
1422
|
+
}
|
|
1423
|
+
subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
|
|
1424
|
+
const helper = this.helper();
|
|
1425
|
+
if (helper === null || device.platform === "ios" && device.foldable) return this.screenshots(device).add(listener);
|
|
1426
|
+
let streamed = false;
|
|
1427
|
+
let cancelled = false;
|
|
1428
|
+
const { video } = listener;
|
|
1429
|
+
let detach = this.stream(helper, device).add({
|
|
1430
|
+
frame: (frame) => {
|
|
1431
|
+
streamed = true;
|
|
1432
|
+
listener.frame(frame);
|
|
1433
|
+
},
|
|
1434
|
+
...video ? { video: (unit) => {
|
|
1435
|
+
streamed = true;
|
|
1436
|
+
video(unit);
|
|
1437
|
+
} } : {},
|
|
1438
|
+
delayed: listener.delayed,
|
|
1439
|
+
failed: (message) => {
|
|
1440
|
+
if (streamed || cancelled) return listener.failed(message);
|
|
1441
|
+
console.error(`stim-server: ${message} Falling back to screenshots.`);
|
|
1442
|
+
detach = this.screenshots(device).add(listener);
|
|
1443
|
+
}
|
|
1444
|
+
}, hint);
|
|
1445
|
+
return () => {
|
|
1446
|
+
cancelled = true;
|
|
1447
|
+
detach();
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
/** The posture of an iPhone Duo as its last frame showed it; null before a frame and for other devices. */
|
|
1451
|
+
litPosture(device) {
|
|
1452
|
+
const lit = device.platform === "ios" && device.foldable ? this.litPanels.get(device.udid) : void 0;
|
|
1453
|
+
return lit ? POSTURES[lit] : null;
|
|
1454
|
+
}
|
|
1455
|
+
/** Records the panel a finished fold lit, so a request before the next frame sees the new posture. */
|
|
1456
|
+
folded(udid, posture) {
|
|
1457
|
+
this.litPanels.set(udid, posture === "folded" ? "primary" : "primary-1");
|
|
1458
|
+
}
|
|
1459
|
+
/** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
|
|
1460
|
+
keyframe(device) {
|
|
1461
|
+
this.helperSource(device)?.keyframe();
|
|
1462
|
+
}
|
|
1463
|
+
/** A subscriber of `device` is behind; called until its socket drains, it lowers the shared bitrate. */
|
|
1464
|
+
congested(device) {
|
|
1465
|
+
this.helperSource(device)?.congested();
|
|
1466
|
+
}
|
|
1467
|
+
helperSource(device) {
|
|
1468
|
+
const source = this.sources.get(`helper:${deviceKey(device)}`);
|
|
1469
|
+
return source instanceof HelperSource ? source : null;
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Keeps the device's helper running to send it input, or returns null without a helper. `failed` runs when
|
|
1473
|
+
* the helper exits.
|
|
1474
|
+
*/
|
|
1475
|
+
control(device, failed) {
|
|
1476
|
+
const helper = this.helper();
|
|
1477
|
+
if (helper === null) return null;
|
|
1478
|
+
const source = this.stream(helper, device);
|
|
1479
|
+
return {
|
|
1480
|
+
send: (command) => source.send(command),
|
|
1481
|
+
keys: () => source.keyboard === true,
|
|
1482
|
+
detach: source.add({
|
|
1483
|
+
frame: () => {},
|
|
1484
|
+
delayed: () => {},
|
|
1485
|
+
failed
|
|
1486
|
+
}, null)
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
stream(helper, device) {
|
|
1490
|
+
const key = `helper:${deviceKey(device)}`;
|
|
1491
|
+
const existing = this.sources.get(key);
|
|
1492
|
+
if (existing instanceof HelperSource) return existing;
|
|
1493
|
+
const created = new HelperSource(helper, device, this.env, () => {
|
|
1494
|
+
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
1495
|
+
});
|
|
1496
|
+
this.sources.set(key, created);
|
|
1497
|
+
return created;
|
|
1498
|
+
}
|
|
1499
|
+
screenshots(device) {
|
|
1500
|
+
const key = deviceKey(device);
|
|
1501
|
+
const existing = this.sources.get(key);
|
|
1502
|
+
if (existing instanceof FrameSource) return existing;
|
|
1503
|
+
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, () => {
|
|
1504
|
+
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
1505
|
+
});
|
|
1506
|
+
this.sources.set(key, created);
|
|
1507
|
+
return created;
|
|
1508
|
+
}
|
|
1509
|
+
async close() {
|
|
1510
|
+
await Promise.all([...this.sources.values()].map((source) => source.stop()));
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region src/control.ts
|
|
1515
|
+
const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
1516
|
+
const TEXT = /^[\x20-\x7e\n\t\b]+$/;
|
|
1517
|
+
function parseControlBegin(params) {
|
|
1518
|
+
if (!isJsonObject(params)) return {
|
|
1519
|
+
code: "bad-request",
|
|
1520
|
+
message: "params must be an object."
|
|
1521
|
+
};
|
|
1522
|
+
const { workspace, platform, slot, takeOver, ...rest } = params;
|
|
1523
|
+
if (Object.keys(rest).length) return {
|
|
1524
|
+
code: "bad-request",
|
|
1525
|
+
message: `control.begin does not take ${Object.keys(rest).join(", ")}.`
|
|
1526
|
+
};
|
|
1527
|
+
if (typeof workspace !== "string") return {
|
|
1528
|
+
code: "bad-request",
|
|
1529
|
+
message: "params.workspace must be an environment path from a status payload."
|
|
1530
|
+
};
|
|
1531
|
+
if (platform !== "ios" && platform !== "android") return {
|
|
1532
|
+
code: "bad-request",
|
|
1533
|
+
message: "params.platform must be ios or android."
|
|
1534
|
+
};
|
|
1535
|
+
if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return {
|
|
1536
|
+
code: "bad-request",
|
|
1537
|
+
message: "params.slot must be 1-64 letters, digits, underscores or hyphens."
|
|
1538
|
+
};
|
|
1539
|
+
if (takeOver !== void 0 && typeof takeOver !== "boolean") return {
|
|
1540
|
+
code: "bad-request",
|
|
1541
|
+
message: "params.takeOver must be true or false."
|
|
1542
|
+
};
|
|
1543
|
+
return { value: {
|
|
1544
|
+
workspace,
|
|
1545
|
+
platform,
|
|
1546
|
+
...slot ? { slot } : {},
|
|
1547
|
+
...takeOver ? { takeOver } : {}
|
|
1548
|
+
} };
|
|
1549
|
+
}
|
|
1550
|
+
const IOS_BUTTONS = ["home", "lock"];
|
|
1551
|
+
function fraction(value) {
|
|
1552
|
+
return typeof value === "number" && value >= 0 && value <= 1;
|
|
1553
|
+
}
|
|
1554
|
+
/** Validates an `input.*` request for one of the connection's sessions; returns its session id and command. */
|
|
1555
|
+
function parseInput(method, params, targetOf) {
|
|
1556
|
+
if (!isJsonObject(params) || typeof params.session !== "string") return {
|
|
1557
|
+
code: "bad-request",
|
|
1558
|
+
message: `${method} needs params.session from control.begin.`
|
|
1559
|
+
};
|
|
1560
|
+
const target = targetOf(params.session);
|
|
1561
|
+
if (!target) return {
|
|
1562
|
+
code: "unknown-session",
|
|
1563
|
+
message: `No control session ${params.session} on this connection.`
|
|
1564
|
+
};
|
|
1565
|
+
const { platform, postures } = target;
|
|
1566
|
+
const session = params.session;
|
|
1567
|
+
if (method === "input.rotate") {
|
|
1568
|
+
const { direction } = params;
|
|
1569
|
+
if (!ROTATE_DIRECTIONS.includes(direction)) return {
|
|
1570
|
+
code: "bad-request",
|
|
1571
|
+
message: "input.rotate needs direction left or right."
|
|
1572
|
+
};
|
|
1573
|
+
return { value: {
|
|
1574
|
+
session,
|
|
1575
|
+
command: {
|
|
1576
|
+
input: "rotate",
|
|
1577
|
+
direction
|
|
1578
|
+
}
|
|
1579
|
+
} };
|
|
1580
|
+
}
|
|
1581
|
+
if (method === "input.posture") {
|
|
1582
|
+
const { posture } = params;
|
|
1583
|
+
if (!postures.includes(posture)) return {
|
|
1584
|
+
code: "bad-request",
|
|
1585
|
+
message: `This device ${postures.length ? `takes these postures: ${postures.join(", ")}` : "has no hinge"}.`
|
|
1586
|
+
};
|
|
1587
|
+
return { value: {
|
|
1588
|
+
session,
|
|
1589
|
+
command: {
|
|
1590
|
+
input: "posture",
|
|
1591
|
+
posture
|
|
1592
|
+
}
|
|
1593
|
+
} };
|
|
1594
|
+
}
|
|
1595
|
+
if (method === "input.touch") {
|
|
1596
|
+
const { phase, x, y, display = 0 } = params;
|
|
1597
|
+
if (!TOUCH_PHASES.includes(phase) || !fraction(x) || !fraction(y)) return {
|
|
1598
|
+
code: "bad-request",
|
|
1599
|
+
message: "input.touch needs phase (down, move or up), and x and y from 0 to 1."
|
|
1600
|
+
};
|
|
1601
|
+
if (!Number.isInteger(display) || display < 0 || display > 3) return {
|
|
1602
|
+
code: "bad-request",
|
|
1603
|
+
message: "display must be a display index from 0 to 3."
|
|
1604
|
+
};
|
|
1605
|
+
if (platform === "android" && display !== 0) return {
|
|
1606
|
+
code: "bad-request",
|
|
1607
|
+
message: "An emulator takes input on its main display (0) only."
|
|
1608
|
+
};
|
|
1609
|
+
return { value: {
|
|
1610
|
+
session,
|
|
1611
|
+
command: {
|
|
1612
|
+
input: "touch",
|
|
1613
|
+
phase,
|
|
1614
|
+
x,
|
|
1615
|
+
y,
|
|
1616
|
+
display
|
|
1617
|
+
}
|
|
1618
|
+
} };
|
|
1619
|
+
}
|
|
1620
|
+
if (method === "input.text") {
|
|
1621
|
+
const { text } = params;
|
|
1622
|
+
if (typeof text !== "string" || !text.length || text.length > 256 || !TEXT.test(text)) return {
|
|
1623
|
+
code: "bad-request",
|
|
1624
|
+
message: `input.text takes 1 to 256 printable ASCII characters, with \\n, \\t and \\b.`
|
|
1625
|
+
};
|
|
1626
|
+
return { value: {
|
|
1627
|
+
session,
|
|
1628
|
+
command: {
|
|
1629
|
+
input: "text",
|
|
1630
|
+
text
|
|
1631
|
+
}
|
|
1632
|
+
} };
|
|
1633
|
+
}
|
|
1634
|
+
const { button } = params;
|
|
1635
|
+
const allowed = platform === "ios" ? IOS_BUTTONS : INPUT_BUTTONS;
|
|
1636
|
+
if (!allowed.includes(button)) return {
|
|
1637
|
+
code: "bad-request",
|
|
1638
|
+
message: `An ${platform} device takes these buttons: ${allowed.join(", ")}.`
|
|
1639
|
+
};
|
|
1640
|
+
return { value: {
|
|
1641
|
+
session,
|
|
1642
|
+
command: {
|
|
1643
|
+
input: "button",
|
|
1644
|
+
button
|
|
1645
|
+
}
|
|
1646
|
+
} };
|
|
1647
|
+
}
|
|
1648
|
+
const ANDROID_KEYS = {
|
|
1649
|
+
home: "KEYCODE_HOME",
|
|
1650
|
+
back: "KEYCODE_BACK",
|
|
1651
|
+
"app-switch": "KEYCODE_APP_SWITCH",
|
|
1652
|
+
lock: "KEYCODE_POWER",
|
|
1653
|
+
"\n": "KEYCODE_ENTER",
|
|
1654
|
+
" ": "KEYCODE_TAB",
|
|
1655
|
+
"\b": "KEYCODE_DEL"
|
|
1656
|
+
};
|
|
1657
|
+
/**
|
|
1658
|
+
* The `adb shell` argument lists that type `text` or press `button` on an emulator without a hardware keyboard
|
|
1659
|
+
* (`hw.keyboard=no`), which drops gRPC key events. `adb shell` joins its arguments into one device shell
|
|
1660
|
+
* command, so text goes single-quoted; `input text` reads `%s` as a space.
|
|
1661
|
+
*/
|
|
1662
|
+
function adbInputArgs(command) {
|
|
1663
|
+
if (command.input === "button") return [[
|
|
1664
|
+
"shell",
|
|
1665
|
+
"input",
|
|
1666
|
+
"keyevent",
|
|
1667
|
+
ANDROID_KEYS[command.button]
|
|
1668
|
+
]];
|
|
1669
|
+
const calls = [];
|
|
1670
|
+
for (const part of command.text.split(/([\n\t\b]+)/)) {
|
|
1671
|
+
if (!part) continue;
|
|
1672
|
+
if (/^[\n\t\b]+$/.test(part)) {
|
|
1673
|
+
calls.push([
|
|
1674
|
+
"shell",
|
|
1675
|
+
"input",
|
|
1676
|
+
"keyevent",
|
|
1677
|
+
...Array.from(part, (key) => ANDROID_KEYS[key])
|
|
1678
|
+
]);
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
calls.push([
|
|
1682
|
+
"shell",
|
|
1683
|
+
"input",
|
|
1684
|
+
"text",
|
|
1685
|
+
`'${part.replaceAll(" ", "%s").replaceAll("'", "'\\''")}'`
|
|
1686
|
+
]);
|
|
1687
|
+
}
|
|
1688
|
+
return calls;
|
|
1689
|
+
}
|
|
1690
|
+
function adbPath(env) {
|
|
1691
|
+
for (const sdk of [
|
|
1692
|
+
env.ANDROID_HOME,
|
|
1693
|
+
env.ANDROID_SDK_ROOT,
|
|
1694
|
+
join(env.HOME ?? homedir(), "Library/Android/sdk")
|
|
1695
|
+
]) if (sdk && existsSync(join(sdk, "platform-tools/adb"))) return join(sdk, "platform-tools/adb");
|
|
1696
|
+
return "adb";
|
|
1697
|
+
}
|
|
1698
|
+
const ADB_TIMEOUT_MS = 1e4;
|
|
1699
|
+
/** `sim-fold` waits up to 20 seconds for SpringBoard to finish the fold; Stim Desktop stops it after 40. */
|
|
1700
|
+
const FOLD_TIMEOUT_MS = 4e4;
|
|
1701
|
+
const POSTURE_TIMEOUT_MS = 5e3;
|
|
1702
|
+
function runQuietly(env, file, args, label, timeoutMs) {
|
|
1703
|
+
return new Promise((resolve, reject) => {
|
|
1704
|
+
const child = spawn(file, args, {
|
|
1705
|
+
env,
|
|
1706
|
+
stdio: [
|
|
1707
|
+
"ignore",
|
|
1708
|
+
"ignore",
|
|
1709
|
+
"pipe"
|
|
1710
|
+
]
|
|
1711
|
+
});
|
|
1712
|
+
let stderr = "";
|
|
1713
|
+
const timer = setTimeout(() => void terminate(child), timeoutMs);
|
|
1714
|
+
child.stderr.setEncoding("utf8");
|
|
1715
|
+
child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-500));
|
|
1716
|
+
child.on("error", (error) => {
|
|
1717
|
+
clearTimeout(timer);
|
|
1718
|
+
reject(/* @__PURE__ */ new Error(`${label} could not start (${error.message}).`));
|
|
1719
|
+
});
|
|
1720
|
+
child.on("close", (code) => {
|
|
1721
|
+
clearTimeout(timer);
|
|
1722
|
+
if (code === 0) resolve();
|
|
1723
|
+
else reject(/* @__PURE__ */ new Error(`${label} failed (code ${code}): ${stderr.trim()}`));
|
|
1724
|
+
});
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
function runAdb(env, serial, args) {
|
|
1728
|
+
return runQuietly(env, adbPath(env), [
|
|
1729
|
+
"-s",
|
|
1730
|
+
serial,
|
|
1731
|
+
...args
|
|
1732
|
+
], `adb ${args.slice(0, 3).join(" ")}`, ADB_TIMEOUT_MS);
|
|
1733
|
+
}
|
|
1734
|
+
function activityOf(payload, target) {
|
|
1735
|
+
const environment = payload.environments?.find((candidate) => candidate.path === target.workspace);
|
|
1736
|
+
const slot = target.slot ?? "default";
|
|
1737
|
+
const devices = slot === "default" ? environment : environment?.slots?.find((candidate) => candidate.slot === slot);
|
|
1738
|
+
return target.platform === "ios" ? devices?.ios?.activity : devices?.android?.activity;
|
|
1739
|
+
}
|
|
1740
|
+
function otherDriver(activity, ownLeases) {
|
|
1741
|
+
if (activity?.state !== "driven" || !activity.driver) return null;
|
|
1742
|
+
const { tool, since } = activity.driver;
|
|
1743
|
+
if (tool === "stim device lock" && since && ownLeases.has(since)) return null;
|
|
1744
|
+
return since ? `${tool} (since ${since})` : tool;
|
|
1745
|
+
}
|
|
1746
|
+
const STATUS_WAIT_MS = 6e4;
|
|
1747
|
+
/**
|
|
1748
|
+
* Control sessions across all connections: at most one per device. A session holds the device's `stim-frames`
|
|
1749
|
+
* helper for input and a `stim device lock` lease, renewed while it lasts and released when it ends if the
|
|
1750
|
+
* session took it.
|
|
1751
|
+
*/
|
|
1752
|
+
var ControlHub = class {
|
|
1753
|
+
options;
|
|
1754
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1755
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
1756
|
+
ownLeases = /* @__PURE__ */ new Set();
|
|
1757
|
+
starting = /* @__PURE__ */ new Set();
|
|
1758
|
+
folding = /* @__PURE__ */ new Set();
|
|
1759
|
+
closing = false;
|
|
1760
|
+
next = 1;
|
|
1761
|
+
constructor(options) {
|
|
1762
|
+
this.options = options;
|
|
1763
|
+
}
|
|
1764
|
+
targetOf(owner, session) {
|
|
1765
|
+
const found = this.sessions.get(session);
|
|
1766
|
+
return found && found.owner === owner && !found.ended ? {
|
|
1767
|
+
platform: found.target.platform,
|
|
1768
|
+
postures: found.postures
|
|
1769
|
+
} : null;
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* `stillAllowed` is checked again after the status read and the lock, which can take seconds: the client may
|
|
1773
|
+
* have disconnected or lost `control` meanwhile.
|
|
1774
|
+
*/
|
|
1775
|
+
async begin(owner, target, cwd, stillAllowed) {
|
|
1776
|
+
const beganAt = Date.now();
|
|
1777
|
+
if (this.closing) return {
|
|
1778
|
+
code: "action-failed",
|
|
1779
|
+
message: "stim-server is stopping."
|
|
1780
|
+
};
|
|
1781
|
+
const status = await this.status();
|
|
1782
|
+
if ("code" in status) return status;
|
|
1783
|
+
const device = ownedDevice(status, target, null);
|
|
1784
|
+
if (typeof device === "string") return {
|
|
1785
|
+
code: "action-failed",
|
|
1786
|
+
message: device
|
|
1787
|
+
};
|
|
1788
|
+
const key = deviceKey(device);
|
|
1789
|
+
if (this.starting.has(key)) return {
|
|
1790
|
+
code: "device-busy",
|
|
1791
|
+
message: "Another client is starting to control this device. Try again."
|
|
1792
|
+
};
|
|
1793
|
+
const earlier = this.byDevice.get(key);
|
|
1794
|
+
const driver = earlier ? `${earlier.owner.device.name} through stim-server` : otherDriver(activityOf(status, target), this.ownLeases);
|
|
1795
|
+
if (driver && !target.takeOver) return {
|
|
1796
|
+
code: "device-busy",
|
|
1797
|
+
message: `This device is driven by ${driver}. Take over to control it anyway.`
|
|
1798
|
+
};
|
|
1799
|
+
this.starting.add(key);
|
|
1800
|
+
let lease;
|
|
1801
|
+
let postures;
|
|
1802
|
+
try {
|
|
1803
|
+
[lease, postures] = await Promise.all([this.lock(device, target, cwd, beganAt), devicePostures(device, this.options.env, POSTURE_TIMEOUT_MS)]);
|
|
1804
|
+
} finally {
|
|
1805
|
+
this.starting.delete(key);
|
|
1806
|
+
}
|
|
1807
|
+
const granted = "code" in lease ? null : lease;
|
|
1808
|
+
if (!granted && !target.takeOver) return lease;
|
|
1809
|
+
const current = this.byDevice.get(key);
|
|
1810
|
+
const refuse = (refusal) => {
|
|
1811
|
+
if (granted?.mine && !current?.lease?.mine) this.unlock(target, cwd);
|
|
1812
|
+
return refusal;
|
|
1813
|
+
};
|
|
1814
|
+
if (this.closing || !stillAllowed()) return refuse({
|
|
1815
|
+
code: "forbidden",
|
|
1816
|
+
message: "This device can no longer control devices."
|
|
1817
|
+
});
|
|
1818
|
+
if (current && current !== earlier && !target.takeOver) return refuse({
|
|
1819
|
+
code: "device-busy",
|
|
1820
|
+
message: `${current.owner.device.name} started controlling this device.`
|
|
1821
|
+
});
|
|
1822
|
+
let session;
|
|
1823
|
+
const input = this.options.frames.control(device, (message) => queueMicrotask(() => void this.end(session, "failed", message)));
|
|
1824
|
+
if (!input) return refuse({
|
|
1825
|
+
code: "action-failed",
|
|
1826
|
+
message: "Input needs the stim-frames helper, which this Mac has not built."
|
|
1827
|
+
});
|
|
1828
|
+
const inherited = granted !== null && current?.lease?.mine === true;
|
|
1829
|
+
if (current) this.end(current, "taken-over", `${owner.device.name} took over this device.`, !inherited);
|
|
1830
|
+
const id = `c${this.next++}`;
|
|
1831
|
+
session = {
|
|
1832
|
+
id,
|
|
1833
|
+
owner,
|
|
1834
|
+
device,
|
|
1835
|
+
key,
|
|
1836
|
+
target,
|
|
1837
|
+
cwd,
|
|
1838
|
+
lease: granted ? {
|
|
1839
|
+
...granted,
|
|
1840
|
+
mine: granted.mine || inherited
|
|
1841
|
+
} : null,
|
|
1842
|
+
input,
|
|
1843
|
+
postures,
|
|
1844
|
+
startedAt: Date.now(),
|
|
1845
|
+
idle: setTimeout(() => void this.end(session, "idle", "No input for 5 minutes."), this.options.idleMs),
|
|
1846
|
+
renew: setInterval(() => this.renew(session, beganAt), this.options.renewMs),
|
|
1847
|
+
renewing: Promise.resolve(),
|
|
1848
|
+
unwatch: () => {},
|
|
1849
|
+
adb: Promise.resolve(),
|
|
1850
|
+
ended: false
|
|
1851
|
+
};
|
|
1852
|
+
this.sessions.set(id, session);
|
|
1853
|
+
this.byDevice.set(key, session);
|
|
1854
|
+
session.unwatch = this.options.feeds.subscribe(this.options.statusFeed, {
|
|
1855
|
+
item: (payload) => {
|
|
1856
|
+
const resolved = ownedDevice(payload, target, key);
|
|
1857
|
+
if (typeof resolved === "string" || deviceKey(resolved) !== key) {
|
|
1858
|
+
const message = typeof resolved === "string" ? resolved : "The device changed.";
|
|
1859
|
+
queueMicrotask(() => void this.end(session, "device-gone", message));
|
|
1860
|
+
}
|
|
1861
|
+
},
|
|
1862
|
+
failed: () => {}
|
|
1863
|
+
});
|
|
1864
|
+
this.audit(owner, target, driver || current ? "control.take-over" : "control.begin", {
|
|
1865
|
+
ok: true,
|
|
1866
|
+
...driver || current ? { reason: `took over from ${driver ?? current.owner.device.name}` } : {}
|
|
1867
|
+
});
|
|
1868
|
+
return {
|
|
1869
|
+
session: id,
|
|
1870
|
+
platform: target.platform,
|
|
1871
|
+
lease: session.lease ? {
|
|
1872
|
+
grantedAt: session.lease.grantedAt,
|
|
1873
|
+
expiresAt: session.lease.expiresAt
|
|
1874
|
+
} : null,
|
|
1875
|
+
postures
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
renew(session, beganAt) {
|
|
1879
|
+
if (session.ended || !session.lease?.mine) return;
|
|
1880
|
+
session.renewing = (async () => {
|
|
1881
|
+
const renewed = await this.lock(session.device, session.target, session.cwd, beganAt);
|
|
1882
|
+
if ("code" in renewed) console.error(`stim-server: could not renew the device lease: ${renewed.message}`);
|
|
1883
|
+
else if (session.lease) session.lease.expiresAt = renewed.expiresAt;
|
|
1884
|
+
})();
|
|
1885
|
+
}
|
|
1886
|
+
input(owner, id, command) {
|
|
1887
|
+
const session = this.sessions.get(id);
|
|
1888
|
+
if (!session || session.owner !== owner || session.ended) return Promise.resolve({
|
|
1889
|
+
code: "unknown-session",
|
|
1890
|
+
message: `No control session ${id} on this connection.`
|
|
1891
|
+
});
|
|
1892
|
+
session.idle.refresh();
|
|
1893
|
+
if (command.input === "posture" && session.device.platform === "ios") return this.fold(session, session.device.udid, command.posture);
|
|
1894
|
+
if (session.device.platform === "ios" || command.input === "touch" || command.input === "rotate" || command.input === "posture" || session.input.keys()) {
|
|
1895
|
+
session.input.send(command);
|
|
1896
|
+
return Promise.resolve(null);
|
|
1897
|
+
}
|
|
1898
|
+
const serial = session.device.serial;
|
|
1899
|
+
const previous = session.adb;
|
|
1900
|
+
const run = (async () => {
|
|
1901
|
+
await previous;
|
|
1902
|
+
try {
|
|
1903
|
+
for (const args of adbInputArgs(command)) {
|
|
1904
|
+
if (session.ended) return null;
|
|
1905
|
+
await runAdb(this.options.env, serial, args);
|
|
1906
|
+
}
|
|
1907
|
+
return null;
|
|
1908
|
+
} catch (cause) {
|
|
1909
|
+
return {
|
|
1910
|
+
code: "action-failed",
|
|
1911
|
+
message: cause.message
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
})();
|
|
1915
|
+
session.adb = run.then(() => void 0);
|
|
1916
|
+
return run;
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* `sim-fold` sweeps the hinge to the other posture, so it runs only when the Duo's last frame shows the
|
|
1920
|
+
* other one.
|
|
1921
|
+
*/
|
|
1922
|
+
async fold(session, udid, posture) {
|
|
1923
|
+
if (this.folding.has(udid)) return {
|
|
1924
|
+
code: "device-busy",
|
|
1925
|
+
message: "The device is still folding."
|
|
1926
|
+
};
|
|
1927
|
+
const current = this.options.frames.litPosture(session.device);
|
|
1928
|
+
if (!current) return {
|
|
1929
|
+
code: "action-failed",
|
|
1930
|
+
message: "Subscribe to frames of this device to learn its posture first."
|
|
1931
|
+
};
|
|
1932
|
+
if (current === posture) return null;
|
|
1933
|
+
this.folding.add(udid);
|
|
1934
|
+
try {
|
|
1935
|
+
const helper = await this.options.foldHelper();
|
|
1936
|
+
if (session.ended) return null;
|
|
1937
|
+
await runQuietly(this.options.env, "xcrun", [
|
|
1938
|
+
"simctl",
|
|
1939
|
+
"spawn",
|
|
1940
|
+
udid,
|
|
1941
|
+
helper
|
|
1942
|
+
], "sim-fold", FOLD_TIMEOUT_MS);
|
|
1943
|
+
this.options.frames.folded(udid, posture === "folded" ? "folded" : "unfolded");
|
|
1944
|
+
return null;
|
|
1945
|
+
} catch (cause) {
|
|
1946
|
+
return {
|
|
1947
|
+
code: "action-failed",
|
|
1948
|
+
message: cause.message
|
|
1949
|
+
};
|
|
1950
|
+
} finally {
|
|
1951
|
+
this.folding.delete(udid);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
endById(owner, id) {
|
|
1955
|
+
const session = this.sessions.get(id);
|
|
1956
|
+
if (!session || session.owner !== owner) return false;
|
|
1957
|
+
this.end(session, null, "The client ended the session.");
|
|
1958
|
+
return true;
|
|
1959
|
+
}
|
|
1960
|
+
endFor(owner, reason, message) {
|
|
1961
|
+
for (const session of this.sessions.values()) if (session.owner === owner) this.end(session, reason, message);
|
|
1962
|
+
}
|
|
1963
|
+
async close() {
|
|
1964
|
+
this.closing = true;
|
|
1965
|
+
const releases = [...this.sessions.values()].map((session) => this.end(session, null, "stim-server stopped."));
|
|
1966
|
+
await Promise.all(releases);
|
|
1967
|
+
}
|
|
1968
|
+
end(session, reason, message, release = true) {
|
|
1969
|
+
if (session.ended) return Promise.resolve();
|
|
1970
|
+
session.ended = true;
|
|
1971
|
+
clearTimeout(session.idle);
|
|
1972
|
+
clearInterval(session.renew);
|
|
1973
|
+
session.unwatch();
|
|
1974
|
+
session.input.detach();
|
|
1975
|
+
this.sessions.delete(session.id);
|
|
1976
|
+
if (this.byDevice.get(session.key) === session) this.byDevice.delete(session.key);
|
|
1977
|
+
if (reason) session.owner.send({
|
|
1978
|
+
event: "control-ended",
|
|
1979
|
+
session: session.id,
|
|
1980
|
+
reason,
|
|
1981
|
+
message
|
|
1982
|
+
});
|
|
1983
|
+
this.audit(session.owner, session.target, "control.end", {
|
|
1984
|
+
ok: true,
|
|
1985
|
+
durationMs: Date.now() - session.startedAt,
|
|
1986
|
+
reason: `${reason ?? "ended"}: ${message}`
|
|
1987
|
+
});
|
|
1988
|
+
if (!release || !session.lease?.mine) return Promise.resolve();
|
|
1989
|
+
return session.renewing.then(() => this.unlock(session.target, session.cwd));
|
|
1990
|
+
}
|
|
1991
|
+
status() {
|
|
1992
|
+
return new Promise((resolve) => {
|
|
1993
|
+
let settled = false;
|
|
1994
|
+
let unsubscribe = null;
|
|
1995
|
+
const finish = (value) => {
|
|
1996
|
+
if (settled) return;
|
|
1997
|
+
settled = true;
|
|
1998
|
+
clearTimeout(timer);
|
|
1999
|
+
unsubscribe?.();
|
|
2000
|
+
resolve(value);
|
|
2001
|
+
};
|
|
2002
|
+
const timer = setTimeout(() => finish({
|
|
2003
|
+
code: "status-failed",
|
|
2004
|
+
message: "stim status did not report the device in time."
|
|
2005
|
+
}), STATUS_WAIT_MS);
|
|
2006
|
+
unsubscribe = this.options.feeds.subscribe(this.options.statusFeed, {
|
|
2007
|
+
item: (payload) => finish(payload),
|
|
2008
|
+
failed: (message) => finish({
|
|
2009
|
+
code: "status-failed",
|
|
2010
|
+
message
|
|
2011
|
+
})
|
|
2012
|
+
});
|
|
2013
|
+
if (settled) unsubscribe();
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
async lock(device, target, cwd, beganAt) {
|
|
2017
|
+
const id = device.platform === "ios" ? device.udid : device.serial;
|
|
2018
|
+
const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
|
|
2019
|
+
const args = [
|
|
2020
|
+
"device",
|
|
2021
|
+
"lock",
|
|
2022
|
+
target.platform,
|
|
2023
|
+
id,
|
|
2024
|
+
"--for",
|
|
2025
|
+
this.options.leaseFor,
|
|
2026
|
+
"--wait",
|
|
2027
|
+
"0",
|
|
2028
|
+
"--json",
|
|
2029
|
+
...slot
|
|
2030
|
+
];
|
|
2031
|
+
const printed = actionOutcome(await runStim(this.options.stimCli, this.options.env, args, cwd, this.options.lockLimits).outcome);
|
|
2032
|
+
if (!printed.ok) return {
|
|
2033
|
+
code: printed.error.message.startsWith("STIM_DEVICE_BUSY") ? "device-busy" : "action-failed",
|
|
2034
|
+
message: `stim device lock: ${printed.error.message}`
|
|
2035
|
+
};
|
|
2036
|
+
const grantedAt = typeof printed.output.grantedAt === "string" ? printed.output.grantedAt : null;
|
|
2037
|
+
const expiresAt = typeof printed.output.expiresAt === "string" ? printed.output.expiresAt : "";
|
|
2038
|
+
const mine = grantedAt !== null && Date.parse(grantedAt) >= beganAt - 1e3;
|
|
2039
|
+
if (mine) this.ownLeases.add(grantedAt);
|
|
2040
|
+
return {
|
|
2041
|
+
grantedAt,
|
|
2042
|
+
expiresAt,
|
|
2043
|
+
mine
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
async unlock(target, cwd) {
|
|
2047
|
+
const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
|
|
2048
|
+
const outcome = await runStim(this.options.stimCli, this.options.env, [
|
|
2049
|
+
"device",
|
|
2050
|
+
"unlock",
|
|
2051
|
+
target.platform,
|
|
2052
|
+
"--json",
|
|
2053
|
+
...slot
|
|
2054
|
+
], cwd, this.options.lockLimits).outcome;
|
|
2055
|
+
if (!outcome.ok) console.error(`stim-server: could not release the device lease: ${outcome.message}`);
|
|
2056
|
+
}
|
|
2057
|
+
audit(owner, target, action, outcome) {
|
|
2058
|
+
this.options.audit({
|
|
2059
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2060
|
+
device: {
|
|
2061
|
+
id: owner.device.id,
|
|
2062
|
+
name: owner.device.name
|
|
2063
|
+
},
|
|
2064
|
+
action,
|
|
2065
|
+
workspace: target.workspace,
|
|
2066
|
+
platform: target.platform,
|
|
2067
|
+
...outcome
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
//#endregion
|
|
2072
|
+
//#region src/feed.ts
|
|
2073
|
+
var Feed = class {
|
|
2074
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2075
|
+
kept = [];
|
|
2076
|
+
child;
|
|
2077
|
+
ended;
|
|
2078
|
+
constructor(spec, stimCli, env, ended) {
|
|
2079
|
+
this.ended = ended;
|
|
2080
|
+
const child = spawn(process.execPath, [stimCli, ...spec.args], {
|
|
2081
|
+
cwd: spec.cwd,
|
|
2082
|
+
env,
|
|
2083
|
+
stdio: [
|
|
2084
|
+
"ignore",
|
|
2085
|
+
"pipe",
|
|
2086
|
+
"pipe"
|
|
2087
|
+
]
|
|
2088
|
+
});
|
|
2089
|
+
this.child = child;
|
|
2090
|
+
let stderr = "";
|
|
2091
|
+
child.stderr?.setEncoding("utf8");
|
|
2092
|
+
child.stderr?.on("data", (chunk) => {
|
|
2093
|
+
stderr = (stderr + chunk).slice(-2e3);
|
|
2094
|
+
});
|
|
2095
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
2096
|
+
if (this.child !== child) return;
|
|
2097
|
+
let value;
|
|
2098
|
+
try {
|
|
2099
|
+
value = JSON.parse(line);
|
|
2100
|
+
} catch {
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
if (!isJsonObject(value)) return;
|
|
2104
|
+
this.kept.push(value);
|
|
2105
|
+
if (this.kept.length > spec.keep) this.kept.shift();
|
|
2106
|
+
for (const listener of this.listeners) listener.item(value);
|
|
2107
|
+
});
|
|
2108
|
+
const exited = (reason) => {
|
|
2109
|
+
if (this.child !== child) return;
|
|
2110
|
+
this.child = null;
|
|
2111
|
+
this.ended();
|
|
2112
|
+
const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
|
|
2113
|
+
const listeners = [...this.listeners];
|
|
2114
|
+
this.listeners.clear();
|
|
2115
|
+
for (const listener of listeners) listener.failed(message);
|
|
2116
|
+
};
|
|
2117
|
+
child.on("error", (error) => exited(`could not start (${error.message})`));
|
|
2118
|
+
child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
|
|
2119
|
+
}
|
|
2120
|
+
add(listener) {
|
|
2121
|
+
this.listeners.add(listener);
|
|
2122
|
+
for (const value of this.kept) listener.item(value);
|
|
2123
|
+
return () => {
|
|
2124
|
+
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
stop() {
|
|
2128
|
+
const child = this.child;
|
|
2129
|
+
if (!child) return Promise.resolve();
|
|
2130
|
+
this.child = null;
|
|
2131
|
+
this.listeners.clear();
|
|
2132
|
+
this.ended();
|
|
2133
|
+
return terminate(child);
|
|
847
2134
|
}
|
|
848
2135
|
};
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
* off to once a second while it does not, and spends at most half of its time capturing. At most two
|
|
853
|
-
* captures run at once across all devices.
|
|
854
|
-
*/
|
|
855
|
-
var FramePool = class {
|
|
856
|
-
sources = /* @__PURE__ */ new Map();
|
|
857
|
-
limiter = new Limiter(MAX_CAPTURES);
|
|
2136
|
+
var FeedPool = class {
|
|
2137
|
+
feeds = /* @__PURE__ */ new Map();
|
|
2138
|
+
stimCli;
|
|
858
2139
|
env;
|
|
859
|
-
|
|
860
|
-
|
|
2140
|
+
constructor(stimCli, env) {
|
|
2141
|
+
this.stimCli = stimCli;
|
|
861
2142
|
this.env = env;
|
|
862
|
-
this.limits = limits;
|
|
863
2143
|
}
|
|
864
|
-
subscribe(
|
|
865
|
-
const key =
|
|
866
|
-
let
|
|
867
|
-
if (!
|
|
868
|
-
const created = new
|
|
869
|
-
if (this.
|
|
2144
|
+
subscribe(spec, listener) {
|
|
2145
|
+
const key = JSON.stringify([spec.cwd, spec.args]);
|
|
2146
|
+
let feed = this.feeds.get(key);
|
|
2147
|
+
if (!feed) {
|
|
2148
|
+
const created = new Feed(spec, this.stimCli, this.env, () => {
|
|
2149
|
+
if (this.feeds.get(key) === created) this.feeds.delete(key);
|
|
870
2150
|
});
|
|
871
|
-
this.
|
|
872
|
-
|
|
2151
|
+
this.feeds.set(key, created);
|
|
2152
|
+
feed = created;
|
|
873
2153
|
}
|
|
874
|
-
return
|
|
2154
|
+
return feed.add(listener);
|
|
875
2155
|
}
|
|
876
2156
|
async close() {
|
|
877
|
-
await Promise.all([...this.
|
|
2157
|
+
await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
|
|
878
2158
|
}
|
|
879
2159
|
};
|
|
880
2160
|
//#endregion
|
|
@@ -884,8 +2164,8 @@ function parseLogFilter(params) {
|
|
|
884
2164
|
const { sources, level, slot, grep, errors, tail } = params;
|
|
885
2165
|
if (sources !== void 0 && (!Array.isArray(sources) || sources.length === 0 || !sources.every((source) => LOG_SOURCES.includes(source)))) return { error: `sources must be a non-empty list of ${LOG_SOURCES.join(", ")}.` };
|
|
886
2166
|
if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
|
|
887
|
-
if (slot !== void 0 && (typeof slot !== "string" || slot
|
|
888
|
-
if (grep !== void 0 && (typeof grep !== "string" || compileGrep(grep).error)) return { error: "grep must be a valid regular expression." };
|
|
2167
|
+
if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return { error: "slot must be 1-64 letters, digits, underscores or hyphens." };
|
|
2168
|
+
if (grep !== void 0 && (typeof grep !== "string" || grep.includes("\0") || compileGrep(grep).error)) return { error: "grep must be a valid regular expression without NUL characters." };
|
|
889
2169
|
if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
|
|
890
2170
|
if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > 5e3)) return { error: `tail must be an integer from 1 to ${MAX_LOG_TAIL}.` };
|
|
891
2171
|
return { filter: {
|
|
@@ -951,6 +2231,198 @@ var LogBatcher = class {
|
|
|
951
2231
|
}
|
|
952
2232
|
};
|
|
953
2233
|
//#endregion
|
|
2234
|
+
//#region src/machine.ts
|
|
2235
|
+
function stimDiskLocations() {
|
|
2236
|
+
let workspaces = [];
|
|
2237
|
+
try {
|
|
2238
|
+
workspaces = Object.keys(loadConfig()?.projects ?? {});
|
|
2239
|
+
} catch {}
|
|
2240
|
+
return [
|
|
2241
|
+
...workspaces.map((path) => ({
|
|
2242
|
+
label: "Workspaces",
|
|
2243
|
+
path
|
|
2244
|
+
})),
|
|
2245
|
+
{
|
|
2246
|
+
label: "Stim home",
|
|
2247
|
+
path: configDir()
|
|
2248
|
+
},
|
|
2249
|
+
...process.platform === "darwin" ? [{
|
|
2250
|
+
label: "Simulators",
|
|
2251
|
+
path: join(homedir(), "Library", "Developer", "CoreSimulator")
|
|
2252
|
+
}] : []
|
|
2253
|
+
];
|
|
2254
|
+
}
|
|
2255
|
+
function existingAncestor(path) {
|
|
2256
|
+
let current = path;
|
|
2257
|
+
while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
|
|
2258
|
+
return current;
|
|
2259
|
+
}
|
|
2260
|
+
function mountOf(path) {
|
|
2261
|
+
return /^\/Volumes\/[^/]+/.exec(path)?.[0] ?? "/";
|
|
2262
|
+
}
|
|
2263
|
+
function readVolumes(locations) {
|
|
2264
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
2265
|
+
for (const { label, path } of locations) try {
|
|
2266
|
+
const existing = existingAncestor(path);
|
|
2267
|
+
const device = statSync(existing).dev;
|
|
2268
|
+
const known = byDevice.get(device);
|
|
2269
|
+
if (known) {
|
|
2270
|
+
if (!known.holds.includes(label)) known.holds.push(label);
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
const fs = statfsSync(existing);
|
|
2274
|
+
byDevice.set(device, {
|
|
2275
|
+
mount: mountOf(existing),
|
|
2276
|
+
holds: [label],
|
|
2277
|
+
freeBytes: fs.bavail * fs.bsize,
|
|
2278
|
+
totalBytes: fs.blocks * fs.bsize
|
|
2279
|
+
});
|
|
2280
|
+
} catch {}
|
|
2281
|
+
return [...byDevice.values()];
|
|
2282
|
+
}
|
|
2283
|
+
function readMemoryPressure() {
|
|
2284
|
+
if (process.platform !== "darwin") return Promise.resolve(null);
|
|
2285
|
+
return new Promise((resolve) => {
|
|
2286
|
+
execFile("/usr/sbin/sysctl", ["-n", "kern.memorystatus_vm_pressure_level"], { timeout: 2e3 }, (error, stdout) => {
|
|
2287
|
+
const level = error ? "" : stdout.trim();
|
|
2288
|
+
resolve(level === "1" ? "normal" : level === "2" ? "warning" : level === "4" ? "critical" : null);
|
|
2289
|
+
});
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Activity Monitor's "Memory Used" from `vm_stat` output: app memory (anonymous minus purgeable pages),
|
|
2294
|
+
* wired, and compressed pages, times the page size its header names. Null when a count is missing.
|
|
2295
|
+
*/
|
|
2296
|
+
function parseVmStatUsedBytes(output) {
|
|
2297
|
+
const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
|
|
2298
|
+
const count = (label) => {
|
|
2299
|
+
const value = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(output)?.[1];
|
|
2300
|
+
return value === void 0 ? null : Number(value);
|
|
2301
|
+
};
|
|
2302
|
+
const anonymous = count("Anonymous pages");
|
|
2303
|
+
const purgeable = count("Pages purgeable");
|
|
2304
|
+
const wired = count("Pages wired down");
|
|
2305
|
+
const compressed = count("Pages occupied by compressor");
|
|
2306
|
+
if (!pageSize || anonymous === null || purgeable === null || wired === null || compressed === null) return null;
|
|
2307
|
+
return (Math.max(0, anonymous - purgeable) + wired + compressed) * Number(pageSize);
|
|
2308
|
+
}
|
|
2309
|
+
function readMemoryUsed() {
|
|
2310
|
+
if (process.platform !== "darwin") return Promise.resolve(null);
|
|
2311
|
+
return new Promise((resolve) => {
|
|
2312
|
+
execFile("/usr/bin/vm_stat", { timeout: 2e3 }, (error, stdout) => {
|
|
2313
|
+
resolve(error ? null : parseVmStatUsedBytes(stdout));
|
|
2314
|
+
});
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
function cpuTicks(list) {
|
|
2318
|
+
let idle = 0;
|
|
2319
|
+
let total = 0;
|
|
2320
|
+
for (const cpu of list) {
|
|
2321
|
+
idle += cpu.times.idle;
|
|
2322
|
+
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
|
|
2323
|
+
}
|
|
2324
|
+
return {
|
|
2325
|
+
idle,
|
|
2326
|
+
total
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
/** The busy fraction (0..1) between two `os.cpus()` tick totals. Null when the totals did not advance. */
|
|
2330
|
+
function cpuUsageFraction(previous, current) {
|
|
2331
|
+
const totalDelta = current.total - previous.total;
|
|
2332
|
+
if (totalDelta <= 0) return null;
|
|
2333
|
+
const idleDelta = current.idle - previous.idle;
|
|
2334
|
+
return Math.min(1, Math.max(0, 1 - idleDelta / totalDelta));
|
|
2335
|
+
}
|
|
2336
|
+
let previousCpuTicks = null;
|
|
2337
|
+
function readCpuUsage() {
|
|
2338
|
+
const current = cpuTicks(cpus());
|
|
2339
|
+
const usage = previousCpuTicks ? cpuUsageFraction(previousCpuTicks, current) : null;
|
|
2340
|
+
previousCpuTicks = current;
|
|
2341
|
+
return {
|
|
2342
|
+
usage,
|
|
2343
|
+
cores: availableParallelism()
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
async function readMachineUsage() {
|
|
2347
|
+
const [avg1 = 0, avg5 = 0, avg15 = 0] = loadavg();
|
|
2348
|
+
const [pressure, usedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
|
|
2349
|
+
return {
|
|
2350
|
+
volumes: readVolumes(stimDiskLocations()),
|
|
2351
|
+
memory: {
|
|
2352
|
+
totalBytes: totalmem(),
|
|
2353
|
+
usedBytes,
|
|
2354
|
+
pressure
|
|
2355
|
+
},
|
|
2356
|
+
load: {
|
|
2357
|
+
avg1,
|
|
2358
|
+
avg5,
|
|
2359
|
+
avg15,
|
|
2360
|
+
cpus: availableParallelism()
|
|
2361
|
+
},
|
|
2362
|
+
cpu: readCpuUsage(),
|
|
2363
|
+
sampledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
const PRESSURE_LEVEL = {
|
|
2367
|
+
normal: 0,
|
|
2368
|
+
warning: 1,
|
|
2369
|
+
critical: 2
|
|
2370
|
+
};
|
|
2371
|
+
const HISTORY_INTERVAL_MS = 5e3;
|
|
2372
|
+
const HISTORY_CAPACITY = 720;
|
|
2373
|
+
var UsageSampler = class {
|
|
2374
|
+
samples = [];
|
|
2375
|
+
previousTicks = null;
|
|
2376
|
+
timer = null;
|
|
2377
|
+
intervalMs;
|
|
2378
|
+
capacity;
|
|
2379
|
+
constructor(intervalMs = HISTORY_INTERVAL_MS, capacity = HISTORY_CAPACITY) {
|
|
2380
|
+
this.intervalMs = intervalMs;
|
|
2381
|
+
this.capacity = capacity;
|
|
2382
|
+
}
|
|
2383
|
+
start() {
|
|
2384
|
+
if (this.timer) return;
|
|
2385
|
+
this.previousTicks = cpuTicks(cpus());
|
|
2386
|
+
this.timer = setInterval(() => void this.sample(), this.intervalMs);
|
|
2387
|
+
}
|
|
2388
|
+
stop() {
|
|
2389
|
+
if (this.timer) clearInterval(this.timer);
|
|
2390
|
+
this.timer = null;
|
|
2391
|
+
this.previousTicks = null;
|
|
2392
|
+
}
|
|
2393
|
+
record(sample) {
|
|
2394
|
+
this.samples.push(sample);
|
|
2395
|
+
if (this.samples.length > this.capacity) this.samples.splice(0, this.samples.length - this.capacity);
|
|
2396
|
+
}
|
|
2397
|
+
history(sinceMs) {
|
|
2398
|
+
const samples = sinceMs === void 0 ? [...this.samples] : this.samples.filter((s) => s.at > sinceMs);
|
|
2399
|
+
return {
|
|
2400
|
+
intervalMs: this.intervalMs,
|
|
2401
|
+
samples
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
async sample() {
|
|
2405
|
+
const at = Date.now();
|
|
2406
|
+
const ticks = cpuTicks(cpus());
|
|
2407
|
+
const cpu = this.previousTicks ? cpuUsageFraction(this.previousTicks, ticks) : null;
|
|
2408
|
+
this.previousTicks = ticks;
|
|
2409
|
+
const [pressure, memoryUsedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
|
|
2410
|
+
let diskFreeBytes = null;
|
|
2411
|
+
try {
|
|
2412
|
+
const fs = statfsSync("/");
|
|
2413
|
+
diskFreeBytes = fs.bavail * fs.bsize;
|
|
2414
|
+
} catch {}
|
|
2415
|
+
if (!this.timer) return;
|
|
2416
|
+
this.record({
|
|
2417
|
+
at,
|
|
2418
|
+
cpu,
|
|
2419
|
+
memoryUsedBytes,
|
|
2420
|
+
memoryPressure: pressure === null ? null : PRESSURE_LEVEL[pressure],
|
|
2421
|
+
diskFreeBytes
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
//#endregion
|
|
954
2426
|
//#region src/tailscale.ts
|
|
955
2427
|
const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
956
2428
|
const TIMEOUT_MS = 3e3;
|
|
@@ -1192,8 +2664,21 @@ const LOG_LIMITS = {
|
|
|
1192
2664
|
maxBufferedBytes: 4194304,
|
|
1193
2665
|
maxPendingRecords: 2e4
|
|
1194
2666
|
};
|
|
1195
|
-
const
|
|
1196
|
-
const FRAME_RETRY_MS =
|
|
2667
|
+
const FRAME_BUFFER_FRAMES = 2;
|
|
2668
|
+
const FRAME_RETRY_MS = 50;
|
|
2669
|
+
const HELPER_RETRY_MS = 3e5;
|
|
2670
|
+
const CONTROL_LIMITS = {
|
|
2671
|
+
idleMs: 3e5,
|
|
2672
|
+
renewMs: 6e4,
|
|
2673
|
+
leaseFor: "2m",
|
|
2674
|
+
inputPerSecond: 120,
|
|
2675
|
+
textCharsPerSecond: 40,
|
|
2676
|
+
shapeChangesPerSecond: 2
|
|
2677
|
+
};
|
|
2678
|
+
const LOCK_LIMITS = {
|
|
2679
|
+
timeoutMs: 3e4,
|
|
2680
|
+
maxOutputBytes: 65536
|
|
2681
|
+
};
|
|
1197
2682
|
const STATUS_FEED = {
|
|
1198
2683
|
args: [
|
|
1199
2684
|
"status",
|
|
@@ -1209,6 +2694,12 @@ const COMMAND_LIMITS = {
|
|
|
1209
2694
|
timeoutMs: 6e4,
|
|
1210
2695
|
maxOutputBytes: 33554432
|
|
1211
2696
|
};
|
|
2697
|
+
const PLAN_TIMEOUT_MS = 15e4;
|
|
2698
|
+
const AUDIT_FIELD_CHARS = 256;
|
|
2699
|
+
const ACTION_LIMITS = {
|
|
2700
|
+
timeoutMs: 12e4,
|
|
2701
|
+
maxOutputBytes: 1048576
|
|
2702
|
+
};
|
|
1212
2703
|
const AUTH_REFUSALS = {
|
|
1213
2704
|
"pairing-unknown": {
|
|
1214
2705
|
code: "pairing-expired",
|
|
@@ -1261,12 +2752,52 @@ var FailureLimiter = class {
|
|
|
1261
2752
|
this.failures.set(key, [...this.recent(key, now), now]);
|
|
1262
2753
|
}
|
|
1263
2754
|
};
|
|
2755
|
+
function auditSafely(record) {
|
|
2756
|
+
try {
|
|
2757
|
+
appendAudit(record);
|
|
2758
|
+
} catch (cause) {
|
|
2759
|
+
console.error(`stim-server: could not append to the action log: ${cause.message}`);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
function take(bucket, cost, perSecond, capacity) {
|
|
2763
|
+
const now = Date.now();
|
|
2764
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + (now - bucket.at) / 1e3 * perSecond);
|
|
2765
|
+
bucket.at = now;
|
|
2766
|
+
if (bucket.tokens < cost) return false;
|
|
2767
|
+
bucket.tokens -= cost;
|
|
2768
|
+
return true;
|
|
2769
|
+
}
|
|
1264
2770
|
function send(socket, message) {
|
|
1265
2771
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
1266
2772
|
}
|
|
1267
2773
|
function requestId(value) {
|
|
1268
2774
|
return typeof value === "number" || typeof value === "string" ? value : null;
|
|
1269
2775
|
}
|
|
2776
|
+
function registeredWorkspace(workspace) {
|
|
2777
|
+
if (typeof workspace !== "string") return {
|
|
2778
|
+
code: "bad-request",
|
|
2779
|
+
message: "params.workspace must be an environment path from a status payload."
|
|
2780
|
+
};
|
|
2781
|
+
let projects;
|
|
2782
|
+
try {
|
|
2783
|
+
projects = loadConfig()?.projects ?? {};
|
|
2784
|
+
} catch (cause) {
|
|
2785
|
+
return {
|
|
2786
|
+
code: "stim-failed",
|
|
2787
|
+
message: cause.message
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
const dir = Object.keys(projects).find((path) => path === workspace);
|
|
2791
|
+
if (dir === void 0) return {
|
|
2792
|
+
code: "unknown-workspace",
|
|
2793
|
+
message: `${workspace} is not a Stim workspace on this Mac.`
|
|
2794
|
+
};
|
|
2795
|
+
if (!existsSync(dir)) return {
|
|
2796
|
+
code: "unknown-workspace",
|
|
2797
|
+
message: `${dir} is registered but no longer exists on this Mac.`
|
|
2798
|
+
};
|
|
2799
|
+
return { dir };
|
|
2800
|
+
}
|
|
1270
2801
|
async function startServer(options) {
|
|
1271
2802
|
const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
|
|
1272
2803
|
const authTimeoutMs = options.authTimeoutMs ?? 5e3;
|
|
@@ -1275,7 +2806,38 @@ async function startServer(options) {
|
|
|
1275
2806
|
...DEFAULT_FRAME_LIMITS,
|
|
1276
2807
|
...options.frameLimits
|
|
1277
2808
|
};
|
|
1278
|
-
|
|
2809
|
+
let helperPath = options.frameHelper ?? null;
|
|
2810
|
+
let helperBuilding = false;
|
|
2811
|
+
let helperRetryAt = 0;
|
|
2812
|
+
const helperAbort = new AbortController();
|
|
2813
|
+
const buildHelper = () => {
|
|
2814
|
+
helperBuilding = true;
|
|
2815
|
+
(async () => {
|
|
2816
|
+
try {
|
|
2817
|
+
helperPath = await buildFrameHelper(options.env, helperAbort.signal);
|
|
2818
|
+
} catch (cause) {
|
|
2819
|
+
helperRetryAt = Date.now() + HELPER_RETRY_MS;
|
|
2820
|
+
if (!helperAbort.signal.aborted) console.error(`stim-server: frames come from screenshots: ${cause.message}`);
|
|
2821
|
+
} finally {
|
|
2822
|
+
helperBuilding = false;
|
|
2823
|
+
}
|
|
2824
|
+
})();
|
|
2825
|
+
};
|
|
2826
|
+
const frameHelper = () => {
|
|
2827
|
+
if (options.frameHelper === void 0 && !helperPath && !helperBuilding && Date.now() >= helperRetryAt) buildHelper();
|
|
2828
|
+
return helperPath;
|
|
2829
|
+
};
|
|
2830
|
+
if (options.frameHelper === void 0) buildHelper();
|
|
2831
|
+
const frames = new FramePool(options.env, frameLimits, frameHelper);
|
|
2832
|
+
let foldBuild = null;
|
|
2833
|
+
const foldHelper = () => {
|
|
2834
|
+
if (options.foldHelper !== void 0) return Promise.resolve(options.foldHelper);
|
|
2835
|
+
foldBuild ??= buildFoldHelper(options.env, helperAbort.signal).catch((cause) => {
|
|
2836
|
+
foldBuild = null;
|
|
2837
|
+
throw new Error(`sim-fold could not be built: ${cause.message}`);
|
|
2838
|
+
});
|
|
2839
|
+
return foldBuild;
|
|
2840
|
+
};
|
|
1279
2841
|
const running = /* @__PURE__ */ new Set();
|
|
1280
2842
|
const logLimits = {
|
|
1281
2843
|
...LOG_LIMITS,
|
|
@@ -1285,7 +2847,32 @@ async function startServer(options) {
|
|
|
1285
2847
|
...COMMAND_LIMITS,
|
|
1286
2848
|
...options.commandLimits
|
|
1287
2849
|
};
|
|
2850
|
+
const actionLimits = {
|
|
2851
|
+
...ACTION_LIMITS,
|
|
2852
|
+
...options.actionLimits
|
|
2853
|
+
};
|
|
2854
|
+
const busyWorkspaces = /* @__PURE__ */ new Set();
|
|
2855
|
+
const planQueues = /* @__PURE__ */ new Map();
|
|
1288
2856
|
const sessions = /* @__PURE__ */ new Map();
|
|
2857
|
+
const sampler = new UsageSampler();
|
|
2858
|
+
const controllers = /* @__PURE__ */ new Map();
|
|
2859
|
+
const controlLimits = {
|
|
2860
|
+
...CONTROL_LIMITS,
|
|
2861
|
+
...options.controlLimits
|
|
2862
|
+
};
|
|
2863
|
+
const control = new ControlHub({
|
|
2864
|
+
env: options.env,
|
|
2865
|
+
stimCli: options.stimCli,
|
|
2866
|
+
feeds,
|
|
2867
|
+
frames,
|
|
2868
|
+
statusFeed: STATUS_FEED,
|
|
2869
|
+
audit: auditSafely,
|
|
2870
|
+
lockLimits: LOCK_LIMITS,
|
|
2871
|
+
idleMs: controlLimits.idleMs,
|
|
2872
|
+
renewMs: controlLimits.renewMs,
|
|
2873
|
+
leaseFor: controlLimits.leaseFor,
|
|
2874
|
+
foldHelper
|
|
2875
|
+
});
|
|
1289
2876
|
const wss = new WebSocketServer({
|
|
1290
2877
|
noServer: true,
|
|
1291
2878
|
maxPayload: MAX_PAYLOAD
|
|
@@ -1298,13 +2885,18 @@ async function startServer(options) {
|
|
|
1298
2885
|
const watcher = watch(serverDir(), () => {
|
|
1299
2886
|
revocationCheck ??= setTimeout(() => {
|
|
1300
2887
|
revocationCheck = null;
|
|
1301
|
-
const paired = new
|
|
2888
|
+
const paired = new Map(readDevices().map((device) => [device.id, device]));
|
|
1302
2889
|
for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
|
|
2890
|
+
for (const [socket, controller] of controllers) if (!paired.get(controller.device.id)?.capabilities.includes("control")) {
|
|
2891
|
+
control.endFor(controller, "forbidden", "This device can no longer control devices.");
|
|
2892
|
+
controllers.delete(socket);
|
|
2893
|
+
}
|
|
1303
2894
|
}, 50);
|
|
1304
2895
|
});
|
|
1305
2896
|
function connection(socket, peer) {
|
|
1306
2897
|
const limitKey = peer ?? "local";
|
|
1307
2898
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
2899
|
+
const keyframes = /* @__PURE__ */ new Map();
|
|
1308
2900
|
const commands = /* @__PURE__ */ new Set();
|
|
1309
2901
|
let nextSubscription = 1;
|
|
1310
2902
|
let device = null;
|
|
@@ -1353,6 +2945,7 @@ async function startServer(options) {
|
|
|
1353
2945
|
}
|
|
1354
2946
|
device = outcome.device;
|
|
1355
2947
|
sessions.set(socket, device);
|
|
2948
|
+
sampler.start();
|
|
1356
2949
|
send(socket, {
|
|
1357
2950
|
id,
|
|
1358
2951
|
result: {
|
|
@@ -1360,9 +2953,11 @@ async function startServer(options) {
|
|
|
1360
2953
|
server: {
|
|
1361
2954
|
name: options.name,
|
|
1362
2955
|
version: options.serverVersion,
|
|
1363
|
-
stim: options.stimVersion
|
|
2956
|
+
stim: options.stimVersion,
|
|
2957
|
+
home: homedir()
|
|
1364
2958
|
},
|
|
1365
2959
|
capabilities: device.capabilities,
|
|
2960
|
+
actions: device.capabilities.includes("control") ? [...ACTIONS] : [],
|
|
1366
2961
|
device: {
|
|
1367
2962
|
id: device.id,
|
|
1368
2963
|
name: device.name
|
|
@@ -1380,30 +2975,92 @@ async function startServer(options) {
|
|
|
1380
2975
|
}
|
|
1381
2976
|
});
|
|
1382
2977
|
}
|
|
2978
|
+
const inputs = {
|
|
2979
|
+
tokens: controlLimits.inputPerSecond,
|
|
2980
|
+
at: Date.now()
|
|
2981
|
+
};
|
|
2982
|
+
const characters = {
|
|
2983
|
+
tokens: 256,
|
|
2984
|
+
at: Date.now()
|
|
2985
|
+
};
|
|
2986
|
+
const shapeChanges = {
|
|
2987
|
+
tokens: controlLimits.shapeChangesPerSecond,
|
|
2988
|
+
at: Date.now()
|
|
2989
|
+
};
|
|
2990
|
+
function controller(session) {
|
|
2991
|
+
let found = controllers.get(socket);
|
|
2992
|
+
if (!found) {
|
|
2993
|
+
found = {
|
|
2994
|
+
device: session,
|
|
2995
|
+
send: (message) => send(socket, message)
|
|
2996
|
+
};
|
|
2997
|
+
controllers.set(socket, found);
|
|
2998
|
+
}
|
|
2999
|
+
return found;
|
|
3000
|
+
}
|
|
3001
|
+
async function beginControl(id, params, session) {
|
|
3002
|
+
const raw = isJsonObject(params) ? params : {};
|
|
3003
|
+
const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
|
|
3004
|
+
const refuseControl = (code, message) => {
|
|
3005
|
+
auditSafely({
|
|
3006
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3007
|
+
device: {
|
|
3008
|
+
id: session.id,
|
|
3009
|
+
name: session.name
|
|
3010
|
+
},
|
|
3011
|
+
action: raw.takeOver === true ? "control.take-over" : "control.begin",
|
|
3012
|
+
workspace: clip(raw.workspace),
|
|
3013
|
+
...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
|
|
3014
|
+
ok: false,
|
|
3015
|
+
error: {
|
|
3016
|
+
code,
|
|
3017
|
+
message: clip(message)
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
error(id, code, message);
|
|
3021
|
+
};
|
|
3022
|
+
if (!readDevices().find((entry) => entry.id === session.id)?.capabilities.includes("control")) return refuseControl("forbidden", `This device can only read. On the Mac, run \`stim-server devices grant ${session.id} --control\` to let it control devices.`);
|
|
3023
|
+
const parsed = parseControlBegin(params);
|
|
3024
|
+
if ("code" in parsed) return refuseControl(parsed.code, parsed.message);
|
|
3025
|
+
const resolved = registeredWorkspace(parsed.value.workspace);
|
|
3026
|
+
if ("code" in resolved) return refuseControl(resolved.code, resolved.message);
|
|
3027
|
+
const owner = controller(session);
|
|
3028
|
+
const outcome = await control.begin(owner, {
|
|
3029
|
+
...parsed.value,
|
|
3030
|
+
workspace: resolved.dir
|
|
3031
|
+
}, resolved.dir, () => socket.readyState === socket.OPEN && controllers.get(socket) === owner && readDevices().some((entry) => entry.id === session.id && entry.capabilities.includes("control")));
|
|
3032
|
+
if ("code" in outcome) return refuseControl(outcome.code, outcome.message);
|
|
3033
|
+
send(socket, {
|
|
3034
|
+
id,
|
|
3035
|
+
result: outcome
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
async function input(id, method, params, session) {
|
|
3039
|
+
const owner = controller(session);
|
|
3040
|
+
const parsed = parseInput(method, params, (name) => control.targetOf(owner, name));
|
|
3041
|
+
if ("code" in parsed) return error(id, parsed.code, parsed.message);
|
|
3042
|
+
if (!take(inputs, 1, controlLimits.inputPerSecond, controlLimits.inputPerSecond)) return error(id, "limit-exceeded", `A connection can send ${controlLimits.inputPerSecond} inputs a second.`);
|
|
3043
|
+
const { command: sent } = parsed.value;
|
|
3044
|
+
if (sent.input === "text" && !take(characters, sent.text.length, controlLimits.textCharsPerSecond, 256)) return error(id, "limit-exceeded", `A connection can type ${controlLimits.textCharsPerSecond} characters a second. Send the rest shortly.`);
|
|
3045
|
+
const { shapeChangesPerSecond } = controlLimits;
|
|
3046
|
+
if ((sent.input === "rotate" || sent.input === "posture") && !take(shapeChanges, 1, shapeChangesPerSecond, shapeChangesPerSecond)) return error(id, "limit-exceeded", `A connection can rotate or fold ${shapeChangesPerSecond} times a second.`);
|
|
3047
|
+
const refused = await control.input(owner, parsed.value.session, parsed.value.command);
|
|
3048
|
+
if (refused) return error(id, refused.code, refused.message);
|
|
3049
|
+
send(socket, {
|
|
3050
|
+
id,
|
|
3051
|
+
result: {}
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
1383
3054
|
function workspaceDir(id, workspace, required) {
|
|
1384
3055
|
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.`);
|
|
1398
|
-
return null;
|
|
1399
|
-
}
|
|
1400
|
-
if (!existsSync(workspace)) {
|
|
1401
|
-
error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
|
|
3056
|
+
const resolved = registeredWorkspace(workspace);
|
|
3057
|
+
if ("code" in resolved) {
|
|
3058
|
+
error(id, resolved.code, resolved.message);
|
|
1402
3059
|
return null;
|
|
1403
3060
|
}
|
|
1404
|
-
return
|
|
3061
|
+
return resolved.dir;
|
|
1405
3062
|
}
|
|
1406
|
-
function openSubscription(id) {
|
|
3063
|
+
function openSubscription(id, result = {}) {
|
|
1407
3064
|
if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
|
|
1408
3065
|
error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
|
|
1409
3066
|
return null;
|
|
@@ -1411,7 +3068,10 @@ async function startServer(options) {
|
|
|
1411
3068
|
const subscription = `s${nextSubscription++}`;
|
|
1412
3069
|
send(socket, {
|
|
1413
3070
|
id,
|
|
1414
|
-
result: {
|
|
3071
|
+
result: {
|
|
3072
|
+
subscription,
|
|
3073
|
+
...result
|
|
3074
|
+
}
|
|
1415
3075
|
});
|
|
1416
3076
|
return subscription;
|
|
1417
3077
|
}
|
|
@@ -1495,31 +3155,59 @@ async function startServer(options) {
|
|
|
1495
3155
|
});
|
|
1496
3156
|
}
|
|
1497
3157
|
function subscribeFrames(id, params) {
|
|
1498
|
-
const { workspace, platform, slot } = isJsonObject(params) ? params : {};
|
|
3158
|
+
const { workspace, platform, slot, fps, maxEdge, video } = isJsonObject(params) ? params : {};
|
|
1499
3159
|
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
3160
|
if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
|
|
3161
|
+
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.");
|
|
3162
|
+
const wantsVideo = video?.includes("h264") === true;
|
|
3163
|
+
const offersVideo = wantsVideo && frameHelper() !== null;
|
|
3164
|
+
const maxFps = wantsVideo ? FRAME_FPS.video : FRAME_FPS.max;
|
|
3165
|
+
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}.`);
|
|
3166
|
+
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}.`);
|
|
3167
|
+
const hint = {
|
|
3168
|
+
fps: Math.min(fps ?? FRAME_FPS.default, offersVideo ? FRAME_FPS.video : FRAME_FPS.max),
|
|
3169
|
+
maxEdge: maxEdge ?? FRAME_EDGE.default
|
|
3170
|
+
};
|
|
1501
3171
|
if (!workspaceDir(id, workspace, true)) return;
|
|
1502
|
-
const subscription = openSubscription(id);
|
|
3172
|
+
const subscription = openSubscription(id, offersVideo ? { video: "h264" } : {});
|
|
1503
3173
|
if (!subscription) return;
|
|
1504
3174
|
const frameTarget = {
|
|
1505
3175
|
workspace,
|
|
1506
3176
|
platform,
|
|
1507
3177
|
...slot ? { slot } : {}
|
|
1508
3178
|
};
|
|
3179
|
+
const gate = new VideoGate(DEFAULT_VIDEO_LIMITS.congestedBytes);
|
|
3180
|
+
let sequence = 0;
|
|
3181
|
+
let streamed = null;
|
|
3182
|
+
let draining = null;
|
|
3183
|
+
const drain = () => {
|
|
3184
|
+
draining = null;
|
|
3185
|
+
if (ended || !streamed) return;
|
|
3186
|
+
if (socket.bufferedAmount <= DEFAULT_VIDEO_LIMITS.congestedBytes) return frames.keyframe(streamed);
|
|
3187
|
+
frames.congested(streamed);
|
|
3188
|
+
draining = setTimeout(drain, FRAME_RETRY_MS);
|
|
3189
|
+
};
|
|
1509
3190
|
let attached = null;
|
|
1510
3191
|
let detach = null;
|
|
1511
3192
|
let pending = null;
|
|
1512
3193
|
let retry = null;
|
|
1513
3194
|
let ended = false;
|
|
3195
|
+
let sentAt = 0;
|
|
1514
3196
|
const flush = () => {
|
|
1515
3197
|
retry = null;
|
|
1516
3198
|
if (!pending || ended) return;
|
|
1517
|
-
|
|
3199
|
+
const wait = sentAt + 1e3 / hint.fps - Date.now();
|
|
3200
|
+
if (wait > 0) {
|
|
3201
|
+
retry = setTimeout(flush, wait);
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
if (socket.bufferedAmount > FRAME_BUFFER_FRAMES * pending.data.length) {
|
|
1518
3205
|
retry = setTimeout(flush, FRAME_RETRY_MS);
|
|
1519
3206
|
return;
|
|
1520
3207
|
}
|
|
1521
3208
|
const frame = pending;
|
|
1522
3209
|
pending = null;
|
|
3210
|
+
sentAt = Date.now();
|
|
1523
3211
|
send(socket, {
|
|
1524
3212
|
event: "frame",
|
|
1525
3213
|
subscription,
|
|
@@ -1531,7 +3219,9 @@ async function startServer(options) {
|
|
|
1531
3219
|
};
|
|
1532
3220
|
const cleanup = () => {
|
|
1533
3221
|
ended = true;
|
|
3222
|
+
keyframes.delete(subscription);
|
|
1534
3223
|
if (retry) clearTimeout(retry);
|
|
3224
|
+
if (draining) clearTimeout(draining);
|
|
1535
3225
|
detach?.();
|
|
1536
3226
|
detach = null;
|
|
1537
3227
|
unsubscribeStatus?.();
|
|
@@ -1561,8 +3251,18 @@ async function startServer(options) {
|
|
|
1561
3251
|
delayed
|
|
1562
3252
|
});
|
|
1563
3253
|
},
|
|
1564
|
-
failed: end
|
|
3254
|
+
failed: end,
|
|
3255
|
+
...offersVideo ? { video: (unit) => {
|
|
3256
|
+
if (ended || socket.readyState !== socket.OPEN) return;
|
|
3257
|
+
const verdict = gate.admit(unit, socket.bufferedAmount);
|
|
3258
|
+
if (verdict === "send") socket.send(videoPacket(subscription, sequence++, unit));
|
|
3259
|
+
else if (verdict === "congested" && !draining) drain();
|
|
3260
|
+
} } : {}
|
|
1565
3261
|
};
|
|
3262
|
+
if (offersVideo) keyframes.set(subscription, () => {
|
|
3263
|
+
gate.reset();
|
|
3264
|
+
if (streamed) frames.keyframe(streamed);
|
|
3265
|
+
});
|
|
1566
3266
|
let unsubscribeStatus = null;
|
|
1567
3267
|
unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
|
|
1568
3268
|
item: (payload) => {
|
|
@@ -1571,34 +3271,62 @@ async function startServer(options) {
|
|
|
1571
3271
|
if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
|
|
1572
3272
|
if (deviceKey(resolved) === attached) return;
|
|
1573
3273
|
detach?.();
|
|
3274
|
+
gate.reset();
|
|
3275
|
+
streamed = resolved;
|
|
1574
3276
|
attached = deviceKey(resolved);
|
|
1575
|
-
detach = frames.subscribe(resolved, listener);
|
|
3277
|
+
detach = frames.subscribe(resolved, listener, hint);
|
|
1576
3278
|
},
|
|
1577
3279
|
failed: (message) => queueMicrotask(() => end(message))
|
|
1578
3280
|
});
|
|
1579
3281
|
subscriptions.set(subscription, cleanup);
|
|
1580
3282
|
}
|
|
1581
|
-
function command(id, args, cwd, result) {
|
|
1582
|
-
if (commands.size >= MAX_COMMANDS)
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
3283
|
+
function command(id, args, cwd, result, limits = commandLimits, turn = null) {
|
|
3284
|
+
if (commands.size >= MAX_COMMANDS) {
|
|
3285
|
+
error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
|
|
3286
|
+
return null;
|
|
3287
|
+
}
|
|
3288
|
+
let run = null;
|
|
3289
|
+
let dropped = false;
|
|
3290
|
+
let release;
|
|
3291
|
+
const released = new Promise((resolve) => {
|
|
3292
|
+
release = resolve;
|
|
3293
|
+
});
|
|
3294
|
+
const cancel = async () => {
|
|
3295
|
+
dropped = true;
|
|
3296
|
+
if (!run) return void (turn ?? Promise.resolve()).then(release);
|
|
3297
|
+
await run.cancel();
|
|
3298
|
+
release();
|
|
3299
|
+
};
|
|
3300
|
+
const start = async () => {
|
|
3301
|
+
if (dropped) return;
|
|
3302
|
+
run = runStim(options.stimCli, options.env, args, cwd, limits);
|
|
1587
3303
|
const outcome = await run.outcome;
|
|
1588
|
-
commands.delete(
|
|
1589
|
-
running.delete(
|
|
1590
|
-
if (!outcome.ok) return error(id, "stim-failed", outcome.message);
|
|
1591
|
-
let value;
|
|
3304
|
+
commands.delete(cancel);
|
|
3305
|
+
running.delete(cancel);
|
|
1592
3306
|
try {
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
3307
|
+
if (!outcome.ok) {
|
|
3308
|
+
const printed = actionOutcome(outcome);
|
|
3309
|
+
return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
|
|
3310
|
+
}
|
|
3311
|
+
let value;
|
|
3312
|
+
try {
|
|
3313
|
+
value = result(outcome.stdout);
|
|
3314
|
+
} catch {
|
|
3315
|
+
return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
|
|
3316
|
+
}
|
|
3317
|
+
send(socket, {
|
|
3318
|
+
id,
|
|
3319
|
+
result: value
|
|
3320
|
+
});
|
|
3321
|
+
} finally {
|
|
3322
|
+
release();
|
|
1596
3323
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
3324
|
+
};
|
|
3325
|
+
commands.add(cancel);
|
|
3326
|
+
running.add(cancel);
|
|
3327
|
+
if (turn) turn.then(start);
|
|
3328
|
+
else start();
|
|
3329
|
+
return released;
|
|
1602
3330
|
}
|
|
1603
3331
|
function queryLogs(id, params) {
|
|
1604
3332
|
const parsed = parseLogFilter(params);
|
|
@@ -1617,6 +3345,123 @@ async function startServer(options) {
|
|
|
1617
3345
|
return value;
|
|
1618
3346
|
});
|
|
1619
3347
|
}
|
|
3348
|
+
function runAction(id, params, session) {
|
|
3349
|
+
const startedAt = Date.now();
|
|
3350
|
+
const raw = isJsonObject(params) ? params : {};
|
|
3351
|
+
const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
|
|
3352
|
+
const record = {
|
|
3353
|
+
at: new Date(startedAt).toISOString(),
|
|
3354
|
+
device: {
|
|
3355
|
+
id: session.id,
|
|
3356
|
+
name: session.name
|
|
3357
|
+
},
|
|
3358
|
+
action: clip(raw.action),
|
|
3359
|
+
workspace: clip(raw.workspace),
|
|
3360
|
+
...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
|
|
3361
|
+
ok: false
|
|
3362
|
+
};
|
|
3363
|
+
const audit = (outcome) => {
|
|
3364
|
+
const logged = outcome.error ? {
|
|
3365
|
+
...outcome.error,
|
|
3366
|
+
message: clip(outcome.error.message)
|
|
3367
|
+
} : void 0;
|
|
3368
|
+
try {
|
|
3369
|
+
appendAudit({
|
|
3370
|
+
...record,
|
|
3371
|
+
...outcome,
|
|
3372
|
+
...logged ? { error: logged } : {}
|
|
3373
|
+
});
|
|
3374
|
+
} catch (cause) {
|
|
3375
|
+
console.error(`stim-server: could not append to the action log: ${cause.message}`);
|
|
3376
|
+
}
|
|
3377
|
+
};
|
|
3378
|
+
const refuseAction = (code, message) => {
|
|
3379
|
+
audit({
|
|
3380
|
+
ok: false,
|
|
3381
|
+
error: {
|
|
3382
|
+
code,
|
|
3383
|
+
message
|
|
3384
|
+
}
|
|
3385
|
+
});
|
|
3386
|
+
error(id, code, message);
|
|
3387
|
+
};
|
|
3388
|
+
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.`);
|
|
3389
|
+
const parsed = parseAction(params);
|
|
3390
|
+
if ("code" in parsed) return refuseAction(parsed.code, parsed.message);
|
|
3391
|
+
const { action } = parsed;
|
|
3392
|
+
const resolved = registeredWorkspace(action.workspace);
|
|
3393
|
+
if ("code" in resolved) return refuseAction(resolved.code, resolved.message);
|
|
3394
|
+
if (busyWorkspaces.has(resolved.dir)) return refuseAction("action-busy", `An action is already running in ${resolved.dir}. Try again when it finishes.`);
|
|
3395
|
+
let run;
|
|
3396
|
+
try {
|
|
3397
|
+
run = runStim(options.stimCli, options.env, actionArgs(action), resolved.dir, actionLimits);
|
|
3398
|
+
} catch (cause) {
|
|
3399
|
+
return refuseAction("action-failed", `stim ${action.action} could not start (${cause.message}).`);
|
|
3400
|
+
}
|
|
3401
|
+
busyWorkspaces.add(resolved.dir);
|
|
3402
|
+
let finished = false;
|
|
3403
|
+
const finish = (outcome) => {
|
|
3404
|
+
if (finished) return;
|
|
3405
|
+
finished = true;
|
|
3406
|
+
running.delete(cancel);
|
|
3407
|
+
busyWorkspaces.delete(resolved.dir);
|
|
3408
|
+
audit({
|
|
3409
|
+
ok: outcome.ok,
|
|
3410
|
+
...outcome.ok ? {} : { error: outcome.error },
|
|
3411
|
+
durationMs: Date.now() - startedAt
|
|
3412
|
+
});
|
|
3413
|
+
if (outcome.ok) send(socket, {
|
|
3414
|
+
id,
|
|
3415
|
+
result: {
|
|
3416
|
+
action: action.action,
|
|
3417
|
+
workspace: resolved.dir,
|
|
3418
|
+
output: outcome.output
|
|
3419
|
+
}
|
|
3420
|
+
});
|
|
3421
|
+
else send(socket, {
|
|
3422
|
+
id,
|
|
3423
|
+
error: outcome.error
|
|
3424
|
+
});
|
|
3425
|
+
};
|
|
3426
|
+
const cancel = async () => {
|
|
3427
|
+
finish({
|
|
3428
|
+
ok: false,
|
|
3429
|
+
error: {
|
|
3430
|
+
code: "action-failed",
|
|
3431
|
+
message: "stim-server stopped before the action finished."
|
|
3432
|
+
}
|
|
3433
|
+
});
|
|
3434
|
+
await run.cancel();
|
|
3435
|
+
};
|
|
3436
|
+
running.add(cancel);
|
|
3437
|
+
run.outcome.then((outcome) => finish(actionOutcome(outcome)));
|
|
3438
|
+
}
|
|
3439
|
+
function planBuild(id, params) {
|
|
3440
|
+
if (!isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
|
|
3441
|
+
const { platform, slot } = params;
|
|
3442
|
+
if (platform !== "ios" && platform !== "android") return error(id, "bad-request", "params.platform must be ios or android.");
|
|
3443
|
+
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.");
|
|
3444
|
+
const cwd = workspaceDir(id, params.workspace, true);
|
|
3445
|
+
if (!cwd) return;
|
|
3446
|
+
const finished = command(id, [
|
|
3447
|
+
platform,
|
|
3448
|
+
"--plan",
|
|
3449
|
+
"--json",
|
|
3450
|
+
...slot === void 0 ? [] : [`--slot=${slot}`]
|
|
3451
|
+
], cwd, (stdout) => {
|
|
3452
|
+
const value = JSON.parse(stdout);
|
|
3453
|
+
if (!isJsonObject(value) || value.platform !== platform) throw new Error("not a plan");
|
|
3454
|
+
return value;
|
|
3455
|
+
}, {
|
|
3456
|
+
...commandLimits,
|
|
3457
|
+
timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
|
|
3458
|
+
}, planQueues.get(cwd) ?? null);
|
|
3459
|
+
if (!finished) return;
|
|
3460
|
+
planQueues.set(cwd, finished);
|
|
3461
|
+
finished.finally(() => {
|
|
3462
|
+
if (planQueues.get(cwd) === finished) planQueues.delete(cwd);
|
|
3463
|
+
});
|
|
3464
|
+
}
|
|
1620
3465
|
async function handle(raw) {
|
|
1621
3466
|
if (socket.readyState !== socket.OPEN) return;
|
|
1622
3467
|
let message;
|
|
@@ -1642,7 +3487,55 @@ async function startServer(options) {
|
|
|
1642
3487
|
if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
|
|
1643
3488
|
if (message.method === "logs.query") return queryLogs(id, message.params);
|
|
1644
3489
|
if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
|
|
3490
|
+
if (message.method === "frames.keyframe") {
|
|
3491
|
+
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
3492
|
+
const keyframe = typeof name === "string" ? keyframes.get(name) : void 0;
|
|
3493
|
+
if (!keyframe) return send(socket, {
|
|
3494
|
+
id,
|
|
3495
|
+
error: {
|
|
3496
|
+
code: "unknown-subscription",
|
|
3497
|
+
message: `No video subscription ${String(name)}.`
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
keyframe();
|
|
3501
|
+
return send(socket, {
|
|
3502
|
+
id,
|
|
3503
|
+
result: {}
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
if (message.method === "build.plan") return planBuild(id, message.params);
|
|
3507
|
+
if (message.method === "machine.get") return send(socket, {
|
|
3508
|
+
id,
|
|
3509
|
+
result: await readMachineUsage()
|
|
3510
|
+
});
|
|
3511
|
+
if (message.method === "machine.history") {
|
|
3512
|
+
const params = message.params ?? {};
|
|
3513
|
+
const sinceMs = isJsonObject(params) ? params.sinceMs : void 0;
|
|
3514
|
+
if (!isJsonObject(params) || sinceMs !== void 0 && typeof sinceMs !== "number") return error(id, "bad-request", "machine.history takes an optional numeric sinceMs.");
|
|
3515
|
+
return send(socket, {
|
|
3516
|
+
id,
|
|
3517
|
+
result: sampler.history(sinceMs)
|
|
3518
|
+
});
|
|
3519
|
+
}
|
|
1645
3520
|
if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
|
|
3521
|
+
if (message.method === "action") return runAction(id, message.params, device);
|
|
3522
|
+
if (message.method === "control.begin") {
|
|
3523
|
+
beginControl(id, message.params, device);
|
|
3524
|
+
return;
|
|
3525
|
+
}
|
|
3526
|
+
if (message.method === "control.end") {
|
|
3527
|
+
const name = isJsonObject(message.params) ? message.params.session : void 0;
|
|
3528
|
+
if (typeof name !== "string" || !control.endById(controller(device), name)) return error(id, "unknown-session", `No control session ${String(name)} on this connection.`);
|
|
3529
|
+
return send(socket, {
|
|
3530
|
+
id,
|
|
3531
|
+
result: {}
|
|
3532
|
+
});
|
|
3533
|
+
}
|
|
3534
|
+
if (message.method === "input.touch" || message.method === "input.text" || message.method === "input.button" || message.method === "input.rotate" || message.method === "input.posture") {
|
|
3535
|
+
const method = message.method;
|
|
3536
|
+
input(id, method, message.params, device);
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
1646
3539
|
if (message.method === "unsubscribe") {
|
|
1647
3540
|
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
1648
3541
|
const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
|
|
@@ -1675,6 +3568,10 @@ async function startServer(options) {
|
|
|
1675
3568
|
socket.on("close", () => {
|
|
1676
3569
|
clearTimeout(timer);
|
|
1677
3570
|
sessions.delete(socket);
|
|
3571
|
+
if (sessions.size === 0) sampler.stop();
|
|
3572
|
+
const owner = controllers.get(socket);
|
|
3573
|
+
if (owner) control.endFor(owner, null, "The client disconnected.");
|
|
3574
|
+
controllers.delete(socket);
|
|
1678
3575
|
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
1679
3576
|
subscriptions.clear();
|
|
1680
3577
|
for (const cancel of commands) {
|
|
@@ -1713,8 +3610,11 @@ async function startServer(options) {
|
|
|
1713
3610
|
const addresses = [];
|
|
1714
3611
|
const close = async () => {
|
|
1715
3612
|
watcher.close();
|
|
3613
|
+
helperAbort.abort();
|
|
3614
|
+
sampler.stop();
|
|
1716
3615
|
if (revocationCheck) clearTimeout(revocationCheck);
|
|
1717
3616
|
for (const client of wss.clients) client.terminate();
|
|
3617
|
+
await control.close();
|
|
1718
3618
|
await Promise.all([
|
|
1719
3619
|
frames.close(),
|
|
1720
3620
|
feeds.close(),
|
|
@@ -1760,11 +3660,15 @@ async function startServer(options) {
|
|
|
1760
3660
|
const DEFAULT_PORT = 7787;
|
|
1761
3661
|
const USAGE = `Usage:
|
|
1762
3662
|
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
|
|
3663
|
+
stim-server pair [--port <n>] [--control] [--json]
|
|
3664
|
+
print a single-use pairing payload for the QR code;
|
|
3665
|
+
--control lets the paired device run actions
|
|
1765
3666
|
stim-server devices [list] [--json]
|
|
1766
3667
|
list paired devices
|
|
1767
|
-
stim-server devices
|
|
3668
|
+
stim-server devices grant <id> --control|--read
|
|
3669
|
+
let a paired device run actions, or only read
|
|
3670
|
+
stim-server devices revoke <id> revoke a paired device
|
|
3671
|
+
stim-server log [--json] list the actions paired devices ran`;
|
|
1768
3672
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1769
3673
|
function fail(message) {
|
|
1770
3674
|
console.error(`stim-server: ${message}`);
|
|
@@ -1829,7 +3733,7 @@ async function serve(port) {
|
|
|
1829
3733
|
process.on("SIGTERM", shutdown);
|
|
1830
3734
|
process.on("SIGHUP", shutdown);
|
|
1831
3735
|
}
|
|
1832
|
-
async function pair(port, json) {
|
|
3736
|
+
async function pair(port, json, control) {
|
|
1833
3737
|
const binary = findTailscale(process.env);
|
|
1834
3738
|
const tailscale = tailscaleStatus(binary, process.env);
|
|
1835
3739
|
let endpoint = `ws://127.0.0.1:${port}`;
|
|
@@ -1841,7 +3745,7 @@ async function pair(port, json) {
|
|
|
1841
3745
|
endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
|
|
1842
3746
|
note = routeNote(route, port);
|
|
1843
3747
|
}
|
|
1844
|
-
const { token, expiresAt } = createPairingToken();
|
|
3748
|
+
const { token, expiresAt } = createPairingToken(Date.now(), capabilitiesFor(control));
|
|
1845
3749
|
const payload = {
|
|
1846
3750
|
v: 1,
|
|
1847
3751
|
name: macName(tailscale),
|
|
@@ -1853,12 +3757,13 @@ async function pair(port, json) {
|
|
|
1853
3757
|
expiresAt
|
|
1854
3758
|
}));
|
|
1855
3759
|
console.log(JSON.stringify(payload));
|
|
1856
|
-
console.error(`The pairing token is single use and expires at ${expiresAt}.`);
|
|
3760
|
+
console.error(`The pairing token is single use and expires at ${expiresAt}. The device it pairs can ${control ? "run actions" : "only read"}.`);
|
|
1857
3761
|
if (note) console.error(note);
|
|
1858
3762
|
}
|
|
1859
3763
|
function describe(device) {
|
|
1860
3764
|
const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
|
|
1861
|
-
|
|
3765
|
+
const scope = device.capabilities.includes("control") ? "control" : "read";
|
|
3766
|
+
return `${device.id} ${device.name} ${scope} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
|
|
1862
3767
|
}
|
|
1863
3768
|
async function main() {
|
|
1864
3769
|
const { values, positionals } = parseArgs({
|
|
@@ -1866,6 +3771,8 @@ async function main() {
|
|
|
1866
3771
|
options: {
|
|
1867
3772
|
port: { type: "string" },
|
|
1868
3773
|
json: { type: "boolean" },
|
|
3774
|
+
control: { type: "boolean" },
|
|
3775
|
+
read: { type: "boolean" },
|
|
1869
3776
|
help: {
|
|
1870
3777
|
type: "boolean",
|
|
1871
3778
|
short: "h"
|
|
@@ -1882,7 +3789,27 @@ async function main() {
|
|
|
1882
3789
|
if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
|
|
1883
3790
|
const [command, sub, arg, ...rest] = positionals;
|
|
1884
3791
|
if (command === void 0) return serve(port);
|
|
1885
|
-
|
|
3792
|
+
const grant = command === "devices" && sub === "grant";
|
|
3793
|
+
if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
|
|
3794
|
+
if (values.control && !grant && command !== "pair") fail(`--control applies only to \`pair\` and \`devices grant\`.\n${USAGE}`);
|
|
3795
|
+
if (command === "pair" && sub === void 0) return pair(port, values.json === true, values.control === true);
|
|
3796
|
+
if (grant && arg !== void 0 && rest.length === 0) {
|
|
3797
|
+
if (values.control === values.read) fail("devices grant takes exactly one of --control or --read.");
|
|
3798
|
+
if (!grantDevice(arg, capabilitiesFor(values.control === true))) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
|
|
3799
|
+
console.log(`${arg} can now ${values.control ? "run actions" : "only read"}.`);
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
if (command === "log" && sub === void 0) {
|
|
3803
|
+
const records = readAudit();
|
|
3804
|
+
if (values.json) return void console.log(JSON.stringify({ actions: records }));
|
|
3805
|
+
if (!records.length) console.log("No actions.");
|
|
3806
|
+
for (const record of records) {
|
|
3807
|
+
const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
|
|
3808
|
+
const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
|
|
3809
|
+
console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
|
|
3810
|
+
}
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
1886
3813
|
if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
|
|
1887
3814
|
const devices = readDevices();
|
|
1888
3815
|
if (values.json) {
|