@stim-cli/server 1.13.0 → 1.15.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 +1590 -42
- package/dist/protocol-MBMvJpvV.mjs +2715 -0
- package/dist/protocol.d.mts +2 -473
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5121 -514
- package/dist/scrcpy/LICENSE +204 -0
- package/dist/scrcpy/NOTICE +22 -0
- package/dist/scrcpy/scrcpy-server +0 -0
- package/dist/stats-read.d.mts +1 -0
- package/dist/stats-read.mjs +46 -0
- package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
- package/dist/stim-frames/DevToolsClient.swift +180 -0
- package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
- package/dist/stim-frames/DeviceFrameView.swift +167 -0
- package/dist/stim-frames/DuoCaptureSource.swift +132 -0
- package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
- package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
- package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
- package/dist/stim-frames/DuoMesh.swift +100 -0
- package/dist/stim-frames/DuoModelView.swift +275 -0
- package/dist/stim-frames/DuoPoseClip.swift +140 -0
- package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
- package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
- package/dist/stim-frames/EmulatorInput.swift +13 -2
- package/dist/stim-frames/EmulatorPosture.swift +2 -2
- package/dist/stim-frames/FrameArtwork.swift +37 -0
- package/dist/stim-frames/H264Decoder.swift +109 -0
- package/dist/stim-frames/InputMessages.swift +27 -6
- package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
- package/dist/stim-frames/MacosSource.swift +677 -0
- package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
- package/dist/stim-frames/OwnedAppWindows.swift +108 -0
- package/dist/stim-frames/PhoneSource.swift +315 -0
- package/dist/stim-frames/ProcessStream.swift +155 -0
- package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
- package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
- package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
- package/dist/stim-frames/SimulatorHID.swift +70 -29
- package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
- package/dist/stim-frames/SimulatorPosture.swift +277 -0
- package/dist/stim-frames/SimulatorRotation.swift +28 -8
- package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
- package/dist/stim-frames/StoragePaths.swift +42 -0
- package/dist/stim-frames/VideoEncoder.swift +8 -6
- package/dist/stim-frames/WebPage.swift +301 -0
- package/dist/stim-frames/main.swift +651 -22
- package/dist/stim-host/Info.plist +28 -0
- package/dist/stim-host/stim-host.c +153 -0
- package/dist/stim-server-OiJI9xLU.mjs +13336 -0
- package/dist/stim-server.mjs +6 -3880
- package/package.json +3 -3
- package/dist/protocol-rC-5KT1O.mjs +0 -822
package/dist/stim-server.mjs
CHANGED
|
@@ -1,3883 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { basename, delimiter, dirname, join } from "node:path";
|
|
9
|
-
import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
|
|
10
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
-
import { configDir, withDirLock } from "@stim-cli/core";
|
|
12
|
-
import { createServer } from "node:http";
|
|
13
|
-
import { isIP } from "node:net";
|
|
14
|
-
import { WebSocketServer } from "ws";
|
|
15
|
-
import { connect } from "node:http2";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { createInterface } from "node:readline";
|
|
18
|
-
//#region src/environment.ts
|
|
19
|
-
function parseEnvironment(output) {
|
|
20
|
-
const environment = {};
|
|
21
|
-
for (const entry of output.split("\0")) {
|
|
22
|
-
const equals = entry.indexOf("=");
|
|
23
|
-
if (equals > 0) environment[entry.slice(0, equals)] = entry.slice(equals + 1);
|
|
24
|
-
}
|
|
25
|
-
return environment;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* The login shell's environment, as Stim Desktop captures it: a process started by launchd lacks the
|
|
29
|
-
* PATH entries and variables such as ANDROID_HOME that the shell profile sets. The shell writes to a
|
|
30
|
-
* file because a background process started by a profile can keep a pipe open after the shell exits.
|
|
31
|
-
*/
|
|
32
|
-
function loginShellEnvironment() {
|
|
33
|
-
const dir = mkdtempSync(join(tmpdir(), "stim-server-env-"));
|
|
34
|
-
const file = join(dir, "env");
|
|
35
|
-
try {
|
|
36
|
-
spawnSync(process.env.SHELL || "/bin/zsh", [
|
|
37
|
-
"-lic",
|
|
38
|
-
"command env -0 > \"$1\"",
|
|
39
|
-
"stim-server",
|
|
40
|
-
file
|
|
41
|
-
], {
|
|
42
|
-
stdio: "ignore",
|
|
43
|
-
timeout: 15e3
|
|
44
|
-
});
|
|
45
|
-
const environment = parseEnvironment(readFileSync(file, "utf8"));
|
|
46
|
-
return Object.keys(environment).length ? environment : null;
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
} finally {
|
|
50
|
-
rmSync(dir, {
|
|
51
|
-
recursive: true,
|
|
52
|
-
force: true
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
function bundledStim() {
|
|
57
|
-
const manifest = createRequire(import.meta.url).resolve("stim/package.json");
|
|
58
|
-
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
59
|
-
return {
|
|
60
|
-
cli: join(dirname(manifest), pkg.bin.stim),
|
|
61
|
-
version: pkg.version
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
//#endregion
|
|
65
|
-
//#region src/registry.ts
|
|
66
|
-
const PAIRING_TTL_MS = 3e5;
|
|
67
|
-
function serverDir() {
|
|
68
|
-
return join(configDir(), "server");
|
|
69
|
-
}
|
|
70
|
-
function devicesFile() {
|
|
71
|
-
return join(serverDir(), "devices.json");
|
|
72
|
-
}
|
|
73
|
-
function pairingFile() {
|
|
74
|
-
return join(serverDir(), "pairing.json");
|
|
75
|
-
}
|
|
76
|
-
function hashToken(token) {
|
|
77
|
-
return createHash("sha256").update(token).digest("hex");
|
|
78
|
-
}
|
|
79
|
-
function newToken() {
|
|
80
|
-
return randomBytes(32).toString("base64url");
|
|
81
|
-
}
|
|
82
|
-
function parseIdentity(value) {
|
|
83
|
-
if (!isJsonObject(value)) return null;
|
|
84
|
-
if (value.kind === "local") return { kind: "local" };
|
|
85
|
-
const { nodeId, nodeName, user } = value;
|
|
86
|
-
if (value.kind === "tailnet" && typeof nodeId === "string" && typeof nodeName === "string" && typeof user === "string") return {
|
|
87
|
-
kind: "tailnet",
|
|
88
|
-
nodeId,
|
|
89
|
-
nodeName,
|
|
90
|
-
user
|
|
91
|
-
};
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
function parseCapabilities(value) {
|
|
95
|
-
return Array.isArray(value) ? CAPABILITIES.filter((capability) => value.includes(capability)) : [];
|
|
96
|
-
}
|
|
97
|
-
function capabilitiesFor(control) {
|
|
98
|
-
return control ? ["read", "control"] : ["read"];
|
|
99
|
-
}
|
|
100
|
-
function parseDevice(value) {
|
|
101
|
-
if (!isJsonObject(value)) return null;
|
|
102
|
-
const { id, name, tokenHash, pairedAt, lastSeenAt } = value;
|
|
103
|
-
const identity = parseIdentity(value.identity);
|
|
104
|
-
if (typeof id !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || !identity) return null;
|
|
105
|
-
if (typeof pairedAt !== "string") return null;
|
|
106
|
-
return {
|
|
107
|
-
id,
|
|
108
|
-
name,
|
|
109
|
-
tokenHash,
|
|
110
|
-
identity,
|
|
111
|
-
pairedAt,
|
|
112
|
-
lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
|
|
113
|
-
capabilities: parseCapabilities(value.capabilities)
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
function readDevices() {
|
|
117
|
-
const devices = readJsonObject(devicesFile())?.devices;
|
|
118
|
-
return Array.isArray(devices) ? devices.flatMap((entry) => parseDevice(entry) ?? []) : [];
|
|
119
|
-
}
|
|
120
|
-
function readPairings() {
|
|
121
|
-
const tokens = readJsonObject(pairingFile())?.tokens;
|
|
122
|
-
if (!Array.isArray(tokens)) return [];
|
|
123
|
-
return tokens.flatMap((entry) => isJsonObject(entry) && typeof entry.tokenHash === "string" && typeof entry.expiresAt === "string" ? [{
|
|
124
|
-
tokenHash: entry.tokenHash,
|
|
125
|
-
expiresAt: entry.expiresAt,
|
|
126
|
-
capabilities: "capabilities" in entry ? parseCapabilities(entry.capabilities) : capabilitiesFor(false)
|
|
127
|
-
}] : []);
|
|
128
|
-
}
|
|
129
|
-
function writeJson(file, value) {
|
|
130
|
-
const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
|
|
131
|
-
try {
|
|
132
|
-
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
133
|
-
renameSync(temporary, file);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
rmSync(temporary, { force: true });
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function transaction(fn) {
|
|
140
|
-
return withDirLock(join(serverDir(), "registry.lock"), fn, { ensureParent: () => mkdirSync(serverDir(), {
|
|
141
|
-
recursive: true,
|
|
142
|
-
mode: 448
|
|
143
|
-
}) });
|
|
144
|
-
}
|
|
145
|
-
function unexpired(record, now) {
|
|
146
|
-
return Date.parse(record.expiresAt) > now;
|
|
147
|
-
}
|
|
148
|
-
/** The device that spends the token gets `capabilities`. */
|
|
149
|
-
function createPairingToken(now = Date.now(), capabilities = capabilitiesFor(false)) {
|
|
150
|
-
const token = newToken();
|
|
151
|
-
const expiresAt = new Date(now + PAIRING_TTL_MS).toISOString();
|
|
152
|
-
transaction(() => {
|
|
153
|
-
const pending = readPairings().filter((record) => unexpired(record, now));
|
|
154
|
-
writeJson(pairingFile(), {
|
|
155
|
-
version: 1,
|
|
156
|
-
tokens: [...pending, {
|
|
157
|
-
tokenHash: hashToken(token),
|
|
158
|
-
expiresAt,
|
|
159
|
-
capabilities
|
|
160
|
-
}]
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
return {
|
|
164
|
-
token,
|
|
165
|
-
expiresAt
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
function sameNode(a, b) {
|
|
169
|
-
if (a.kind === "local" || b.kind === "local") return a.kind === b.kind;
|
|
170
|
-
return a.nodeId === b.nodeId;
|
|
171
|
-
}
|
|
172
|
-
function spendPairingToken(token, name, identity, now = Date.now()) {
|
|
173
|
-
return transaction(() => {
|
|
174
|
-
const pairings = readPairings();
|
|
175
|
-
const tokenHash = hashToken(token);
|
|
176
|
-
const match = pairings.find((record) => record.tokenHash === tokenHash);
|
|
177
|
-
const pending = pairings.filter((record) => record !== match && unexpired(record, now));
|
|
178
|
-
if (pending.length !== pairings.length) writeJson(pairingFile(), {
|
|
179
|
-
version: 1,
|
|
180
|
-
tokens: pending
|
|
181
|
-
});
|
|
182
|
-
if (!match) return {
|
|
183
|
-
ok: false,
|
|
184
|
-
reason: "pairing-unknown"
|
|
185
|
-
};
|
|
186
|
-
if (!unexpired(match, now)) return {
|
|
187
|
-
ok: false,
|
|
188
|
-
reason: "pairing-expired"
|
|
189
|
-
};
|
|
190
|
-
const deviceToken = newToken();
|
|
191
|
-
const at = new Date(now).toISOString();
|
|
192
|
-
const device = {
|
|
193
|
-
id: randomBytes(4).toString("hex"),
|
|
194
|
-
name,
|
|
195
|
-
tokenHash: hashToken(deviceToken),
|
|
196
|
-
identity,
|
|
197
|
-
pairedAt: at,
|
|
198
|
-
lastSeenAt: at,
|
|
199
|
-
capabilities: match.capabilities
|
|
200
|
-
};
|
|
201
|
-
writeJson(devicesFile(), {
|
|
202
|
-
version: 1,
|
|
203
|
-
devices: [...readDevices(), device]
|
|
204
|
-
});
|
|
205
|
-
return {
|
|
206
|
-
ok: true,
|
|
207
|
-
device,
|
|
208
|
-
deviceToken
|
|
209
|
-
};
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
function authenticateDevice(token, identity, now = Date.now()) {
|
|
213
|
-
return transaction(() => {
|
|
214
|
-
const devices = readDevices();
|
|
215
|
-
const tokenHash = hashToken(token);
|
|
216
|
-
const device = devices.find((entry) => entry.tokenHash === tokenHash);
|
|
217
|
-
if (!device) return {
|
|
218
|
-
ok: false,
|
|
219
|
-
reason: "device-unknown"
|
|
220
|
-
};
|
|
221
|
-
if (!sameNode(device.identity, identity)) return {
|
|
222
|
-
ok: false,
|
|
223
|
-
reason: "node-mismatch"
|
|
224
|
-
};
|
|
225
|
-
device.lastSeenAt = new Date(now).toISOString();
|
|
226
|
-
writeJson(devicesFile(), {
|
|
227
|
-
version: 1,
|
|
228
|
-
devices
|
|
229
|
-
});
|
|
230
|
-
return {
|
|
231
|
-
ok: true,
|
|
232
|
-
device
|
|
233
|
-
};
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
function grantDevice(id, capabilities) {
|
|
237
|
-
return transaction(() => {
|
|
238
|
-
const devices = readDevices();
|
|
239
|
-
const device = devices.find((entry) => entry.id === id);
|
|
240
|
-
if (!device) return false;
|
|
241
|
-
device.capabilities = capabilities;
|
|
242
|
-
writeJson(devicesFile(), {
|
|
243
|
-
version: 1,
|
|
244
|
-
devices
|
|
245
|
-
});
|
|
246
|
-
return true;
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
function revokeDevice(id) {
|
|
250
|
-
return transaction(() => {
|
|
251
|
-
const devices = readDevices();
|
|
252
|
-
const remaining = devices.filter((device) => device.id !== id);
|
|
253
|
-
if (remaining.length === devices.length) return false;
|
|
254
|
-
writeJson(devicesFile(), {
|
|
255
|
-
version: 1,
|
|
256
|
-
devices: remaining
|
|
257
|
-
});
|
|
258
|
-
return true;
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
//#endregion
|
|
262
|
-
//#region src/actions.ts
|
|
263
|
-
function parseAction(params) {
|
|
264
|
-
if (!isJsonObject(params)) return {
|
|
265
|
-
code: "bad-request",
|
|
266
|
-
message: "params must be an object."
|
|
267
|
-
};
|
|
268
|
-
const { action, workspace, platform, ...rest } = params;
|
|
269
|
-
if (typeof action !== "string" || !ACTIONS.includes(action)) return {
|
|
270
|
-
code: "unknown-action",
|
|
271
|
-
message: `Unknown action ${JSON.stringify(action)}. Actions: ${ACTIONS.join(", ")}.`
|
|
272
|
-
};
|
|
273
|
-
if (typeof workspace !== "string") return {
|
|
274
|
-
code: "bad-request",
|
|
275
|
-
message: "params.workspace must be an environment path from a status payload."
|
|
276
|
-
};
|
|
277
|
-
const extra = Object.keys(rest);
|
|
278
|
-
if (action === "stop" && platform !== void 0) extra.push("platform");
|
|
279
|
-
if (extra.length) return {
|
|
280
|
-
code: "bad-request",
|
|
281
|
-
message: `${action} does not take ${extra.join(", ")}.`
|
|
282
|
-
};
|
|
283
|
-
if (action === "stop") return { action: {
|
|
284
|
-
action,
|
|
285
|
-
workspace
|
|
286
|
-
} };
|
|
287
|
-
if (platform !== void 0 && !RELOAD_PLATFORMS.includes(platform)) return {
|
|
288
|
-
code: "bad-request",
|
|
289
|
-
message: `platform must be one of ${RELOAD_PLATFORMS.join(", ")}.`
|
|
290
|
-
};
|
|
291
|
-
return { action: {
|
|
292
|
-
action: "reload",
|
|
293
|
-
workspace,
|
|
294
|
-
...platform ? { platform } : {}
|
|
295
|
-
} };
|
|
296
|
-
}
|
|
297
|
-
function actionArgs(action) {
|
|
298
|
-
if (action.action === "stop") return ["stop", "--json"];
|
|
299
|
-
return [
|
|
300
|
-
"reload",
|
|
301
|
-
...action.platform ? [action.platform] : [],
|
|
302
|
-
"--json"
|
|
303
|
-
];
|
|
304
|
-
}
|
|
305
|
-
/** The JSON object the command printed, and on failure the message and remedy it printed, if any. */
|
|
306
|
-
function actionOutcome(outcome) {
|
|
307
|
-
let printed = null;
|
|
308
|
-
try {
|
|
309
|
-
printed = JSON.parse(outcome.stdout?.trim() ?? "");
|
|
310
|
-
} catch {
|
|
311
|
-
printed = null;
|
|
312
|
-
}
|
|
313
|
-
if (outcome.ok) return isJsonObject(printed) ? {
|
|
314
|
-
ok: true,
|
|
315
|
-
output: printed
|
|
316
|
-
} : {
|
|
317
|
-
ok: false,
|
|
318
|
-
error: {
|
|
319
|
-
code: "action-failed",
|
|
320
|
-
message: "The command printed output that is not a JSON object."
|
|
321
|
-
}
|
|
322
|
-
};
|
|
323
|
-
if (isJsonObject(printed) && typeof printed.message === "string") {
|
|
324
|
-
const code = typeof printed.code === "string" ? `${printed.code}: ` : "";
|
|
325
|
-
const remedy = typeof printed.remedy === "string" ? ` ${printed.remedy}` : "";
|
|
326
|
-
return {
|
|
327
|
-
ok: false,
|
|
328
|
-
error: {
|
|
329
|
-
code: "action-failed",
|
|
330
|
-
message: `${code}${printed.message}${remedy}`
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
return {
|
|
335
|
-
ok: false,
|
|
336
|
-
error: {
|
|
337
|
-
code: "action-failed",
|
|
338
|
-
message: outcome.message
|
|
339
|
-
}
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
function auditFile() {
|
|
343
|
-
return join(serverDir(), "actions.ndjson");
|
|
344
|
-
}
|
|
345
|
-
function appendAudit(record) {
|
|
346
|
-
mkdirSync(serverDir(), {
|
|
347
|
-
recursive: true,
|
|
348
|
-
mode: 448
|
|
349
|
-
});
|
|
350
|
-
appendFileSync(auditFile(), `${JSON.stringify(record)}\n`, { mode: 384 });
|
|
351
|
-
}
|
|
352
|
-
function readAudit() {
|
|
353
|
-
let text;
|
|
354
|
-
try {
|
|
355
|
-
text = readFileSync(auditFile(), "utf8");
|
|
356
|
-
} catch {
|
|
357
|
-
return [];
|
|
358
|
-
}
|
|
359
|
-
return text.split("\n").flatMap((line) => {
|
|
360
|
-
try {
|
|
361
|
-
const value = JSON.parse(line);
|
|
362
|
-
return isJsonObject(value) ? [value] : [];
|
|
363
|
-
} catch {
|
|
364
|
-
return [];
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
//#endregion
|
|
369
|
-
//#region src/stim-command.ts
|
|
370
|
-
const KILL_GRACE_MS = 1e3;
|
|
371
|
-
function terminate(child) {
|
|
372
|
-
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
|
|
373
|
-
child.kill("SIGTERM");
|
|
374
|
-
const timer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
|
|
375
|
-
return new Promise((resolve) => {
|
|
376
|
-
const done = () => {
|
|
377
|
-
clearTimeout(timer);
|
|
378
|
-
resolve();
|
|
379
|
-
};
|
|
380
|
-
child.once("exit", done);
|
|
381
|
-
child.once("close", done);
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
function runStim(stimCli, env, args, cwd, limits) {
|
|
385
|
-
const child = spawn(process.execPath, [stimCli, ...args], {
|
|
386
|
-
cwd,
|
|
387
|
-
env,
|
|
388
|
-
stdio: [
|
|
389
|
-
"ignore",
|
|
390
|
-
"pipe",
|
|
391
|
-
"pipe"
|
|
392
|
-
]
|
|
393
|
-
});
|
|
394
|
-
const label = `stim ${args[0]}`;
|
|
395
|
-
let cancelled = false;
|
|
396
|
-
let failure = null;
|
|
397
|
-
const chunks = [];
|
|
398
|
-
let size = 0;
|
|
399
|
-
let stderr = "";
|
|
400
|
-
const release = () => {
|
|
401
|
-
child.stdout.destroy();
|
|
402
|
-
child.stderr.destroy();
|
|
403
|
-
};
|
|
404
|
-
const fail = (message) => {
|
|
405
|
-
if (failure) return;
|
|
406
|
-
failure = message;
|
|
407
|
-
release();
|
|
408
|
-
terminate(child);
|
|
409
|
-
};
|
|
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);
|
|
414
|
-
child.stdout.on("data", (chunk) => {
|
|
415
|
-
size += chunk.length;
|
|
416
|
-
if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
|
|
417
|
-
chunks.push(chunk);
|
|
418
|
-
});
|
|
419
|
-
child.stderr.setEncoding("utf8");
|
|
420
|
-
child.stderr.on("data", (chunk) => {
|
|
421
|
-
stderr = (stderr + chunk).slice(-2e3);
|
|
422
|
-
});
|
|
423
|
-
return {
|
|
424
|
-
outcome: new Promise((resolve) => {
|
|
425
|
-
const settle = (result) => {
|
|
426
|
-
clearTimeout(timer);
|
|
427
|
-
if (!cancelled) resolve(result);
|
|
428
|
-
};
|
|
429
|
-
child.on("error", (error) => settle({
|
|
430
|
-
ok: false,
|
|
431
|
-
message: `${label} could not start (${error.message}).`
|
|
432
|
-
}));
|
|
433
|
-
child.on("close", (code, signal) => {
|
|
434
|
-
if (failure) return settle({
|
|
435
|
-
ok: false,
|
|
436
|
-
message: failure
|
|
437
|
-
});
|
|
438
|
-
if (code === 0) return settle({
|
|
439
|
-
ok: true,
|
|
440
|
-
stdout: Buffer.concat(chunks).toString("utf8")
|
|
441
|
-
});
|
|
442
|
-
const detail = stderr.trim();
|
|
443
|
-
settle({
|
|
444
|
-
ok: false,
|
|
445
|
-
message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`,
|
|
446
|
-
stdout: Buffer.concat(chunks).toString("utf8")
|
|
447
|
-
});
|
|
448
|
-
});
|
|
449
|
-
}),
|
|
450
|
-
cancel: () => {
|
|
451
|
-
cancelled = true;
|
|
452
|
-
clearTimeout(timer);
|
|
453
|
-
return terminate(child);
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
//#endregion
|
|
458
|
-
//#region src/video.ts
|
|
459
|
-
const FIXED_HEADER_BYTES = 21;
|
|
460
|
-
/** The binary WebSocket message for `unit` on `subscription`; see `VideoPacket` in protocol.ts for the layout. */
|
|
461
|
-
function videoPacket(subscription, sequence, unit) {
|
|
462
|
-
const id = Buffer.from(subscription, "ascii");
|
|
463
|
-
const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
|
|
464
|
-
header.writeUInt8(1, 0);
|
|
465
|
-
const posture = unit.posture === "folded" ? 2 : unit.posture === "unfolded" ? 4 : 0;
|
|
466
|
-
header.writeUInt8((unit.keyframe ? 1 : 0) | posture, 1);
|
|
467
|
-
header.writeUInt16BE(header.length, 2);
|
|
468
|
-
header.writeUInt32BE(sequence >>> 0, 4);
|
|
469
|
-
header.writeDoubleBE(unit.capturedAt, 8);
|
|
470
|
-
header.writeUInt16BE(unit.width, 16);
|
|
471
|
-
header.writeUInt16BE(unit.height, 18);
|
|
472
|
-
header.writeUInt8(id.length, 20);
|
|
473
|
-
id.copy(header, FIXED_HEADER_BYTES);
|
|
474
|
-
return Buffer.concat([header, unit.data]);
|
|
475
|
-
}
|
|
476
|
-
const DEFAULT_VIDEO_LIMITS = {
|
|
477
|
-
congestedBytes: 262144,
|
|
478
|
-
startBitrate: 3e6,
|
|
479
|
-
minBitrate: 25e4,
|
|
480
|
-
maxBitrate: 8e6,
|
|
481
|
-
recoverMs: 2e3
|
|
482
|
-
};
|
|
483
|
-
/**
|
|
484
|
-
* Decides, for one subscriber, whether each access unit goes out. A subscriber starts waiting for a keyframe.
|
|
485
|
-
* When its socket backs up it drops everything until the next keyframe, because a P-frame is useless without
|
|
486
|
-
* the frames before it; `congested` is reported once per episode.
|
|
487
|
-
*/
|
|
488
|
-
var VideoGate = class {
|
|
489
|
-
waiting = true;
|
|
490
|
-
congestedBytes;
|
|
491
|
-
constructor(congestedBytes) {
|
|
492
|
-
this.congestedBytes = congestedBytes;
|
|
493
|
-
}
|
|
494
|
-
admit(unit, bufferedBytes) {
|
|
495
|
-
if (bufferedBytes > this.congestedBytes) {
|
|
496
|
-
if (this.waiting) return "drop";
|
|
497
|
-
this.waiting = true;
|
|
498
|
-
return "congested";
|
|
499
|
-
}
|
|
500
|
-
if (this.waiting && !unit.keyframe) return "drop";
|
|
501
|
-
this.waiting = false;
|
|
502
|
-
return "send";
|
|
503
|
-
}
|
|
504
|
-
/** The client lost its decoder state; drop until the keyframe it asked for. */
|
|
505
|
-
reset() {
|
|
506
|
-
this.waiting = true;
|
|
507
|
-
}
|
|
508
|
-
};
|
|
509
|
-
/**
|
|
510
|
-
* One encoder's bitrate, shared by all subscribers of a device: halved on congestion at most once per `recoverMs`,
|
|
511
|
-
* and raised by a quarter after `recoverMs` without congestion.
|
|
512
|
-
*/
|
|
513
|
-
var Bitrate = class {
|
|
514
|
-
value;
|
|
515
|
-
calmSince;
|
|
516
|
-
cutAt = -Infinity;
|
|
517
|
-
limits;
|
|
518
|
-
constructor(limits, now) {
|
|
519
|
-
this.limits = limits;
|
|
520
|
-
this.value = limits.startBitrate;
|
|
521
|
-
this.calmSince = now;
|
|
522
|
-
}
|
|
523
|
-
get current() {
|
|
524
|
-
return this.value;
|
|
525
|
-
}
|
|
526
|
-
/** Called while any subscriber is congested. Returns the new bitrate when it changed. */
|
|
527
|
-
congested(now) {
|
|
528
|
-
this.calmSince = now;
|
|
529
|
-
if (now - this.cutAt < this.limits.recoverMs) return null;
|
|
530
|
-
this.cutAt = now;
|
|
531
|
-
return this.set(Math.max(this.limits.minBitrate, Math.round(this.value / 2)));
|
|
532
|
-
}
|
|
533
|
-
/** Returns the new bitrate when it changed. */
|
|
534
|
-
tick(now) {
|
|
535
|
-
if (now - this.calmSince < this.limits.recoverMs) return null;
|
|
536
|
-
this.calmSince = now;
|
|
537
|
-
return this.set(Math.min(this.limits.maxBitrate, Math.round(this.value * 1.25)));
|
|
538
|
-
}
|
|
539
|
-
set(next) {
|
|
540
|
-
if (next === this.value) return null;
|
|
541
|
-
this.value = next;
|
|
542
|
-
return next;
|
|
543
|
-
}
|
|
544
|
-
};
|
|
545
|
-
//#endregion
|
|
546
|
-
//#region src/frame-helper.ts
|
|
547
|
-
const DEFAULT_FRAME_HINT = {
|
|
548
|
-
fps: FRAME_FPS.default,
|
|
549
|
-
maxEdge: FRAME_EDGE.default
|
|
550
|
-
};
|
|
551
|
-
const SOURCES_DIR = fileURLToPath(new URL("./stim-frames/", import.meta.url));
|
|
552
|
-
const BUILD_TIMEOUT_MS = 18e4;
|
|
553
|
-
const VERSION_TIMEOUT_MS = 3e4;
|
|
554
|
-
const MAX_MESSAGE_BYTES = 16777216;
|
|
555
|
-
const FRAME_MESSAGE = 1;
|
|
556
|
-
const NOTICE_MESSAGE = 2;
|
|
557
|
-
const VIDEO_MESSAGE = 3;
|
|
558
|
-
const VIDEO_HEADER_BYTES = 14;
|
|
559
|
-
const KEYFRAME_INTERVAL_MS = 250;
|
|
560
|
-
/** Runs the compiler in its own process group, so a timeout or `signal` also stops `swift-frontend` and `ld`. */
|
|
561
|
-
function run(file, args, env, timeoutMs, signal) {
|
|
562
|
-
return new Promise((resolve, reject) => {
|
|
563
|
-
const child = spawn(file, args, {
|
|
564
|
-
env,
|
|
565
|
-
stdio: [
|
|
566
|
-
"ignore",
|
|
567
|
-
"pipe",
|
|
568
|
-
"pipe"
|
|
569
|
-
],
|
|
570
|
-
detached: true
|
|
571
|
-
});
|
|
572
|
-
let output = "";
|
|
573
|
-
let stopped = null;
|
|
574
|
-
const stop = (reason) => {
|
|
575
|
-
stopped ??= reason;
|
|
576
|
-
try {
|
|
577
|
-
process.kill(-child.pid, "SIGKILL");
|
|
578
|
-
} catch {}
|
|
579
|
-
};
|
|
580
|
-
const timer = setTimeout(() => stop(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`), timeoutMs);
|
|
581
|
-
const abort = () => stop(`${file} ${args[0]} was stopped with the server.`);
|
|
582
|
-
signal?.addEventListener("abort", abort);
|
|
583
|
-
child.stdout.setEncoding("utf8");
|
|
584
|
-
child.stderr.setEncoding("utf8");
|
|
585
|
-
child.stdout.on("data", (chunk) => output = (output + chunk).slice(-4e3));
|
|
586
|
-
child.stderr.on("data", (chunk) => output = (output + chunk).slice(-4e3));
|
|
587
|
-
child.on("error", (error) => {
|
|
588
|
-
clearTimeout(timer);
|
|
589
|
-
signal?.removeEventListener("abort", abort);
|
|
590
|
-
reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
|
|
591
|
-
});
|
|
592
|
-
child.on("close", (code) => {
|
|
593
|
-
clearTimeout(timer);
|
|
594
|
-
signal?.removeEventListener("abort", abort);
|
|
595
|
-
if (stopped) reject(new Error(stopped));
|
|
596
|
-
else if (code === 0) resolve(output);
|
|
597
|
-
else reject(/* @__PURE__ */ new Error(`${file} ${args[0]} exited (code ${code}): ${output.trim()}`));
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* Compiles the `stim-frames` helper from the Swift sources shipped in `dist/stim-frames/` into
|
|
603
|
-
* `$STIM_HOME/server/helpers/`, named by a hash of the sources and the compiler version. Resolves to the
|
|
604
|
-
* helper's path, or rejects with the reason it cannot be built.
|
|
605
|
-
*/
|
|
606
|
-
async function buildFrameHelper(env, signal, sourcesDir = SOURCES_DIR) {
|
|
607
|
-
if (process.platform !== "darwin") throw new Error("stim-frames runs only on macOS.");
|
|
608
|
-
const sources = readdirSync(sourcesDir).filter((name) => name.endsWith(".swift")).toSorted().map((name) => join(sourcesDir, name));
|
|
609
|
-
if (!sources.length) throw new Error(`${sourcesDir} has no Swift sources.`);
|
|
610
|
-
return compiled("stim-frames", sources, await run("xcrun", ["swiftc", "--version"], env, VERSION_TIMEOUT_MS, signal), async (output) => {
|
|
611
|
-
await run("xcrun", [
|
|
612
|
-
"swiftc",
|
|
613
|
-
"-O",
|
|
614
|
-
"-swift-version",
|
|
615
|
-
"5",
|
|
616
|
-
"-module-name",
|
|
617
|
-
"StimFrames",
|
|
618
|
-
"-o",
|
|
619
|
-
output,
|
|
620
|
-
...sources
|
|
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);
|
|
675
|
-
renameSync(output, helper);
|
|
676
|
-
} finally {
|
|
677
|
-
rmSync(output, { force: true });
|
|
678
|
-
}
|
|
679
|
-
return helper;
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
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
|
|
684
|
-
* at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
|
|
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.
|
|
687
|
-
*/
|
|
688
|
-
var HelperSource = class {
|
|
689
|
-
listeners = /* @__PURE__ */ new Map();
|
|
690
|
-
child;
|
|
691
|
-
last = null;
|
|
692
|
-
config = "";
|
|
693
|
-
stopped = false;
|
|
694
|
-
notice = null;
|
|
695
|
-
keyboard = null;
|
|
696
|
-
stderr = "";
|
|
697
|
-
ended;
|
|
698
|
-
bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
|
|
699
|
-
keyframeAt = -Infinity;
|
|
700
|
-
keyframeTimer = null;
|
|
701
|
-
lit;
|
|
702
|
-
posture;
|
|
703
|
-
lingerMs;
|
|
704
|
-
lingerTimer = null;
|
|
705
|
-
constructor(helper, device, env, ended, lingerMs, lit) {
|
|
706
|
-
this.ended = ended;
|
|
707
|
-
this.lingerMs = lingerMs;
|
|
708
|
-
this.lit = lit;
|
|
709
|
-
const id = device.platform === "ios" ? device.udid : device.serial;
|
|
710
|
-
this.child = spawn(helper, [device.platform, id], {
|
|
711
|
-
env,
|
|
712
|
-
stdio: [
|
|
713
|
-
"pipe",
|
|
714
|
-
"pipe",
|
|
715
|
-
"pipe"
|
|
716
|
-
]
|
|
717
|
-
});
|
|
718
|
-
this.child.stdin.on("error", () => {});
|
|
719
|
-
this.child.stderr.setEncoding("utf8");
|
|
720
|
-
this.child.stderr.on("data", (chunk) => {
|
|
721
|
-
this.stderr = (this.stderr + chunk).slice(-1e3);
|
|
722
|
-
});
|
|
723
|
-
this.readMessages();
|
|
724
|
-
this.child.on("error", (error) => this.fail(`stim-frames could not start (${error.message}).`));
|
|
725
|
-
this.child.on("close", (code, signal) => {
|
|
726
|
-
const detail = this.notice ?? this.stderr.trim();
|
|
727
|
-
this.fail(`stim-frames exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`);
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
/** A null `hint` keeps the helper running for input without asking for frames. */
|
|
731
|
-
add(listener, hint) {
|
|
732
|
-
if (this.lingerTimer) clearTimeout(this.lingerTimer);
|
|
733
|
-
this.lingerTimer = null;
|
|
734
|
-
this.listeners.set(listener, hint);
|
|
735
|
-
this.configure();
|
|
736
|
-
if (listener.video) this.keyframe();
|
|
737
|
-
else if (this.last) listener.frame(this.last);
|
|
738
|
-
return () => {
|
|
739
|
-
if (!this.listeners.delete(listener)) return;
|
|
740
|
-
if (this.listeners.size > 0) this.configure();
|
|
741
|
-
else this.lingerTimer = setTimeout(() => void this.stop(), this.lingerMs);
|
|
742
|
-
};
|
|
743
|
-
}
|
|
744
|
-
stop() {
|
|
745
|
-
if (this.stopped) return Promise.resolve();
|
|
746
|
-
this.stopped = true;
|
|
747
|
-
if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
|
|
748
|
-
if (this.lingerTimer) clearTimeout(this.lingerTimer);
|
|
749
|
-
this.listeners.clear();
|
|
750
|
-
this.ended();
|
|
751
|
-
this.child.stdin.end();
|
|
752
|
-
return terminate(this.child);
|
|
753
|
-
}
|
|
754
|
-
/** Asks for at most one keyframe per {@link KEYFRAME_INTERVAL_MS}; a request inside the interval is sent at its end. */
|
|
755
|
-
keyframe() {
|
|
756
|
-
if (this.stopped || this.keyframeTimer) return;
|
|
757
|
-
const wait = this.keyframeAt + KEYFRAME_INTERVAL_MS - Date.now();
|
|
758
|
-
if (wait > 0) {
|
|
759
|
-
this.keyframeTimer = setTimeout(() => {
|
|
760
|
-
this.keyframeTimer = null;
|
|
761
|
-
this.keyframe();
|
|
762
|
-
}, wait);
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
this.keyframeAt = Date.now();
|
|
766
|
-
this.child.stdin.write("{\"keyframe\":true}\n");
|
|
767
|
-
}
|
|
768
|
-
congested() {
|
|
769
|
-
if (this.bitrate.congested(Date.now()) !== null) this.configure();
|
|
770
|
-
}
|
|
771
|
-
send(command) {
|
|
772
|
-
if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
|
|
773
|
-
}
|
|
774
|
-
configure() {
|
|
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]);
|
|
782
|
-
const config = JSON.stringify({
|
|
783
|
-
fps: Math.max(0, ...hints.map((hint) => hint.fps)),
|
|
784
|
-
maxEdge: Math.max(FRAME_EDGE.min, ...hints.map((hint) => hint.maxEdge)),
|
|
785
|
-
jpeg: jpegFps.length > 0,
|
|
786
|
-
...jpegFps.length ? { jpegFps: Math.max(...jpegFps) } : {},
|
|
787
|
-
video: jpegFps.length < hints.length,
|
|
788
|
-
bitrate: this.bitrate.current
|
|
789
|
-
});
|
|
790
|
-
if (!jpegFps.length) this.last = null;
|
|
791
|
-
if (config === this.config || this.stopped) return;
|
|
792
|
-
this.config = config;
|
|
793
|
-
this.child.stdin.write(`${config}\n`);
|
|
794
|
-
}
|
|
795
|
-
fail(message) {
|
|
796
|
-
if (this.stopped) return;
|
|
797
|
-
const listeners = [...this.listeners.keys()];
|
|
798
|
-
this.stop();
|
|
799
|
-
for (const listener of listeners) listener.failed(message);
|
|
800
|
-
}
|
|
801
|
-
readMessages() {
|
|
802
|
-
let buffer = Buffer.alloc(0);
|
|
803
|
-
this.child.stdout.on("data", (chunk) => {
|
|
804
|
-
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
805
|
-
while (buffer.length >= 4) {
|
|
806
|
-
const length = buffer.readUInt32BE(0);
|
|
807
|
-
if (length < 1 || length > MAX_MESSAGE_BYTES) {
|
|
808
|
-
this.child.stdout.removeAllListeners("data");
|
|
809
|
-
return this.fail("stim-frames wrote a malformed message.");
|
|
810
|
-
}
|
|
811
|
-
if (buffer.length < 4 + length) break;
|
|
812
|
-
const body = buffer.subarray(4, 4 + length);
|
|
813
|
-
buffer = buffer.subarray(4 + length);
|
|
814
|
-
if (body[0] === FRAME_MESSAGE && body.length > 5) this.frame(body);
|
|
815
|
-
else if (body[0] === VIDEO_MESSAGE && body.length > VIDEO_HEADER_BYTES) this.video(body);
|
|
816
|
-
else if (body[0] === NOTICE_MESSAGE) this.readNotice(body.subarray(1).toString("utf8"));
|
|
817
|
-
}
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
frame(body) {
|
|
821
|
-
if (this.stopped) return;
|
|
822
|
-
this.last = {
|
|
823
|
-
width: body.readUInt16BE(1),
|
|
824
|
-
height: body.readUInt16BE(3),
|
|
825
|
-
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
826
|
-
data: body.subarray(5).toString("base64"),
|
|
827
|
-
...this.posture ? { posture: this.posture } : {}
|
|
828
|
-
};
|
|
829
|
-
for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
|
|
830
|
-
}
|
|
831
|
-
video(body) {
|
|
832
|
-
if (this.stopped) return;
|
|
833
|
-
const unit = {
|
|
834
|
-
keyframe: (body[1] & 1) !== 0,
|
|
835
|
-
capturedAt: body.readDoubleBE(2),
|
|
836
|
-
width: body.readUInt16BE(10),
|
|
837
|
-
height: body.readUInt16BE(12),
|
|
838
|
-
data: body.subarray(VIDEO_HEADER_BYTES),
|
|
839
|
-
...this.posture ? { posture: this.posture } : {}
|
|
840
|
-
};
|
|
841
|
-
if (this.bitrate.tick(Date.now()) !== null) this.configure();
|
|
842
|
-
for (const listener of this.listeners.keys()) listener.video?.(unit);
|
|
843
|
-
}
|
|
844
|
-
readNotice(text) {
|
|
845
|
-
try {
|
|
846
|
-
const notice = JSON.parse(text);
|
|
847
|
-
const error = notice?.error;
|
|
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);
|
|
855
|
-
} catch {}
|
|
856
|
-
}
|
|
857
|
-
};
|
|
858
|
-
//#endregion
|
|
859
|
-
//#region src/frames.ts
|
|
860
|
-
const DEFAULT_FRAME_LIMITS = {
|
|
861
|
-
toolTimeoutMs: 3e4,
|
|
862
|
-
slowCaptureMs: 3e3,
|
|
863
|
-
failureBackoffMs: 3e3,
|
|
864
|
-
maxConsecutiveFailures: 3,
|
|
865
|
-
lingerMs: 1e4
|
|
866
|
-
};
|
|
867
|
-
const MIN_INTERVAL_MS = 200;
|
|
868
|
-
const MAX_INTERVAL_MS = 1e3;
|
|
869
|
-
const MAX_CAPTURES = 2;
|
|
870
|
-
const MAX_EDGE = 1280;
|
|
871
|
-
const JPEG_QUALITY = 70;
|
|
872
|
-
function deviceKey(device) {
|
|
873
|
-
return device.platform === "ios" ? `ios:${device.udid}` : `android:${device.serial}`;
|
|
874
|
-
}
|
|
875
|
-
function ownedDevice(payload, target, attached) {
|
|
876
|
-
const slot = target.slot ?? "default";
|
|
877
|
-
if (!Array.isArray(payload.environments)) return "stim status printed a payload without environments.";
|
|
878
|
-
const environment = payload.environments.find((candidate) => candidate.path === target.workspace);
|
|
879
|
-
if (!environment) return `${target.workspace} is not a Stim workspace on this Mac.`;
|
|
880
|
-
const devices = slot === "default" ? {
|
|
881
|
-
ios: environment.ios,
|
|
882
|
-
android: environment.android
|
|
883
|
-
} : environment.slots?.find((candidate) => candidate.slot === slot);
|
|
884
|
-
const where = `${target.platform} in slot ${slot} of ${target.workspace}`;
|
|
885
|
-
if (target.platform === "ios") {
|
|
886
|
-
const sim = devices?.ios;
|
|
887
|
-
if (!sim?.owned) return `No simulator Stim owns runs ${where}.`;
|
|
888
|
-
if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
|
|
889
|
-
return {
|
|
890
|
-
platform: "ios",
|
|
891
|
-
udid: sim.udid,
|
|
892
|
-
foldable: /\bDuo\b/.test(sim.name ?? "")
|
|
893
|
-
};
|
|
894
|
-
}
|
|
895
|
-
const emulator = devices?.android;
|
|
896
|
-
if (!emulator?.owned || emulator.physical) return `No emulator Stim owns runs ${where}.`;
|
|
897
|
-
if (emulator.state === "unknown" && attached?.startsWith("android:")) return {
|
|
898
|
-
platform: "android",
|
|
899
|
-
serial: attached.slice(8)
|
|
900
|
-
};
|
|
901
|
-
if (emulator.state !== "detected" || !emulator.serial) return `The emulator for ${where} is not running.`;
|
|
902
|
-
return {
|
|
903
|
-
platform: "android",
|
|
904
|
-
serial: emulator.serial
|
|
905
|
-
};
|
|
906
|
-
}
|
|
907
|
-
function jpegSize(bytes) {
|
|
908
|
-
if (bytes[0] !== 255 || bytes[1] !== 216) return null;
|
|
909
|
-
let offset = 2;
|
|
910
|
-
while (offset + 9 <= bytes.length) {
|
|
911
|
-
if (bytes[offset] !== 255) return null;
|
|
912
|
-
const marker = bytes[offset + 1];
|
|
913
|
-
if (marker === 255) {
|
|
914
|
-
offset += 1;
|
|
915
|
-
continue;
|
|
916
|
-
}
|
|
917
|
-
if (marker === 1 || marker >= 208 && marker <= 216) {
|
|
918
|
-
offset += 2;
|
|
919
|
-
continue;
|
|
920
|
-
}
|
|
921
|
-
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
|
|
922
|
-
height: bytes.readUInt16BE(offset + 5),
|
|
923
|
-
width: bytes.readUInt16BE(offset + 7)
|
|
924
|
-
};
|
|
925
|
-
offset += 2 + bytes.readUInt16BE(offset + 2);
|
|
926
|
-
}
|
|
927
|
-
return null;
|
|
928
|
-
}
|
|
929
|
-
/** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
|
|
930
|
-
function timeoutError(message) {
|
|
931
|
-
return Object.assign(new Error(message), { transient: true });
|
|
932
|
-
}
|
|
933
|
-
function isTransient(error) {
|
|
934
|
-
return error instanceof Error && error.transient === true;
|
|
935
|
-
}
|
|
936
|
-
function runTool(file, args, env, running, timeoutMs) {
|
|
937
|
-
return new Promise((resolve, reject) => {
|
|
938
|
-
const child = spawn(file, args, {
|
|
939
|
-
env,
|
|
940
|
-
stdio: [
|
|
941
|
-
"ignore",
|
|
942
|
-
"pipe",
|
|
943
|
-
"pipe"
|
|
944
|
-
]
|
|
945
|
-
});
|
|
946
|
-
running.add(child);
|
|
947
|
-
child.once("close", () => running.delete(child));
|
|
948
|
-
const chunks = [];
|
|
949
|
-
let stderr = "";
|
|
950
|
-
const timer = setTimeout(() => {
|
|
951
|
-
terminate(child);
|
|
952
|
-
reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
|
|
953
|
-
}, timeoutMs);
|
|
954
|
-
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
955
|
-
child.stderr.setEncoding("utf8");
|
|
956
|
-
child.stderr.on("data", (chunk) => {
|
|
957
|
-
stderr = (stderr + chunk).slice(-1e3);
|
|
958
|
-
});
|
|
959
|
-
child.on("error", (error) => {
|
|
960
|
-
clearTimeout(timer);
|
|
961
|
-
reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
|
|
962
|
-
});
|
|
963
|
-
child.on("close", (code) => {
|
|
964
|
-
clearTimeout(timer);
|
|
965
|
-
if (code === 0) resolve(Buffer.concat(chunks));
|
|
966
|
-
else reject(Object.assign(/* @__PURE__ */ new Error(`${file} ${args.join(" ")} exited (code ${code}): ${stderr.trim()}`), { stderr }));
|
|
967
|
-
});
|
|
968
|
-
});
|
|
969
|
-
}
|
|
970
|
-
async function stopTools(running, tmp) {
|
|
971
|
-
await Promise.all([...running].map((child) => terminate(child)));
|
|
972
|
-
if (tmp) rmSync(tmp, {
|
|
973
|
-
recursive: true,
|
|
974
|
-
force: true
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* Without `--display`, simctl captures the first panel it finds; `primary` is the panel CoreDevice reports as
|
|
979
|
-
* primary. A simctl that rejects `primary` gets the default display instead.
|
|
980
|
-
*
|
|
981
|
-
* An iPhone Duo lights one of two panels: `primary` is the cover, lit when folded, and `primary-1` the inner
|
|
982
|
-
* panel, lit when unfolded; the other one's framebuffer is all black. `litPanels` remembers the lit panel per
|
|
983
|
-
* simulator across subscriptions, and a black capture tries the other panel.
|
|
984
|
-
*/
|
|
985
|
-
function simulatorCapturer(device, env, limits, litPanels) {
|
|
986
|
-
let tmp = null;
|
|
987
|
-
let display = ["--display=primary"];
|
|
988
|
-
let closed = false;
|
|
989
|
-
const running = /* @__PURE__ */ new Set();
|
|
990
|
-
const screenshot = async (panel) => {
|
|
991
|
-
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
992
|
-
const output = join(tmp, "frame.jpg");
|
|
993
|
-
await runTool("xcrun", [
|
|
994
|
-
"simctl",
|
|
995
|
-
"io",
|
|
996
|
-
device.udid,
|
|
997
|
-
"screenshot",
|
|
998
|
-
"--type=jpeg",
|
|
999
|
-
...panel,
|
|
1000
|
-
output
|
|
1001
|
-
], env, running, limits.toolTimeoutMs);
|
|
1002
|
-
return readFileSync(output);
|
|
1003
|
-
};
|
|
1004
|
-
const isBlack = async (jpeg) => {
|
|
1005
|
-
const input = join(tmp, "check.jpg");
|
|
1006
|
-
const output = join(tmp, "check.bmp");
|
|
1007
|
-
writeFileSync(input, jpeg);
|
|
1008
|
-
await runTool("sips", [
|
|
1009
|
-
"-s",
|
|
1010
|
-
"format",
|
|
1011
|
-
"bmp",
|
|
1012
|
-
"-z",
|
|
1013
|
-
"24",
|
|
1014
|
-
"24",
|
|
1015
|
-
input,
|
|
1016
|
-
"--out",
|
|
1017
|
-
output
|
|
1018
|
-
], env, running, limits.toolTimeoutMs);
|
|
1019
|
-
return bmpIsBlack(readFileSync(output));
|
|
1020
|
-
};
|
|
1021
|
-
const open = () => {
|
|
1022
|
-
if (closed) throw new Error("The capture was stopped.");
|
|
1023
|
-
};
|
|
1024
|
-
const captureDuo = async () => {
|
|
1025
|
-
const lit = litPanels.get(device.udid) ?? "primary";
|
|
1026
|
-
const jpeg = await screenshot([`--display=${lit}`]);
|
|
1027
|
-
open();
|
|
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
|
-
}
|
|
1036
|
-
const other = lit === "primary" ? "primary-1" : "primary";
|
|
1037
|
-
open();
|
|
1038
|
-
const otherJpeg = await screenshot([`--display=${other}`]);
|
|
1039
|
-
open();
|
|
1040
|
-
if (await isBlack(otherJpeg)) return {
|
|
1041
|
-
raw: jpeg,
|
|
1042
|
-
jpeg: async () => jpeg
|
|
1043
|
-
};
|
|
1044
|
-
litPanels.set(device.udid, other);
|
|
1045
|
-
return {
|
|
1046
|
-
raw: otherJpeg,
|
|
1047
|
-
jpeg: async () => otherJpeg,
|
|
1048
|
-
posture: POSTURES[other]
|
|
1049
|
-
};
|
|
1050
|
-
};
|
|
1051
|
-
return {
|
|
1052
|
-
capture: async () => {
|
|
1053
|
-
if (device.foldable) return captureDuo();
|
|
1054
|
-
let jpeg;
|
|
1055
|
-
try {
|
|
1056
|
-
jpeg = await screenshot(display);
|
|
1057
|
-
} catch (error) {
|
|
1058
|
-
if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
|
|
1059
|
-
display = [];
|
|
1060
|
-
jpeg = await screenshot(display);
|
|
1061
|
-
}
|
|
1062
|
-
return {
|
|
1063
|
-
raw: jpeg,
|
|
1064
|
-
jpeg: async () => jpeg
|
|
1065
|
-
};
|
|
1066
|
-
},
|
|
1067
|
-
close: () => {
|
|
1068
|
-
closed = true;
|
|
1069
|
-
return stopTools(running, tmp);
|
|
1070
|
-
}
|
|
1071
|
-
};
|
|
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"];
|
|
1075
|
-
const POSTURES = {
|
|
1076
|
-
primary: "folded",
|
|
1077
|
-
"primary-1": "unfolded"
|
|
1078
|
-
};
|
|
1079
|
-
/** Whether every pixel of an uncompressed 24- or 32-bit BMP, as `sips -s format bmp` writes it, is black. */
|
|
1080
|
-
function bmpIsBlack(bmp) {
|
|
1081
|
-
if (bmp.length < 54 || bmp.toString("latin1", 0, 2) !== "BM") throw new Error("sips did not write a BMP image.");
|
|
1082
|
-
const offset = bmp.readUInt32LE(10);
|
|
1083
|
-
const bytesPerPixel = bmp.readUInt16LE(28) / 8;
|
|
1084
|
-
for (let at = offset; at + 3 <= bmp.length; at += bytesPerPixel) if (bmp[at] > 8 || bmp[at + 1] > 8 || bmp[at + 2] > 8) return false;
|
|
1085
|
-
return true;
|
|
1086
|
-
}
|
|
1087
|
-
/**
|
|
1088
|
-
* The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
|
|
1089
|
-
* Stim passes when it boots an owned AVD (#1064).
|
|
1090
|
-
*/
|
|
1091
|
-
function emulatorEndpoint(env, serial) {
|
|
1092
|
-
const console = /^emulator-(\d+)$/.exec(serial)?.[1];
|
|
1093
|
-
if (!console || !env.HOME) return null;
|
|
1094
|
-
const dir = join(env.HOME, "Library/Caches/TemporaryItems/avd/running");
|
|
1095
|
-
let names;
|
|
1096
|
-
try {
|
|
1097
|
-
names = readdirSync(dir);
|
|
1098
|
-
} catch {
|
|
1099
|
-
return null;
|
|
1100
|
-
}
|
|
1101
|
-
for (const name of names) {
|
|
1102
|
-
const pid = /^pid_(\d+)\.ini$/.exec(name)?.[1];
|
|
1103
|
-
if (!pid) continue;
|
|
1104
|
-
try {
|
|
1105
|
-
process.kill(Number(pid), 0);
|
|
1106
|
-
} catch {
|
|
1107
|
-
continue;
|
|
1108
|
-
}
|
|
1109
|
-
let text;
|
|
1110
|
-
try {
|
|
1111
|
-
text = readFileSync(join(dir, name), "utf8");
|
|
1112
|
-
} catch {
|
|
1113
|
-
continue;
|
|
1114
|
-
}
|
|
1115
|
-
const values = new Map(text.split(/\r?\n/).flatMap((line) => {
|
|
1116
|
-
const at = line.indexOf("=");
|
|
1117
|
-
return at > 0 ? [[line.slice(0, at), line.slice(at + 1)]] : [];
|
|
1118
|
-
}));
|
|
1119
|
-
const grpcPort = Number(values.get("grpc.port"));
|
|
1120
|
-
if (values.get("port.serial") === console && Number.isInteger(grpcPort) && grpcPort > 0) return {
|
|
1121
|
-
grpcPort,
|
|
1122
|
-
token: values.get("grpc.token") ?? null
|
|
1123
|
-
};
|
|
1124
|
-
}
|
|
1125
|
-
return null;
|
|
1126
|
-
}
|
|
1127
|
-
function varint(value) {
|
|
1128
|
-
const out = [];
|
|
1129
|
-
let rest = value;
|
|
1130
|
-
while (rest >= 128) {
|
|
1131
|
-
out.push(rest & 127 | 128);
|
|
1132
|
-
rest = Math.floor(rest / 128);
|
|
1133
|
-
}
|
|
1134
|
-
out.push(rest);
|
|
1135
|
-
return out;
|
|
1136
|
-
}
|
|
1137
|
-
function* protoFields(bytes) {
|
|
1138
|
-
let offset = 0;
|
|
1139
|
-
const readVarint = () => {
|
|
1140
|
-
let result = 0;
|
|
1141
|
-
let scale = 1;
|
|
1142
|
-
for (;;) {
|
|
1143
|
-
if (offset >= bytes.length) throw new Error("truncated protobuf");
|
|
1144
|
-
const byte = bytes[offset++];
|
|
1145
|
-
result += (byte & 127) * scale;
|
|
1146
|
-
if (byte < 128) return result;
|
|
1147
|
-
scale *= 128;
|
|
1148
|
-
}
|
|
1149
|
-
};
|
|
1150
|
-
while (offset < bytes.length) {
|
|
1151
|
-
const key = readVarint();
|
|
1152
|
-
const field = Math.floor(key / 8);
|
|
1153
|
-
const type = key % 8;
|
|
1154
|
-
if (type === 0) yield {
|
|
1155
|
-
field,
|
|
1156
|
-
varint: readVarint()
|
|
1157
|
-
};
|
|
1158
|
-
else if (type === 2) {
|
|
1159
|
-
const length = readVarint();
|
|
1160
|
-
if (offset + length > bytes.length) throw new Error("truncated protobuf");
|
|
1161
|
-
yield {
|
|
1162
|
-
field,
|
|
1163
|
-
bytes: bytes.subarray(offset, offset + length)
|
|
1164
|
-
};
|
|
1165
|
-
offset += length;
|
|
1166
|
-
} else if (type === 1) offset += 8;
|
|
1167
|
-
else if (type === 5) offset += 4;
|
|
1168
|
-
else throw new Error(`unsupported protobuf wire type ${type}`);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
function grpcMessage(message) {
|
|
1172
|
-
const header = Buffer.alloc(5);
|
|
1173
|
-
header.writeUInt32BE(message.length, 1);
|
|
1174
|
-
return Buffer.concat([header, Buffer.from(message)]);
|
|
1175
|
-
}
|
|
1176
|
-
function grpcReply(body, method) {
|
|
1177
|
-
if (body.length < 5 || body[0] !== 0) throw new Error(`${method} returned no message.`);
|
|
1178
|
-
return body.subarray(5, 5 + body.readUInt32BE(1));
|
|
1179
|
-
}
|
|
1180
|
-
/**
|
|
1181
|
-
* `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. The reply's
|
|
1182
|
-
* format carries `foldedDisplay` (field 7) while a foldable is folded.
|
|
1183
|
-
*/
|
|
1184
|
-
function screenshotReply(body) {
|
|
1185
|
-
let png = null;
|
|
1186
|
-
let folded = false;
|
|
1187
|
-
for (const entry of protoFields(grpcReply(body, "getScreenshot"))) {
|
|
1188
|
-
if (entry.field === 4 && "bytes" in entry) png = Buffer.from(entry.bytes);
|
|
1189
|
-
if (entry.field === 1 && "bytes" in entry) folded = [...protoFields(entry.bytes)].some((format) => format.field === 7);
|
|
1190
|
-
}
|
|
1191
|
-
if (!png) throw new Error("getScreenshot returned no image.");
|
|
1192
|
-
return {
|
|
1193
|
-
png,
|
|
1194
|
-
folded
|
|
1195
|
-
};
|
|
1196
|
-
}
|
|
1197
|
-
/**
|
|
1198
|
-
* `PhysicalModelValue` for POSTURE (PhysicalType 16), whose one float is a `Posture.PostureValue`; 1 to 5
|
|
1199
|
-
* are real postures, so any of them means the emulator has a hinge.
|
|
1200
|
-
*/
|
|
1201
|
-
function hasHinge(body) {
|
|
1202
|
-
for (const entry of protoFields(grpcReply(body, "getPhysicalModel"))) {
|
|
1203
|
-
if (entry.field === 2 && "varint" in entry && entry.varint !== 0) return false;
|
|
1204
|
-
if (entry.field !== 3 || !("bytes" in entry)) continue;
|
|
1205
|
-
for (const value of protoFields(entry.bytes)) {
|
|
1206
|
-
if (value.field !== 1 || !("bytes" in value) || value.bytes.length < 4) continue;
|
|
1207
|
-
const posture = Math.round(value.bytes.readFloatLE(0));
|
|
1208
|
-
return posture >= 1 && posture <= 5;
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
return false;
|
|
1212
|
-
}
|
|
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) => {
|
|
1220
|
-
const request = session.request({
|
|
1221
|
-
":method": "POST",
|
|
1222
|
-
":path": `/android.emulation.control.EmulatorController/${method}`,
|
|
1223
|
-
"content-type": "application/grpc",
|
|
1224
|
-
te: "trailers",
|
|
1225
|
-
...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
|
|
1226
|
-
});
|
|
1227
|
-
const chunks = [];
|
|
1228
|
-
let status;
|
|
1229
|
-
let message;
|
|
1230
|
-
let timedOut = false;
|
|
1231
|
-
const record = (headers) => {
|
|
1232
|
-
if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
|
|
1233
|
-
if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
|
|
1234
|
-
};
|
|
1235
|
-
request.setTimeout(timeoutMs, () => {
|
|
1236
|
-
timedOut = true;
|
|
1237
|
-
request.close();
|
|
1238
|
-
});
|
|
1239
|
-
request.on("response", record);
|
|
1240
|
-
request.on("trailers", record);
|
|
1241
|
-
request.on("data", (chunk) => chunks.push(chunk));
|
|
1242
|
-
request.on("error", (error) => {
|
|
1243
|
-
const text = `${method} failed on ${serial}: ${error.message}`;
|
|
1244
|
-
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
1245
|
-
});
|
|
1246
|
-
request.on("close", () => {
|
|
1247
|
-
if (status === "0") return resolve(Buffer.concat(chunks));
|
|
1248
|
-
const text = `${method} failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
|
|
1249
|
-
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
1250
|
-
});
|
|
1251
|
-
request.end(requestBody);
|
|
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
|
-
};
|
|
1285
|
-
return {
|
|
1286
|
-
capture: async () => {
|
|
1287
|
-
const endpoint = emulatorEndpoint(env, serial);
|
|
1288
|
-
if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
|
|
1289
|
-
hinged ??= await call(endpoint, "getPhysicalModel", POSTURE_MODEL).then(hasHinge).catch(() => false);
|
|
1290
|
-
const { png, folded } = screenshotReply(await call(endpoint, "getScreenshot", grpcMessage([
|
|
1291
|
-
24,
|
|
1292
|
-
...varint(MAX_EDGE),
|
|
1293
|
-
32,
|
|
1294
|
-
...varint(MAX_EDGE)
|
|
1295
|
-
])));
|
|
1296
|
-
return {
|
|
1297
|
-
raw: png,
|
|
1298
|
-
...hinged ? { posture: folded ? "folded" : "unfolded" } : {},
|
|
1299
|
-
jpeg: async () => {
|
|
1300
|
-
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
1301
|
-
const input = join(tmp, "frame.png");
|
|
1302
|
-
const output = join(tmp, "frame.jpg");
|
|
1303
|
-
writeFileSync(input, png);
|
|
1304
|
-
await runTool("sips", [
|
|
1305
|
-
...[
|
|
1306
|
-
"-s",
|
|
1307
|
-
"format",
|
|
1308
|
-
"jpeg",
|
|
1309
|
-
"-s",
|
|
1310
|
-
"formatOptions",
|
|
1311
|
-
String(JPEG_QUALITY)
|
|
1312
|
-
],
|
|
1313
|
-
input,
|
|
1314
|
-
"--out",
|
|
1315
|
-
output
|
|
1316
|
-
], env, running, limits.toolTimeoutMs);
|
|
1317
|
-
return readFileSync(output);
|
|
1318
|
-
}
|
|
1319
|
-
};
|
|
1320
|
-
},
|
|
1321
|
-
close: () => {
|
|
1322
|
-
session?.close();
|
|
1323
|
-
return stopTools(running, tmp);
|
|
1324
|
-
}
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
var Limiter = class {
|
|
1328
|
-
active = 0;
|
|
1329
|
-
waiting = [];
|
|
1330
|
-
max;
|
|
1331
|
-
constructor(max) {
|
|
1332
|
-
this.max = max;
|
|
1333
|
-
}
|
|
1334
|
-
async run(task) {
|
|
1335
|
-
if (this.active >= this.max) await new Promise((resolve) => this.waiting.push(resolve));
|
|
1336
|
-
else this.active++;
|
|
1337
|
-
try {
|
|
1338
|
-
return await task();
|
|
1339
|
-
} finally {
|
|
1340
|
-
const next = this.waiting.shift();
|
|
1341
|
-
if (next) next();
|
|
1342
|
-
else this.active--;
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
};
|
|
1346
|
-
var FrameSource = class {
|
|
1347
|
-
listeners = /* @__PURE__ */ new Set();
|
|
1348
|
-
last = null;
|
|
1349
|
-
lastHash = null;
|
|
1350
|
-
interval = MIN_INTERVAL_MS;
|
|
1351
|
-
timer = null;
|
|
1352
|
-
stopped = false;
|
|
1353
|
-
delayed = false;
|
|
1354
|
-
consecutiveFailures = 0;
|
|
1355
|
-
capturer;
|
|
1356
|
-
limiter;
|
|
1357
|
-
limits;
|
|
1358
|
-
ended;
|
|
1359
|
-
constructor(capturer, limiter, limits, ended) {
|
|
1360
|
-
this.capturer = capturer;
|
|
1361
|
-
this.limiter = limiter;
|
|
1362
|
-
this.limits = limits;
|
|
1363
|
-
this.ended = ended;
|
|
1364
|
-
this.tick();
|
|
1365
|
-
}
|
|
1366
|
-
add(listener) {
|
|
1367
|
-
this.listeners.add(listener);
|
|
1368
|
-
if (this.last) listener.frame(this.last);
|
|
1369
|
-
if (this.delayed) listener.delayed(true);
|
|
1370
|
-
return () => {
|
|
1371
|
-
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
1372
|
-
};
|
|
1373
|
-
}
|
|
1374
|
-
stop() {
|
|
1375
|
-
if (this.stopped) return Promise.resolve();
|
|
1376
|
-
this.stopped = true;
|
|
1377
|
-
if (this.timer) clearTimeout(this.timer);
|
|
1378
|
-
this.listeners.clear();
|
|
1379
|
-
this.ended();
|
|
1380
|
-
return this.capturer.close();
|
|
1381
|
-
}
|
|
1382
|
-
setDelayed(delayed) {
|
|
1383
|
-
if (this.delayed === delayed) return;
|
|
1384
|
-
this.delayed = delayed;
|
|
1385
|
-
for (const listener of this.listeners) listener.delayed(delayed);
|
|
1386
|
-
}
|
|
1387
|
-
async tick() {
|
|
1388
|
-
this.timer = null;
|
|
1389
|
-
let took = 0;
|
|
1390
|
-
try {
|
|
1391
|
-
const changed = await this.limiter.run(async () => {
|
|
1392
|
-
if (this.stopped) return false;
|
|
1393
|
-
const started = Date.now();
|
|
1394
|
-
const capture = await this.capturer.capture();
|
|
1395
|
-
const hash = createHash("sha256").update(capture.raw).digest("hex");
|
|
1396
|
-
took = Date.now() - started;
|
|
1397
|
-
if (hash === this.lastHash || this.stopped) return false;
|
|
1398
|
-
const jpeg = await capture.jpeg();
|
|
1399
|
-
const size = jpegSize(jpeg);
|
|
1400
|
-
if (!size) throw new Error("The screenshot is not a JPEG image.");
|
|
1401
|
-
this.lastHash = hash;
|
|
1402
|
-
this.last = {
|
|
1403
|
-
...size,
|
|
1404
|
-
capturedAt: new Date(started).toISOString(),
|
|
1405
|
-
data: jpeg.toString("base64"),
|
|
1406
|
-
...capture.posture ? { posture: capture.posture } : {}
|
|
1407
|
-
};
|
|
1408
|
-
took = Date.now() - started;
|
|
1409
|
-
return true;
|
|
1410
|
-
});
|
|
1411
|
-
if (this.stopped) return;
|
|
1412
|
-
this.consecutiveFailures = 0;
|
|
1413
|
-
this.setDelayed(took > this.limits.slowCaptureMs);
|
|
1414
|
-
if (changed) for (const listener of this.listeners) listener.frame(this.last);
|
|
1415
|
-
this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
|
|
1416
|
-
this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
|
|
1417
|
-
} catch (error) {
|
|
1418
|
-
if (this.stopped) return;
|
|
1419
|
-
if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
|
|
1420
|
-
this.consecutiveFailures++;
|
|
1421
|
-
this.setDelayed(true);
|
|
1422
|
-
this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
const listeners = [...this.listeners];
|
|
1426
|
-
this.stop();
|
|
1427
|
-
for (const listener of listeners) listener.failed(error.message);
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
};
|
|
1431
|
-
/**
|
|
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.
|
|
1441
|
-
*/
|
|
1442
|
-
var FramePool = class {
|
|
1443
|
-
sources = /* @__PURE__ */ new Map();
|
|
1444
|
-
limiter = new Limiter(MAX_CAPTURES);
|
|
1445
|
-
litPanels = /* @__PURE__ */ new Map();
|
|
1446
|
-
env;
|
|
1447
|
-
limits;
|
|
1448
|
-
helper;
|
|
1449
|
-
constructor(env, limits = DEFAULT_FRAME_LIMITS, helper = () => null) {
|
|
1450
|
-
this.env = env;
|
|
1451
|
-
this.limits = limits;
|
|
1452
|
-
this.helper = helper;
|
|
1453
|
-
}
|
|
1454
|
-
subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
|
|
1455
|
-
const helper = this.helper();
|
|
1456
|
-
if (helper === null) return this.screenshots(device).add(listener);
|
|
1457
|
-
let streamed = false;
|
|
1458
|
-
let cancelled = false;
|
|
1459
|
-
const { video } = listener;
|
|
1460
|
-
let detach = this.stream(helper, device).add({
|
|
1461
|
-
frame: (frame) => {
|
|
1462
|
-
streamed = true;
|
|
1463
|
-
listener.frame(frame);
|
|
1464
|
-
},
|
|
1465
|
-
...video ? { video: (unit) => {
|
|
1466
|
-
streamed = true;
|
|
1467
|
-
video(unit);
|
|
1468
|
-
} } : {},
|
|
1469
|
-
delayed: listener.delayed,
|
|
1470
|
-
failed: (message) => {
|
|
1471
|
-
if (streamed || cancelled) return listener.failed(message);
|
|
1472
|
-
console.error(`stim-server: ${message} Falling back to screenshots.`);
|
|
1473
|
-
detach = this.screenshots(device).add(listener);
|
|
1474
|
-
}
|
|
1475
|
-
}, hint);
|
|
1476
|
-
return () => {
|
|
1477
|
-
cancelled = true;
|
|
1478
|
-
detach();
|
|
1479
|
-
};
|
|
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
|
-
}
|
|
1490
|
-
/** Makes the next video frame of `device` a keyframe, for a subscriber whose decoder lost its state. */
|
|
1491
|
-
keyframe(device) {
|
|
1492
|
-
this.helperSource(device)?.keyframe();
|
|
1493
|
-
}
|
|
1494
|
-
/** A subscriber of `device` is behind; called until its socket drains, it lowers the shared bitrate. */
|
|
1495
|
-
congested(device) {
|
|
1496
|
-
this.helperSource(device)?.congested();
|
|
1497
|
-
}
|
|
1498
|
-
helperSource(device) {
|
|
1499
|
-
const source = this.sources.get(`helper:${deviceKey(device)}`);
|
|
1500
|
-
return source instanceof HelperSource ? source : null;
|
|
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
|
-
}
|
|
1520
|
-
stream(helper, device) {
|
|
1521
|
-
const key = `helper:${deviceKey(device)}`;
|
|
1522
|
-
const existing = this.sources.get(key);
|
|
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;
|
|
1530
|
-
const created = new HelperSource(helper, device, this.env, () => {
|
|
1531
|
-
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
1532
|
-
}, this.limits.lingerMs, lit);
|
|
1533
|
-
this.sources.set(key, created);
|
|
1534
|
-
return created;
|
|
1535
|
-
}
|
|
1536
|
-
screenshots(device) {
|
|
1537
|
-
const key = deviceKey(device);
|
|
1538
|
-
const existing = this.sources.get(key);
|
|
1539
|
-
if (existing instanceof FrameSource) return existing;
|
|
1540
|
-
const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device, this.env, this.limits, this.litPanels) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
|
|
1541
|
-
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
1542
|
-
});
|
|
1543
|
-
this.sources.set(key, created);
|
|
1544
|
-
return created;
|
|
1545
|
-
}
|
|
1546
|
-
async close() {
|
|
1547
|
-
await Promise.all([...this.sources.values()].map((source) => source.stop()));
|
|
1548
|
-
}
|
|
1549
|
-
};
|
|
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
|
|
2205
|
-
//#region src/logs.ts
|
|
2206
|
-
function parseLogFilter(params) {
|
|
2207
|
-
if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
|
|
2208
|
-
const { sources, level, slot, grep, errors, tail } = params;
|
|
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(", ")}.` };
|
|
2210
|
-
if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
|
|
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." };
|
|
2213
|
-
if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
|
|
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}.` };
|
|
2215
|
-
return { filter: {
|
|
2216
|
-
workspace: params.workspace,
|
|
2217
|
-
...sources ? { sources } : {},
|
|
2218
|
-
...level && level !== "debug" ? { level } : {},
|
|
2219
|
-
...slot ? { slot } : {},
|
|
2220
|
-
...grep ? { grep } : {},
|
|
2221
|
-
...errors ? { errors } : {},
|
|
2222
|
-
tail: tail ?? 5e3
|
|
2223
|
-
} };
|
|
2224
|
-
}
|
|
2225
|
-
function logArgs(filter, follow) {
|
|
2226
|
-
const args = [
|
|
2227
|
-
"logs",
|
|
2228
|
-
"--json",
|
|
2229
|
-
...follow ? ["--follow"] : [],
|
|
2230
|
-
`--tail=${filter.tail ?? 5e3}`
|
|
2231
|
-
];
|
|
2232
|
-
if (filter.sources) args.push("--source", ...LOG_SOURCES.filter((source) => filter.sources.includes(source)));
|
|
2233
|
-
if (filter.slot) args.push(`--slot=${filter.slot}`);
|
|
2234
|
-
if (filter.level) args.push(`--level=${filter.level}`);
|
|
2235
|
-
if (filter.grep) args.push(`--grep=${filter.grep}`);
|
|
2236
|
-
if (filter.errors) args.push("--errors");
|
|
2237
|
-
return args;
|
|
2238
|
-
}
|
|
2239
|
-
const BATCH_MS = 100;
|
|
2240
|
-
const MAX_BATCH = 500;
|
|
2241
|
-
var LogBatcher = class {
|
|
2242
|
-
pending = [];
|
|
2243
|
-
timer = null;
|
|
2244
|
-
stopped = false;
|
|
2245
|
-
sink;
|
|
2246
|
-
limits;
|
|
2247
|
-
constructor(sink, limits) {
|
|
2248
|
-
this.sink = sink;
|
|
2249
|
-
this.limits = limits;
|
|
2250
|
-
}
|
|
2251
|
-
push(record) {
|
|
2252
|
-
if (this.stopped) return;
|
|
2253
|
-
this.pending.push(record);
|
|
2254
|
-
if (this.pending.length > this.limits.maxPendingRecords) {
|
|
2255
|
-
this.stop();
|
|
2256
|
-
queueMicrotask(this.sink.overflow);
|
|
2257
|
-
return;
|
|
2258
|
-
}
|
|
2259
|
-
this.timer ??= setTimeout(() => this.flush(false), BATCH_MS);
|
|
2260
|
-
}
|
|
2261
|
-
flush(force) {
|
|
2262
|
-
if (this.timer) clearTimeout(this.timer);
|
|
2263
|
-
this.timer = null;
|
|
2264
|
-
while (this.pending.length) {
|
|
2265
|
-
if (!force && this.sink.bufferedBytes() >= this.limits.maxBufferedBytes) break;
|
|
2266
|
-
this.sink.send(this.pending.splice(0, MAX_BATCH));
|
|
2267
|
-
}
|
|
2268
|
-
if (this.pending.length && !this.stopped) this.timer = setTimeout(() => this.flush(false), BATCH_MS);
|
|
2269
|
-
}
|
|
2270
|
-
stop() {
|
|
2271
|
-
this.stopped = true;
|
|
2272
|
-
if (this.timer) clearTimeout(this.timer);
|
|
2273
|
-
this.timer = null;
|
|
2274
|
-
this.pending = [];
|
|
2275
|
-
}
|
|
2276
|
-
};
|
|
2277
|
-
//#endregion
|
|
2278
|
-
//#region src/machine.ts
|
|
2279
|
-
function stimDiskLocations() {
|
|
2280
|
-
let workspaces = [];
|
|
2281
|
-
try {
|
|
2282
|
-
workspaces = Object.keys(loadConfig()?.projects ?? {});
|
|
2283
|
-
} catch {}
|
|
2284
|
-
return [
|
|
2285
|
-
...workspaces.map((path) => ({
|
|
2286
|
-
label: "Workspaces",
|
|
2287
|
-
path
|
|
2288
|
-
})),
|
|
2289
|
-
{
|
|
2290
|
-
label: "Stim home",
|
|
2291
|
-
path: configDir()
|
|
2292
|
-
},
|
|
2293
|
-
...process.platform === "darwin" ? [{
|
|
2294
|
-
label: "Simulators",
|
|
2295
|
-
path: join(homedir(), "Library", "Developer", "CoreSimulator")
|
|
2296
|
-
}] : []
|
|
2297
|
-
];
|
|
2298
|
-
}
|
|
2299
|
-
function existingAncestor(path) {
|
|
2300
|
-
let current = path;
|
|
2301
|
-
while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
|
|
2302
|
-
return current;
|
|
2303
|
-
}
|
|
2304
|
-
function mountOf(path) {
|
|
2305
|
-
return /^\/Volumes\/[^/]+/.exec(path)?.[0] ?? "/";
|
|
2306
|
-
}
|
|
2307
|
-
function readVolumes(locations) {
|
|
2308
|
-
const byDevice = /* @__PURE__ */ new Map();
|
|
2309
|
-
for (const { label, path } of locations) try {
|
|
2310
|
-
const existing = existingAncestor(path);
|
|
2311
|
-
const device = statSync(existing).dev;
|
|
2312
|
-
const known = byDevice.get(device);
|
|
2313
|
-
if (known) {
|
|
2314
|
-
if (!known.holds.includes(label)) known.holds.push(label);
|
|
2315
|
-
continue;
|
|
2316
|
-
}
|
|
2317
|
-
const fs = statfsSync(existing);
|
|
2318
|
-
byDevice.set(device, {
|
|
2319
|
-
mount: mountOf(existing),
|
|
2320
|
-
holds: [label],
|
|
2321
|
-
freeBytes: fs.bavail * fs.bsize,
|
|
2322
|
-
totalBytes: fs.blocks * fs.bsize
|
|
2323
|
-
});
|
|
2324
|
-
} catch {}
|
|
2325
|
-
return [...byDevice.values()];
|
|
2326
|
-
}
|
|
2327
|
-
function readMemoryPressure() {
|
|
2328
|
-
if (process.platform !== "darwin") return Promise.resolve(null);
|
|
2329
|
-
return new Promise((resolve) => {
|
|
2330
|
-
execFile("/usr/sbin/sysctl", ["-n", "kern.memorystatus_vm_pressure_level"], { timeout: 2e3 }, (error, stdout) => {
|
|
2331
|
-
const level = error ? "" : stdout.trim();
|
|
2332
|
-
resolve(level === "1" ? "normal" : level === "2" ? "warning" : level === "4" ? "critical" : null);
|
|
2333
|
-
});
|
|
2334
|
-
});
|
|
2335
|
-
}
|
|
2336
|
-
/**
|
|
2337
|
-
* Activity Monitor's "Memory Used" from `vm_stat` output: app memory (anonymous minus purgeable pages),
|
|
2338
|
-
* wired, and compressed pages, times the page size its header names. Null when a count is missing.
|
|
2339
|
-
*/
|
|
2340
|
-
function parseVmStatUsedBytes(output) {
|
|
2341
|
-
const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
|
|
2342
|
-
const count = (label) => {
|
|
2343
|
-
const value = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(output)?.[1];
|
|
2344
|
-
return value === void 0 ? null : Number(value);
|
|
2345
|
-
};
|
|
2346
|
-
const anonymous = count("Anonymous pages");
|
|
2347
|
-
const purgeable = count("Pages purgeable");
|
|
2348
|
-
const wired = count("Pages wired down");
|
|
2349
|
-
const compressed = count("Pages occupied by compressor");
|
|
2350
|
-
if (!pageSize || anonymous === null || purgeable === null || wired === null || compressed === null) return null;
|
|
2351
|
-
return (Math.max(0, anonymous - purgeable) + wired + compressed) * Number(pageSize);
|
|
2352
|
-
}
|
|
2353
|
-
function readMemoryUsed() {
|
|
2354
|
-
if (process.platform !== "darwin") return Promise.resolve(null);
|
|
2355
|
-
return new Promise((resolve) => {
|
|
2356
|
-
execFile("/usr/bin/vm_stat", { timeout: 2e3 }, (error, stdout) => {
|
|
2357
|
-
resolve(error ? null : parseVmStatUsedBytes(stdout));
|
|
2358
|
-
});
|
|
2359
|
-
});
|
|
2360
|
-
}
|
|
2361
|
-
function cpuTicks(list) {
|
|
2362
|
-
let idle = 0;
|
|
2363
|
-
let total = 0;
|
|
2364
|
-
for (const cpu of list) {
|
|
2365
|
-
idle += cpu.times.idle;
|
|
2366
|
-
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
|
|
2367
|
-
}
|
|
2368
|
-
return {
|
|
2369
|
-
idle,
|
|
2370
|
-
total
|
|
2371
|
-
};
|
|
2372
|
-
}
|
|
2373
|
-
/** The busy fraction (0..1) between two `os.cpus()` tick totals. Null when the totals did not advance. */
|
|
2374
|
-
function cpuUsageFraction(previous, current) {
|
|
2375
|
-
const totalDelta = current.total - previous.total;
|
|
2376
|
-
if (totalDelta <= 0) return null;
|
|
2377
|
-
const idleDelta = current.idle - previous.idle;
|
|
2378
|
-
return Math.min(1, Math.max(0, 1 - idleDelta / totalDelta));
|
|
2379
|
-
}
|
|
2380
|
-
let previousCpuTicks = null;
|
|
2381
|
-
function readCpuUsage() {
|
|
2382
|
-
const current = cpuTicks(cpus());
|
|
2383
|
-
const usage = previousCpuTicks ? cpuUsageFraction(previousCpuTicks, current) : null;
|
|
2384
|
-
previousCpuTicks = current;
|
|
2385
|
-
return {
|
|
2386
|
-
usage,
|
|
2387
|
-
cores: availableParallelism()
|
|
2388
|
-
};
|
|
2389
|
-
}
|
|
2390
|
-
async function readMachineUsage() {
|
|
2391
|
-
const [avg1 = 0, avg5 = 0, avg15 = 0] = loadavg();
|
|
2392
|
-
const [pressure, usedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
|
|
2393
|
-
return {
|
|
2394
|
-
volumes: readVolumes(stimDiskLocations()),
|
|
2395
|
-
memory: {
|
|
2396
|
-
totalBytes: totalmem(),
|
|
2397
|
-
usedBytes,
|
|
2398
|
-
pressure
|
|
2399
|
-
},
|
|
2400
|
-
load: {
|
|
2401
|
-
avg1,
|
|
2402
|
-
avg5,
|
|
2403
|
-
avg15,
|
|
2404
|
-
cpus: availableParallelism()
|
|
2405
|
-
},
|
|
2406
|
-
cpu: readCpuUsage(),
|
|
2407
|
-
sampledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2408
|
-
};
|
|
2409
|
-
}
|
|
2410
|
-
const PRESSURE_LEVEL = {
|
|
2411
|
-
normal: 0,
|
|
2412
|
-
warning: 1,
|
|
2413
|
-
critical: 2
|
|
2414
|
-
};
|
|
2415
|
-
const HISTORY_INTERVAL_MS = 5e3;
|
|
2416
|
-
const HISTORY_CAPACITY = 720;
|
|
2417
|
-
var UsageSampler = class {
|
|
2418
|
-
samples = [];
|
|
2419
|
-
previousTicks = null;
|
|
2420
|
-
timer = null;
|
|
2421
|
-
intervalMs;
|
|
2422
|
-
capacity;
|
|
2423
|
-
constructor(intervalMs = HISTORY_INTERVAL_MS, capacity = HISTORY_CAPACITY) {
|
|
2424
|
-
this.intervalMs = intervalMs;
|
|
2425
|
-
this.capacity = capacity;
|
|
2426
|
-
}
|
|
2427
|
-
start() {
|
|
2428
|
-
if (this.timer) return;
|
|
2429
|
-
this.previousTicks = cpuTicks(cpus());
|
|
2430
|
-
this.timer = setInterval(() => void this.sample(), this.intervalMs);
|
|
2431
|
-
}
|
|
2432
|
-
stop() {
|
|
2433
|
-
if (this.timer) clearInterval(this.timer);
|
|
2434
|
-
this.timer = null;
|
|
2435
|
-
this.previousTicks = null;
|
|
2436
|
-
}
|
|
2437
|
-
record(sample) {
|
|
2438
|
-
this.samples.push(sample);
|
|
2439
|
-
if (this.samples.length > this.capacity) this.samples.splice(0, this.samples.length - this.capacity);
|
|
2440
|
-
}
|
|
2441
|
-
history(sinceMs) {
|
|
2442
|
-
const samples = sinceMs === void 0 ? [...this.samples] : this.samples.filter((s) => s.at > sinceMs);
|
|
2443
|
-
return {
|
|
2444
|
-
intervalMs: this.intervalMs,
|
|
2445
|
-
samples
|
|
2446
|
-
};
|
|
2447
|
-
}
|
|
2448
|
-
async sample() {
|
|
2449
|
-
const at = Date.now();
|
|
2450
|
-
const ticks = cpuTicks(cpus());
|
|
2451
|
-
const cpu = this.previousTicks ? cpuUsageFraction(this.previousTicks, ticks) : null;
|
|
2452
|
-
this.previousTicks = ticks;
|
|
2453
|
-
const [pressure, memoryUsedBytes] = await Promise.all([readMemoryPressure(), readMemoryUsed()]);
|
|
2454
|
-
let diskFreeBytes = null;
|
|
2455
|
-
try {
|
|
2456
|
-
const fs = statfsSync("/");
|
|
2457
|
-
diskFreeBytes = fs.bavail * fs.bsize;
|
|
2458
|
-
} catch {}
|
|
2459
|
-
if (!this.timer) return;
|
|
2460
|
-
this.record({
|
|
2461
|
-
at,
|
|
2462
|
-
cpu,
|
|
2463
|
-
memoryUsedBytes,
|
|
2464
|
-
memoryPressure: pressure === null ? null : PRESSURE_LEVEL[pressure],
|
|
2465
|
-
diskFreeBytes
|
|
2466
|
-
});
|
|
2467
|
-
}
|
|
2468
|
-
};
|
|
2469
|
-
//#endregion
|
|
2470
|
-
//#region src/tailscale.ts
|
|
2471
|
-
const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
2472
|
-
const TIMEOUT_MS = 3e3;
|
|
2473
|
-
/** The tailnet-only HTTPS port the setup command serves stim-server on. */
|
|
2474
|
-
const SERVE_PORT = 7443;
|
|
2475
|
-
function executable(path) {
|
|
2476
|
-
try {
|
|
2477
|
-
accessSync(path, constants.X_OK);
|
|
2478
|
-
return true;
|
|
2479
|
-
} catch {
|
|
2480
|
-
return false;
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
function findTailscale(env) {
|
|
2484
|
-
for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "tailscale"))) return join(dir, "tailscale");
|
|
2485
|
-
return executable(MAC_APP_BINARY) ? MAC_APP_BINARY : null;
|
|
2486
|
-
}
|
|
2487
|
-
function parseTailscaleStatus(value) {
|
|
2488
|
-
if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
|
|
2489
|
-
state: "unavailable",
|
|
2490
|
-
reason: "`tailscale status --json` printed no BackendState"
|
|
2491
|
-
};
|
|
2492
|
-
if (value.BackendState !== "Running") return {
|
|
2493
|
-
state: "not-running",
|
|
2494
|
-
backendState: value.BackendState
|
|
2495
|
-
};
|
|
2496
|
-
const self = isJsonObject(value.Self) ? value.Self : {};
|
|
2497
|
-
return {
|
|
2498
|
-
state: "running",
|
|
2499
|
-
ips: Array.isArray(value.TailscaleIPs) ? value.TailscaleIPs.filter((ip) => typeof ip === "string") : [],
|
|
2500
|
-
dnsName: typeof self.DNSName === "string" && self.DNSName ? self.DNSName.replace(/\.$/, "") : null,
|
|
2501
|
-
hostName: typeof self.HostName === "string" && self.HostName ? self.HostName : null
|
|
2502
|
-
};
|
|
2503
|
-
}
|
|
2504
|
-
function tailscaleStatus(binary, env) {
|
|
2505
|
-
if (!binary) return {
|
|
2506
|
-
state: "unavailable",
|
|
2507
|
-
reason: "the tailscale command was not found"
|
|
2508
|
-
};
|
|
2509
|
-
try {
|
|
2510
|
-
const output = execFileSync(binary, ["status", "--json"], {
|
|
2511
|
-
env,
|
|
2512
|
-
timeout: TIMEOUT_MS,
|
|
2513
|
-
encoding: "utf8",
|
|
2514
|
-
stdio: [
|
|
2515
|
-
"ignore",
|
|
2516
|
-
"pipe",
|
|
2517
|
-
"ignore"
|
|
2518
|
-
]
|
|
2519
|
-
});
|
|
2520
|
-
return parseTailscaleStatus(JSON.parse(output));
|
|
2521
|
-
} catch (error) {
|
|
2522
|
-
return {
|
|
2523
|
-
state: "unavailable",
|
|
2524
|
-
reason: `\`tailscale status --json\` failed: ${error.message}`
|
|
2525
|
-
};
|
|
2526
|
-
}
|
|
2527
|
-
}
|
|
2528
|
-
function reaches(address, target, hosts) {
|
|
2529
|
-
if (typeof address !== "string") return null;
|
|
2530
|
-
let url;
|
|
2531
|
-
try {
|
|
2532
|
-
url = new URL(address.includes("://") ? address : `tcp://${address}`);
|
|
2533
|
-
} catch {
|
|
2534
|
-
return null;
|
|
2535
|
-
}
|
|
2536
|
-
return hosts.has(url.hostname) && url.port === String(target) ? url : null;
|
|
2537
|
-
}
|
|
2538
|
-
function portOf(hostPort) {
|
|
2539
|
-
return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
|
|
2540
|
-
}
|
|
2541
|
-
/**
|
|
2542
|
-
* Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
|
|
2543
|
-
* `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
|
|
2544
|
-
* `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
|
|
2545
|
-
* foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
|
|
2546
|
-
* it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
|
|
2547
|
-
*/
|
|
2548
|
-
function parseServeStatus(value, target, ips) {
|
|
2549
|
-
if (!isJsonObject(value)) return {
|
|
2550
|
-
state: "unknown",
|
|
2551
|
-
reason: "it printed no serve config",
|
|
2552
|
-
port: SERVE_PORT
|
|
2553
|
-
};
|
|
2554
|
-
const hosts = /* @__PURE__ */ new Set([
|
|
2555
|
-
"127.0.0.1",
|
|
2556
|
-
"localhost",
|
|
2557
|
-
"[::1]",
|
|
2558
|
-
"0.0.0.0",
|
|
2559
|
-
"[::]",
|
|
2560
|
-
...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
|
|
2561
|
-
]);
|
|
2562
|
-
const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
|
|
2563
|
-
const used = /* @__PURE__ */ new Set();
|
|
2564
|
-
const funneled = /* @__PURE__ */ new Set();
|
|
2565
|
-
const reaching = /* @__PURE__ */ new Set();
|
|
2566
|
-
const routes = /* @__PURE__ */ new Set();
|
|
2567
|
-
for (const config of configs) {
|
|
2568
|
-
const tcp = isJsonObject(config.TCP) ? config.TCP : {};
|
|
2569
|
-
for (const [port, listener] of Object.entries(tcp)) {
|
|
2570
|
-
used.add(Number(port));
|
|
2571
|
-
if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
|
|
2572
|
-
}
|
|
2573
|
-
for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
|
|
2574
|
-
for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
|
|
2575
|
-
const port = portOf(hostPort);
|
|
2576
|
-
const listener = tcp[String(port)];
|
|
2577
|
-
const https = isJsonObject(listener) && listener.HTTPS === true;
|
|
2578
|
-
const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
|
|
2579
|
-
for (const [mount, handler] of Object.entries(handlers)) {
|
|
2580
|
-
const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
|
|
2581
|
-
if (!url) continue;
|
|
2582
|
-
reaching.add(port);
|
|
2583
|
-
if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
|
|
2584
|
-
}
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
let free = SERVE_PORT;
|
|
2588
|
-
while (used.has(free) || funneled.has(free)) free++;
|
|
2589
|
-
const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
|
|
2590
|
-
if (exposed.length) return {
|
|
2591
|
-
state: "funneled",
|
|
2592
|
-
ports: exposed,
|
|
2593
|
-
port: free
|
|
2594
|
-
};
|
|
2595
|
-
const tailnet = [...routes].toSorted((a, b) => a - b);
|
|
2596
|
-
if (tailnet.length) return {
|
|
2597
|
-
state: "routed",
|
|
2598
|
-
port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
|
|
2599
|
-
};
|
|
2600
|
-
return {
|
|
2601
|
-
state: "missing",
|
|
2602
|
-
port: free
|
|
2603
|
-
};
|
|
2604
|
-
}
|
|
2605
|
-
function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
|
|
2606
|
-
if (!binary) return Promise.resolve({
|
|
2607
|
-
state: "unknown",
|
|
2608
|
-
reason: "the tailscale command was not found",
|
|
2609
|
-
port: SERVE_PORT
|
|
2610
|
-
});
|
|
2611
|
-
return new Promise((resolve) => {
|
|
2612
|
-
execFile(binary, [
|
|
2613
|
-
"serve",
|
|
2614
|
-
"status",
|
|
2615
|
-
"--json"
|
|
2616
|
-
], {
|
|
2617
|
-
env,
|
|
2618
|
-
timeout: timeoutMs,
|
|
2619
|
-
killSignal: "SIGKILL",
|
|
2620
|
-
encoding: "utf8"
|
|
2621
|
-
}, (error, stdout) => {
|
|
2622
|
-
let route;
|
|
2623
|
-
try {
|
|
2624
|
-
route = error ? {
|
|
2625
|
-
state: "unknown",
|
|
2626
|
-
reason: error.killed ? "it timed out" : error.message.split("\n")[0],
|
|
2627
|
-
port: SERVE_PORT
|
|
2628
|
-
} : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
|
|
2629
|
-
} catch {
|
|
2630
|
-
route = {
|
|
2631
|
-
state: "unknown",
|
|
2632
|
-
reason: "it printed output that is not JSON",
|
|
2633
|
-
port: SERVE_PORT
|
|
2634
|
-
};
|
|
2635
|
-
}
|
|
2636
|
-
resolve(route);
|
|
2637
|
-
});
|
|
2638
|
-
});
|
|
2639
|
-
}
|
|
2640
|
-
function tailnetEndpoint(dnsName, port) {
|
|
2641
|
-
return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
|
|
2642
|
-
}
|
|
2643
|
-
function serveCommand(port, target) {
|
|
2644
|
-
return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
|
|
2645
|
-
}
|
|
2646
|
-
/** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
|
|
2647
|
-
function parseWhois(value) {
|
|
2648
|
-
if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
|
|
2649
|
-
const { StableID, Name } = value.Node;
|
|
2650
|
-
const login = isJsonObject(value.UserProfile) ? value.UserProfile.LoginName : void 0;
|
|
2651
|
-
if (typeof StableID !== "string" || !StableID) return null;
|
|
2652
|
-
return {
|
|
2653
|
-
kind: "tailnet",
|
|
2654
|
-
nodeId: StableID,
|
|
2655
|
-
nodeName: typeof Name === "string" ? Name.replace(/\.$/, "") : "",
|
|
2656
|
-
user: typeof login === "string" ? login : ""
|
|
2657
|
-
};
|
|
2658
|
-
}
|
|
2659
|
-
function whois(binary, env, ip) {
|
|
2660
|
-
if (!binary) return Promise.resolve(null);
|
|
2661
|
-
return new Promise((resolve) => {
|
|
2662
|
-
execFile(binary, [
|
|
2663
|
-
"whois",
|
|
2664
|
-
"--json",
|
|
2665
|
-
ip
|
|
2666
|
-
], {
|
|
2667
|
-
env,
|
|
2668
|
-
timeout: TIMEOUT_MS,
|
|
2669
|
-
encoding: "utf8"
|
|
2670
|
-
}, (error, stdout) => {
|
|
2671
|
-
if (error) return resolve(null);
|
|
2672
|
-
try {
|
|
2673
|
-
resolve(parseWhois(JSON.parse(stdout)));
|
|
2674
|
-
} catch {
|
|
2675
|
-
resolve(null);
|
|
2676
|
-
}
|
|
2677
|
-
});
|
|
2678
|
-
});
|
|
2679
|
-
}
|
|
2680
|
-
//#endregion
|
|
2681
|
-
//#region src/server.ts
|
|
2682
|
-
function healthTailscale(tailscale) {
|
|
2683
|
-
if (tailscale.state === "running") return {
|
|
2684
|
-
state: "running",
|
|
2685
|
-
dnsName: tailscale.dnsName
|
|
2686
|
-
};
|
|
2687
|
-
if (tailscale.state === "not-running") return {
|
|
2688
|
-
state: "not-running",
|
|
2689
|
-
backendState: tailscale.backendState
|
|
2690
|
-
};
|
|
2691
|
-
return {
|
|
2692
|
-
state: "unavailable",
|
|
2693
|
-
reason: tailscale.reason
|
|
2694
|
-
};
|
|
2695
|
-
}
|
|
2696
|
-
/** Only a request made to this Mac's loopback name, so a DNS-rebound web page cannot read the health payload. */
|
|
2697
|
-
function localHealthRequest(request) {
|
|
2698
|
-
const host = request.headers.host?.replace(/:\d+$/, "");
|
|
2699
|
-
return request.method === "GET" && request.url === "/health" && (host === "127.0.0.1" || host === "localhost") && peerAddress(request) === null;
|
|
2700
|
-
}
|
|
2701
|
-
const CLOSE_UNAUTHORIZED = 4401;
|
|
2702
|
-
const CLOSE_BAD_REQUEST = 4400;
|
|
2703
|
-
const CLOSE_AUTH_TIMEOUT = 4408;
|
|
2704
|
-
const MAX_PAYLOAD = 65536;
|
|
2705
|
-
const MAX_SUBSCRIPTIONS = 32;
|
|
2706
|
-
const MAX_COMMANDS = 4;
|
|
2707
|
-
const LOG_LIMITS = {
|
|
2708
|
-
maxBufferedBytes: 4194304,
|
|
2709
|
-
maxPendingRecords: 2e4
|
|
2710
|
-
};
|
|
2711
|
-
const FRAME_BUFFER_FRAMES = 2;
|
|
2712
|
-
const FRAME_RETRY_MS = 50;
|
|
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
|
-
};
|
|
2727
|
-
const STATUS_FEED = {
|
|
2728
|
-
args: [
|
|
2729
|
-
"status",
|
|
2730
|
-
"--watch",
|
|
2731
|
-
"--json"
|
|
2732
|
-
],
|
|
2733
|
-
cwd: homedir(),
|
|
2734
|
-
keep: 1,
|
|
2735
|
-
label: "stim status --watch"
|
|
2736
|
-
};
|
|
2737
|
-
const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
|
|
2738
|
-
const COMMAND_LIMITS = {
|
|
2739
|
-
timeoutMs: 6e4,
|
|
2740
|
-
maxOutputBytes: 33554432
|
|
2741
|
-
};
|
|
2742
|
-
const PLAN_TIMEOUT_MS = 15e4;
|
|
2743
|
-
const AUDIT_FIELD_CHARS = 256;
|
|
2744
|
-
const ACTION_LIMITS = {
|
|
2745
|
-
timeoutMs: 12e4,
|
|
2746
|
-
maxOutputBytes: 1048576
|
|
2747
|
-
};
|
|
2748
|
-
const AUTH_REFUSALS = {
|
|
2749
|
-
"pairing-unknown": {
|
|
2750
|
-
code: "pairing-expired",
|
|
2751
|
-
message: "This pairing code is unknown or was already used. Pair again."
|
|
2752
|
-
},
|
|
2753
|
-
"pairing-expired": {
|
|
2754
|
-
code: "pairing-expired",
|
|
2755
|
-
message: "This pairing code expired. Pair again."
|
|
2756
|
-
},
|
|
2757
|
-
"device-unknown": {
|
|
2758
|
-
code: "unauthorized",
|
|
2759
|
-
message: "This Mac does not recognize this device token. Pair again."
|
|
2760
|
-
},
|
|
2761
|
-
"node-mismatch": {
|
|
2762
|
-
code: "unauthorized",
|
|
2763
|
-
message: "This device token was paired from a different tailnet node."
|
|
2764
|
-
}
|
|
2765
|
-
};
|
|
2766
|
-
function isLoopback(address) {
|
|
2767
|
-
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
2768
|
-
}
|
|
2769
|
-
/**
|
|
2770
|
-
* The tailnet address of the peer. `tailscale serve` proxies from loopback and names the peer in
|
|
2771
|
-
* X-Forwarded-For; a loopback connection without that header comes from this Mac.
|
|
2772
|
-
*/
|
|
2773
|
-
function peerAddress(request) {
|
|
2774
|
-
const remote = request.socket.remoteAddress;
|
|
2775
|
-
if (!isLoopback(remote)) return remote ?? null;
|
|
2776
|
-
const forwarded = request.headers["x-forwarded-for"];
|
|
2777
|
-
return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
|
|
2778
|
-
}
|
|
2779
|
-
var FailureLimiter = class {
|
|
2780
|
-
failures = /* @__PURE__ */ new Map();
|
|
2781
|
-
max;
|
|
2782
|
-
windowMs;
|
|
2783
|
-
constructor(max, windowMs) {
|
|
2784
|
-
this.max = max;
|
|
2785
|
-
this.windowMs = windowMs;
|
|
2786
|
-
}
|
|
2787
|
-
recent(key, now) {
|
|
2788
|
-
const kept = (this.failures.get(key) ?? []).filter((at) => now - at < this.windowMs);
|
|
2789
|
-
if (kept.length) this.failures.set(key, kept);
|
|
2790
|
-
else this.failures.delete(key);
|
|
2791
|
-
return kept;
|
|
2792
|
-
}
|
|
2793
|
-
blocked(key, now = Date.now()) {
|
|
2794
|
-
return this.recent(key, now).length >= this.max;
|
|
2795
|
-
}
|
|
2796
|
-
record(key, now = Date.now()) {
|
|
2797
|
-
this.failures.set(key, [...this.recent(key, now), now]);
|
|
2798
|
-
}
|
|
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
|
-
}
|
|
2815
|
-
function send(socket, message) {
|
|
2816
|
-
if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
|
|
2817
|
-
}
|
|
2818
|
-
function requestId(value) {
|
|
2819
|
-
return typeof value === "number" || typeof value === "string" ? value : null;
|
|
2820
|
-
}
|
|
2821
|
-
function registeredWorkspace(workspace) {
|
|
2822
|
-
if (typeof workspace !== "string") return {
|
|
2823
|
-
code: "bad-request",
|
|
2824
|
-
message: "params.workspace must be an environment path from a status payload."
|
|
2825
|
-
};
|
|
2826
|
-
let projects;
|
|
2827
|
-
try {
|
|
2828
|
-
projects = loadConfig()?.projects ?? {};
|
|
2829
|
-
} catch (cause) {
|
|
2830
|
-
return {
|
|
2831
|
-
code: "stim-failed",
|
|
2832
|
-
message: cause.message
|
|
2833
|
-
};
|
|
2834
|
-
}
|
|
2835
|
-
const dir = Object.keys(projects).find((path) => path === workspace);
|
|
2836
|
-
if (dir === void 0) return {
|
|
2837
|
-
code: "unknown-workspace",
|
|
2838
|
-
message: `${workspace} is not a Stim workspace on this Mac.`
|
|
2839
|
-
};
|
|
2840
|
-
if (!existsSync(dir)) return {
|
|
2841
|
-
code: "unknown-workspace",
|
|
2842
|
-
message: `${dir} is registered but no longer exists on this Mac.`
|
|
2843
|
-
};
|
|
2844
|
-
return { dir };
|
|
2845
|
-
}
|
|
2846
|
-
async function startServer(options) {
|
|
2847
|
-
const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
|
|
2848
|
-
const authTimeoutMs = options.authTimeoutMs ?? 5e3;
|
|
2849
|
-
const feeds = new FeedPool(options.stimCli, options.env);
|
|
2850
|
-
const frameLimits = {
|
|
2851
|
-
...DEFAULT_FRAME_LIMITS,
|
|
2852
|
-
...options.frameLimits
|
|
2853
|
-
};
|
|
2854
|
-
let helperPath = options.frameHelper ?? null;
|
|
2855
|
-
let helperBuilding = false;
|
|
2856
|
-
let helperRetryAt = 0;
|
|
2857
|
-
const helperAbort = new AbortController();
|
|
2858
|
-
const buildHelper = () => {
|
|
2859
|
-
helperBuilding = true;
|
|
2860
|
-
(async () => {
|
|
2861
|
-
try {
|
|
2862
|
-
helperPath = await buildFrameHelper(options.env, helperAbort.signal);
|
|
2863
|
-
} catch (cause) {
|
|
2864
|
-
helperRetryAt = Date.now() + HELPER_RETRY_MS;
|
|
2865
|
-
if (!helperAbort.signal.aborted) console.error(`stim-server: frames come from screenshots: ${cause.message}`);
|
|
2866
|
-
} finally {
|
|
2867
|
-
helperBuilding = false;
|
|
2868
|
-
}
|
|
2869
|
-
})();
|
|
2870
|
-
};
|
|
2871
|
-
const frameHelper = () => {
|
|
2872
|
-
if (options.frameHelper === void 0 && !helperPath && !helperBuilding && Date.now() >= helperRetryAt) buildHelper();
|
|
2873
|
-
return helperPath;
|
|
2874
|
-
};
|
|
2875
|
-
if (options.frameHelper === void 0) buildHelper();
|
|
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
|
-
};
|
|
2886
|
-
const running = /* @__PURE__ */ new Set();
|
|
2887
|
-
const logLimits = {
|
|
2888
|
-
...LOG_LIMITS,
|
|
2889
|
-
...options.logLimits
|
|
2890
|
-
};
|
|
2891
|
-
const commandLimits = {
|
|
2892
|
-
...COMMAND_LIMITS,
|
|
2893
|
-
...options.commandLimits
|
|
2894
|
-
};
|
|
2895
|
-
const actionLimits = {
|
|
2896
|
-
...ACTION_LIMITS,
|
|
2897
|
-
...options.actionLimits
|
|
2898
|
-
};
|
|
2899
|
-
const busyWorkspaces = /* @__PURE__ */ new Set();
|
|
2900
|
-
const planQueues = /* @__PURE__ */ new Map();
|
|
2901
|
-
const sessions = /* @__PURE__ */ new Map();
|
|
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
|
-
});
|
|
2922
|
-
const wss = new WebSocketServer({
|
|
2923
|
-
noServer: true,
|
|
2924
|
-
maxPayload: MAX_PAYLOAD
|
|
2925
|
-
});
|
|
2926
|
-
mkdirSync(serverDir(), {
|
|
2927
|
-
recursive: true,
|
|
2928
|
-
mode: 448
|
|
2929
|
-
});
|
|
2930
|
-
let revocationCheck = null;
|
|
2931
|
-
const watcher = watch(serverDir(), () => {
|
|
2932
|
-
revocationCheck ??= setTimeout(() => {
|
|
2933
|
-
revocationCheck = null;
|
|
2934
|
-
const paired = new Map(readDevices().map((device) => [device.id, device]));
|
|
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
|
-
}
|
|
2940
|
-
}, 50);
|
|
2941
|
-
});
|
|
2942
|
-
function connection(socket, peer) {
|
|
2943
|
-
const limitKey = peer ?? "local";
|
|
2944
|
-
const subscriptions = /* @__PURE__ */ new Map();
|
|
2945
|
-
const keyframes = /* @__PURE__ */ new Map();
|
|
2946
|
-
const commands = /* @__PURE__ */ new Set();
|
|
2947
|
-
let nextSubscription = 1;
|
|
2948
|
-
let device = null;
|
|
2949
|
-
let queue = Promise.resolve();
|
|
2950
|
-
const refuse = (id, code, message, closeCode) => {
|
|
2951
|
-
if (!device) limiter.record(limitKey);
|
|
2952
|
-
send(socket, {
|
|
2953
|
-
id,
|
|
2954
|
-
error: {
|
|
2955
|
-
code,
|
|
2956
|
-
message
|
|
2957
|
-
}
|
|
2958
|
-
});
|
|
2959
|
-
socket.close(closeCode, code);
|
|
2960
|
-
};
|
|
2961
|
-
const timer = setTimeout(() => {
|
|
2962
|
-
limiter.record(limitKey);
|
|
2963
|
-
socket.close(CLOSE_AUTH_TIMEOUT, "authentication timeout");
|
|
2964
|
-
}, authTimeoutMs);
|
|
2965
|
-
async function identify() {
|
|
2966
|
-
if (peer === null) return { kind: "local" };
|
|
2967
|
-
return isIP(peer) ? whois(options.tailscale, options.env, peer) : null;
|
|
2968
|
-
}
|
|
2969
|
-
async function hello(id, params) {
|
|
2970
|
-
if (device) return send(socket, {
|
|
2971
|
-
id,
|
|
2972
|
-
error: {
|
|
2973
|
-
code: "already-authenticated",
|
|
2974
|
-
message: "hello was already accepted."
|
|
2975
|
-
}
|
|
2976
|
-
});
|
|
2977
|
-
const auth = isJsonObject(params) && isJsonObject(params.auth) ? params.auth : null;
|
|
2978
|
-
if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
|
|
2979
|
-
if (params.protocol !== 1) return refuse(id, "protocol-unsupported", `This server speaks protocol 1.`, CLOSE_BAD_REQUEST);
|
|
2980
|
-
clearTimeout(timer);
|
|
2981
|
-
const identity = await identify();
|
|
2982
|
-
if (socket.readyState !== socket.OPEN) return;
|
|
2983
|
-
if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
|
|
2984
|
-
let outcome;
|
|
2985
|
-
if (typeof auth.pairingToken === "string" && typeof auth.deviceName === "string" && auth.deviceName.trim()) outcome = spendPairingToken(auth.pairingToken, auth.deviceName.trim(), identity);
|
|
2986
|
-
else if (typeof auth.deviceToken === "string") outcome = authenticateDevice(auth.deviceToken, identity);
|
|
2987
|
-
else return refuse(id, "bad-request", "auth needs pairingToken and deviceName, or deviceToken.", CLOSE_BAD_REQUEST);
|
|
2988
|
-
if (!outcome.ok) {
|
|
2989
|
-
const { code, message } = AUTH_REFUSALS[outcome.reason];
|
|
2990
|
-
return refuse(id, code, message, CLOSE_UNAUTHORIZED);
|
|
2991
|
-
}
|
|
2992
|
-
device = outcome.device;
|
|
2993
|
-
sessions.set(socket, device);
|
|
2994
|
-
sampler.start();
|
|
2995
|
-
send(socket, {
|
|
2996
|
-
id,
|
|
2997
|
-
result: {
|
|
2998
|
-
protocol: 1,
|
|
2999
|
-
server: {
|
|
3000
|
-
name: options.name,
|
|
3001
|
-
version: options.serverVersion,
|
|
3002
|
-
stim: options.stimVersion,
|
|
3003
|
-
home: homedir()
|
|
3004
|
-
},
|
|
3005
|
-
capabilities: device.capabilities,
|
|
3006
|
-
actions: device.capabilities.includes("control") ? [...ACTIONS] : [],
|
|
3007
|
-
device: {
|
|
3008
|
-
id: device.id,
|
|
3009
|
-
name: device.name
|
|
3010
|
-
},
|
|
3011
|
-
...outcome.deviceToken ? { deviceToken: outcome.deviceToken } : {}
|
|
3012
|
-
}
|
|
3013
|
-
});
|
|
3014
|
-
}
|
|
3015
|
-
function error(id, code, message) {
|
|
3016
|
-
send(socket, {
|
|
3017
|
-
id,
|
|
3018
|
-
error: {
|
|
3019
|
-
code,
|
|
3020
|
-
message
|
|
3021
|
-
}
|
|
3022
|
-
});
|
|
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
|
-
}
|
|
3100
|
-
function workspaceDir(id, workspace, required) {
|
|
3101
|
-
if (workspace === void 0 && !required) return homedir();
|
|
3102
|
-
const resolved = registeredWorkspace(workspace);
|
|
3103
|
-
if ("code" in resolved) {
|
|
3104
|
-
error(id, resolved.code, resolved.message);
|
|
3105
|
-
return null;
|
|
3106
|
-
}
|
|
3107
|
-
return resolved.dir;
|
|
3108
|
-
}
|
|
3109
|
-
function openSubscription(id, result = {}) {
|
|
3110
|
-
if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
|
|
3111
|
-
error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
|
|
3112
|
-
return null;
|
|
3113
|
-
}
|
|
3114
|
-
const subscription = `s${nextSubscription++}`;
|
|
3115
|
-
send(socket, {
|
|
3116
|
-
id,
|
|
3117
|
-
result: {
|
|
3118
|
-
subscription,
|
|
3119
|
-
...result
|
|
3120
|
-
}
|
|
3121
|
-
});
|
|
3122
|
-
return subscription;
|
|
3123
|
-
}
|
|
3124
|
-
function subscribeStatus(id) {
|
|
3125
|
-
const subscription = openSubscription(id);
|
|
3126
|
-
if (!subscription) return;
|
|
3127
|
-
const envelope = `{"event":"status","subscription":${JSON.stringify(subscription)},"payload":`;
|
|
3128
|
-
const unsubscribe = feeds.subscribe(STATUS_FEED, {
|
|
3129
|
-
item: (_payload, text) => send(socket, `${envelope}${text}}`),
|
|
3130
|
-
failed: (message) => {
|
|
3131
|
-
subscriptions.delete(subscription);
|
|
3132
|
-
send(socket, {
|
|
3133
|
-
event: "error",
|
|
3134
|
-
subscription,
|
|
3135
|
-
error: {
|
|
3136
|
-
code: "status-failed",
|
|
3137
|
-
message
|
|
3138
|
-
}
|
|
3139
|
-
});
|
|
3140
|
-
}
|
|
3141
|
-
});
|
|
3142
|
-
subscriptions.set(subscription, unsubscribe);
|
|
3143
|
-
}
|
|
3144
|
-
function subscribeLogs(id, params) {
|
|
3145
|
-
const parsed = parseLogFilter(params);
|
|
3146
|
-
if ("error" in parsed) return error(id, "bad-request", parsed.error);
|
|
3147
|
-
const cwd = workspaceDir(id, parsed.filter.workspace, true);
|
|
3148
|
-
if (!cwd) return;
|
|
3149
|
-
const subscription = openSubscription(id);
|
|
3150
|
-
if (!subscription) return;
|
|
3151
|
-
const end = () => {
|
|
3152
|
-
subscriptions.get(subscription)?.();
|
|
3153
|
-
subscriptions.delete(subscription);
|
|
3154
|
-
};
|
|
3155
|
-
const batcher = new LogBatcher({
|
|
3156
|
-
send: (records) => send(socket, {
|
|
3157
|
-
event: "logs",
|
|
3158
|
-
subscription,
|
|
3159
|
-
records
|
|
3160
|
-
}),
|
|
3161
|
-
bufferedBytes: () => socket.bufferedAmount,
|
|
3162
|
-
overflow: () => {
|
|
3163
|
-
end();
|
|
3164
|
-
send(socket, {
|
|
3165
|
-
event: "error",
|
|
3166
|
-
subscription,
|
|
3167
|
-
error: {
|
|
3168
|
-
code: "slow-client",
|
|
3169
|
-
message: "This client fell behind the log stream. Subscribe again."
|
|
3170
|
-
}
|
|
3171
|
-
});
|
|
3172
|
-
}
|
|
3173
|
-
}, logLimits);
|
|
3174
|
-
const unsubscribe = feeds.subscribe({
|
|
3175
|
-
args: logArgs(parsed.filter, true),
|
|
3176
|
-
cwd,
|
|
3177
|
-
keep: parsed.filter.tail,
|
|
3178
|
-
label: "stim logs --follow"
|
|
3179
|
-
}, {
|
|
3180
|
-
item: (record) => batcher.push(record),
|
|
3181
|
-
failed: (message) => {
|
|
3182
|
-
batcher.flush(true);
|
|
3183
|
-
batcher.stop();
|
|
3184
|
-
subscriptions.delete(subscription);
|
|
3185
|
-
send(socket, {
|
|
3186
|
-
event: "error",
|
|
3187
|
-
subscription,
|
|
3188
|
-
error: {
|
|
3189
|
-
code: "logs-failed",
|
|
3190
|
-
message
|
|
3191
|
-
}
|
|
3192
|
-
});
|
|
3193
|
-
}
|
|
3194
|
-
});
|
|
3195
|
-
subscriptions.set(subscription, () => {
|
|
3196
|
-
batcher.stop();
|
|
3197
|
-
unsubscribe();
|
|
3198
|
-
});
|
|
3199
|
-
}
|
|
3200
|
-
function subscribeFrames(id, params) {
|
|
3201
|
-
const { workspace, platform, slot, fps, maxEdge, video } = isJsonObject(params) ? params : {};
|
|
3202
|
-
if (typeof workspace !== "string" || platform !== "ios" && platform !== "android") return error(id, "bad-request", "frames.subscribe needs params.workspace and params.platform (ios or android).");
|
|
3203
|
-
if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
|
|
3204
|
-
if (video !== void 0 && (!Array.isArray(video) || !video.every((codec) => typeof codec === "string"))) return error(id, "bad-request", "video must be a list of codec names.");
|
|
3205
|
-
const wantsVideo = video?.includes("h264") === true;
|
|
3206
|
-
const offersVideo = wantsVideo && frameHelper() !== null;
|
|
3207
|
-
const maxFps = wantsVideo ? FRAME_FPS.video : FRAME_FPS.max;
|
|
3208
|
-
if (fps !== void 0 && (!Number.isInteger(fps) || fps < 1 || fps > maxFps)) return error(id, "bad-request", `fps must be a whole number from 1 to ${maxFps}.`);
|
|
3209
|
-
if (maxEdge !== void 0 && (!Number.isInteger(maxEdge) || maxEdge < FRAME_EDGE.min || maxEdge > FRAME_EDGE.max)) return error(id, "bad-request", `maxEdge must be a whole number of pixels from ${FRAME_EDGE.min} to ${FRAME_EDGE.max}.`);
|
|
3210
|
-
const hint = {
|
|
3211
|
-
fps: Math.min(fps ?? FRAME_FPS.default, offersVideo ? FRAME_FPS.video : FRAME_FPS.max),
|
|
3212
|
-
maxEdge: maxEdge ?? FRAME_EDGE.default
|
|
3213
|
-
};
|
|
3214
|
-
if (!workspaceDir(id, workspace, true)) return;
|
|
3215
|
-
const subscription = openSubscription(id, offersVideo ? { video: "h264" } : {});
|
|
3216
|
-
if (!subscription) return;
|
|
3217
|
-
const frameTarget = {
|
|
3218
|
-
workspace,
|
|
3219
|
-
platform,
|
|
3220
|
-
...slot ? { slot } : {}
|
|
3221
|
-
};
|
|
3222
|
-
const gate = new VideoGate(DEFAULT_VIDEO_LIMITS.congestedBytes);
|
|
3223
|
-
let sequence = 0;
|
|
3224
|
-
let streamed = null;
|
|
3225
|
-
let draining = null;
|
|
3226
|
-
const drain = () => {
|
|
3227
|
-
draining = null;
|
|
3228
|
-
if (ended || !streamed) return;
|
|
3229
|
-
if (socket.bufferedAmount <= DEFAULT_VIDEO_LIMITS.congestedBytes) return frames.keyframe(streamed);
|
|
3230
|
-
frames.congested(streamed);
|
|
3231
|
-
draining = setTimeout(drain, FRAME_RETRY_MS);
|
|
3232
|
-
};
|
|
3233
|
-
let attached = null;
|
|
3234
|
-
let detach = null;
|
|
3235
|
-
let pending = null;
|
|
3236
|
-
let retry = null;
|
|
3237
|
-
let ended = false;
|
|
3238
|
-
let sentAt = 0;
|
|
3239
|
-
const flush = () => {
|
|
3240
|
-
retry = null;
|
|
3241
|
-
if (!pending || ended) return;
|
|
3242
|
-
const wait = sentAt + 1e3 / hint.fps - Date.now();
|
|
3243
|
-
if (wait > 0) {
|
|
3244
|
-
retry = setTimeout(flush, wait);
|
|
3245
|
-
return;
|
|
3246
|
-
}
|
|
3247
|
-
if (socket.bufferedAmount > FRAME_BUFFER_FRAMES * pending.data.length) {
|
|
3248
|
-
retry = setTimeout(flush, FRAME_RETRY_MS);
|
|
3249
|
-
return;
|
|
3250
|
-
}
|
|
3251
|
-
const frame = pending;
|
|
3252
|
-
pending = null;
|
|
3253
|
-
sentAt = Date.now();
|
|
3254
|
-
send(socket, {
|
|
3255
|
-
event: "frame",
|
|
3256
|
-
subscription,
|
|
3257
|
-
platform,
|
|
3258
|
-
slot: slot ?? "default",
|
|
3259
|
-
mime: "image/jpeg",
|
|
3260
|
-
...frame
|
|
3261
|
-
});
|
|
3262
|
-
};
|
|
3263
|
-
const cleanup = () => {
|
|
3264
|
-
ended = true;
|
|
3265
|
-
keyframes.delete(subscription);
|
|
3266
|
-
if (retry) clearTimeout(retry);
|
|
3267
|
-
if (draining) clearTimeout(draining);
|
|
3268
|
-
detach?.();
|
|
3269
|
-
detach = null;
|
|
3270
|
-
unsubscribeStatus?.();
|
|
3271
|
-
};
|
|
3272
|
-
const end = (message) => {
|
|
3273
|
-
if (ended) return;
|
|
3274
|
-
cleanup();
|
|
3275
|
-
subscriptions.delete(subscription);
|
|
3276
|
-
send(socket, {
|
|
3277
|
-
event: "error",
|
|
3278
|
-
subscription,
|
|
3279
|
-
error: {
|
|
3280
|
-
code: "frames-failed",
|
|
3281
|
-
message
|
|
3282
|
-
}
|
|
3283
|
-
});
|
|
3284
|
-
};
|
|
3285
|
-
const listener = {
|
|
3286
|
-
frame: (frame) => {
|
|
3287
|
-
pending = frame;
|
|
3288
|
-
if (!retry) flush();
|
|
3289
|
-
},
|
|
3290
|
-
delayed: (delayed) => {
|
|
3291
|
-
if (!ended) send(socket, {
|
|
3292
|
-
event: "frame-delayed",
|
|
3293
|
-
subscription,
|
|
3294
|
-
delayed
|
|
3295
|
-
});
|
|
3296
|
-
},
|
|
3297
|
-
failed: end,
|
|
3298
|
-
...offersVideo ? { video: (unit) => {
|
|
3299
|
-
if (ended || socket.readyState !== socket.OPEN) return;
|
|
3300
|
-
const verdict = gate.admit(unit, socket.bufferedAmount);
|
|
3301
|
-
if (verdict === "send") socket.send(videoPacket(subscription, sequence++, unit));
|
|
3302
|
-
else if (verdict === "congested" && !draining) drain();
|
|
3303
|
-
} } : {}
|
|
3304
|
-
};
|
|
3305
|
-
if (offersVideo) keyframes.set(subscription, () => {
|
|
3306
|
-
gate.reset();
|
|
3307
|
-
if (streamed) frames.keyframe(streamed);
|
|
3308
|
-
});
|
|
3309
|
-
let unsubscribeStatus = null;
|
|
3310
|
-
unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
|
|
3311
|
-
item: (payload) => {
|
|
3312
|
-
if (ended) return;
|
|
3313
|
-
const resolved = ownedDevice(payload, frameTarget, attached);
|
|
3314
|
-
if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
|
|
3315
|
-
if (deviceKey(resolved) === attached) return;
|
|
3316
|
-
detach?.();
|
|
3317
|
-
gate.reset();
|
|
3318
|
-
streamed = resolved;
|
|
3319
|
-
attached = deviceKey(resolved);
|
|
3320
|
-
detach = frames.subscribe(resolved, listener, hint);
|
|
3321
|
-
},
|
|
3322
|
-
failed: (message) => queueMicrotask(() => end(message))
|
|
3323
|
-
});
|
|
3324
|
-
subscriptions.set(subscription, cleanup);
|
|
3325
|
-
}
|
|
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);
|
|
3346
|
-
const outcome = await run.outcome;
|
|
3347
|
-
commands.delete(cancel);
|
|
3348
|
-
running.delete(cancel);
|
|
3349
|
-
try {
|
|
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();
|
|
3366
|
-
}
|
|
3367
|
-
};
|
|
3368
|
-
commands.add(cancel);
|
|
3369
|
-
running.add(cancel);
|
|
3370
|
-
if (turn) turn.then(start);
|
|
3371
|
-
else start();
|
|
3372
|
-
return released;
|
|
3373
|
-
}
|
|
3374
|
-
function queryLogs(id, params) {
|
|
3375
|
-
const parsed = parseLogFilter(params);
|
|
3376
|
-
if ("error" in parsed) return error(id, "bad-request", parsed.error);
|
|
3377
|
-
const cwd = workspaceDir(id, parsed.filter.workspace, true);
|
|
3378
|
-
if (!cwd) return;
|
|
3379
|
-
command(id, logArgs(parsed.filter, false), cwd, (stdout) => ({ records: stdout.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)) }));
|
|
3380
|
-
}
|
|
3381
|
-
function workspaceCommand(id, method, params) {
|
|
3382
|
-
if (params !== void 0 && !isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
|
|
3383
|
-
const cwd = workspaceDir(id, params?.workspace, false);
|
|
3384
|
-
if (!cwd) return;
|
|
3385
|
-
command(id, [method === "stats.get" ? "stats" : "settings", "--json"], cwd, (stdout) => {
|
|
3386
|
-
const value = JSON.parse(stdout);
|
|
3387
|
-
if (!isJsonObject(value)) throw new Error("not an object");
|
|
3388
|
-
return value;
|
|
3389
|
-
});
|
|
3390
|
-
}
|
|
3391
|
-
function runAction(id, params, session) {
|
|
3392
|
-
const startedAt = Date.now();
|
|
3393
|
-
const raw = isJsonObject(params) ? params : {};
|
|
3394
|
-
const clip = (value) => typeof value === "string" ? value.slice(0, AUDIT_FIELD_CHARS) : null;
|
|
3395
|
-
const record = {
|
|
3396
|
-
at: new Date(startedAt).toISOString(),
|
|
3397
|
-
device: {
|
|
3398
|
-
id: session.id,
|
|
3399
|
-
name: session.name
|
|
3400
|
-
},
|
|
3401
|
-
action: clip(raw.action),
|
|
3402
|
-
workspace: clip(raw.workspace),
|
|
3403
|
-
...typeof raw.platform === "string" ? { platform: clip(raw.platform) } : {},
|
|
3404
|
-
ok: false
|
|
3405
|
-
};
|
|
3406
|
-
const audit = (outcome) => {
|
|
3407
|
-
const logged = outcome.error ? {
|
|
3408
|
-
...outcome.error,
|
|
3409
|
-
message: clip(outcome.error.message)
|
|
3410
|
-
} : void 0;
|
|
3411
|
-
try {
|
|
3412
|
-
appendAudit({
|
|
3413
|
-
...record,
|
|
3414
|
-
...outcome,
|
|
3415
|
-
...logged ? { error: logged } : {}
|
|
3416
|
-
});
|
|
3417
|
-
} catch (cause) {
|
|
3418
|
-
console.error(`stim-server: could not append to the action log: ${cause.message}`);
|
|
3419
|
-
}
|
|
3420
|
-
};
|
|
3421
|
-
const refuseAction = (code, message) => {
|
|
3422
|
-
audit({
|
|
3423
|
-
ok: false,
|
|
3424
|
-
error: {
|
|
3425
|
-
code,
|
|
3426
|
-
message
|
|
3427
|
-
}
|
|
3428
|
-
});
|
|
3429
|
-
error(id, code, message);
|
|
3430
|
-
};
|
|
3431
|
-
if (!readDevices().find((entry) => entry.id === session.id)?.capabilities.includes("control")) return refuseAction("forbidden", `This device can only read. On the Mac, run \`stim-server devices grant ${session.id} --control\` to let it run actions.`);
|
|
3432
|
-
const parsed = parseAction(params);
|
|
3433
|
-
if ("code" in parsed) return refuseAction(parsed.code, parsed.message);
|
|
3434
|
-
const { action } = parsed;
|
|
3435
|
-
const resolved = registeredWorkspace(action.workspace);
|
|
3436
|
-
if ("code" in resolved) return refuseAction(resolved.code, resolved.message);
|
|
3437
|
-
if (busyWorkspaces.has(resolved.dir)) return refuseAction("action-busy", `An action is already running in ${resolved.dir}. Try again when it finishes.`);
|
|
3438
|
-
let run;
|
|
3439
|
-
try {
|
|
3440
|
-
run = runStim(options.stimCli, options.env, actionArgs(action), resolved.dir, actionLimits);
|
|
3441
|
-
} catch (cause) {
|
|
3442
|
-
return refuseAction("action-failed", `stim ${action.action} could not start (${cause.message}).`);
|
|
3443
|
-
}
|
|
3444
|
-
busyWorkspaces.add(resolved.dir);
|
|
3445
|
-
let finished = false;
|
|
3446
|
-
const finish = (outcome) => {
|
|
3447
|
-
if (finished) return;
|
|
3448
|
-
finished = true;
|
|
3449
|
-
running.delete(cancel);
|
|
3450
|
-
busyWorkspaces.delete(resolved.dir);
|
|
3451
|
-
audit({
|
|
3452
|
-
ok: outcome.ok,
|
|
3453
|
-
...outcome.ok ? {} : { error: outcome.error },
|
|
3454
|
-
durationMs: Date.now() - startedAt
|
|
3455
|
-
});
|
|
3456
|
-
if (outcome.ok) send(socket, {
|
|
3457
|
-
id,
|
|
3458
|
-
result: {
|
|
3459
|
-
action: action.action,
|
|
3460
|
-
workspace: resolved.dir,
|
|
3461
|
-
output: outcome.output
|
|
3462
|
-
}
|
|
3463
|
-
});
|
|
3464
|
-
else send(socket, {
|
|
3465
|
-
id,
|
|
3466
|
-
error: outcome.error
|
|
3467
|
-
});
|
|
3468
|
-
};
|
|
3469
|
-
const cancel = async () => {
|
|
3470
|
-
finish({
|
|
3471
|
-
ok: false,
|
|
3472
|
-
error: {
|
|
3473
|
-
code: "action-failed",
|
|
3474
|
-
message: "stim-server stopped before the action finished."
|
|
3475
|
-
}
|
|
3476
|
-
});
|
|
3477
|
-
await run.cancel();
|
|
3478
|
-
};
|
|
3479
|
-
running.add(cancel);
|
|
3480
|
-
run.outcome.then((outcome) => finish(actionOutcome(outcome)));
|
|
3481
|
-
}
|
|
3482
|
-
function planBuild(id, params) {
|
|
3483
|
-
if (!isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
|
|
3484
|
-
const { platform, slot } = params;
|
|
3485
|
-
if (platform !== "ios" && platform !== "android") return error(id, "bad-request", "params.platform must be ios or android.");
|
|
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.");
|
|
3487
|
-
const cwd = workspaceDir(id, params.workspace, true);
|
|
3488
|
-
if (!cwd) return;
|
|
3489
|
-
const finished = command(id, [
|
|
3490
|
-
platform,
|
|
3491
|
-
"--plan",
|
|
3492
|
-
"--json",
|
|
3493
|
-
...slot === void 0 ? [] : [`--slot=${slot}`]
|
|
3494
|
-
], cwd, (stdout) => {
|
|
3495
|
-
const value = JSON.parse(stdout);
|
|
3496
|
-
if (!isJsonObject(value) || value.platform !== platform) throw new Error("not a plan");
|
|
3497
|
-
return value;
|
|
3498
|
-
}, {
|
|
3499
|
-
...commandLimits,
|
|
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);
|
|
3506
|
-
});
|
|
3507
|
-
}
|
|
3508
|
-
async function handle(raw) {
|
|
3509
|
-
if (socket.readyState !== socket.OPEN) return;
|
|
3510
|
-
let message;
|
|
3511
|
-
try {
|
|
3512
|
-
message = JSON.parse(raw);
|
|
3513
|
-
} catch {
|
|
3514
|
-
message = null;
|
|
3515
|
-
}
|
|
3516
|
-
const id = isJsonObject(message) ? requestId(message.id) : null;
|
|
3517
|
-
if (!isJsonObject(message) || id === null || typeof message.method !== "string") {
|
|
3518
|
-
if (device) return send(socket, {
|
|
3519
|
-
id,
|
|
3520
|
-
error: {
|
|
3521
|
-
code: "bad-request",
|
|
3522
|
-
message: "Expected {id, method, params}."
|
|
3523
|
-
}
|
|
3524
|
-
});
|
|
3525
|
-
return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
|
|
3526
|
-
}
|
|
3527
|
-
if (message.method === "hello") return hello(id, message.params);
|
|
3528
|
-
if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
|
|
3529
|
-
if (message.method === "status.subscribe") return subscribeStatus(id);
|
|
3530
|
-
if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
|
|
3531
|
-
if (message.method === "logs.query") return queryLogs(id, message.params);
|
|
3532
|
-
if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
|
|
3533
|
-
if (message.method === "frames.keyframe") {
|
|
3534
|
-
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
3535
|
-
const keyframe = typeof name === "string" ? keyframes.get(name) : void 0;
|
|
3536
|
-
if (!keyframe) return send(socket, {
|
|
3537
|
-
id,
|
|
3538
|
-
error: {
|
|
3539
|
-
code: "unknown-subscription",
|
|
3540
|
-
message: `No video subscription ${String(name)}.`
|
|
3541
|
-
}
|
|
3542
|
-
});
|
|
3543
|
-
keyframe();
|
|
3544
|
-
return send(socket, {
|
|
3545
|
-
id,
|
|
3546
|
-
result: {}
|
|
3547
|
-
});
|
|
3548
|
-
}
|
|
3549
|
-
if (message.method === "build.plan") return planBuild(id, message.params);
|
|
3550
|
-
if (message.method === "machine.get") return send(socket, {
|
|
3551
|
-
id,
|
|
3552
|
-
result: await readMachineUsage()
|
|
3553
|
-
});
|
|
3554
|
-
if (message.method === "machine.history") {
|
|
3555
|
-
const params = message.params ?? {};
|
|
3556
|
-
const sinceMs = isJsonObject(params) ? params.sinceMs : void 0;
|
|
3557
|
-
if (!isJsonObject(params) || sinceMs !== void 0 && typeof sinceMs !== "number") return error(id, "bad-request", "machine.history takes an optional numeric sinceMs.");
|
|
3558
|
-
return send(socket, {
|
|
3559
|
-
id,
|
|
3560
|
-
result: sampler.history(sinceMs)
|
|
3561
|
-
});
|
|
3562
|
-
}
|
|
3563
|
-
if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
|
|
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
|
-
}
|
|
3582
|
-
if (message.method === "unsubscribe") {
|
|
3583
|
-
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
3584
|
-
const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
|
|
3585
|
-
if (!unsubscribe) return send(socket, {
|
|
3586
|
-
id,
|
|
3587
|
-
error: {
|
|
3588
|
-
code: "unknown-subscription",
|
|
3589
|
-
message: `No subscription ${String(name)}.`
|
|
3590
|
-
}
|
|
3591
|
-
});
|
|
3592
|
-
subscriptions.delete(name);
|
|
3593
|
-
unsubscribe();
|
|
3594
|
-
return send(socket, {
|
|
3595
|
-
id,
|
|
3596
|
-
result: {}
|
|
3597
|
-
});
|
|
3598
|
-
}
|
|
3599
|
-
send(socket, {
|
|
3600
|
-
id,
|
|
3601
|
-
error: {
|
|
3602
|
-
code: "unknown-method",
|
|
3603
|
-
message: `Unknown method ${message.method}.`
|
|
3604
|
-
}
|
|
3605
|
-
});
|
|
3606
|
-
}
|
|
3607
|
-
socket.on("message", (data) => {
|
|
3608
|
-
const raw = data.toString();
|
|
3609
|
-
queue = queue.then(() => handle(raw)).catch(() => socket.close(1011, "internal error"));
|
|
3610
|
-
});
|
|
3611
|
-
socket.on("close", () => {
|
|
3612
|
-
clearTimeout(timer);
|
|
3613
|
-
sessions.delete(socket);
|
|
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);
|
|
3618
|
-
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
3619
|
-
subscriptions.clear();
|
|
3620
|
-
for (const cancel of commands) {
|
|
3621
|
-
running.delete(cancel);
|
|
3622
|
-
cancel();
|
|
3623
|
-
}
|
|
3624
|
-
commands.clear();
|
|
3625
|
-
});
|
|
3626
|
-
}
|
|
3627
|
-
function upgrade(request, socket, head) {
|
|
3628
|
-
const peer = peerAddress(request);
|
|
3629
|
-
if (limiter.blocked(peer ?? "local")) {
|
|
3630
|
-
socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
3631
|
-
return;
|
|
3632
|
-
}
|
|
3633
|
-
wss.handleUpgrade(request, socket, head, (ws) => connection(ws, peer));
|
|
3634
|
-
}
|
|
3635
|
-
const health = {
|
|
3636
|
-
server: "stim-server",
|
|
3637
|
-
name: options.name,
|
|
3638
|
-
version: options.serverVersion,
|
|
3639
|
-
stim: options.stimVersion,
|
|
3640
|
-
protocol: 1,
|
|
3641
|
-
stimHome: configDir(),
|
|
3642
|
-
tailscale: healthTailscale(options.tailscaleState)
|
|
3643
|
-
};
|
|
3644
|
-
const answerHealth = async (response) => {
|
|
3645
|
-
const tailscale = options.tailscaleState;
|
|
3646
|
-
const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
|
|
3647
|
-
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
|
3648
|
-
...health,
|
|
3649
|
-
route
|
|
3650
|
-
}));
|
|
3651
|
-
};
|
|
3652
|
-
const servers = [];
|
|
3653
|
-
const addresses = [];
|
|
3654
|
-
const close = async () => {
|
|
3655
|
-
watcher.close();
|
|
3656
|
-
helperAbort.abort();
|
|
3657
|
-
sampler.stop();
|
|
3658
|
-
if (revocationCheck) clearTimeout(revocationCheck);
|
|
3659
|
-
for (const client of wss.clients) client.terminate();
|
|
3660
|
-
await control.close();
|
|
3661
|
-
await Promise.all([
|
|
3662
|
-
frames.close(),
|
|
3663
|
-
feeds.close(),
|
|
3664
|
-
...[...running].map((cancel) => cancel())
|
|
3665
|
-
]);
|
|
3666
|
-
wss.close();
|
|
3667
|
-
await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));
|
|
3668
|
-
};
|
|
3669
|
-
try {
|
|
3670
|
-
for (const host of options.hosts) {
|
|
3671
|
-
const server = createServer((request, response) => {
|
|
3672
|
-
if (localHealthRequest(request)) {
|
|
3673
|
-
answerHealth(response);
|
|
3674
|
-
return;
|
|
3675
|
-
}
|
|
3676
|
-
response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
|
|
3677
|
-
});
|
|
3678
|
-
server.on("upgrade", upgrade);
|
|
3679
|
-
servers.push(server);
|
|
3680
|
-
await new Promise((resolve, reject) => {
|
|
3681
|
-
server.once("error", reject);
|
|
3682
|
-
server.listen(options.port, host, () => {
|
|
3683
|
-
server.off("error", reject);
|
|
3684
|
-
resolve();
|
|
3685
|
-
});
|
|
3686
|
-
});
|
|
3687
|
-
addresses.push({
|
|
3688
|
-
host,
|
|
3689
|
-
port: server.address().port
|
|
3690
|
-
});
|
|
3691
|
-
}
|
|
3692
|
-
} catch (error) {
|
|
3693
|
-
await close();
|
|
3694
|
-
throw error;
|
|
3695
|
-
}
|
|
3696
|
-
return {
|
|
3697
|
-
addresses,
|
|
3698
|
-
close
|
|
3699
|
-
};
|
|
3700
|
-
}
|
|
3701
|
-
//#endregion
|
|
3702
|
-
//#region bin/stim-server.ts
|
|
3703
|
-
const DEFAULT_PORT = 7787;
|
|
3704
|
-
const USAGE = `Usage:
|
|
3705
|
-
stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
|
|
3706
|
-
stim-server pair [--port <n>] [--control] [--json]
|
|
3707
|
-
print a single-use pairing payload for the QR code;
|
|
3708
|
-
--control lets the paired device run actions
|
|
3709
|
-
stim-server devices [list] [--json]
|
|
3710
|
-
list paired devices
|
|
3711
|
-
stim-server devices grant <id> --control|--read
|
|
3712
|
-
let a paired device run actions, or only read
|
|
3713
|
-
stim-server devices revoke <id> revoke a paired device
|
|
3714
|
-
stim-server log [--json] list the actions paired devices ran`;
|
|
3715
|
-
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
3716
|
-
function fail(message) {
|
|
3717
|
-
console.error(`stim-server: ${message}`);
|
|
3718
|
-
process.exit(1);
|
|
3719
|
-
}
|
|
3720
|
-
function tailscaleNote(tailscale) {
|
|
3721
|
-
const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
|
|
3722
|
-
if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
|
|
3723
|
-
if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
|
|
3724
|
-
return null;
|
|
3725
|
-
}
|
|
3726
|
-
function routeNote(route, port) {
|
|
3727
|
-
if (route.state === "missing") return `No \`tailscale serve\` route reaches port ${port}, so phones cannot connect yet. Run this once to serve it on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
|
|
3728
|
-
if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
|
|
3729
|
-
if (route.state === "funneled") return `Tailscale Funnel is on for port ${route.ports.join(", ")}, which proxies to stim-server, so the server is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then serve stim-server on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
|
|
3730
|
-
return null;
|
|
3731
|
-
}
|
|
3732
|
-
function macName(tailscale) {
|
|
3733
|
-
return tailscale.state === "running" && tailscale.hostName || hostname();
|
|
3734
|
-
}
|
|
3735
|
-
async function serve(port) {
|
|
3736
|
-
const login = loginShellEnvironment();
|
|
3737
|
-
if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
|
|
3738
|
-
const env = { ...login ?? process.env };
|
|
3739
|
-
if (!process.env.STIM_HOME && env.STIM_HOME) process.env.STIM_HOME = env.STIM_HOME;
|
|
3740
|
-
if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
|
|
3741
|
-
const tailscaleBinary = findTailscale(env);
|
|
3742
|
-
const tailscale = tailscaleStatus(tailscaleBinary, env);
|
|
3743
|
-
const stim = bundledStim();
|
|
3744
|
-
const hosts = ["127.0.0.1", ...tailscale.state === "running" ? tailscale.ips : []];
|
|
3745
|
-
let server;
|
|
3746
|
-
try {
|
|
3747
|
-
server = await startServer({
|
|
3748
|
-
name: macName(tailscale),
|
|
3749
|
-
hosts,
|
|
3750
|
-
port,
|
|
3751
|
-
stimCli: stim.cli,
|
|
3752
|
-
stimVersion: stim.version,
|
|
3753
|
-
serverVersion: pkg.version,
|
|
3754
|
-
env,
|
|
3755
|
-
tailscale: tailscaleBinary,
|
|
3756
|
-
tailscaleState: tailscale
|
|
3757
|
-
});
|
|
3758
|
-
} catch (error) {
|
|
3759
|
-
if (error.code === "EADDRINUSE") fail(`port ${port} is in use; another stim-server may already be running. Pass --port to use another port.`);
|
|
3760
|
-
throw error;
|
|
3761
|
-
}
|
|
3762
|
-
console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
|
|
3763
|
-
for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
|
|
3764
|
-
let note = tailscaleNote(tailscale);
|
|
3765
|
-
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
3766
|
-
const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
|
|
3767
|
-
if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
|
|
3768
|
-
note = routeNote(route, port);
|
|
3769
|
-
if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
|
|
3770
|
-
}
|
|
3771
|
-
if (note) console.error(note);
|
|
3772
|
-
const shutdown = () => {
|
|
3773
|
-
server.close().then(() => process.exit(0));
|
|
3774
|
-
};
|
|
3775
|
-
process.on("SIGINT", shutdown);
|
|
3776
|
-
process.on("SIGTERM", shutdown);
|
|
3777
|
-
process.on("SIGHUP", shutdown);
|
|
3778
|
-
}
|
|
3779
|
-
async function pair(port, json, control) {
|
|
3780
|
-
const binary = findTailscale(process.env);
|
|
3781
|
-
const tailscale = tailscaleStatus(binary, process.env);
|
|
3782
|
-
let endpoint = `ws://127.0.0.1:${port}`;
|
|
3783
|
-
let note = tailscaleNote(tailscale);
|
|
3784
|
-
if (note) note = `${note} The endpoint above only works on this Mac.`;
|
|
3785
|
-
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
3786
|
-
const route = await serveRoute(binary, process.env, port, tailscale.ips);
|
|
3787
|
-
if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
|
|
3788
|
-
endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
|
|
3789
|
-
note = routeNote(route, port);
|
|
3790
|
-
}
|
|
3791
|
-
const { token, expiresAt } = createPairingToken(Date.now(), capabilitiesFor(control));
|
|
3792
|
-
const payload = {
|
|
3793
|
-
v: 1,
|
|
3794
|
-
name: macName(tailscale),
|
|
3795
|
-
endpoint,
|
|
3796
|
-
pairingToken: token
|
|
3797
|
-
};
|
|
3798
|
-
if (json) return void console.log(JSON.stringify({
|
|
3799
|
-
qr: payload,
|
|
3800
|
-
expiresAt
|
|
3801
|
-
}));
|
|
3802
|
-
console.log(JSON.stringify(payload));
|
|
3803
|
-
console.error(`The pairing token is single use and expires at ${expiresAt}. The device it pairs can ${control ? "run actions" : "only read"}.`);
|
|
3804
|
-
if (note) console.error(note);
|
|
3805
|
-
}
|
|
3806
|
-
function describe(device) {
|
|
3807
|
-
const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
|
|
3808
|
-
const scope = device.capabilities.includes("control") ? "control" : "read";
|
|
3809
|
-
return `${device.id} ${device.name} ${scope} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
|
|
3810
|
-
}
|
|
3811
|
-
async function main() {
|
|
3812
|
-
const { values, positionals } = parseArgs({
|
|
3813
|
-
allowPositionals: true,
|
|
3814
|
-
options: {
|
|
3815
|
-
port: { type: "string" },
|
|
3816
|
-
json: { type: "boolean" },
|
|
3817
|
-
control: { type: "boolean" },
|
|
3818
|
-
read: { type: "boolean" },
|
|
3819
|
-
help: {
|
|
3820
|
-
type: "boolean",
|
|
3821
|
-
short: "h"
|
|
3822
|
-
},
|
|
3823
|
-
version: {
|
|
3824
|
-
type: "boolean",
|
|
3825
|
-
short: "V"
|
|
3826
|
-
}
|
|
3827
|
-
}
|
|
3828
|
-
});
|
|
3829
|
-
if (values.help) return void console.log(USAGE);
|
|
3830
|
-
if (values.version) return void console.log(pkg.version);
|
|
3831
|
-
const port = values.port === void 0 ? DEFAULT_PORT : Number(values.port);
|
|
3832
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
|
|
3833
|
-
const [command, sub, arg, ...rest] = positionals;
|
|
3834
|
-
if (command === void 0) return serve(port);
|
|
3835
|
-
const grant = command === "devices" && sub === "grant";
|
|
3836
|
-
if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
|
|
3837
|
-
if (values.control && !grant && command !== "pair") fail(`--control applies only to \`pair\` and \`devices grant\`.\n${USAGE}`);
|
|
3838
|
-
if (command === "pair" && sub === void 0) return pair(port, values.json === true, values.control === true);
|
|
3839
|
-
if (grant && arg !== void 0 && rest.length === 0) {
|
|
3840
|
-
if (values.control === values.read) fail("devices grant takes exactly one of --control or --read.");
|
|
3841
|
-
if (!grantDevice(arg, capabilitiesFor(values.control === true))) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
|
|
3842
|
-
console.log(`${arg} can now ${values.control ? "run actions" : "only read"}.`);
|
|
3843
|
-
return;
|
|
3844
|
-
}
|
|
3845
|
-
if (command === "log" && sub === void 0) {
|
|
3846
|
-
const records = readAudit();
|
|
3847
|
-
if (values.json) return void console.log(JSON.stringify({ actions: records }));
|
|
3848
|
-
if (!records.length) console.log("No actions.");
|
|
3849
|
-
for (const record of records) {
|
|
3850
|
-
const outcome = record.ok ? `ok${record.reason ? ` (${record.reason})` : ""}` : `${record.error?.code ?? "failed"}: ${record.error?.message ?? ""}`;
|
|
3851
|
-
const line = `${record.at} ${record.device.id} (${record.device.name}) ${record.action} ${record.workspace} ${outcome}`;
|
|
3852
|
-
console.log(Array.from(line, (char) => /\p{Cc}/u.test(char) ? "?" : char).join(""));
|
|
3853
|
-
}
|
|
3854
|
-
return;
|
|
3855
|
-
}
|
|
3856
|
-
if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
|
|
3857
|
-
const devices = readDevices();
|
|
3858
|
-
if (values.json) {
|
|
3859
|
-
const listed = devices.map(({ id, name, identity, pairedAt, lastSeenAt, capabilities }) => ({
|
|
3860
|
-
id,
|
|
3861
|
-
name,
|
|
3862
|
-
identity,
|
|
3863
|
-
pairedAt,
|
|
3864
|
-
lastSeenAt,
|
|
3865
|
-
capabilities
|
|
3866
|
-
}));
|
|
3867
|
-
console.log(JSON.stringify({ devices: listed }));
|
|
3868
|
-
return;
|
|
3869
|
-
}
|
|
3870
|
-
if (!devices.length) console.log("No paired devices.");
|
|
3871
|
-
for (const device of devices) console.log(describe(device));
|
|
3872
|
-
return;
|
|
3873
|
-
}
|
|
3874
|
-
if (command === "devices" && sub === "revoke" && arg !== void 0 && rest.length === 0) {
|
|
3875
|
-
if (!revokeDevice(arg)) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
|
|
3876
|
-
console.log(`Revoked ${arg}.`);
|
|
3877
|
-
return;
|
|
3878
|
-
}
|
|
3879
|
-
fail(`unknown command.\n${USAGE}`);
|
|
3880
|
-
}
|
|
3881
|
-
await main();
|
|
2
|
+
//#region bin/node-check.ts
|
|
3
|
+
const encode = (version) => version.split(".", 3).reduce((sum, part) => sum * 1e3 + parseInt(part, 10), 0);
|
|
4
|
+
if (encode(process.versions.node) < encode("22.12.0")) {
|
|
5
|
+
process.stderr.write(`STIM_NODE_UNSUPPORTED: stim-server needs Node 22.12.0 or later; this is Node ${process.versions.node} at ${process.execPath}. To run stim-server where a project pins an older Node, see https://stim.appandflow.com/docs/requirements#older-node-pins\n`);
|
|
6
|
+
process.exitCode = 1;
|
|
7
|
+
} else import("./stim-server-OiJI9xLU.mjs");
|
|
3882
8
|
//#endregion
|
|
3883
9
|
export {};
|