@stim-cli/server 1.11.0 → 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 +82 -5
- package/dist/{protocol-CmlG7X7_.mjs → protocol-BElQc2vm.mjs} +159 -5
- package/dist/protocol.d.mts +109 -4
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +399 -2
- package/dist/stim-frames/EmulatorPosture.swift +73 -0
- package/dist/stim-frames/EmulatorRotation.swift +59 -0
- package/dist/stim-frames/InputMessages.swift +56 -6
- package/dist/stim-frames/ScreenshotMessages.swift +27 -4
- package/dist/stim-frames/SimulatorHID.swift +256 -21
- package/dist/stim-frames/SimulatorRotation.swift +64 -0
- package/dist/stim-frames/main.swift +261 -27
- package/dist/stim-frames/sim-fold.entitlements +8 -0
- package/dist/stim-frames/sim-fold.m +47 -0
- package/dist/stim-server.mjs +1011 -155
- package/package.json +3 -3
package/dist/stim-server.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
4
|
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, watch, writeFileSync } from "node:fs";
|
|
5
5
|
import { availableParallelism, cpus, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
|
|
@@ -12,9 +12,9 @@ import { configDir, withDirLock } from "@stim-cli/core";
|
|
|
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
|
-
import { fileURLToPath } from "node:url";
|
|
17
15
|
import { connect } from "node:http2";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
18
|
//#region src/environment.ts
|
|
19
19
|
function parseEnvironment(output) {
|
|
20
20
|
const environment = {};
|
|
@@ -447,95 +447,6 @@ function runStim(stimCli, env, args, cwd, limits) {
|
|
|
447
447
|
};
|
|
448
448
|
}
|
|
449
449
|
//#endregion
|
|
450
|
-
//#region src/feed.ts
|
|
451
|
-
var Feed = class {
|
|
452
|
-
listeners = /* @__PURE__ */ new Set();
|
|
453
|
-
kept = [];
|
|
454
|
-
child;
|
|
455
|
-
ended;
|
|
456
|
-
constructor(spec, stimCli, env, ended) {
|
|
457
|
-
this.ended = ended;
|
|
458
|
-
const child = spawn(process.execPath, [stimCli, ...spec.args], {
|
|
459
|
-
cwd: spec.cwd,
|
|
460
|
-
env,
|
|
461
|
-
stdio: [
|
|
462
|
-
"ignore",
|
|
463
|
-
"pipe",
|
|
464
|
-
"pipe"
|
|
465
|
-
]
|
|
466
|
-
});
|
|
467
|
-
this.child = child;
|
|
468
|
-
let stderr = "";
|
|
469
|
-
child.stderr?.setEncoding("utf8");
|
|
470
|
-
child.stderr?.on("data", (chunk) => {
|
|
471
|
-
stderr = (stderr + chunk).slice(-2e3);
|
|
472
|
-
});
|
|
473
|
-
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
474
|
-
if (this.child !== child) return;
|
|
475
|
-
let value;
|
|
476
|
-
try {
|
|
477
|
-
value = JSON.parse(line);
|
|
478
|
-
} catch {
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
if (!isJsonObject(value)) return;
|
|
482
|
-
this.kept.push(value);
|
|
483
|
-
if (this.kept.length > spec.keep) this.kept.shift();
|
|
484
|
-
for (const listener of this.listeners) listener.item(value);
|
|
485
|
-
});
|
|
486
|
-
const exited = (reason) => {
|
|
487
|
-
if (this.child !== child) return;
|
|
488
|
-
this.child = null;
|
|
489
|
-
this.ended();
|
|
490
|
-
const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
|
|
491
|
-
const listeners = [...this.listeners];
|
|
492
|
-
this.listeners.clear();
|
|
493
|
-
for (const listener of listeners) listener.failed(message);
|
|
494
|
-
};
|
|
495
|
-
child.on("error", (error) => exited(`could not start (${error.message})`));
|
|
496
|
-
child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
|
|
497
|
-
}
|
|
498
|
-
add(listener) {
|
|
499
|
-
this.listeners.add(listener);
|
|
500
|
-
for (const value of this.kept) listener.item(value);
|
|
501
|
-
return () => {
|
|
502
|
-
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
503
|
-
};
|
|
504
|
-
}
|
|
505
|
-
stop() {
|
|
506
|
-
const child = this.child;
|
|
507
|
-
if (!child) return Promise.resolve();
|
|
508
|
-
this.child = null;
|
|
509
|
-
this.listeners.clear();
|
|
510
|
-
this.ended();
|
|
511
|
-
return terminate(child);
|
|
512
|
-
}
|
|
513
|
-
};
|
|
514
|
-
var FeedPool = class {
|
|
515
|
-
feeds = /* @__PURE__ */ new Map();
|
|
516
|
-
stimCli;
|
|
517
|
-
env;
|
|
518
|
-
constructor(stimCli, env) {
|
|
519
|
-
this.stimCli = stimCli;
|
|
520
|
-
this.env = env;
|
|
521
|
-
}
|
|
522
|
-
subscribe(spec, listener) {
|
|
523
|
-
const key = JSON.stringify([spec.cwd, spec.args]);
|
|
524
|
-
let feed = this.feeds.get(key);
|
|
525
|
-
if (!feed) {
|
|
526
|
-
const created = new Feed(spec, this.stimCli, this.env, () => {
|
|
527
|
-
if (this.feeds.get(key) === created) this.feeds.delete(key);
|
|
528
|
-
});
|
|
529
|
-
this.feeds.set(key, created);
|
|
530
|
-
feed = created;
|
|
531
|
-
}
|
|
532
|
-
return feed.add(listener);
|
|
533
|
-
}
|
|
534
|
-
async close() {
|
|
535
|
-
await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
|
|
536
|
-
}
|
|
537
|
-
};
|
|
538
|
-
//#endregion
|
|
539
450
|
//#region src/video.ts
|
|
540
451
|
const FIXED_HEADER_BYTES = 21;
|
|
541
452
|
/** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
|
|
@@ -685,20 +596,9 @@ function run(file, args, env, timeoutMs, signal) {
|
|
|
685
596
|
*/
|
|
686
597
|
async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
687
598
|
if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
|
|
688
|
-
const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted();
|
|
599
|
+
const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
|
|
689
600
|
if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
|
|
690
|
-
|
|
691
|
-
const hash = createHash("sha256").update(version);
|
|
692
|
-
for (const name of sources) hash.update(name).update(readFileSync(join(sourcesDir, name)));
|
|
693
|
-
const dir = join(serverDir(), "helpers");
|
|
694
|
-
const helper = join(dir, `stim-frames-${hash.digest("hex").slice(0, 16)}`);
|
|
695
|
-
if (existsSync(helper)) return helper;
|
|
696
|
-
mkdirSync(dir, {
|
|
697
|
-
recursive: true,
|
|
698
|
-
mode: 448
|
|
699
|
-
});
|
|
700
|
-
const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
701
|
-
try {
|
|
601
|
+
return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
|
|
702
602
|
await run("xcrun", [
|
|
703
603
|
"swiftc",
|
|
704
604
|
"-O",
|
|
@@ -708,8 +608,61 @@ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
|
708
608
|
"StimFrames",
|
|
709
609
|
"-o",
|
|
710
610
|
output,
|
|
711
|
-
...sources
|
|
611
|
+
...sources
|
|
712
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);
|
|
713
666
|
renameSync(output, helper);
|
|
714
667
|
} finally {
|
|
715
668
|
rmSync(output, { force: true });
|
|
@@ -728,6 +681,7 @@ var HelperSource = class {
|
|
|
728
681
|
config = "";
|
|
729
682
|
stopped = false;
|
|
730
683
|
notice = null;
|
|
684
|
+
keyboard = null;
|
|
731
685
|
stderr = "";
|
|
732
686
|
ended;
|
|
733
687
|
bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
|
|
@@ -756,6 +710,7 @@ var HelperSource = class {
|
|
|
756
710
|
this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
|
|
757
711
|
});
|
|
758
712
|
}
|
|
713
|
+
/** A null `hint` keeps the helper running for input without asking for frames. */
|
|
759
714
|
add(listener, hint) {
|
|
760
715
|
this.listeners.set(listener, hint);
|
|
761
716
|
this.configure();
|
|
@@ -793,12 +748,19 @@ var HelperSource = class {
|
|
|
793
748
|
congested() {
|
|
794
749
|
if (this.bitrate.congested(Date.now()) !== null) this.configure();
|
|
795
750
|
}
|
|
751
|
+
send(command) {
|
|
752
|
+
if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
753
|
+
}
|
|
796
754
|
configure() {
|
|
797
|
-
const
|
|
798
|
-
|
|
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]);
|
|
799
761
|
const config = JSON.stringify({
|
|
800
|
-
fps: Math.max(...hints.map((hint) => hint.fps)),
|
|
801
|
-
maxEdge: Math.max(...hints.map((hint) => hint.maxEdge)),
|
|
762
|
+
fps: Math.max(0, ...hints.map((hint) => hint.fps)),
|
|
763
|
+
maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
|
|
802
764
|
jpeg: jpegFps.length > 0,
|
|
803
765
|
...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
|
|
804
766
|
video: jpegFps.length < hints.length,
|
|
@@ -858,8 +820,13 @@ var HelperSource = class {
|
|
|
858
820
|
}
|
|
859
821
|
readNotice(text) {
|
|
860
822
|
try {
|
|
861
|
-
const
|
|
823
|
+
const notice = JSON.parse(text);
|
|
824
|
+
const error = notice?.error;
|
|
862
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}`);
|
|
863
830
|
} catch {}
|
|
864
831
|
}
|
|
865
832
|
};
|
|
@@ -1032,11 +999,14 @@ function simulatorCapturer(device, env, limits, litPanels) {
|
|
|
1032
999
|
const lit = litPanels.get(device.udid) ?? "primary";
|
|
1033
1000
|
const jpeg = await screenshot([`--display=${lit}`]);
|
|
1034
1001
|
open();
|
|
1035
|
-
if (!await isBlack(jpeg))
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
+
}
|
|
1040
1010
|
const other = lit === "primary" ? "primary-1" : "primary";
|
|
1041
1011
|
open();
|
|
1042
1012
|
const otherJpeg = await screenshot([`--display=${other}`]);
|
|
@@ -1212,16 +1182,13 @@ function hasHinge(body) {
|
|
|
1212
1182
|
}
|
|
1213
1183
|
return false;
|
|
1214
1184
|
}
|
|
1215
|
-
function
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
|
|
1223
|
-
session.on("error", () => {});
|
|
1224
|
-
}
|
|
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) => {
|
|
1225
1192
|
const request = session.request({
|
|
1226
1193
|
":method": "POST",
|
|
1227
1194
|
":path": `/android.emulation.control.EmulatorController/${method}`,
|
|
@@ -1237,7 +1204,7 @@ function emulatorCapturer(serial, env, limits) {
|
|
|
1237
1204
|
if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
|
|
1238
1205
|
if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
|
|
1239
1206
|
};
|
|
1240
|
-
request.setTimeout(
|
|
1207
|
+
request.setTimeout(timeoutMs, () => {
|
|
1241
1208
|
timedOut = true;
|
|
1242
1209
|
request.close();
|
|
1243
1210
|
});
|
|
@@ -1255,11 +1222,43 @@ function emulatorCapturer(serial, env, limits) {
|
|
|
1255
1222
|
});
|
|
1256
1223
|
request.end(requestBody);
|
|
1257
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
|
+
};
|
|
1258
1257
|
return {
|
|
1259
1258
|
capture: async () => {
|
|
1260
1259
|
const endpoint = emulatorEndpoint(env, serial);
|
|
1261
1260
|
if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
|
|
1262
|
-
hinged ??= await call(endpoint, "getPhysicalModel",
|
|
1261
|
+
hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
|
|
1263
1262
|
const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
|
|
1264
1263
|
24,
|
|
1265
1264
|
...varint(MAX_EDGE),
|
|
@@ -1448,6 +1447,15 @@ var FramePool = class {
|
|
|
1448
1447
|
detach();
|
|
1449
1448
|
};
|
|
1450
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
|
+
}
|
|
1451
1459
|
/** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
|
|
1452
1460
|
keyframe(device) {
|
|
1453
1461
|
this.helperSource(device)?.keyframe();
|
|
@@ -1460,6 +1468,24 @@ var FramePool = class {
|
|
|
1460
1468
|
const source = this.sources.get(`helper:${deviceKey(device)}`);
|
|
1461
1469
|
return source instanceof HelperSource ? source : null;
|
|
1462
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
|
+
}
|
|
1463
1489
|
stream(helper, device) {
|
|
1464
1490
|
const key = `helper:${deviceKey(device)}`;
|
|
1465
1491
|
const existing = this.sources.get(key);
|
|
@@ -1485,14 +1511,661 @@ var FramePool = class {
|
|
|
1485
1511
|
}
|
|
1486
1512
|
};
|
|
1487
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);
|
|
2134
|
+
}
|
|
2135
|
+
};
|
|
2136
|
+
var FeedPool = class {
|
|
2137
|
+
feeds = /* @__PURE__ */ new Map();
|
|
2138
|
+
stimCli;
|
|
2139
|
+
env;
|
|
2140
|
+
constructor(stimCli, env) {
|
|
2141
|
+
this.stimCli = stimCli;
|
|
2142
|
+
this.env = env;
|
|
2143
|
+
}
|
|
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);
|
|
2150
|
+
});
|
|
2151
|
+
this.feeds.set(key, created);
|
|
2152
|
+
feed = created;
|
|
2153
|
+
}
|
|
2154
|
+
return feed.add(listener);
|
|
2155
|
+
}
|
|
2156
|
+
async close() {
|
|
2157
|
+
await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
|
|
2158
|
+
}
|
|
2159
|
+
};
|
|
2160
|
+
//#endregion
|
|
1488
2161
|
//#region src/logs.ts
|
|
1489
2162
|
function parseLogFilter(params) {
|
|
1490
2163
|
if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
|
|
1491
2164
|
const { sources, level, slot, grep, errors, tail } = params;
|
|
1492
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(", ")}.` };
|
|
1493
2166
|
if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
|
|
1494
|
-
if (slot !== void 0 && (typeof slot !== "string" || slot
|
|
1495
|
-
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." };
|
|
1496
2169
|
if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
|
|
1497
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}.` };
|
|
1498
2171
|
return { filter: {
|
|
@@ -1994,6 +2667,18 @@ const LOG_LIMITS = {
|
|
|
1994
2667
|
const FRAME_BUFFER_FRAMES = 2;
|
|
1995
2668
|
const FRAME_RETRY_MS = 50;
|
|
1996
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
|
+
};
|
|
1997
2682
|
const STATUS_FEED = {
|
|
1998
2683
|
args: [
|
|
1999
2684
|
"status",
|
|
@@ -2010,7 +2695,6 @@ const COMMAND_LIMITS = {
|
|
|
2010
2695
|
maxOutputBytes: 33554432
|
|
2011
2696
|
};
|
|
2012
2697
|
const PLAN_TIMEOUT_MS = 15e4;
|
|
2013
|
-
const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
2014
2698
|
const AUDIT_FIELD_CHARS = 256;
|
|
2015
2699
|
const ACTION_LIMITS = {
|
|
2016
2700
|
timeoutMs: 12e4,
|
|
@@ -2068,6 +2752,21 @@ var FailureLimiter = class {
|
|
|
2068
2752
|
this.failures.set(key, [...this.recent(key, now), now]);
|
|
2069
2753
|
}
|
|
2070
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
|
+
}
|
|
2071
2770
|
function send(socket, message) {
|
|
2072
2771
|
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
2073
2772
|
}
|
|
@@ -2130,6 +2829,15 @@ async function startServer(options) {
|
|
|
2130
2829
|
};
|
|
2131
2830
|
if (options.frameHelper === void 0) buildHelper();
|
|
2132
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
|
+
};
|
|
2133
2841
|
const running = /* @__PURE__ */ new Set();
|
|
2134
2842
|
const logLimits = {
|
|
2135
2843
|
...LOG_LIMITS,
|
|
@@ -2144,8 +2852,27 @@ async function startServer(options) {
|
|
|
2144
2852
|
...options.actionLimits
|
|
2145
2853
|
};
|
|
2146
2854
|
const busyWorkspaces = /* @__PURE__ */ new Set();
|
|
2855
|
+
const planQueues = /* @__PURE__ */ new Map();
|
|
2147
2856
|
const sessions = /* @__PURE__ */ new Map();
|
|
2148
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
|
+
});
|
|
2149
2876
|
const wss = new WebSocketServer({
|
|
2150
2877
|
noServer: true,
|
|
2151
2878
|
maxPayload: MAX_PAYLOAD
|
|
@@ -2158,8 +2885,12 @@ async function startServer(options) {
|
|
|
2158
2885
|
const watcher = watch(serverDir(), () => {
|
|
2159
2886
|
revocationCheck ??= setTimeout(() => {
|
|
2160
2887
|
revocationCheck = null;
|
|
2161
|
-
const paired = new
|
|
2888
|
+
const paired = new Map(readDevices().map((device) => [device.id, device]));
|
|
2162
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
|
+
}
|
|
2163
2894
|
}, 50);
|
|
2164
2895
|
});
|
|
2165
2896
|
function connection(socket, peer) {
|
|
@@ -2244,6 +2975,82 @@ async function startServer(options) {
|
|
|
2244
2975
|
}
|
|
2245
2976
|
});
|
|
2246
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
|
+
}
|
|
2247
3054
|
function workspaceDir(id, workspace, required) {
|
|
2248
3055
|
if (workspace === void 0 && !required) return homedir();
|
|
2249
3056
|
const resolved = registeredWorkspace(workspace);
|
|
@@ -2473,30 +3280,53 @@ async function startServer(options) {
|
|
|
2473
3280
|
});
|
|
2474
3281
|
subscriptions.set(subscription, cleanup);
|
|
2475
3282
|
}
|
|
2476
|
-
function command(id, args, cwd, result, limits = commandLimits) {
|
|
2477
|
-
if (commands.size >= MAX_COMMANDS)
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
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);
|
|
2482
3303
|
const outcome = await run.outcome;
|
|
2483
|
-
commands.delete(
|
|
2484
|
-
running.delete(
|
|
2485
|
-
if (!outcome.ok) {
|
|
2486
|
-
const printed = actionOutcome(outcome);
|
|
2487
|
-
return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
|
|
2488
|
-
}
|
|
2489
|
-
let value;
|
|
3304
|
+
commands.delete(cancel);
|
|
3305
|
+
running.delete(cancel);
|
|
2490
3306
|
try {
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
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();
|
|
2494
3323
|
}
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
3324
|
+
};
|
|
3325
|
+
commands.add(cancel);
|
|
3326
|
+
running.add(cancel);
|
|
3327
|
+
if (turn) turn.then(start);
|
|
3328
|
+
else start();
|
|
3329
|
+
return released;
|
|
2500
3330
|
}
|
|
2501
3331
|
function queryLogs(id, params) {
|
|
2502
3332
|
const parsed = parseLogFilter(params);
|
|
@@ -2613,7 +3443,7 @@ async function startServer(options) {
|
|
|
2613
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.");
|
|
2614
3444
|
const cwd = workspaceDir(id, params.workspace, true);
|
|
2615
3445
|
if (!cwd) return;
|
|
2616
|
-
command(id, [
|
|
3446
|
+
const finished = command(id, [
|
|
2617
3447
|
platform,
|
|
2618
3448
|
"--plan",
|
|
2619
3449
|
"--json",
|
|
@@ -2625,6 +3455,11 @@ async function startServer(options) {
|
|
|
2625
3455
|
}, {
|
|
2626
3456
|
...commandLimits,
|
|
2627
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);
|
|
2628
3463
|
});
|
|
2629
3464
|
}
|
|
2630
3465
|
async function handle(raw) {
|
|
@@ -2684,6 +3519,23 @@ async function startServer(options) {
|
|
|
2684
3519
|
}
|
|
2685
3520
|
if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
|
|
2686
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
|
+
}
|
|
2687
3539
|
if (message.method === "unsubscribe") {
|
|
2688
3540
|
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
2689
3541
|
const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
|
|
@@ -2717,6 +3569,9 @@ async function startServer(options) {
|
|
|
2717
3569
|
clearTimeout(timer);
|
|
2718
3570
|
sessions.delete(socket);
|
|
2719
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);
|
|
2720
3575
|
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
2721
3576
|
subscriptions.clear();
|
|
2722
3577
|
for (const cancel of commands) {
|
|
@@ -2759,6 +3614,7 @@ async function startServer(options) {
|
|
|
2759
3614
|
sampler.stop();
|
|
2760
3615
|
if (revocationCheck) clearTimeout(revocationCheck);
|
|
2761
3616
|
for (const client of wss.clients) client.terminate();
|
|
3617
|
+
await control.close();
|
|
2762
3618
|
await Promise.all([
|
|
2763
3619
|
frames.close(),
|
|
2764
3620
|
feeds.close(),
|
|
@@ -2948,7 +3804,7 @@ async function main() {
|
|
|
2948
3804
|
if (values.json) return void console.log(JSON.stringify({ actions: records }));
|
|
2949
3805
|
if (!records.length) console.log("No actions.");
|
|
2950
3806
|
for (const record of records) {
|
|
2951
|
-
const outcome = record.ok ?
|
|
3807
|
+
const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
|
|
2952
3808
|
const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
|
|
2953
3809
|
console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
|
|
2954
3810
|
}
|