@stim-cli/server 1.11.0 → 1.13.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 +100 -14
- package/dist/{protocol-CmlG7X7_.mjs → protocol-rC-5KT1O.mjs} +175 -7
- package/dist/protocol.d.mts +120 -8
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +408 -3
- 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 +299 -36
- package/dist/stim-frames/sim-fold.entitlements +8 -0
- package/dist/stim-frames/sim-fold.m +47 -0
- package/dist/stim-server.mjs +1080 -181
- 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 ROTATE_DIRECTIONS, c as INPUT_BUTTONS, f as MAX_LOG_TAIL, g as RELOAD_PLATFORMS, l as LOG_LEVELS, n as CAPABILITIES, o as FRAME_EDGE, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES, v as TOUCH_PHASES } from "./protocol-rC-5KT1O.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 = {};
|
|
@@ -284,9 +284,9 @@ function parseAction(params) {
|
|
|
284
284
|
action,
|
|
285
285
|
workspace
|
|
286
286
|
} };
|
|
287
|
-
if (platform !== void 0 && platform
|
|
287
|
+
if (platform !== void 0 && !RELOAD_PLATFORMS.includes(platform)) return {
|
|
288
288
|
code: "bad-request",
|
|
289
|
-
message:
|
|
289
|
+
message: `platform must be one of ${RELOAD_PLATFORMS.join(", ")}.`
|
|
290
290
|
};
|
|
291
291
|
return { action: {
|
|
292
292
|
action: "reload",
|
|
@@ -397,12 +397,20 @@ function runStim(stimCli, env, args, cwd, limits) {
|
|
|
397
397
|
const chunks = [];
|
|
398
398
|
let size = 0;
|
|
399
399
|
let stderr = "";
|
|
400
|
+
const release = () => {
|
|
401
|
+
child.stdout.destroy();
|
|
402
|
+
child.stderr.destroy();
|
|
403
|
+
};
|
|
400
404
|
const fail = (message) => {
|
|
401
405
|
if (failure) return;
|
|
402
406
|
failure = message;
|
|
407
|
+
release();
|
|
403
408
|
terminate(child);
|
|
404
409
|
};
|
|
405
|
-
const timer = setTimeout(() =>
|
|
410
|
+
const timer = setTimeout(() => {
|
|
411
|
+
if (child.exitCode !== null || child.signalCode !== null) return release();
|
|
412
|
+
fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`);
|
|
413
|
+
}, limits.timeoutMs);
|
|
406
414
|
child.stdout.on("data", (chunk) => {
|
|
407
415
|
size += chunk.length;
|
|
408
416
|
if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
|
|
@@ -447,95 +455,6 @@ function runStim(stimCli, env, args, cwd, limits) {
|
|
|
447
455
|
};
|
|
448
456
|
}
|
|
449
457
|
//#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
458
|
//#region src/video.ts
|
|
540
459
|
const FIXED_HEADER_BYTES = 21;
|
|
541
460
|
/** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
|
|
@@ -543,7 +462,8 @@ function videoPacket(subscription, sequence, unit) {
|
|
|
543
462
|
const id = Buffer.from(subscription, "ascii");
|
|
544
463
|
const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
|
|
545
464
|
header.writeUInt8(1, 0);
|
|
546
|
-
|
|
465
|
+
const posture = unit.posture === "folded" ? 2 : unit.posture === "unfolded" ? 4 : 0;
|
|
466
|
+
header.writeUInt8((unit.keyframe ? 1 : 0) | posture, 1);
|
|
547
467
|
header.writeUInt16BE(header.length, 2);
|
|
548
468
|
header.writeUInt32BE(sequence >>> 0, 4);
|
|
549
469
|
header.writeDoubleBE(unit.capturedAt, 8);
|
|
@@ -685,20 +605,9 @@ function run(file, args, env, timeoutMs, signal) {
|
|
|
685
605
|
*/
|
|
686
606
|
async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
687
607
|
if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
|
|
688
|
-
const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted();
|
|
608
|
+
const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
|
|
689
609
|
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 {
|
|
610
|
+
return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
|
|
702
611
|
await run("xcrun", [
|
|
703
612
|
"swiftc",
|
|
704
613
|
"-O",
|
|
@@ -708,8 +617,61 @@ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
|
708
617
|
"StimFrames",
|
|
709
618
|
"-o",
|
|
710
619
|
output,
|
|
711
|
-
...sources
|
|
620
|
+
...sources
|
|
712
621
|
], env, BUILD_TIMEOUT_MS, signal);
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Compiles `sim-fold`, which folds or unfolds an iPhone Duo from inside the simulator, from the Stim Desktop
|
|
626
|
+
* sources shipped in `dist/stim-frames/`, the way Stim Desktop's bundle script builds it.
|
|
627
|
+
*/
|
|
628
|
+
async function buildFoldHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
629
|
+
if (process.platform !== "darwin") throw new Error("sim-fold runs only on macOS.");
|
|
630
|
+
const source = join(sourcesDir, "sim-fold.m");
|
|
631
|
+
const entitlements = join(sourcesDir, "sim-fold.entitlements");
|
|
632
|
+
const clang = [
|
|
633
|
+
"-sdk",
|
|
634
|
+
"iphonesimulator",
|
|
635
|
+
"clang"
|
|
636
|
+
];
|
|
637
|
+
const version = await run("xcrun", [...clang, "--version"], env, VERSION_TIMEOUT_MS, signal);
|
|
638
|
+
return compiled("sim-fold", [source, entitlements], version, async (output) => {
|
|
639
|
+
await run("xcrun", [
|
|
640
|
+
...clang,
|
|
641
|
+
"-fobjc-arc",
|
|
642
|
+
"-arch",
|
|
643
|
+
"arm64",
|
|
644
|
+
"-arch",
|
|
645
|
+
"x86_64",
|
|
646
|
+
"-mios-simulator-version-min=18.0",
|
|
647
|
+
"-framework",
|
|
648
|
+
"Foundation",
|
|
649
|
+
source,
|
|
650
|
+
"-o",
|
|
651
|
+
output,
|
|
652
|
+
`-Wl,-sectcreate,__TEXT,__entitlements,${entitlements}`
|
|
653
|
+
], env, BUILD_TIMEOUT_MS, signal);
|
|
654
|
+
await run("codesign", [
|
|
655
|
+
"--force",
|
|
656
|
+
"--sign",
|
|
657
|
+
"-",
|
|
658
|
+
output
|
|
659
|
+
], env, VERSION_TIMEOUT_MS, signal);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
async function compiled(name, inputs, version, compile) {
|
|
663
|
+
const hash = createHash("sha256").update(version);
|
|
664
|
+
for (const input of inputs) hash.update(basename(input)).update(readFileSync(input));
|
|
665
|
+
const dir = join(serverDir(), "helpers");
|
|
666
|
+
const helper = join(dir, `${name}-${hash.digest("hex").slice(0, 16)}`);
|
|
667
|
+
if (existsSync(helper)) return helper;
|
|
668
|
+
mkdirSync(dir, {
|
|
669
|
+
recursive: true,
|
|
670
|
+
mode: 448
|
|
671
|
+
});
|
|
672
|
+
const output = `${helper}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
673
|
+
try {
|
|
674
|
+
await compile(output);
|
|
713
675
|
renameSync(output, helper);
|
|
714
676
|
} finally {
|
|
715
677
|
rmSync(output, { force: true });
|
|
@@ -717,9 +679,11 @@ async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
|
717
679
|
return helper;
|
|
718
680
|
}
|
|
719
681
|
/**
|
|
720
|
-
* One `stim-frames` process per device, shared by its subscribers and stopped
|
|
682
|
+
* One `stim-frames` process per device, shared by its subscribers and stopped `lingerMs` after the last of them
|
|
683
|
+
* leaves, unchanged meanwhile, so a subscriber that comes back within that time gets the latest frame at once. It runs
|
|
721
684
|
* at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
|
|
722
|
-
* helper exits when its stdin closes, so it cannot outlive the server.
|
|
685
|
+
* helper exits when its stdin closes, so it cannot outlive the server. `lit` turns the display the helper
|
|
686
|
+
* reports it streams into a posture, which the frames and access units after it carry.
|
|
723
687
|
*/
|
|
724
688
|
var HelperSource = class {
|
|
725
689
|
listeners = /* @__PURE__ */ new Map();
|
|
@@ -728,13 +692,20 @@ var HelperSource = class {
|
|
|
728
692
|
config = "";
|
|
729
693
|
stopped = false;
|
|
730
694
|
notice = null;
|
|
695
|
+
keyboard = null;
|
|
731
696
|
stderr = "";
|
|
732
697
|
ended;
|
|
733
698
|
bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
|
|
734
699
|
keyframeAt = -Infinity;
|
|
735
700
|
keyframeTimer = null;
|
|
736
|
-
|
|
701
|
+
lit;
|
|
702
|
+
posture;
|
|
703
|
+
lingerMs;
|
|
704
|
+
lingerTimer = null;
|
|
705
|
+
constructor(helper, device, env, ended, lingerMs, lit) {
|
|
737
706
|
this.ended = ended;
|
|
707
|
+
this.lingerMs = lingerMs;
|
|
708
|
+
this.lit = lit;
|
|
738
709
|
const id = device.platform === "ios" ? device.udid : device.serial;
|
|
739
710
|
this.child = spawn(helper, [device.platform, id], {
|
|
740
711
|
env,
|
|
@@ -756,21 +727,25 @@ var HelperSource = class {
|
|
|
756
727
|
this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
|
|
757
728
|
});
|
|
758
729
|
}
|
|
730
|
+
/** A null `hint` keeps the helper running for input without asking for frames. */
|
|
759
731
|
add(listener, hint) {
|
|
732
|
+
if (this.lingerTimer) clearTimeout(this.lingerTimer);
|
|
733
|
+
this.lingerTimer = null;
|
|
760
734
|
this.listeners.set(listener, hint);
|
|
761
735
|
this.configure();
|
|
762
736
|
if (listener.video) this.keyframe();
|
|
763
737
|
else if (this.last) listener.frame(this.last);
|
|
764
738
|
return () => {
|
|
765
739
|
if (!this.listeners.delete(listener)) return;
|
|
766
|
-
if (this.listeners.size
|
|
767
|
-
else this.
|
|
740
|
+
if (this.listeners.size > 0) this.configure();
|
|
741
|
+
else this.lingerTimer = setTimeout(() => void this.stop(), this.lingerMs);
|
|
768
742
|
};
|
|
769
743
|
}
|
|
770
744
|
stop() {
|
|
771
745
|
if (this.stopped) return Promise.resolve();
|
|
772
746
|
this.stopped = true;
|
|
773
747
|
if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
|
|
748
|
+
if (this.lingerTimer) clearTimeout(this.lingerTimer);
|
|
774
749
|
this.listeners.clear();
|
|
775
750
|
this.ended();
|
|
776
751
|
this.child.stdin.end();
|
|
@@ -793,12 +768,20 @@ var HelperSource = class {
|
|
|
793
768
|
congested() {
|
|
794
769
|
if (this.bitrate.congested(Date.now()) !== null) this.configure();
|
|
795
770
|
}
|
|
771
|
+
send(command) {
|
|
772
|
+
if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
773
|
+
}
|
|
796
774
|
configure() {
|
|
797
|
-
|
|
798
|
-
const
|
|
775
|
+
if (this.listeners.size === 0) return;
|
|
776
|
+
const watching = [...this.listeners].flatMap(([listener, hint]) => hint ? [{
|
|
777
|
+
listener,
|
|
778
|
+
hint
|
|
779
|
+
}] : []);
|
|
780
|
+
const hints = watching.map(({ hint }) => hint);
|
|
781
|
+
const jpegFps = watching.flatMap(({ listener, hint }) => listener.video ? [] : [hint.fps]);
|
|
799
782
|
const config = JSON.stringify({
|
|
800
|
-
fps: Math.max(...hints.map((hint) => hint.fps)),
|
|
801
|
-
maxEdge: Math.max(...hints.map((hint) => hint.maxEdge)),
|
|
783
|
+
fps: Math.max(0, ...hints.map((hint) => hint.fps)),
|
|
784
|
+
maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
|
|
802
785
|
jpeg: jpegFps.length > 0,
|
|
803
786
|
...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
|
|
804
787
|
video: jpegFps.length < hints.length,
|
|
@@ -840,7 +823,8 @@ var HelperSource = class {
|
|
|
840
823
|
width: body.readUInt16BE(1),
|
|
841
824
|
height: body.readUInt16BE(3),
|
|
842
825
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
843
|
-
data: body.subarray(5).toString("base64")
|
|
826
|
+
data: body.subarray(5).toString("base64"),
|
|
827
|
+
...this.posture ? { posture: this.posture } : {}
|
|
844
828
|
};
|
|
845
829
|
for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
|
|
846
830
|
}
|
|
@@ -851,15 +835,23 @@ var HelperSource = class {
|
|
|
851
835
|
capturedAt: body.readDoubleBE(2),
|
|
852
836
|
width: body.readUInt16BE(10),
|
|
853
837
|
height: body.readUInt16BE(12),
|
|
854
|
-
data: body.subarray(VIDEO_HEADER_BYTES)
|
|
838
|
+
data: body.subarray(VIDEO_HEADER_BYTES),
|
|
839
|
+
...this.posture ? { posture: this.posture } : {}
|
|
855
840
|
};
|
|
856
841
|
if (this.bitrate.tick(Date.now()) !== null) this.configure();
|
|
857
842
|
for (const listener of this.listeners.keys()) listener.video?.(unit);
|
|
858
843
|
}
|
|
859
844
|
readNotice(text) {
|
|
860
845
|
try {
|
|
861
|
-
const
|
|
846
|
+
const notice = JSON.parse(text);
|
|
847
|
+
const error = notice?.error;
|
|
862
848
|
if (typeof error === "string") this.notice = error;
|
|
849
|
+
const keyboard = notice?.keyboard;
|
|
850
|
+
if (keyboard === "yes" || keyboard === "no") this.keyboard = keyboard === "yes";
|
|
851
|
+
const inputError = notice?.inputError;
|
|
852
|
+
if (typeof inputError === "string") console.error(`stim-server: stim-frames: ${inputError}`);
|
|
853
|
+
const display = notice?.display;
|
|
854
|
+
if (this.lit && typeof display === "number") this.posture = this.lit(display);
|
|
863
855
|
} catch {}
|
|
864
856
|
}
|
|
865
857
|
};
|
|
@@ -869,7 +861,8 @@ const DEFAULT_FRAME_LIMITS = {
|
|
|
869
861
|
toolTimeoutMs: 3e4,
|
|
870
862
|
slowCaptureMs: 3e3,
|
|
871
863
|
failureBackoffMs: 3e3,
|
|
872
|
-
maxConsecutiveFailures: 3
|
|
864
|
+
maxConsecutiveFailures: 3,
|
|
865
|
+
lingerMs: 1e4
|
|
873
866
|
};
|
|
874
867
|
const MIN_INTERVAL_MS = 200;
|
|
875
868
|
const MAX_INTERVAL_MS = 1e3;
|
|
@@ -1032,11 +1025,14 @@ function simulatorCapturer(device, env, limits, litPanels) {
|
|
|
1032
1025
|
const lit = litPanels.get(device.udid) ?? "primary";
|
|
1033
1026
|
const jpeg = await screenshot([`--display=${lit}`]);
|
|
1034
1027
|
open();
|
|
1035
|
-
if (!await isBlack(jpeg))
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1028
|
+
if (!await isBlack(jpeg)) {
|
|
1029
|
+
litPanels.set(device.udid, lit);
|
|
1030
|
+
return {
|
|
1031
|
+
raw: jpeg,
|
|
1032
|
+
jpeg: async () => jpeg,
|
|
1033
|
+
posture: POSTURES[lit]
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1040
1036
|
const other = lit === "primary" ? "primary-1" : "primary";
|
|
1041
1037
|
open();
|
|
1042
1038
|
const otherJpeg = await screenshot([`--display=${other}`]);
|
|
@@ -1074,6 +1070,8 @@ function simulatorCapturer(device, env, limits, litPanels) {
|
|
|
1074
1070
|
}
|
|
1075
1071
|
};
|
|
1076
1072
|
}
|
|
1073
|
+
/** The Duo's panels in the order the helper indexes CoreSimulator's displays, by screen ID. */
|
|
1074
|
+
const DUO_PANELS = ["primary", "primary-1"];
|
|
1077
1075
|
const POSTURES = {
|
|
1078
1076
|
primary: "folded",
|
|
1079
1077
|
"primary-1": "unfolded"
|
|
@@ -1212,16 +1210,13 @@ function hasHinge(body) {
|
|
|
1212
1210
|
}
|
|
1213
1211
|
return false;
|
|
1214
1212
|
}
|
|
1215
|
-
function
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
|
|
1223
|
-
session.on("error", () => {});
|
|
1224
|
-
}
|
|
1213
|
+
function grpcSession(endpoint) {
|
|
1214
|
+
const session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
|
|
1215
|
+
session.on("error", () => {});
|
|
1216
|
+
return session;
|
|
1217
|
+
}
|
|
1218
|
+
function grpcCall(session, endpoint, serial, method, requestBody, timeoutMs) {
|
|
1219
|
+
return new Promise((resolve, reject) => {
|
|
1225
1220
|
const request = session.request({
|
|
1226
1221
|
":method": "POST",
|
|
1227
1222
|
":path": `/android.emulation.control.EmulatorController/${method}`,
|
|
@@ -1237,7 +1232,7 @@ function emulatorCapturer(serial, env, limits) {
|
|
|
1237
1232
|
if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
|
|
1238
1233
|
if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
|
|
1239
1234
|
};
|
|
1240
|
-
request.setTimeout(
|
|
1235
|
+
request.setTimeout(timeoutMs, () => {
|
|
1241
1236
|
timedOut = true;
|
|
1242
1237
|
request.close();
|
|
1243
1238
|
});
|
|
@@ -1255,11 +1250,43 @@ function emulatorCapturer(serial, env, limits) {
|
|
|
1255
1250
|
});
|
|
1256
1251
|
request.end(requestBody);
|
|
1257
1252
|
});
|
|
1253
|
+
}
|
|
1254
|
+
const POSTURE_MODEL = grpcMessage([8, 16]);
|
|
1255
|
+
/**
|
|
1256
|
+
* The postures `input.posture` accepts for an owned device: the two panels of an iPhone Duo, and the hinge
|
|
1257
|
+
* positions of an emulator whose physical model has a posture.
|
|
1258
|
+
*/
|
|
1259
|
+
async function devicePostures(device, env, timeoutMs) {
|
|
1260
|
+
if (device.platform === "ios") return device.foldable ? ["folded", "unfolded"] : [];
|
|
1261
|
+
const endpoint = emulatorEndpoint(env, device.serial);
|
|
1262
|
+
if (!endpoint) return [];
|
|
1263
|
+
const session = grpcSession(endpoint);
|
|
1264
|
+
try {
|
|
1265
|
+
return hasHinge(await grpcCall(session, endpoint, device.serial, "getPhysicalModel", POSTURE_MODEL, timeoutMs)) ? [
|
|
1266
|
+
"folded",
|
|
1267
|
+
"half-open",
|
|
1268
|
+
"unfolded"
|
|
1269
|
+
] : [];
|
|
1270
|
+
} catch {
|
|
1271
|
+
return [];
|
|
1272
|
+
} finally {
|
|
1273
|
+
session.close();
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
function emulatorCapturer(serial, env, limits) {
|
|
1277
|
+
let session = null;
|
|
1278
|
+
let tmp = null;
|
|
1279
|
+
const running = /* @__PURE__ */ new Set();
|
|
1280
|
+
let hinged = null;
|
|
1281
|
+
const call = (endpoint, method, requestBody) => {
|
|
1282
|
+
if (!session || session.closed || session.destroyed) session = grpcSession(endpoint);
|
|
1283
|
+
return grpcCall(session, endpoint, serial, method, requestBody, limits.toolTimeoutMs);
|
|
1284
|
+
};
|
|
1258
1285
|
return {
|
|
1259
1286
|
capture: async () => {
|
|
1260
1287
|
const endpoint = emulatorEndpoint(env, serial);
|
|
1261
1288
|
if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
|
|
1262
|
-
hinged ??= await call(endpoint, "getPhysicalModel",
|
|
1289
|
+
hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
|
|
1263
1290
|
const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
|
|
1264
1291
|
24,
|
|
1265
1292
|
...varint(MAX_EDGE),
|
|
@@ -1402,12 +1429,15 @@ var FrameSource = class {
|
|
|
1402
1429
|
}
|
|
1403
1430
|
};
|
|
1404
1431
|
/**
|
|
1405
|
-
* One capture per device, shared by its subscribers
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1432
|
+
* One capture per device, shared by its subscribers. A `stim-frames` helper stops `lingerMs` after the last of
|
|
1433
|
+
* them leaves, so a client that takes one frame at a time reuses it; a screenshot loop stops with the last of
|
|
1434
|
+
* them. With the `stim-frames`
|
|
1435
|
+
* helper, a device streams frames as its screen changes, at the rate its subscribers ask for; an iPhone Duo
|
|
1436
|
+
* streams its lit panel, which is its posture. Without the helper, and while it is still being built, a
|
|
1437
|
+
* screenshot loop sends a frame only when the screen changed: up to 5 times a second while it changes, backing
|
|
1438
|
+
* off to once a second while it does not, spending at most half of its time capturing, with at most two
|
|
1439
|
+
* captures at once across all devices. A helper that fails before its first frame gives way to the screenshot
|
|
1440
|
+
* loop.
|
|
1411
1441
|
*/
|
|
1412
1442
|
var FramePool = class {
|
|
1413
1443
|
sources = /* @__PURE__ */ new Map();
|
|
@@ -1423,7 +1453,7 @@ var FramePool = class {
|
|
|
1423
1453
|
}
|
|
1424
1454
|
subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
|
|
1425
1455
|
const helper = this.helper();
|
|
1426
|
-
if (helper === null
|
|
1456
|
+
if (helper === null) return this.screenshots(device).add(listener);
|
|
1427
1457
|
let streamed = false;
|
|
1428
1458
|
let cancelled = false;
|
|
1429
1459
|
const { video } = listener;
|
|
@@ -1448,6 +1478,15 @@ var FramePool = class {
|
|
|
1448
1478
|
detach();
|
|
1449
1479
|
};
|
|
1450
1480
|
}
|
|
1481
|
+
/** The posture of an iPhone Duo as its last frame showed it; null before a frame and for other devices. */
|
|
1482
|
+
litPosture(device) {
|
|
1483
|
+
const lit = device.platform === "ios" && device.foldable ? this.litPanels.get(device.udid) : void 0;
|
|
1484
|
+
return lit ? POSTURES[lit] : null;
|
|
1485
|
+
}
|
|
1486
|
+
/** Records the panel a finished fold lit, so a request before the next frame sees the new posture. */
|
|
1487
|
+
folded(udid, posture) {
|
|
1488
|
+
this.litPanels.set(udid, posture === "folded" ? "primary" : "primary-1");
|
|
1489
|
+
}
|
|
1451
1490
|
/** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
|
|
1452
1491
|
keyframe(device) {
|
|
1453
1492
|
this.helperSource(device)?.keyframe();
|
|
@@ -1460,13 +1499,37 @@ var FramePool = class {
|
|
|
1460
1499
|
const source = this.sources.get(`helper:${deviceKey(device)}`);
|
|
1461
1500
|
return source instanceof HelperSource ? source : null;
|
|
1462
1501
|
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Keeps the device's helper running to send it input, or returns null without a helper. `failed` runs when
|
|
1504
|
+
* the helper exits.
|
|
1505
|
+
*/
|
|
1506
|
+
control(device, failed) {
|
|
1507
|
+
const helper = this.helper();
|
|
1508
|
+
if (helper === null) return null;
|
|
1509
|
+
const source = this.stream(helper, device);
|
|
1510
|
+
return {
|
|
1511
|
+
send: (command) => source.send(command),
|
|
1512
|
+
keys: () => source.keyboard === true,
|
|
1513
|
+
detach: source.add({
|
|
1514
|
+
frame: () => {},
|
|
1515
|
+
delayed: () => {},
|
|
1516
|
+
failed
|
|
1517
|
+
}, null)
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1463
1520
|
stream(helper, device) {
|
|
1464
1521
|
const key = `helper:${deviceKey(device)}`;
|
|
1465
1522
|
const existing = this.sources.get(key);
|
|
1466
1523
|
if (existing instanceof HelperSource) return existing;
|
|
1524
|
+
const lit = device.platform === "ios" && device.foldable ? (display) => {
|
|
1525
|
+
const panel = DUO_PANELS[display];
|
|
1526
|
+
if (!panel) return void 0;
|
|
1527
|
+
this.litPanels.set(device.udid, panel);
|
|
1528
|
+
return POSTURES[panel];
|
|
1529
|
+
} : void 0;
|
|
1467
1530
|
const created = new HelperSource(helper, device, this.env, () => {
|
|
1468
1531
|
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
1469
|
-
});
|
|
1532
|
+
}, this.limits.lingerMs, lit);
|
|
1470
1533
|
this.sources.set(key, created);
|
|
1471
1534
|
return created;
|
|
1472
1535
|
}
|
|
@@ -1485,14 +1548,668 @@ var FramePool = class {
|
|
|
1485
1548
|
}
|
|
1486
1549
|
};
|
|
1487
1550
|
//#endregion
|
|
1551
|
+
//#region src/control.ts
|
|
1552
|
+
const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
1553
|
+
const TEXT = /^[\x20-\x7e\n\t\b]+$/;
|
|
1554
|
+
function parseControlBegin(params) {
|
|
1555
|
+
if (!isJsonObject(params)) return {
|
|
1556
|
+
code: "bad-request",
|
|
1557
|
+
message: "params must be an object."
|
|
1558
|
+
};
|
|
1559
|
+
const { workspace, platform, slot, takeOver, ...rest } = params;
|
|
1560
|
+
if (Object.keys(rest).length) return {
|
|
1561
|
+
code: "bad-request",
|
|
1562
|
+
message: `control.begin does not take ${Object.keys(rest).join(", ")}.`
|
|
1563
|
+
};
|
|
1564
|
+
if (typeof workspace !== "string") return {
|
|
1565
|
+
code: "bad-request",
|
|
1566
|
+
message: "params.workspace must be an environment path from a status payload."
|
|
1567
|
+
};
|
|
1568
|
+
if (platform !== "ios" && platform !== "android") return {
|
|
1569
|
+
code: "bad-request",
|
|
1570
|
+
message: "params.platform must be ios or android."
|
|
1571
|
+
};
|
|
1572
|
+
if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return {
|
|
1573
|
+
code: "bad-request",
|
|
1574
|
+
message: "params.slot must be 1-64 letters, digits, underscores or hyphens."
|
|
1575
|
+
};
|
|
1576
|
+
if (takeOver !== void 0 && typeof takeOver !== "boolean") return {
|
|
1577
|
+
code: "bad-request",
|
|
1578
|
+
message: "params.takeOver must be true or false."
|
|
1579
|
+
};
|
|
1580
|
+
return { value: {
|
|
1581
|
+
workspace,
|
|
1582
|
+
platform,
|
|
1583
|
+
...slot ? { slot } : {},
|
|
1584
|
+
...takeOver ? { takeOver } : {}
|
|
1585
|
+
} };
|
|
1586
|
+
}
|
|
1587
|
+
const IOS_BUTTONS = ["home", "lock"];
|
|
1588
|
+
function fraction(value) {
|
|
1589
|
+
return typeof value === "number" && value >= 0 && value <= 1;
|
|
1590
|
+
}
|
|
1591
|
+
/** Validates an `input.*` request for one of the connection's sessions; returns its session id and command. */
|
|
1592
|
+
function parseInput(method, params, targetOf) {
|
|
1593
|
+
if (!isJsonObject(params) || typeof params.session !== "string") return {
|
|
1594
|
+
code: "bad-request",
|
|
1595
|
+
message: `${method} needs params.session from control.begin.`
|
|
1596
|
+
};
|
|
1597
|
+
const target = targetOf(params.session);
|
|
1598
|
+
if (!target) return {
|
|
1599
|
+
code: "unknown-session",
|
|
1600
|
+
message: `No control session ${params.session} on this connection.`
|
|
1601
|
+
};
|
|
1602
|
+
const { platform, postures } = target;
|
|
1603
|
+
const session = params.session;
|
|
1604
|
+
if (method === "input.rotate") {
|
|
1605
|
+
const { direction } = params;
|
|
1606
|
+
if (!ROTATE_DIRECTIONS.includes(direction)) return {
|
|
1607
|
+
code: "bad-request",
|
|
1608
|
+
message: "input.rotate needs direction left or right."
|
|
1609
|
+
};
|
|
1610
|
+
return { value: {
|
|
1611
|
+
session,
|
|
1612
|
+
command: {
|
|
1613
|
+
input: "rotate",
|
|
1614
|
+
direction
|
|
1615
|
+
}
|
|
1616
|
+
} };
|
|
1617
|
+
}
|
|
1618
|
+
if (method === "input.posture") {
|
|
1619
|
+
const { posture } = params;
|
|
1620
|
+
if (!postures.includes(posture)) return {
|
|
1621
|
+
code: "bad-request",
|
|
1622
|
+
message: `This device ${postures.length ? `takes these postures: ${postures.join(", ")}` : "has no hinge"}.`
|
|
1623
|
+
};
|
|
1624
|
+
return { value: {
|
|
1625
|
+
session,
|
|
1626
|
+
command: {
|
|
1627
|
+
input: "posture",
|
|
1628
|
+
posture
|
|
1629
|
+
}
|
|
1630
|
+
} };
|
|
1631
|
+
}
|
|
1632
|
+
if (method === "input.touch") {
|
|
1633
|
+
const { phase, x, y, display = 0 } = params;
|
|
1634
|
+
if (!TOUCH_PHASES.includes(phase) || !fraction(x) || !fraction(y)) return {
|
|
1635
|
+
code: "bad-request",
|
|
1636
|
+
message: "input.touch needs phase (down, move or up), and x and y from 0 to 1."
|
|
1637
|
+
};
|
|
1638
|
+
if (!Number.isInteger(display) || display < 0 || display > 3) return {
|
|
1639
|
+
code: "bad-request",
|
|
1640
|
+
message: "display must be a display index from 0 to 3."
|
|
1641
|
+
};
|
|
1642
|
+
if (platform === "android" && display !== 0) return {
|
|
1643
|
+
code: "bad-request",
|
|
1644
|
+
message: "An emulator takes input on its main display (0) only."
|
|
1645
|
+
};
|
|
1646
|
+
return { value: {
|
|
1647
|
+
session,
|
|
1648
|
+
command: {
|
|
1649
|
+
input: "touch",
|
|
1650
|
+
phase,
|
|
1651
|
+
x,
|
|
1652
|
+
y,
|
|
1653
|
+
display
|
|
1654
|
+
}
|
|
1655
|
+
} };
|
|
1656
|
+
}
|
|
1657
|
+
if (method === "input.text") {
|
|
1658
|
+
const { text } = params;
|
|
1659
|
+
if (typeof text !== "string" || !text.length || text.length > 256 || !TEXT.test(text)) return {
|
|
1660
|
+
code: "bad-request",
|
|
1661
|
+
message: `input.text takes 1 to 256 printable ASCII characters, with \\n, \\t and \\b.`
|
|
1662
|
+
};
|
|
1663
|
+
return { value: {
|
|
1664
|
+
session,
|
|
1665
|
+
command: {
|
|
1666
|
+
input: "text",
|
|
1667
|
+
text
|
|
1668
|
+
}
|
|
1669
|
+
} };
|
|
1670
|
+
}
|
|
1671
|
+
const { button } = params;
|
|
1672
|
+
const allowed = platform === "ios" ? IOS_BUTTONS : INPUT_BUTTONS;
|
|
1673
|
+
if (!allowed.includes(button)) return {
|
|
1674
|
+
code: "bad-request",
|
|
1675
|
+
message: `An ${platform} device takes these buttons: ${allowed.join(", ")}.`
|
|
1676
|
+
};
|
|
1677
|
+
return { value: {
|
|
1678
|
+
session,
|
|
1679
|
+
command: {
|
|
1680
|
+
input: "button",
|
|
1681
|
+
button
|
|
1682
|
+
}
|
|
1683
|
+
} };
|
|
1684
|
+
}
|
|
1685
|
+
const ANDROID_KEYS = {
|
|
1686
|
+
home: "KEYCODE_HOME",
|
|
1687
|
+
back: "KEYCODE_BACK",
|
|
1688
|
+
"app-switch": "KEYCODE_APP_SWITCH",
|
|
1689
|
+
lock: "KEYCODE_POWER",
|
|
1690
|
+
"\n": "KEYCODE_ENTER",
|
|
1691
|
+
" ": "KEYCODE_TAB",
|
|
1692
|
+
"\b": "KEYCODE_DEL"
|
|
1693
|
+
};
|
|
1694
|
+
/**
|
|
1695
|
+
* The `adb shell` argument lists that type `text` or press `button` on an emulator without a hardware keyboard
|
|
1696
|
+
* (`hw.keyboard=no`), which drops gRPC key events. `adb shell` joins its arguments into one device shell
|
|
1697
|
+
* command, so text goes single-quoted; `input text` reads `%s` as a space.
|
|
1698
|
+
*/
|
|
1699
|
+
function adbInputArgs(command) {
|
|
1700
|
+
if (command.input === "button") return [[
|
|
1701
|
+
"shell",
|
|
1702
|
+
"input",
|
|
1703
|
+
"keyevent",
|
|
1704
|
+
ANDROID_KEYS[command.button]
|
|
1705
|
+
]];
|
|
1706
|
+
const calls = [];
|
|
1707
|
+
for (const part of command.text.split(/([\n\t\b]+)/)) {
|
|
1708
|
+
if (!part) continue;
|
|
1709
|
+
if (/^[\n\t\b]+$/.test(part)) {
|
|
1710
|
+
calls.push([
|
|
1711
|
+
"shell",
|
|
1712
|
+
"input",
|
|
1713
|
+
"keyevent",
|
|
1714
|
+
...Array.from(part, (key) => ANDROID_KEYS[key])
|
|
1715
|
+
]);
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
calls.push([
|
|
1719
|
+
"shell",
|
|
1720
|
+
"input",
|
|
1721
|
+
"text",
|
|
1722
|
+
`'${part.replaceAll(" ", "%s").replaceAll("'", "'\\''")}'`
|
|
1723
|
+
]);
|
|
1724
|
+
}
|
|
1725
|
+
return calls;
|
|
1726
|
+
}
|
|
1727
|
+
function adbPath(env) {
|
|
1728
|
+
for (const sdk of [
|
|
1729
|
+
env.ANDROID_HOME,
|
|
1730
|
+
env.ANDROID_SDK_ROOT,
|
|
1731
|
+
join(env.HOME ?? homedir(), "Library/Android/sdk")
|
|
1732
|
+
]) if (sdk && existsSync(join(sdk, "platform-tools/adb"))) return join(sdk, "platform-tools/adb");
|
|
1733
|
+
return "adb";
|
|
1734
|
+
}
|
|
1735
|
+
const ADB_TIMEOUT_MS = 1e4;
|
|
1736
|
+
const POSTURE_TIMEOUT_MS = 5e3;
|
|
1737
|
+
function runQuietly(env, file, args, label, timeoutMs) {
|
|
1738
|
+
return new Promise((resolve, reject) => {
|
|
1739
|
+
const child = spawn(file, args, {
|
|
1740
|
+
env,
|
|
1741
|
+
stdio: [
|
|
1742
|
+
"ignore",
|
|
1743
|
+
"ignore",
|
|
1744
|
+
"pipe"
|
|
1745
|
+
]
|
|
1746
|
+
});
|
|
1747
|
+
let stderr = "";
|
|
1748
|
+
const finish = (code) => {
|
|
1749
|
+
clearTimeout(timer);
|
|
1750
|
+
if (code === 0) resolve();
|
|
1751
|
+
else reject(/* @__PURE__ */ new Error(`${label} failed (code ${code}): ${stderr.trim()}`));
|
|
1752
|
+
};
|
|
1753
|
+
const timer = setTimeout(() => {
|
|
1754
|
+
child.stderr.destroy();
|
|
1755
|
+
if (child.exitCode !== null || child.signalCode !== null) return finish(child.exitCode);
|
|
1756
|
+
terminate(child);
|
|
1757
|
+
reject(/* @__PURE__ */ new Error(`${label} did not finish within ${timeoutMs / 1e3} s.`));
|
|
1758
|
+
}, timeoutMs);
|
|
1759
|
+
child.stderr.setEncoding("utf8");
|
|
1760
|
+
child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-500));
|
|
1761
|
+
child.on("error", (error) => {
|
|
1762
|
+
clearTimeout(timer);
|
|
1763
|
+
reject(/* @__PURE__ */ new Error(`${label} could not start (${error.message}).`));
|
|
1764
|
+
});
|
|
1765
|
+
child.on("close", finish);
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
function runAdb(env, serial, args) {
|
|
1769
|
+
return runQuietly(env, adbPath(env), [
|
|
1770
|
+
"-s",
|
|
1771
|
+
serial,
|
|
1772
|
+
...args
|
|
1773
|
+
], `adb ${args.slice(0, 3).join(" ")}`, ADB_TIMEOUT_MS);
|
|
1774
|
+
}
|
|
1775
|
+
function activityOf(payload, target) {
|
|
1776
|
+
const environment = payload.environments?.find((candidate) => candidate.path === target.workspace);
|
|
1777
|
+
const slot = target.slot ?? "default";
|
|
1778
|
+
const devices = slot === "default" ? environment : environment?.slots?.find((candidate) => candidate.slot === slot);
|
|
1779
|
+
return target.platform === "ios" ? devices?.ios?.activity : devices?.android?.activity;
|
|
1780
|
+
}
|
|
1781
|
+
function otherDriver(activity, ownLeases) {
|
|
1782
|
+
if (activity?.state !== "driven" || !activity.driver) return null;
|
|
1783
|
+
const { tool, since } = activity.driver;
|
|
1784
|
+
if (tool === "stim device lock" && since && ownLeases.has(since)) return null;
|
|
1785
|
+
return since ? `${tool} (since ${since})` : tool;
|
|
1786
|
+
}
|
|
1787
|
+
const STATUS_WAIT_MS = 6e4;
|
|
1788
|
+
/**
|
|
1789
|
+
* Control sessions across all connections: at most one per device. A session holds the device's `stim-frames`
|
|
1790
|
+
* helper for input and a `stim device lock` lease, renewed while it lasts and released when it ends if the
|
|
1791
|
+
* session took it.
|
|
1792
|
+
*/
|
|
1793
|
+
var ControlHub = class {
|
|
1794
|
+
options;
|
|
1795
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1796
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
1797
|
+
ownLeases = /* @__PURE__ */ new Set();
|
|
1798
|
+
starting = /* @__PURE__ */ new Set();
|
|
1799
|
+
folding = /* @__PURE__ */ new Set();
|
|
1800
|
+
closing = false;
|
|
1801
|
+
next = 1;
|
|
1802
|
+
constructor(options) {
|
|
1803
|
+
this.options = options;
|
|
1804
|
+
}
|
|
1805
|
+
targetOf(owner, session) {
|
|
1806
|
+
const found = this.sessions.get(session);
|
|
1807
|
+
return found && found.owner === owner && !found.ended ? {
|
|
1808
|
+
platform: found.target.platform,
|
|
1809
|
+
postures: found.postures
|
|
1810
|
+
} : null;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* `stillAllowed` is checked again after the status read and the lock, which can take seconds: the client may
|
|
1814
|
+
* have disconnected or lost `control` meanwhile.
|
|
1815
|
+
*/
|
|
1816
|
+
async begin(owner, target, cwd, stillAllowed) {
|
|
1817
|
+
const beganAt = Date.now();
|
|
1818
|
+
if (this.closing) return {
|
|
1819
|
+
code: "action-failed",
|
|
1820
|
+
message: "stim-server is stopping."
|
|
1821
|
+
};
|
|
1822
|
+
const status = await this.status();
|
|
1823
|
+
if ("code" in status) return status;
|
|
1824
|
+
const device = ownedDevice(status, target, null);
|
|
1825
|
+
if (typeof device === "string") return {
|
|
1826
|
+
code: "action-failed",
|
|
1827
|
+
message: device
|
|
1828
|
+
};
|
|
1829
|
+
const key = deviceKey(device);
|
|
1830
|
+
if (this.starting.has(key)) return {
|
|
1831
|
+
code: "device-busy",
|
|
1832
|
+
message: "Another client is starting to control this device. Try again."
|
|
1833
|
+
};
|
|
1834
|
+
const earlier = this.byDevice.get(key);
|
|
1835
|
+
const driver = earlier ? `${earlier.owner.device.name} through stim-server` : otherDriver(activityOf(status, target), this.ownLeases);
|
|
1836
|
+
if (driver && !target.takeOver) return {
|
|
1837
|
+
code: "device-busy",
|
|
1838
|
+
message: `This device is driven by ${driver}. Take over to control it anyway.`
|
|
1839
|
+
};
|
|
1840
|
+
this.starting.add(key);
|
|
1841
|
+
let lease;
|
|
1842
|
+
let postures;
|
|
1843
|
+
try {
|
|
1844
|
+
[lease, postures] = await Promise.all([this.lock(device, target, cwd, beganAt), devicePostures(device, this.options.env, POSTURE_TIMEOUT_MS)]);
|
|
1845
|
+
} finally {
|
|
1846
|
+
this.starting.delete(key);
|
|
1847
|
+
}
|
|
1848
|
+
const granted = "code" in lease ? null : lease;
|
|
1849
|
+
if (!granted && !target.takeOver) return lease;
|
|
1850
|
+
const current = this.byDevice.get(key);
|
|
1851
|
+
const refuse = (refusal) => {
|
|
1852
|
+
if (granted?.mine && !current?.lease?.mine) this.unlock(target, cwd);
|
|
1853
|
+
return refusal;
|
|
1854
|
+
};
|
|
1855
|
+
if (this.closing || !stillAllowed()) return refuse({
|
|
1856
|
+
code: "forbidden",
|
|
1857
|
+
message: "This device can no longer control devices."
|
|
1858
|
+
});
|
|
1859
|
+
if (current && current !== earlier && !target.takeOver) return refuse({
|
|
1860
|
+
code: "device-busy",
|
|
1861
|
+
message: `${current.owner.device.name} started controlling this device.`
|
|
1862
|
+
});
|
|
1863
|
+
let session;
|
|
1864
|
+
const input = this.options.frames.control(device, (message) => queueMicrotask(() => void this.end(session, "failed", message)));
|
|
1865
|
+
if (!input) return refuse({
|
|
1866
|
+
code: "action-failed",
|
|
1867
|
+
message: "Input needs the stim-frames helper, which this Mac has not built."
|
|
1868
|
+
});
|
|
1869
|
+
const inherited = granted !== null && current?.lease?.mine === true;
|
|
1870
|
+
if (current) this.end(current, "taken-over", `${owner.device.name} took over this device.`, !inherited);
|
|
1871
|
+
const id = `c${this.next++}`;
|
|
1872
|
+
session = {
|
|
1873
|
+
id,
|
|
1874
|
+
owner,
|
|
1875
|
+
device,
|
|
1876
|
+
key,
|
|
1877
|
+
target,
|
|
1878
|
+
cwd,
|
|
1879
|
+
lease: granted ? {
|
|
1880
|
+
...granted,
|
|
1881
|
+
mine: granted.mine || inherited
|
|
1882
|
+
} : null,
|
|
1883
|
+
input,
|
|
1884
|
+
postures,
|
|
1885
|
+
startedAt: Date.now(),
|
|
1886
|
+
idle: setTimeout(() => void this.end(session, "idle", "No input for 5 minutes."), this.options.idleMs),
|
|
1887
|
+
renew: setInterval(() => this.renew(session, beganAt), this.options.renewMs),
|
|
1888
|
+
renewing: Promise.resolve(),
|
|
1889
|
+
unwatch: () => {},
|
|
1890
|
+
adb: Promise.resolve(),
|
|
1891
|
+
ended: false
|
|
1892
|
+
};
|
|
1893
|
+
this.sessions.set(id, session);
|
|
1894
|
+
this.byDevice.set(key, session);
|
|
1895
|
+
session.unwatch = this.options.feeds.subscribe(this.options.statusFeed, {
|
|
1896
|
+
item: (payload) => {
|
|
1897
|
+
const resolved = ownedDevice(payload, target, key);
|
|
1898
|
+
if (typeof resolved === "string" || deviceKey(resolved) !== key) {
|
|
1899
|
+
const message = typeof resolved === "string" ? resolved : "The device changed.";
|
|
1900
|
+
queueMicrotask(() => void this.end(session, "device-gone", message));
|
|
1901
|
+
}
|
|
1902
|
+
},
|
|
1903
|
+
failed: () => {}
|
|
1904
|
+
});
|
|
1905
|
+
this.audit(owner, target, driver || current ? "control.take-over" : "control.begin", {
|
|
1906
|
+
ok: true,
|
|
1907
|
+
...driver || current ? { reason: `took over from ${driver ?? current.owner.device.name}` } : {}
|
|
1908
|
+
});
|
|
1909
|
+
return {
|
|
1910
|
+
session: id,
|
|
1911
|
+
platform: target.platform,
|
|
1912
|
+
lease: session.lease ? {
|
|
1913
|
+
grantedAt: session.lease.grantedAt,
|
|
1914
|
+
expiresAt: session.lease.expiresAt
|
|
1915
|
+
} : null,
|
|
1916
|
+
postures
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
renew(session, beganAt) {
|
|
1920
|
+
if (session.ended || !session.lease?.mine) return;
|
|
1921
|
+
session.renewing = (async () => {
|
|
1922
|
+
const renewed = await this.lock(session.device, session.target, session.cwd, beganAt);
|
|
1923
|
+
if ("code" in renewed) console.error(`stim-server: could not renew the device lease: ${renewed.message}`);
|
|
1924
|
+
else if (session.lease) session.lease.expiresAt = renewed.expiresAt;
|
|
1925
|
+
})();
|
|
1926
|
+
}
|
|
1927
|
+
input(owner, id, command) {
|
|
1928
|
+
const session = this.sessions.get(id);
|
|
1929
|
+
if (!session || session.owner !== owner || session.ended) return Promise.resolve({
|
|
1930
|
+
code: "unknown-session",
|
|
1931
|
+
message: `No control session ${id} on this connection.`
|
|
1932
|
+
});
|
|
1933
|
+
session.idle.refresh();
|
|
1934
|
+
if (command.input === "posture" && session.device.platform === "ios") return this.fold(session, session.device.udid, command.posture);
|
|
1935
|
+
if (session.device.platform === "ios" || command.input === "touch" || command.input === "rotate" || command.input === "posture" || session.input.keys()) {
|
|
1936
|
+
session.input.send(command);
|
|
1937
|
+
return Promise.resolve(null);
|
|
1938
|
+
}
|
|
1939
|
+
const serial = session.device.serial;
|
|
1940
|
+
const previous = session.adb;
|
|
1941
|
+
const run = (async () => {
|
|
1942
|
+
await previous;
|
|
1943
|
+
try {
|
|
1944
|
+
for (const args of adbInputArgs(command)) {
|
|
1945
|
+
if (session.ended) return null;
|
|
1946
|
+
await runAdb(this.options.env, serial, args);
|
|
1947
|
+
}
|
|
1948
|
+
return null;
|
|
1949
|
+
} catch (cause) {
|
|
1950
|
+
return {
|
|
1951
|
+
code: "action-failed",
|
|
1952
|
+
message: cause.message
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
})();
|
|
1956
|
+
session.adb = run.then(() => void 0);
|
|
1957
|
+
return run;
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* `sim-fold` sweeps the hinge to the other posture, so it runs only when the Duo's last frame shows the
|
|
1961
|
+
* other one.
|
|
1962
|
+
*/
|
|
1963
|
+
async fold(session, udid, posture) {
|
|
1964
|
+
if (this.folding.has(udid)) return {
|
|
1965
|
+
code: "device-busy",
|
|
1966
|
+
message: "The device is still folding."
|
|
1967
|
+
};
|
|
1968
|
+
const current = this.options.frames.litPosture(session.device);
|
|
1969
|
+
if (!current) return {
|
|
1970
|
+
code: "action-failed",
|
|
1971
|
+
message: "Subscribe to frames of this device to learn its posture first."
|
|
1972
|
+
};
|
|
1973
|
+
if (current === posture) return null;
|
|
1974
|
+
this.folding.add(udid);
|
|
1975
|
+
try {
|
|
1976
|
+
const helper = await this.options.foldHelper();
|
|
1977
|
+
if (session.ended) return null;
|
|
1978
|
+
await runQuietly(this.options.env, "xcrun", [
|
|
1979
|
+
"simctl",
|
|
1980
|
+
"spawn",
|
|
1981
|
+
udid,
|
|
1982
|
+
helper
|
|
1983
|
+
], "sim-fold", this.options.foldTimeoutMs);
|
|
1984
|
+
this.options.frames.folded(udid, posture === "folded" ? "folded" : "unfolded");
|
|
1985
|
+
return null;
|
|
1986
|
+
} catch (cause) {
|
|
1987
|
+
return {
|
|
1988
|
+
code: "action-failed",
|
|
1989
|
+
message: cause.message
|
|
1990
|
+
};
|
|
1991
|
+
} finally {
|
|
1992
|
+
this.folding.delete(udid);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
endById(owner, id) {
|
|
1996
|
+
const session = this.sessions.get(id);
|
|
1997
|
+
if (!session || session.owner !== owner) return false;
|
|
1998
|
+
this.end(session, null, "The client ended the session.");
|
|
1999
|
+
return true;
|
|
2000
|
+
}
|
|
2001
|
+
endFor(owner, reason, message) {
|
|
2002
|
+
for (const session of this.sessions.values()) if (session.owner === owner) this.end(session, reason, message);
|
|
2003
|
+
}
|
|
2004
|
+
async close() {
|
|
2005
|
+
this.closing = true;
|
|
2006
|
+
const releases = [...this.sessions.values()].map((session) => this.end(session, null, "stim-server stopped."));
|
|
2007
|
+
await Promise.all(releases);
|
|
2008
|
+
}
|
|
2009
|
+
end(session, reason, message, release = true) {
|
|
2010
|
+
if (session.ended) return Promise.resolve();
|
|
2011
|
+
session.ended = true;
|
|
2012
|
+
clearTimeout(session.idle);
|
|
2013
|
+
clearInterval(session.renew);
|
|
2014
|
+
session.unwatch();
|
|
2015
|
+
session.input.detach();
|
|
2016
|
+
this.sessions.delete(session.id);
|
|
2017
|
+
if (this.byDevice.get(session.key) === session) this.byDevice.delete(session.key);
|
|
2018
|
+
if (reason) session.owner.send({
|
|
2019
|
+
event: "control-ended",
|
|
2020
|
+
session: session.id,
|
|
2021
|
+
reason,
|
|
2022
|
+
message
|
|
2023
|
+
});
|
|
2024
|
+
this.audit(session.owner, session.target, "control.end", {
|
|
2025
|
+
ok: true,
|
|
2026
|
+
durationMs: Date.now() - session.startedAt,
|
|
2027
|
+
reason: `${reason ?? "ended"}: ${message}`
|
|
2028
|
+
});
|
|
2029
|
+
if (!release || !session.lease?.mine) return Promise.resolve();
|
|
2030
|
+
return session.renewing.then(() => this.unlock(session.target, session.cwd));
|
|
2031
|
+
}
|
|
2032
|
+
status() {
|
|
2033
|
+
return new Promise((resolve) => {
|
|
2034
|
+
let settled = false;
|
|
2035
|
+
let unsubscribe = null;
|
|
2036
|
+
const finish = (value) => {
|
|
2037
|
+
if (settled) return;
|
|
2038
|
+
settled = true;
|
|
2039
|
+
clearTimeout(timer);
|
|
2040
|
+
unsubscribe?.();
|
|
2041
|
+
resolve(value);
|
|
2042
|
+
};
|
|
2043
|
+
const timer = setTimeout(() => finish({
|
|
2044
|
+
code: "status-failed",
|
|
2045
|
+
message: "stim status did not report the device in time."
|
|
2046
|
+
}), STATUS_WAIT_MS);
|
|
2047
|
+
unsubscribe = this.options.feeds.subscribe(this.options.statusFeed, {
|
|
2048
|
+
item: (payload) => finish(payload),
|
|
2049
|
+
failed: (message) => finish({
|
|
2050
|
+
code: "status-failed",
|
|
2051
|
+
message
|
|
2052
|
+
})
|
|
2053
|
+
});
|
|
2054
|
+
if (settled) unsubscribe();
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
async lock(device, target, cwd, beganAt) {
|
|
2058
|
+
const id = device.platform === "ios" ? device.udid : device.serial;
|
|
2059
|
+
const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
|
|
2060
|
+
const args = [
|
|
2061
|
+
"device",
|
|
2062
|
+
"lock",
|
|
2063
|
+
target.platform,
|
|
2064
|
+
id,
|
|
2065
|
+
"--for",
|
|
2066
|
+
this.options.leaseFor,
|
|
2067
|
+
"--wait",
|
|
2068
|
+
"0",
|
|
2069
|
+
"--json",
|
|
2070
|
+
...slot
|
|
2071
|
+
];
|
|
2072
|
+
const printed = actionOutcome(await runStim(this.options.stimCli, this.options.env, args, cwd, this.options.lockLimits).outcome);
|
|
2073
|
+
if (!printed.ok) return {
|
|
2074
|
+
code: printed.error.message.startsWith("STIM_DEVICE_BUSY") ? "device-busy" : "action-failed",
|
|
2075
|
+
message: `stim device lock: ${printed.error.message}`
|
|
2076
|
+
};
|
|
2077
|
+
const grantedAt = typeof printed.output.grantedAt === "string" ? printed.output.grantedAt : null;
|
|
2078
|
+
const expiresAt = typeof printed.output.expiresAt === "string" ? printed.output.expiresAt : "";
|
|
2079
|
+
const mine = grantedAt !== null && Date.parse(grantedAt) >= beganAt - 1e3;
|
|
2080
|
+
if (mine) this.ownLeases.add(grantedAt);
|
|
2081
|
+
return {
|
|
2082
|
+
grantedAt,
|
|
2083
|
+
expiresAt,
|
|
2084
|
+
mine
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
async unlock(target, cwd) {
|
|
2088
|
+
const slot = target.slot && target.slot !== "default" ? ["--slot", target.slot] : [];
|
|
2089
|
+
const outcome = await runStim(this.options.stimCli, this.options.env, [
|
|
2090
|
+
"device",
|
|
2091
|
+
"unlock",
|
|
2092
|
+
target.platform,
|
|
2093
|
+
"--json",
|
|
2094
|
+
...slot
|
|
2095
|
+
], cwd, this.options.lockLimits).outcome;
|
|
2096
|
+
if (!outcome.ok) console.error(`stim-server: could not release the device lease: ${outcome.message}`);
|
|
2097
|
+
}
|
|
2098
|
+
audit(owner, target, action, outcome) {
|
|
2099
|
+
this.options.audit({
|
|
2100
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2101
|
+
device: {
|
|
2102
|
+
id: owner.device.id,
|
|
2103
|
+
name: owner.device.name
|
|
2104
|
+
},
|
|
2105
|
+
action,
|
|
2106
|
+
workspace: target.workspace,
|
|
2107
|
+
platform: target.platform,
|
|
2108
|
+
...outcome
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
//#endregion
|
|
2113
|
+
//#region src/feed.ts
|
|
2114
|
+
var Feed = class {
|
|
2115
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2116
|
+
kept = [];
|
|
2117
|
+
child;
|
|
2118
|
+
ended;
|
|
2119
|
+
constructor(spec, stimCli, env, ended) {
|
|
2120
|
+
this.ended = ended;
|
|
2121
|
+
const child = spawn(process.execPath, [stimCli, ...spec.args], {
|
|
2122
|
+
cwd: spec.cwd,
|
|
2123
|
+
env,
|
|
2124
|
+
stdio: [
|
|
2125
|
+
"ignore",
|
|
2126
|
+
"pipe",
|
|
2127
|
+
"pipe"
|
|
2128
|
+
]
|
|
2129
|
+
});
|
|
2130
|
+
this.child = child;
|
|
2131
|
+
let stderr = "";
|
|
2132
|
+
child.stderr?.setEncoding("utf8");
|
|
2133
|
+
child.stderr?.on("data", (chunk) => {
|
|
2134
|
+
stderr = (stderr + chunk).slice(-2e3);
|
|
2135
|
+
});
|
|
2136
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
2137
|
+
if (this.child !== child) return;
|
|
2138
|
+
let value;
|
|
2139
|
+
try {
|
|
2140
|
+
value = JSON.parse(line);
|
|
2141
|
+
} catch {
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
if (!isJsonObject(value)) return;
|
|
2145
|
+
this.kept.push({
|
|
2146
|
+
value,
|
|
2147
|
+
text: line
|
|
2148
|
+
});
|
|
2149
|
+
if (this.kept.length > spec.keep) this.kept.shift();
|
|
2150
|
+
for (const listener of this.listeners) listener.item(value, line);
|
|
2151
|
+
});
|
|
2152
|
+
const exited = (reason) => {
|
|
2153
|
+
if (this.child !== child) return;
|
|
2154
|
+
this.child = null;
|
|
2155
|
+
this.ended();
|
|
2156
|
+
const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
|
|
2157
|
+
const listeners = [...this.listeners];
|
|
2158
|
+
this.listeners.clear();
|
|
2159
|
+
for (const listener of listeners) listener.failed(message);
|
|
2160
|
+
};
|
|
2161
|
+
child.on("error", (error) => exited(`could not start (${error.message})`));
|
|
2162
|
+
child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
|
|
2163
|
+
}
|
|
2164
|
+
add(listener) {
|
|
2165
|
+
this.listeners.add(listener);
|
|
2166
|
+
for (const { value, text } of this.kept) listener.item(value, text);
|
|
2167
|
+
return () => {
|
|
2168
|
+
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
stop() {
|
|
2172
|
+
const child = this.child;
|
|
2173
|
+
if (!child) return Promise.resolve();
|
|
2174
|
+
this.child = null;
|
|
2175
|
+
this.listeners.clear();
|
|
2176
|
+
this.ended();
|
|
2177
|
+
return terminate(child);
|
|
2178
|
+
}
|
|
2179
|
+
};
|
|
2180
|
+
var FeedPool = class {
|
|
2181
|
+
feeds = /* @__PURE__ */ new Map();
|
|
2182
|
+
stimCli;
|
|
2183
|
+
env;
|
|
2184
|
+
constructor(stimCli, env) {
|
|
2185
|
+
this.stimCli = stimCli;
|
|
2186
|
+
this.env = env;
|
|
2187
|
+
}
|
|
2188
|
+
subscribe(spec, listener) {
|
|
2189
|
+
const key = JSON.stringify([spec.cwd, spec.args]);
|
|
2190
|
+
let feed = this.feeds.get(key);
|
|
2191
|
+
if (!feed) {
|
|
2192
|
+
const created = new Feed(spec, this.stimCli, this.env, () => {
|
|
2193
|
+
if (this.feeds.get(key) === created) this.feeds.delete(key);
|
|
2194
|
+
});
|
|
2195
|
+
this.feeds.set(key, created);
|
|
2196
|
+
feed = created;
|
|
2197
|
+
}
|
|
2198
|
+
return feed.add(listener);
|
|
2199
|
+
}
|
|
2200
|
+
async close() {
|
|
2201
|
+
await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
//#endregion
|
|
1488
2205
|
//#region src/logs.ts
|
|
1489
2206
|
function parseLogFilter(params) {
|
|
1490
2207
|
if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
|
|
1491
2208
|
const { sources, level, slot, grep, errors, tail } = params;
|
|
1492
2209
|
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
2210
|
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." };
|
|
2211
|
+
if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return { error: "slot must be 1-64 letters, digits, underscores or hyphens." };
|
|
2212
|
+
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
2213
|
if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
|
|
1497
2214
|
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
2215
|
return { filter: {
|
|
@@ -1994,6 +2711,19 @@ const LOG_LIMITS = {
|
|
|
1994
2711
|
const FRAME_BUFFER_FRAMES = 2;
|
|
1995
2712
|
const FRAME_RETRY_MS = 50;
|
|
1996
2713
|
const HELPER_RETRY_MS = 3e5;
|
|
2714
|
+
const CONTROL_LIMITS = {
|
|
2715
|
+
idleMs: 3e5,
|
|
2716
|
+
renewMs: 6e4,
|
|
2717
|
+
leaseFor: "2m",
|
|
2718
|
+
inputPerSecond: 120,
|
|
2719
|
+
textCharsPerSecond: 40,
|
|
2720
|
+
shapeChangesPerSecond: 2,
|
|
2721
|
+
foldTimeoutMs: 4e4
|
|
2722
|
+
};
|
|
2723
|
+
const LOCK_LIMITS = {
|
|
2724
|
+
timeoutMs: 3e4,
|
|
2725
|
+
maxOutputBytes: 65536
|
|
2726
|
+
};
|
|
1997
2727
|
const STATUS_FEED = {
|
|
1998
2728
|
args: [
|
|
1999
2729
|
"status",
|
|
@@ -2010,7 +2740,6 @@ const COMMAND_LIMITS = {
|
|
|
2010
2740
|
maxOutputBytes: 33554432
|
|
2011
2741
|
};
|
|
2012
2742
|
const PLAN_TIMEOUT_MS = 15e4;
|
|
2013
|
-
const SLOT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
2014
2743
|
const AUDIT_FIELD_CHARS = 256;
|
|
2015
2744
|
const ACTION_LIMITS = {
|
|
2016
2745
|
timeoutMs: 12e4,
|
|
@@ -2068,8 +2797,23 @@ var FailureLimiter = class {
|
|
|
2068
2797
|
this.failures.set(key, [...this.recent(key, now), now]);
|
|
2069
2798
|
}
|
|
2070
2799
|
};
|
|
2800
|
+
function auditSafely(record) {
|
|
2801
|
+
try {
|
|
2802
|
+
appendAudit(record);
|
|
2803
|
+
} catch (cause) {
|
|
2804
|
+
console.error(`stim-server: could not append to the action log: ${cause.message}`);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
function take(bucket, cost, perSecond, capacity) {
|
|
2808
|
+
const now = Date.now();
|
|
2809
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + (now - bucket.at) / 1e3 * perSecond);
|
|
2810
|
+
bucket.at = now;
|
|
2811
|
+
if (bucket.tokens < cost) return false;
|
|
2812
|
+
bucket.tokens -= cost;
|
|
2813
|
+
return true;
|
|
2814
|
+
}
|
|
2071
2815
|
function send(socket, message) {
|
|
2072
|
-
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
2816
|
+
if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
|
|
2073
2817
|
}
|
|
2074
2818
|
function requestId(value) {
|
|
2075
2819
|
return typeof value === "number" || typeof value === "string" ? value : null;
|
|
@@ -2130,6 +2874,15 @@ async function startServer(options) {
|
|
|
2130
2874
|
};
|
|
2131
2875
|
if (options.frameHelper === void 0) buildHelper();
|
|
2132
2876
|
const frames = new FramePool(options.env, frameLimits, frameHelper);
|
|
2877
|
+
let foldBuild = null;
|
|
2878
|
+
const foldHelper = () => {
|
|
2879
|
+
if (options.foldHelper !== void 0) return Promise.resolve(options.foldHelper);
|
|
2880
|
+
foldBuild ??= buildFoldHelper(options.env, helperAbort.signal).catch((cause) => {
|
|
2881
|
+
foldBuild = null;
|
|
2882
|
+
throw new Error(`sim-fold could not be built: ${cause.message}`);
|
|
2883
|
+
});
|
|
2884
|
+
return foldBuild;
|
|
2885
|
+
};
|
|
2133
2886
|
const running = /* @__PURE__ */ new Set();
|
|
2134
2887
|
const logLimits = {
|
|
2135
2888
|
...LOG_LIMITS,
|
|
@@ -2144,8 +2897,28 @@ async function startServer(options) {
|
|
|
2144
2897
|
...options.actionLimits
|
|
2145
2898
|
};
|
|
2146
2899
|
const busyWorkspaces = /* @__PURE__ */ new Set();
|
|
2900
|
+
const planQueues = /* @__PURE__ */ new Map();
|
|
2147
2901
|
const sessions = /* @__PURE__ */ new Map();
|
|
2148
2902
|
const sampler = new UsageSampler();
|
|
2903
|
+
const controllers = /* @__PURE__ */ new Map();
|
|
2904
|
+
const controlLimits = {
|
|
2905
|
+
...CONTROL_LIMITS,
|
|
2906
|
+
...options.controlLimits
|
|
2907
|
+
};
|
|
2908
|
+
const control = new ControlHub({
|
|
2909
|
+
env: options.env,
|
|
2910
|
+
stimCli: options.stimCli,
|
|
2911
|
+
feeds,
|
|
2912
|
+
frames,
|
|
2913
|
+
statusFeed: STATUS_FEED,
|
|
2914
|
+
audit: auditSafely,
|
|
2915
|
+
lockLimits: LOCK_LIMITS,
|
|
2916
|
+
idleMs: controlLimits.idleMs,
|
|
2917
|
+
renewMs: controlLimits.renewMs,
|
|
2918
|
+
leaseFor: controlLimits.leaseFor,
|
|
2919
|
+
foldHelper,
|
|
2920
|
+
foldTimeoutMs: controlLimits.foldTimeoutMs
|
|
2921
|
+
});
|
|
2149
2922
|
const wss = new WebSocketServer({
|
|
2150
2923
|
noServer: true,
|
|
2151
2924
|
maxPayload: MAX_PAYLOAD
|
|
@@ -2158,8 +2931,12 @@ async function startServer(options) {
|
|
|
2158
2931
|
const watcher = watch(serverDir(), () => {
|
|
2159
2932
|
revocationCheck ??= setTimeout(() => {
|
|
2160
2933
|
revocationCheck = null;
|
|
2161
|
-
const paired = new
|
|
2934
|
+
const paired = new Map(readDevices().map((device) => [device.id, device]));
|
|
2162
2935
|
for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
|
|
2936
|
+
for (const [socket, controller] of controllers) if (!paired.get(controller.device.id)?.capabilities.includes("control")) {
|
|
2937
|
+
control.endFor(controller, "forbidden", "This device can no longer control devices.");
|
|
2938
|
+
controllers.delete(socket);
|
|
2939
|
+
}
|
|
2163
2940
|
}, 50);
|
|
2164
2941
|
});
|
|
2165
2942
|
function connection(socket, peer) {
|
|
@@ -2244,6 +3021,82 @@ async function startServer(options) {
|
|
|
2244
3021
|
}
|
|
2245
3022
|
});
|
|
2246
3023
|
}
|
|
3024
|
+
const inputs = {
|
|
3025
|
+
tokens: controlLimits.inputPerSecond,
|
|
3026
|
+
at: Date.now()
|
|
3027
|
+
};
|
|
3028
|
+
const characters = {
|
|
3029
|
+
tokens: 256,
|
|
3030
|
+
at: Date.now()
|
|
3031
|
+
};
|
|
3032
|
+
const shapeChanges = {
|
|
3033
|
+
tokens: controlLimits.shapeChangesPerSecond,
|
|
3034
|
+
at: Date.now()
|
|
3035
|
+
};
|
|
3036
|
+
function controller(session) {
|
|
3037
|
+
let found = controllers.get(socket);
|
|
3038
|
+
if (!found) {
|
|
3039
|
+
found = {
|
|
3040
|
+
device: session,
|
|
3041
|
+
send: (message) => send(socket, message)
|
|
3042
|
+
};
|
|
3043
|
+
controllers.set(socket, found);
|
|
3044
|
+
}
|
|
3045
|
+
return found;
|
|
3046
|
+
}
|
|
3047
|
+
async function beginControl(id, params, session) {
|
|
3048
|
+
const raw = isJsonObject(params) ? params : {};
|
|
3049
|
+
const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
|
|
3050
|
+
const refuseControl = (code, message) => {
|
|
3051
|
+
auditSafely({
|
|
3052
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3053
|
+
device: {
|
|
3054
|
+
id: session.id,
|
|
3055
|
+
name: session.name
|
|
3056
|
+
},
|
|
3057
|
+
action: raw.takeOver === true ? "control.take-over" : "control.begin",
|
|
3058
|
+
workspace: clip(raw.workspace),
|
|
3059
|
+
...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
|
|
3060
|
+
ok: false,
|
|
3061
|
+
error: {
|
|
3062
|
+
code,
|
|
3063
|
+
message: clip(message)
|
|
3064
|
+
}
|
|
3065
|
+
});
|
|
3066
|
+
error(id, code, message);
|
|
3067
|
+
};
|
|
3068
|
+
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.`);
|
|
3069
|
+
const parsed = parseControlBegin(params);
|
|
3070
|
+
if ("code" in parsed) return refuseControl(parsed.code, parsed.message);
|
|
3071
|
+
const resolved = registeredWorkspace(parsed.value.workspace);
|
|
3072
|
+
if ("code" in resolved) return refuseControl(resolved.code, resolved.message);
|
|
3073
|
+
const owner = controller(session);
|
|
3074
|
+
const outcome = await control.begin(owner, {
|
|
3075
|
+
...parsed.value,
|
|
3076
|
+
workspace: resolved.dir
|
|
3077
|
+
}, resolved.dir, () => socket.readyState === socket.OPEN && controllers.get(socket) === owner && readDevices().some((entry) => entry.id === session.id && entry.capabilities.includes("control")));
|
|
3078
|
+
if ("code" in outcome) return refuseControl(outcome.code, outcome.message);
|
|
3079
|
+
send(socket, {
|
|
3080
|
+
id,
|
|
3081
|
+
result: outcome
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
async function input(id, method, params, session) {
|
|
3085
|
+
const owner = controller(session);
|
|
3086
|
+
const parsed = parseInput(method, params, (name) => control.targetOf(owner, name));
|
|
3087
|
+
if ("code" in parsed) return error(id, parsed.code, parsed.message);
|
|
3088
|
+
if (!take(inputs, 1, controlLimits.inputPerSecond, controlLimits.inputPerSecond)) return error(id, "limit-exceeded", `A connection can send ${controlLimits.inputPerSecond} inputs a second.`);
|
|
3089
|
+
const { command: sent } = parsed.value;
|
|
3090
|
+
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.`);
|
|
3091
|
+
const { shapeChangesPerSecond } = controlLimits;
|
|
3092
|
+
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.`);
|
|
3093
|
+
const refused = await control.input(owner, parsed.value.session, parsed.value.command);
|
|
3094
|
+
if (refused) return error(id, refused.code, refused.message);
|
|
3095
|
+
send(socket, {
|
|
3096
|
+
id,
|
|
3097
|
+
result: {}
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
2247
3100
|
function workspaceDir(id, workspace, required) {
|
|
2248
3101
|
if (workspace === void 0 && !required) return homedir();
|
|
2249
3102
|
const resolved = registeredWorkspace(workspace);
|
|
@@ -2271,12 +3124,9 @@ async function startServer(options) {
|
|
|
2271
3124
|
function subscribeStatus(id) {
|
|
2272
3125
|
const subscription = openSubscription(id);
|
|
2273
3126
|
if (!subscription) return;
|
|
3127
|
+
const envelope = `{"event":"status","subscription":${JSON.stringify(subscription)},"payload":`;
|
|
2274
3128
|
const unsubscribe = feeds.subscribe(STATUS_FEED, {
|
|
2275
|
-
item: (
|
|
2276
|
-
event: "status",
|
|
2277
|
-
subscription,
|
|
2278
|
-
payload
|
|
2279
|
-
}),
|
|
3129
|
+
item: (_payload, text) => send(socket, `${envelope}${text}}`),
|
|
2280
3130
|
failed: (message) => {
|
|
2281
3131
|
subscriptions.delete(subscription);
|
|
2282
3132
|
send(socket, {
|
|
@@ -2473,30 +3323,53 @@ async function startServer(options) {
|
|
|
2473
3323
|
});
|
|
2474
3324
|
subscriptions.set(subscription, cleanup);
|
|
2475
3325
|
}
|
|
2476
|
-
function command(id, args, cwd, result, limits = commandLimits) {
|
|
2477
|
-
if (commands.size >= MAX_COMMANDS)
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
3326
|
+
function command(id, args, cwd, result, limits = commandLimits, turn = null) {
|
|
3327
|
+
if (commands.size >= MAX_COMMANDS) {
|
|
3328
|
+
error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
|
|
3329
|
+
return null;
|
|
3330
|
+
}
|
|
3331
|
+
let run = null;
|
|
3332
|
+
let dropped = false;
|
|
3333
|
+
let release;
|
|
3334
|
+
const released = new Promise((resolve) => {
|
|
3335
|
+
release = resolve;
|
|
3336
|
+
});
|
|
3337
|
+
const cancel = async () => {
|
|
3338
|
+
dropped = true;
|
|
3339
|
+
if (!run) return void (turn ?? Promise.resolve()).then(release);
|
|
3340
|
+
await run.cancel();
|
|
3341
|
+
release();
|
|
3342
|
+
};
|
|
3343
|
+
const start = async () => {
|
|
3344
|
+
if (dropped) return;
|
|
3345
|
+
run = runStim(options.stimCli, options.env, args, cwd, limits);
|
|
2482
3346
|
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;
|
|
3347
|
+
commands.delete(cancel);
|
|
3348
|
+
running.delete(cancel);
|
|
2490
3349
|
try {
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
3350
|
+
if (!outcome.ok) {
|
|
3351
|
+
const printed = actionOutcome(outcome);
|
|
3352
|
+
return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
|
|
3353
|
+
}
|
|
3354
|
+
let value;
|
|
3355
|
+
try {
|
|
3356
|
+
value = result(outcome.stdout);
|
|
3357
|
+
} catch {
|
|
3358
|
+
return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
|
|
3359
|
+
}
|
|
3360
|
+
send(socket, {
|
|
3361
|
+
id,
|
|
3362
|
+
result: value
|
|
3363
|
+
});
|
|
3364
|
+
} finally {
|
|
3365
|
+
release();
|
|
2494
3366
|
}
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
3367
|
+
};
|
|
3368
|
+
commands.add(cancel);
|
|
3369
|
+
running.add(cancel);
|
|
3370
|
+
if (turn) turn.then(start);
|
|
3371
|
+
else start();
|
|
3372
|
+
return released;
|
|
2500
3373
|
}
|
|
2501
3374
|
function queryLogs(id, params) {
|
|
2502
3375
|
const parsed = parseLogFilter(params);
|
|
@@ -2613,7 +3486,7 @@ async function startServer(options) {
|
|
|
2613
3486
|
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
3487
|
const cwd = workspaceDir(id, params.workspace, true);
|
|
2615
3488
|
if (!cwd) return;
|
|
2616
|
-
command(id, [
|
|
3489
|
+
const finished = command(id, [
|
|
2617
3490
|
platform,
|
|
2618
3491
|
"--plan",
|
|
2619
3492
|
"--json",
|
|
@@ -2625,6 +3498,11 @@ async function startServer(options) {
|
|
|
2625
3498
|
}, {
|
|
2626
3499
|
...commandLimits,
|
|
2627
3500
|
timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
|
|
3501
|
+
}, planQueues.get(cwd) ?? null);
|
|
3502
|
+
if (!finished) return;
|
|
3503
|
+
planQueues.set(cwd, finished);
|
|
3504
|
+
finished.finally(() => {
|
|
3505
|
+
if (planQueues.get(cwd) === finished) planQueues.delete(cwd);
|
|
2628
3506
|
});
|
|
2629
3507
|
}
|
|
2630
3508
|
async function handle(raw) {
|
|
@@ -2684,6 +3562,23 @@ async function startServer(options) {
|
|
|
2684
3562
|
}
|
|
2685
3563
|
if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
|
|
2686
3564
|
if (message.method === "action") return runAction(id, message.params, device);
|
|
3565
|
+
if (message.method === "control.begin") {
|
|
3566
|
+
beginControl(id, message.params, device);
|
|
3567
|
+
return;
|
|
3568
|
+
}
|
|
3569
|
+
if (message.method === "control.end") {
|
|
3570
|
+
const name = isJsonObject(message.params) ? message.params.session : void 0;
|
|
3571
|
+
if (typeof name !== "string" || !control.endById(controller(device), name)) return error(id, "unknown-session", `No control session ${String(name)} on this connection.`);
|
|
3572
|
+
return send(socket, {
|
|
3573
|
+
id,
|
|
3574
|
+
result: {}
|
|
3575
|
+
});
|
|
3576
|
+
}
|
|
3577
|
+
if (message.method === "input.touch" || message.method === "input.text" || message.method === "input.button" || message.method === "input.rotate" || message.method === "input.posture") {
|
|
3578
|
+
const method = message.method;
|
|
3579
|
+
input(id, method, message.params, device);
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
2687
3582
|
if (message.method === "unsubscribe") {
|
|
2688
3583
|
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
2689
3584
|
const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
|
|
@@ -2717,6 +3612,9 @@ async function startServer(options) {
|
|
|
2717
3612
|
clearTimeout(timer);
|
|
2718
3613
|
sessions.delete(socket);
|
|
2719
3614
|
if (sessions.size === 0) sampler.stop();
|
|
3615
|
+
const owner = controllers.get(socket);
|
|
3616
|
+
if (owner) control.endFor(owner, null, "The client disconnected.");
|
|
3617
|
+
controllers.delete(socket);
|
|
2720
3618
|
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
2721
3619
|
subscriptions.clear();
|
|
2722
3620
|
for (const cancel of commands) {
|
|
@@ -2759,6 +3657,7 @@ async function startServer(options) {
|
|
|
2759
3657
|
sampler.stop();
|
|
2760
3658
|
if (revocationCheck) clearTimeout(revocationCheck);
|
|
2761
3659
|
for (const client of wss.clients) client.terminate();
|
|
3660
|
+
await control.close();
|
|
2762
3661
|
await Promise.all([
|
|
2763
3662
|
frames.close(),
|
|
2764
3663
|
feeds.close(),
|
|
@@ -2948,7 +3847,7 @@ async function main() {
|
|
|
2948
3847
|
if (values.json) return void console.log(JSON.stringify({ actions: records }));
|
|
2949
3848
|
if (!records.length) console.log("No actions.");
|
|
2950
3849
|
for (const record of records) {
|
|
2951
|
-
const outcome = record.ok ?
|
|
3850
|
+
const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
|
|
2952
3851
|
const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
|
|
2953
3852
|
console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
|
|
2954
3853
|
}
|