@stim-cli/server 0.0.0 → 1.10.1
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/LICENSE +21 -0
- package/README.md +159 -1
- package/dist/protocol-B3k9Ykuu.mjs +407 -0
- package/dist/protocol.d.mts +196 -0
- package/dist/protocol.mjs +2 -0
- package/dist/protocol.schema.json +689 -0
- package/dist/stim-server.d.mts +1 -0
- package/dist/stim-server.mjs +1913 -0
- package/package.json +36 -3
|
@@ -0,0 +1,1913 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-B3k9Ykuu.mjs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { basename, delimiter, dirname, join } from "node:path";
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { configDir, withDirLock } from "@stim-cli/core";
|
|
11
|
+
import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { isIP } from "node:net";
|
|
14
|
+
import { WebSocketServer } from "ws";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { connect } from "node:http2";
|
|
17
|
+
//#region src/environment.ts
|
|
18
|
+
function parseEnvironment(output) {
|
|
19
|
+
const environment = {};
|
|
20
|
+
for (const entry of output.split("\0")) {
|
|
21
|
+
const equals = entry.indexOf("=");
|
|
22
|
+
if (equals > 0) environment[entry.slice(0, equals)] = entry.slice(equals + 1);
|
|
23
|
+
}
|
|
24
|
+
return environment;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The login shell's environment, as Stim Desktop captures it: a process started by launchd lacks the
|
|
28
|
+
* PATH entries and variables such as ANDROID_HOME that the shell profile sets. The shell writes to a
|
|
29
|
+
* file because a background process started by a profile can keep a pipe open after the shell exits.
|
|
30
|
+
*/
|
|
31
|
+
function loginShellEnvironment() {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "stim-server-env-"));
|
|
33
|
+
const file = join(dir, "env");
|
|
34
|
+
try {
|
|
35
|
+
spawnSync(process.env.SHELL || "/bin/zsh", [
|
|
36
|
+
"-lic",
|
|
37
|
+
"command env -0 > \"$1\"",
|
|
38
|
+
"stim-server",
|
|
39
|
+
file
|
|
40
|
+
], {
|
|
41
|
+
stdio: "ignore",
|
|
42
|
+
timeout: 15e3
|
|
43
|
+
});
|
|
44
|
+
const environment = parseEnvironment(readFileSync(file, "utf8"));
|
|
45
|
+
return Object.keys(environment).length ? environment : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
} finally {
|
|
49
|
+
rmSync(dir, {
|
|
50
|
+
recursive: true,
|
|
51
|
+
force: true
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function bundledStim() {
|
|
56
|
+
const manifest = createRequire(import.meta.url).resolve("stim/package.json");
|
|
57
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
58
|
+
return {
|
|
59
|
+
cli: join(dirname(manifest), pkg.bin.stim),
|
|
60
|
+
version: pkg.version
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/registry.ts
|
|
65
|
+
const PAIRING_TTL_MS = 3e5;
|
|
66
|
+
function serverDir() {
|
|
67
|
+
return join(configDir(), "server");
|
|
68
|
+
}
|
|
69
|
+
function devicesFile() {
|
|
70
|
+
return join(serverDir(), "devices.json");
|
|
71
|
+
}
|
|
72
|
+
function pairingFile() {
|
|
73
|
+
return join(serverDir(), "pairing.json");
|
|
74
|
+
}
|
|
75
|
+
function hashToken(token) {
|
|
76
|
+
return createHash("sha256").update(token).digest("hex");
|
|
77
|
+
}
|
|
78
|
+
function newToken() {
|
|
79
|
+
return randomBytes(32).toString("base64url");
|
|
80
|
+
}
|
|
81
|
+
function parseIdentity(value) {
|
|
82
|
+
if (!isJsonObject(value)) return null;
|
|
83
|
+
if (value.kind === "local") return { kind: "local" };
|
|
84
|
+
const { nodeId, nodeName, user } = value;
|
|
85
|
+
if (value.kind === "tailnet" && typeof nodeId === "string" && typeof nodeName === "string" && typeof user === "string") return {
|
|
86
|
+
kind: "tailnet",
|
|
87
|
+
nodeId,
|
|
88
|
+
nodeName,
|
|
89
|
+
user
|
|
90
|
+
};
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function parseDevice(value) {
|
|
94
|
+
if (!isJsonObject(value)) return null;
|
|
95
|
+
const { id, name, tokenHash, pairedAt, lastSeenAt } = value;
|
|
96
|
+
const identity = parseIdentity(value.identity);
|
|
97
|
+
if (typeof id !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || !identity) return null;
|
|
98
|
+
if (typeof pairedAt !== "string") return null;
|
|
99
|
+
const capabilities = Array.isArray(value.capabilities) ? CAPABILITIES.filter((capability) => value.capabilities.includes(capability)) : [];
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
name,
|
|
103
|
+
tokenHash,
|
|
104
|
+
identity,
|
|
105
|
+
pairedAt,
|
|
106
|
+
lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
|
|
107
|
+
capabilities
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function readDevices() {
|
|
111
|
+
const devices = readJsonObject(devicesFile())?.devices;
|
|
112
|
+
return Array.isArray(devices) ? devices.flatMap((entry) => parseDevice(entry) ?? []) : [];
|
|
113
|
+
}
|
|
114
|
+
function readPairings() {
|
|
115
|
+
const tokens = readJsonObject(pairingFile())?.tokens;
|
|
116
|
+
if (!Array.isArray(tokens)) return [];
|
|
117
|
+
return tokens.flatMap((entry) => isJsonObject(entry) && typeof entry.tokenHash === "string" && typeof entry.expiresAt === "string" ? [{
|
|
118
|
+
tokenHash: entry.tokenHash,
|
|
119
|
+
expiresAt: entry.expiresAt
|
|
120
|
+
}] : []);
|
|
121
|
+
}
|
|
122
|
+
function writeJson(file, value) {
|
|
123
|
+
const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
126
|
+
renameSync(temporary, file);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
rmSync(temporary, { force: true });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function transaction(fn) {
|
|
133
|
+
return withDirLock(join(serverDir(), "registry.lock"), fn, { ensureParent: () => mkdirSync(serverDir(), {
|
|
134
|
+
recursive: true,
|
|
135
|
+
mode: 448
|
|
136
|
+
}) });
|
|
137
|
+
}
|
|
138
|
+
function unexpired(record, now) {
|
|
139
|
+
return Date.parse(record.expiresAt) > now;
|
|
140
|
+
}
|
|
141
|
+
function createPairingToken(now = Date.now()) {
|
|
142
|
+
const token = newToken();
|
|
143
|
+
const expiresAt = new Date(now + PAIRING_TTL_MS).toISOString();
|
|
144
|
+
transaction(() => {
|
|
145
|
+
const pending = readPairings().filter((record) => unexpired(record, now));
|
|
146
|
+
writeJson(pairingFile(), {
|
|
147
|
+
version: 1,
|
|
148
|
+
tokens: [...pending, {
|
|
149
|
+
tokenHash: hashToken(token),
|
|
150
|
+
expiresAt
|
|
151
|
+
}]
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
token,
|
|
156
|
+
expiresAt
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function sameNode(a, b) {
|
|
160
|
+
if (a.kind === "local" || b.kind === "local") return a.kind === b.kind;
|
|
161
|
+
return a.nodeId === b.nodeId;
|
|
162
|
+
}
|
|
163
|
+
function spendPairingToken(token, name, identity, now = Date.now()) {
|
|
164
|
+
return transaction(() => {
|
|
165
|
+
const pairings = readPairings();
|
|
166
|
+
const tokenHash = hashToken(token);
|
|
167
|
+
const match = pairings.find((record) => record.tokenHash === tokenHash);
|
|
168
|
+
const pending = pairings.filter((record) => record !== match && unexpired(record, now));
|
|
169
|
+
if (pending.length !== pairings.length) writeJson(pairingFile(), {
|
|
170
|
+
version: 1,
|
|
171
|
+
tokens: pending
|
|
172
|
+
});
|
|
173
|
+
if (!match) return {
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: "pairing-unknown"
|
|
176
|
+
};
|
|
177
|
+
if (!unexpired(match, now)) return {
|
|
178
|
+
ok: false,
|
|
179
|
+
reason: "pairing-expired"
|
|
180
|
+
};
|
|
181
|
+
const deviceToken = newToken();
|
|
182
|
+
const at = new Date(now).toISOString();
|
|
183
|
+
const device = {
|
|
184
|
+
id: randomBytes(4).toString("hex"),
|
|
185
|
+
name,
|
|
186
|
+
tokenHash: hashToken(deviceToken),
|
|
187
|
+
identity,
|
|
188
|
+
pairedAt: at,
|
|
189
|
+
lastSeenAt: at,
|
|
190
|
+
capabilities: ["read"]
|
|
191
|
+
};
|
|
192
|
+
writeJson(devicesFile(), {
|
|
193
|
+
version: 1,
|
|
194
|
+
devices: [...readDevices(), device]
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
device,
|
|
199
|
+
deviceToken
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function authenticateDevice(token, identity, now = Date.now()) {
|
|
204
|
+
return transaction(() => {
|
|
205
|
+
const devices = readDevices();
|
|
206
|
+
const tokenHash = hashToken(token);
|
|
207
|
+
const device = devices.find((entry) => entry.tokenHash === tokenHash);
|
|
208
|
+
if (!device) return {
|
|
209
|
+
ok: false,
|
|
210
|
+
reason: "device-unknown"
|
|
211
|
+
};
|
|
212
|
+
if (!sameNode(device.identity, identity)) return {
|
|
213
|
+
ok: false,
|
|
214
|
+
reason: "node-mismatch"
|
|
215
|
+
};
|
|
216
|
+
device.lastSeenAt = new Date(now).toISOString();
|
|
217
|
+
writeJson(devicesFile(), {
|
|
218
|
+
version: 1,
|
|
219
|
+
devices
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
device
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function revokeDevice(id) {
|
|
228
|
+
return transaction(() => {
|
|
229
|
+
const devices = readDevices();
|
|
230
|
+
const remaining = devices.filter((device) => device.id !== id);
|
|
231
|
+
if (remaining.length === devices.length) return false;
|
|
232
|
+
writeJson(devicesFile(), {
|
|
233
|
+
version: 1,
|
|
234
|
+
devices: remaining
|
|
235
|
+
});
|
|
236
|
+
return true;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/stim-command.ts
|
|
241
|
+
const KILL_GRACE_MS = 1e3;
|
|
242
|
+
function terminate(child) {
|
|
243
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
|
|
244
|
+
child.kill("SIGTERM");
|
|
245
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
const done = () => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
resolve();
|
|
250
|
+
};
|
|
251
|
+
child.once("exit", done);
|
|
252
|
+
child.once("close", done);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
function runStim(stimCli, env, args, cwd, limits) {
|
|
256
|
+
const child = spawn(process.execPath, [stimCli, ...args], {
|
|
257
|
+
cwd,
|
|
258
|
+
env,
|
|
259
|
+
stdio: [
|
|
260
|
+
"ignore",
|
|
261
|
+
"pipe",
|
|
262
|
+
"pipe"
|
|
263
|
+
]
|
|
264
|
+
});
|
|
265
|
+
const label = `stim ${args[0]}`;
|
|
266
|
+
let cancelled = false;
|
|
267
|
+
let failure = null;
|
|
268
|
+
const chunks = [];
|
|
269
|
+
let size = 0;
|
|
270
|
+
let stderr = "";
|
|
271
|
+
const fail = (message) => {
|
|
272
|
+
if (failure) return;
|
|
273
|
+
failure = message;
|
|
274
|
+
terminate(child);
|
|
275
|
+
};
|
|
276
|
+
const timer = setTimeout(() => fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`), limits.timeoutMs);
|
|
277
|
+
child.stdout.on("data", (chunk) => {
|
|
278
|
+
size += chunk.length;
|
|
279
|
+
if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
|
|
280
|
+
chunks.push(chunk);
|
|
281
|
+
});
|
|
282
|
+
child.stderr.setEncoding("utf8");
|
|
283
|
+
child.stderr.on("data", (chunk) => {
|
|
284
|
+
stderr = (stderr + chunk).slice(-2e3);
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
outcome: new Promise((resolve) => {
|
|
288
|
+
const settle = (result) => {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
if (!cancelled) resolve(result);
|
|
291
|
+
};
|
|
292
|
+
child.on("error", (error) => settle({
|
|
293
|
+
ok: false,
|
|
294
|
+
message: `${label} could not start (${error.message}).`
|
|
295
|
+
}));
|
|
296
|
+
child.on("close", (code, signal) => {
|
|
297
|
+
if (failure) return settle({
|
|
298
|
+
ok: false,
|
|
299
|
+
message: failure
|
|
300
|
+
});
|
|
301
|
+
if (code === 0) return settle({
|
|
302
|
+
ok: true,
|
|
303
|
+
stdout: Buffer.concat(chunks).toString("utf8")
|
|
304
|
+
});
|
|
305
|
+
const detail = stderr.trim();
|
|
306
|
+
settle({
|
|
307
|
+
ok: false,
|
|
308
|
+
message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}),
|
|
312
|
+
cancel: () => {
|
|
313
|
+
cancelled = true;
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
return terminate(child);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/feed.ts
|
|
321
|
+
var Feed = class {
|
|
322
|
+
listeners = /* @__PURE__ */ new Set();
|
|
323
|
+
kept = [];
|
|
324
|
+
child;
|
|
325
|
+
ended;
|
|
326
|
+
constructor(spec, stimCli, env, ended) {
|
|
327
|
+
this.ended = ended;
|
|
328
|
+
const child = spawn(process.execPath, [stimCli, ...spec.args], {
|
|
329
|
+
cwd: spec.cwd,
|
|
330
|
+
env,
|
|
331
|
+
stdio: [
|
|
332
|
+
"ignore",
|
|
333
|
+
"pipe",
|
|
334
|
+
"pipe"
|
|
335
|
+
]
|
|
336
|
+
});
|
|
337
|
+
this.child = child;
|
|
338
|
+
let stderr = "";
|
|
339
|
+
child.stderr?.setEncoding("utf8");
|
|
340
|
+
child.stderr?.on("data", (chunk) => {
|
|
341
|
+
stderr = (stderr + chunk).slice(-2e3);
|
|
342
|
+
});
|
|
343
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
344
|
+
if (this.child !== child) return;
|
|
345
|
+
let value;
|
|
346
|
+
try {
|
|
347
|
+
value = JSON.parse(line);
|
|
348
|
+
} catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!isJsonObject(value)) return;
|
|
352
|
+
this.kept.push(value);
|
|
353
|
+
if (this.kept.length > spec.keep) this.kept.shift();
|
|
354
|
+
for (const listener of this.listeners) listener.item(value);
|
|
355
|
+
});
|
|
356
|
+
const exited = (reason) => {
|
|
357
|
+
if (this.child !== child) return;
|
|
358
|
+
this.child = null;
|
|
359
|
+
this.ended();
|
|
360
|
+
const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
|
|
361
|
+
const listeners = [...this.listeners];
|
|
362
|
+
this.listeners.clear();
|
|
363
|
+
for (const listener of listeners) listener.failed(message);
|
|
364
|
+
};
|
|
365
|
+
child.on("error", (error) => exited(`could not start (${error.message})`));
|
|
366
|
+
child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
|
|
367
|
+
}
|
|
368
|
+
add(listener) {
|
|
369
|
+
this.listeners.add(listener);
|
|
370
|
+
for (const value of this.kept) listener.item(value);
|
|
371
|
+
return () => {
|
|
372
|
+
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
stop() {
|
|
376
|
+
const child = this.child;
|
|
377
|
+
if (!child) return Promise.resolve();
|
|
378
|
+
this.child = null;
|
|
379
|
+
this.listeners.clear();
|
|
380
|
+
this.ended();
|
|
381
|
+
return terminate(child);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
var FeedPool = class {
|
|
385
|
+
feeds = /* @__PURE__ */ new Map();
|
|
386
|
+
stimCli;
|
|
387
|
+
env;
|
|
388
|
+
constructor(stimCli, env) {
|
|
389
|
+
this.stimCli = stimCli;
|
|
390
|
+
this.env = env;
|
|
391
|
+
}
|
|
392
|
+
subscribe(spec, listener) {
|
|
393
|
+
const key = JSON.stringify([spec.cwd, spec.args]);
|
|
394
|
+
let feed = this.feeds.get(key);
|
|
395
|
+
if (!feed) {
|
|
396
|
+
const created = new Feed(spec, this.stimCli, this.env, () => {
|
|
397
|
+
if (this.feeds.get(key) === created) this.feeds.delete(key);
|
|
398
|
+
});
|
|
399
|
+
this.feeds.set(key, created);
|
|
400
|
+
feed = created;
|
|
401
|
+
}
|
|
402
|
+
return feed.add(listener);
|
|
403
|
+
}
|
|
404
|
+
async close() {
|
|
405
|
+
await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region src/frames.ts
|
|
410
|
+
const DEFAULT_FRAME_LIMITS = {
|
|
411
|
+
toolTimeoutMs: 3e4,
|
|
412
|
+
slowCaptureMs: 3e3,
|
|
413
|
+
failureBackoffMs: 3e3,
|
|
414
|
+
maxConsecutiveFailures: 3
|
|
415
|
+
};
|
|
416
|
+
const MIN_INTERVAL_MS = 200;
|
|
417
|
+
const MAX_INTERVAL_MS = 1e3;
|
|
418
|
+
const MAX_CAPTURES = 2;
|
|
419
|
+
const MAX_EDGE = 1280;
|
|
420
|
+
const JPEG_QUALITY = 70;
|
|
421
|
+
function deviceKey(device) {
|
|
422
|
+
return device.platform === "ios" ? `ios:${device.udid}` : `android:${device.serial}`;
|
|
423
|
+
}
|
|
424
|
+
function ownedDevice(payload, target, attached) {
|
|
425
|
+
const slot = target.slot ?? "default";
|
|
426
|
+
if (!Array.isArray(payload.environments)) return "stim status printed a payload without environments.";
|
|
427
|
+
const environment = payload.environments.find((candidate) => candidate.path === target.workspace);
|
|
428
|
+
if (!environment) return `${target.workspace} is not a Stim workspace on this Mac.`;
|
|
429
|
+
const devices = slot === "default" ? {
|
|
430
|
+
ios: environment.ios,
|
|
431
|
+
android: environment.android
|
|
432
|
+
} : environment.slots?.find((candidate) => candidate.slot === slot);
|
|
433
|
+
const where = `${target.platform} in slot ${slot} of ${target.workspace}`;
|
|
434
|
+
if (target.platform === "ios") {
|
|
435
|
+
const sim = devices?.ios;
|
|
436
|
+
if (!sim?.owned) return `No simulator Stim owns runs ${where}.`;
|
|
437
|
+
if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
|
|
438
|
+
return {
|
|
439
|
+
platform: "ios",
|
|
440
|
+
udid: sim.udid
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const emulator = devices?.android;
|
|
444
|
+
if (!emulator?.owned || emulator.physical) return `No emulator Stim owns runs ${where}.`;
|
|
445
|
+
if (emulator.state === "unknown" && attached?.startsWith("android:")) return {
|
|
446
|
+
platform: "android",
|
|
447
|
+
serial: attached.slice(8)
|
|
448
|
+
};
|
|
449
|
+
if (emulator.state !== "detected" || !emulator.serial) return `The emulator for ${where} is not running.`;
|
|
450
|
+
return {
|
|
451
|
+
platform: "android",
|
|
452
|
+
serial: emulator.serial
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function jpegSize(bytes) {
|
|
456
|
+
if (bytes[0] !== 255 || bytes[1] !== 216) return null;
|
|
457
|
+
let offset = 2;
|
|
458
|
+
while (offset + 9 <= bytes.length) {
|
|
459
|
+
if (bytes[offset] !== 255) return null;
|
|
460
|
+
const marker = bytes[offset + 1];
|
|
461
|
+
if (marker === 255) {
|
|
462
|
+
offset += 1;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (marker === 1 || marker >= 208 && marker <= 216) {
|
|
466
|
+
offset += 2;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
|
|
470
|
+
height: bytes.readUInt16BE(offset + 5),
|
|
471
|
+
width: bytes.readUInt16BE(offset + 7)
|
|
472
|
+
};
|
|
473
|
+
offset += 2 + bytes.readUInt16BE(offset + 2);
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
/** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
|
|
478
|
+
function timeoutError(message) {
|
|
479
|
+
return Object.assign(new Error(message), { transient: true });
|
|
480
|
+
}
|
|
481
|
+
function isTransient(error) {
|
|
482
|
+
return error instanceof Error && error.transient === true;
|
|
483
|
+
}
|
|
484
|
+
function runTool(file, args, env, running, timeoutMs) {
|
|
485
|
+
return new Promise((resolve, reject) => {
|
|
486
|
+
const child = spawn(file, args, {
|
|
487
|
+
env,
|
|
488
|
+
stdio: [
|
|
489
|
+
"ignore",
|
|
490
|
+
"pipe",
|
|
491
|
+
"pipe"
|
|
492
|
+
]
|
|
493
|
+
});
|
|
494
|
+
running.add(child);
|
|
495
|
+
child.once("close", () => running.delete(child));
|
|
496
|
+
const chunks = [];
|
|
497
|
+
let stderr = "";
|
|
498
|
+
const timer = setTimeout(() => {
|
|
499
|
+
terminate(child);
|
|
500
|
+
reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
|
|
501
|
+
}, timeoutMs);
|
|
502
|
+
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
503
|
+
child.stderr.setEncoding("utf8");
|
|
504
|
+
child.stderr.on("data", (chunk) => {
|
|
505
|
+
stderr = (stderr + chunk).slice(-1e3);
|
|
506
|
+
});
|
|
507
|
+
child.on("error", (error) => {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
|
|
510
|
+
});
|
|
511
|
+
child.on("close", (code) => {
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
if (code === 0) resolve(Buffer.concat(chunks));
|
|
514
|
+
else reject(Object.assign(/* @__PURE__ */ new Error(`${file} ${args.join(" ")} exited (code ${code}): ${stderr.trim()}`), { stderr }));
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
async function stopTools(running, tmp) {
|
|
519
|
+
await Promise.all([...running].map((child) => terminate(child)));
|
|
520
|
+
if (tmp) rmSync(tmp, {
|
|
521
|
+
recursive: true,
|
|
522
|
+
force: true
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Without `--display`, simctl captures the first panel it finds, which on a foldable simulator (iPhone Duo)
|
|
527
|
+
* can be the unlit one and comes back black; `primary` is the panel CoreDevice reports as primary. A simctl
|
|
528
|
+
* that rejects `primary` gets the default display instead.
|
|
529
|
+
*/
|
|
530
|
+
function simulatorCapturer(udid, env, limits) {
|
|
531
|
+
let tmp = null;
|
|
532
|
+
let display = ["--display=primary"];
|
|
533
|
+
let closed = false;
|
|
534
|
+
const running = /* @__PURE__ */ new Set();
|
|
535
|
+
const screenshot = (output) => runTool("xcrun", [
|
|
536
|
+
"simctl",
|
|
537
|
+
"io",
|
|
538
|
+
udid,
|
|
539
|
+
"screenshot",
|
|
540
|
+
"--type=jpeg",
|
|
541
|
+
...display,
|
|
542
|
+
output
|
|
543
|
+
], env, running, limits.toolTimeoutMs);
|
|
544
|
+
return {
|
|
545
|
+
capture: async () => {
|
|
546
|
+
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
547
|
+
const output = join(tmp, "frame.jpg");
|
|
548
|
+
try {
|
|
549
|
+
await screenshot(output);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
|
|
552
|
+
display = [];
|
|
553
|
+
await screenshot(output);
|
|
554
|
+
}
|
|
555
|
+
const jpeg = readFileSync(output);
|
|
556
|
+
return {
|
|
557
|
+
raw: jpeg,
|
|
558
|
+
jpeg: async () => jpeg
|
|
559
|
+
};
|
|
560
|
+
},
|
|
561
|
+
close: () => {
|
|
562
|
+
closed = true;
|
|
563
|
+
return stopTools(running, tmp);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
|
|
569
|
+
* Stim passes when it boots an owned AVD (#1064).
|
|
570
|
+
*/
|
|
571
|
+
function emulatorEndpoint(env, serial) {
|
|
572
|
+
const console = /^emulator-(\d+)$/.exec(serial)?.[1];
|
|
573
|
+
if (!console || !env.HOME) return null;
|
|
574
|
+
const dir = join(env.HOME, "Library/Caches/TemporaryItems/avd/running");
|
|
575
|
+
let names;
|
|
576
|
+
try {
|
|
577
|
+
names = readdirSync(dir);
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
for (const name of names) {
|
|
582
|
+
const pid = /^pid_(\d+)\.ini$/.exec(name)?.[1];
|
|
583
|
+
if (!pid) continue;
|
|
584
|
+
try {
|
|
585
|
+
process.kill(Number(pid), 0);
|
|
586
|
+
} catch {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
let text;
|
|
590
|
+
try {
|
|
591
|
+
text = readFileSync(join(dir, name), "utf8");
|
|
592
|
+
} catch {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const values = new Map(text.split(/\r?\n/).flatMap((line) => {
|
|
596
|
+
const at = line.indexOf("=");
|
|
597
|
+
return at > 0 ? [[line.slice(0, at), line.slice(at + 1)]] : [];
|
|
598
|
+
}));
|
|
599
|
+
const grpcPort = Number(values.get("grpc.port"));
|
|
600
|
+
if (values.get("port.serial") === console && Number.isInteger(grpcPort) && grpcPort > 0) return {
|
|
601
|
+
grpcPort,
|
|
602
|
+
token: values.get("grpc.token") ?? null
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
function varint(value) {
|
|
608
|
+
const out = [];
|
|
609
|
+
let rest = value;
|
|
610
|
+
while (rest >= 128) {
|
|
611
|
+
out.push(rest & 127 | 128);
|
|
612
|
+
rest = Math.floor(rest / 128);
|
|
613
|
+
}
|
|
614
|
+
out.push(rest);
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
function* protoFields(bytes) {
|
|
618
|
+
let offset = 0;
|
|
619
|
+
const readVarint = () => {
|
|
620
|
+
let result = 0;
|
|
621
|
+
let scale = 1;
|
|
622
|
+
for (;;) {
|
|
623
|
+
if (offset >= bytes.length) throw new Error("truncated protobuf");
|
|
624
|
+
const byte = bytes[offset++];
|
|
625
|
+
result += (byte & 127) * scale;
|
|
626
|
+
if (byte < 128) return result;
|
|
627
|
+
scale *= 128;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
while (offset < bytes.length) {
|
|
631
|
+
const key = readVarint();
|
|
632
|
+
const field = Math.floor(key / 8);
|
|
633
|
+
const type = key % 8;
|
|
634
|
+
if (type === 0) yield {
|
|
635
|
+
field,
|
|
636
|
+
varint: readVarint()
|
|
637
|
+
};
|
|
638
|
+
else if (type === 2) {
|
|
639
|
+
const length = readVarint();
|
|
640
|
+
if (offset + length > bytes.length) throw new Error("truncated protobuf");
|
|
641
|
+
yield {
|
|
642
|
+
field,
|
|
643
|
+
bytes: bytes.subarray(offset, offset + length)
|
|
644
|
+
};
|
|
645
|
+
offset += length;
|
|
646
|
+
} else if (type === 1) offset += 8;
|
|
647
|
+
else if (type === 5) offset += 4;
|
|
648
|
+
else throw new Error(`unsupported protobuf wire type ${type}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. */
|
|
652
|
+
function screenshotRequest() {
|
|
653
|
+
const message = Buffer.from([
|
|
654
|
+
24,
|
|
655
|
+
...varint(MAX_EDGE),
|
|
656
|
+
32,
|
|
657
|
+
...varint(MAX_EDGE)
|
|
658
|
+
]);
|
|
659
|
+
const header = Buffer.alloc(5);
|
|
660
|
+
header.writeUInt32BE(message.length, 1);
|
|
661
|
+
return Buffer.concat([header, message]);
|
|
662
|
+
}
|
|
663
|
+
function screenshotImage(body) {
|
|
664
|
+
if (body.length < 5 || body[0] !== 0) throw new Error("getScreenshot returned no message.");
|
|
665
|
+
const message = body.subarray(5, 5 + body.readUInt32BE(1));
|
|
666
|
+
for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
|
|
667
|
+
throw new Error("getScreenshot returned no image.");
|
|
668
|
+
}
|
|
669
|
+
function emulatorCapturer(serial, env, limits) {
|
|
670
|
+
let session = null;
|
|
671
|
+
let tmp = null;
|
|
672
|
+
const running = /* @__PURE__ */ new Set();
|
|
673
|
+
const call = (endpoint) => new Promise((resolve, reject) => {
|
|
674
|
+
if (!session || session.closed || session.destroyed) {
|
|
675
|
+
session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
|
|
676
|
+
session.on("error", () => {});
|
|
677
|
+
}
|
|
678
|
+
const request = session.request({
|
|
679
|
+
":method": "POST",
|
|
680
|
+
":path": "/android.emulation.control.EmulatorController/getScreenshot",
|
|
681
|
+
"content-type": "application/grpc",
|
|
682
|
+
te: "trailers",
|
|
683
|
+
...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
|
|
684
|
+
});
|
|
685
|
+
const chunks = [];
|
|
686
|
+
let status;
|
|
687
|
+
let message;
|
|
688
|
+
let timedOut = false;
|
|
689
|
+
const record = (headers) => {
|
|
690
|
+
if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
|
|
691
|
+
if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
|
|
692
|
+
};
|
|
693
|
+
request.setTimeout(limits.toolTimeoutMs, () => {
|
|
694
|
+
timedOut = true;
|
|
695
|
+
request.close();
|
|
696
|
+
});
|
|
697
|
+
request.on("response", record);
|
|
698
|
+
request.on("trailers", record);
|
|
699
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
700
|
+
request.on("error", (error) => {
|
|
701
|
+
const text = `getScreenshot failed on ${serial}: ${error.message}`;
|
|
702
|
+
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
703
|
+
});
|
|
704
|
+
request.on("close", () => {
|
|
705
|
+
if (status === "0") return resolve(Buffer.concat(chunks));
|
|
706
|
+
const text = `getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
|
|
707
|
+
reject(timedOut ? timeoutError(text) : new Error(text));
|
|
708
|
+
});
|
|
709
|
+
request.end(screenshotRequest());
|
|
710
|
+
});
|
|
711
|
+
return {
|
|
712
|
+
capture: async () => {
|
|
713
|
+
const endpoint = emulatorEndpoint(env, serial);
|
|
714
|
+
if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
|
|
715
|
+
const png = screenshotImage(await call(endpoint));
|
|
716
|
+
return {
|
|
717
|
+
raw: png,
|
|
718
|
+
jpeg: async () => {
|
|
719
|
+
tmp ??= mkdtempSync(join(serverDir(), "frames-"));
|
|
720
|
+
const input = join(tmp, "frame.png");
|
|
721
|
+
const output = join(tmp, "frame.jpg");
|
|
722
|
+
writeFileSync(input, png);
|
|
723
|
+
await runTool("sips", [
|
|
724
|
+
...[
|
|
725
|
+
"-s",
|
|
726
|
+
"format",
|
|
727
|
+
"jpeg",
|
|
728
|
+
"-s",
|
|
729
|
+
"formatOptions",
|
|
730
|
+
String(JPEG_QUALITY)
|
|
731
|
+
],
|
|
732
|
+
input,
|
|
733
|
+
"--out",
|
|
734
|
+
output
|
|
735
|
+
], env, running, limits.toolTimeoutMs);
|
|
736
|
+
return readFileSync(output);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
close: () => {
|
|
741
|
+
session?.close();
|
|
742
|
+
return stopTools(running, tmp);
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
var Limiter = class {
|
|
747
|
+
active = 0;
|
|
748
|
+
waiting = [];
|
|
749
|
+
max;
|
|
750
|
+
constructor(max) {
|
|
751
|
+
this.max = max;
|
|
752
|
+
}
|
|
753
|
+
async run(task) {
|
|
754
|
+
if (this.active >= this.max) await new Promise((resolve) => this.waiting.push(resolve));
|
|
755
|
+
else this.active++;
|
|
756
|
+
try {
|
|
757
|
+
return await task();
|
|
758
|
+
} finally {
|
|
759
|
+
const next = this.waiting.shift();
|
|
760
|
+
if (next) next();
|
|
761
|
+
else this.active--;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
var FrameSource = class {
|
|
766
|
+
listeners = /* @__PURE__ */ new Set();
|
|
767
|
+
last = null;
|
|
768
|
+
lastHash = null;
|
|
769
|
+
interval = MIN_INTERVAL_MS;
|
|
770
|
+
timer = null;
|
|
771
|
+
stopped = false;
|
|
772
|
+
delayed = false;
|
|
773
|
+
consecutiveFailures = 0;
|
|
774
|
+
capturer;
|
|
775
|
+
limiter;
|
|
776
|
+
limits;
|
|
777
|
+
ended;
|
|
778
|
+
constructor(capturer, limiter, limits, ended) {
|
|
779
|
+
this.capturer = capturer;
|
|
780
|
+
this.limiter = limiter;
|
|
781
|
+
this.limits = limits;
|
|
782
|
+
this.ended = ended;
|
|
783
|
+
this.tick();
|
|
784
|
+
}
|
|
785
|
+
add(listener) {
|
|
786
|
+
this.listeners.add(listener);
|
|
787
|
+
if (this.last) listener.frame(this.last);
|
|
788
|
+
if (this.delayed) listener.delayed(true);
|
|
789
|
+
return () => {
|
|
790
|
+
if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
stop() {
|
|
794
|
+
if (this.stopped) return Promise.resolve();
|
|
795
|
+
this.stopped = true;
|
|
796
|
+
if (this.timer) clearTimeout(this.timer);
|
|
797
|
+
this.listeners.clear();
|
|
798
|
+
this.ended();
|
|
799
|
+
return this.capturer.close();
|
|
800
|
+
}
|
|
801
|
+
setDelayed(delayed) {
|
|
802
|
+
if (this.delayed === delayed) return;
|
|
803
|
+
this.delayed = delayed;
|
|
804
|
+
for (const listener of this.listeners) listener.delayed(delayed);
|
|
805
|
+
}
|
|
806
|
+
async tick() {
|
|
807
|
+
this.timer = null;
|
|
808
|
+
let took = 0;
|
|
809
|
+
try {
|
|
810
|
+
const changed = await this.limiter.run(async () => {
|
|
811
|
+
if (this.stopped) return false;
|
|
812
|
+
const started = Date.now();
|
|
813
|
+
const capture = await this.capturer.capture();
|
|
814
|
+
const hash = createHash("sha256").update(capture.raw).digest("hex");
|
|
815
|
+
took = Date.now() - started;
|
|
816
|
+
if (hash === this.lastHash || this.stopped) return false;
|
|
817
|
+
const jpeg = await capture.jpeg();
|
|
818
|
+
const size = jpegSize(jpeg);
|
|
819
|
+
if (!size) throw new Error("The screenshot is not a JPEG image.");
|
|
820
|
+
this.lastHash = hash;
|
|
821
|
+
this.last = {
|
|
822
|
+
...size,
|
|
823
|
+
capturedAt: new Date(started).toISOString(),
|
|
824
|
+
data: jpeg.toString("base64")
|
|
825
|
+
};
|
|
826
|
+
took = Date.now() - started;
|
|
827
|
+
return true;
|
|
828
|
+
});
|
|
829
|
+
if (this.stopped) return;
|
|
830
|
+
this.consecutiveFailures = 0;
|
|
831
|
+
this.setDelayed(took > this.limits.slowCaptureMs);
|
|
832
|
+
if (changed) for (const listener of this.listeners) listener.frame(this.last);
|
|
833
|
+
this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
|
|
834
|
+
this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
|
|
835
|
+
} catch (error) {
|
|
836
|
+
if (this.stopped) return;
|
|
837
|
+
if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
|
|
838
|
+
this.consecutiveFailures++;
|
|
839
|
+
this.setDelayed(true);
|
|
840
|
+
this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const listeners = [...this.listeners];
|
|
844
|
+
this.stop();
|
|
845
|
+
for (const listener of listeners) listener.failed(error.message);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
/**
|
|
850
|
+
* One capture loop per device, shared by its subscribers and stopped with the last of them. A loop sends a
|
|
851
|
+
* frame only when the screen changed; it captures up to 5 times a second while the screen changes and backs
|
|
852
|
+
* off to once a second while it does not, and spends at most half of its time capturing. At most two
|
|
853
|
+
* captures run at once across all devices.
|
|
854
|
+
*/
|
|
855
|
+
var FramePool = class {
|
|
856
|
+
sources = /* @__PURE__ */ new Map();
|
|
857
|
+
limiter = new Limiter(MAX_CAPTURES);
|
|
858
|
+
env;
|
|
859
|
+
limits;
|
|
860
|
+
constructor(env, limits = DEFAULT_FRAME_LIMITS) {
|
|
861
|
+
this.env = env;
|
|
862
|
+
this.limits = limits;
|
|
863
|
+
}
|
|
864
|
+
subscribe(device, listener) {
|
|
865
|
+
const key = deviceKey(device);
|
|
866
|
+
let source = this.sources.get(key);
|
|
867
|
+
if (!source) {
|
|
868
|
+
const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env, this.limits) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
|
|
869
|
+
if (this.sources.get(key) === created) this.sources.delete(key);
|
|
870
|
+
});
|
|
871
|
+
this.sources.set(key, created);
|
|
872
|
+
source = created;
|
|
873
|
+
}
|
|
874
|
+
return source.add(listener);
|
|
875
|
+
}
|
|
876
|
+
async close() {
|
|
877
|
+
await Promise.all([...this.sources.values()].map((source) => source.stop()));
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region src/logs.ts
|
|
882
|
+
function parseLogFilter(params) {
|
|
883
|
+
if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
|
|
884
|
+
const { sources, level, slot, grep, errors, tail } = params;
|
|
885
|
+
if (sources !== void 0 && (!Array.isArray(sources) || sources.length === 0 || !sources.every((source) => LOG_SOURCES.includes(source)))) return { error: `sources must be a non-empty list of ${LOG_SOURCES.join(", ")}.` };
|
|
886
|
+
if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
|
|
887
|
+
if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return { error: "slot must be a slot name." };
|
|
888
|
+
if (grep !== void 0 && (typeof grep !== "string" || compileGrep(grep).error)) return { error: "grep must be a valid regular expression." };
|
|
889
|
+
if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
|
|
890
|
+
if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > 5e3)) return { error: `tail must be an integer from 1 to ${MAX_LOG_TAIL}.` };
|
|
891
|
+
return { filter: {
|
|
892
|
+
workspace: params.workspace,
|
|
893
|
+
...sources ? { sources } : {},
|
|
894
|
+
...level && level !== "debug" ? { level } : {},
|
|
895
|
+
...slot ? { slot } : {},
|
|
896
|
+
...grep ? { grep } : {},
|
|
897
|
+
...errors ? { errors } : {},
|
|
898
|
+
tail: tail ?? 5e3
|
|
899
|
+
} };
|
|
900
|
+
}
|
|
901
|
+
function logArgs(filter, follow) {
|
|
902
|
+
const args = [
|
|
903
|
+
"logs",
|
|
904
|
+
"--json",
|
|
905
|
+
...follow ? ["--follow"] : [],
|
|
906
|
+
`--tail=${filter.tail ?? 5e3}`
|
|
907
|
+
];
|
|
908
|
+
if (filter.sources) args.push("--source", ...LOG_SOURCES.filter((source) => filter.sources.includes(source)));
|
|
909
|
+
if (filter.slot) args.push(`--slot=${filter.slot}`);
|
|
910
|
+
if (filter.level) args.push(`--level=${filter.level}`);
|
|
911
|
+
if (filter.grep) args.push(`--grep=${filter.grep}`);
|
|
912
|
+
if (filter.errors) args.push("--errors");
|
|
913
|
+
return args;
|
|
914
|
+
}
|
|
915
|
+
const BATCH_MS = 100;
|
|
916
|
+
const MAX_BATCH = 500;
|
|
917
|
+
var LogBatcher = class {
|
|
918
|
+
pending = [];
|
|
919
|
+
timer = null;
|
|
920
|
+
stopped = false;
|
|
921
|
+
sink;
|
|
922
|
+
limits;
|
|
923
|
+
constructor(sink, limits) {
|
|
924
|
+
this.sink = sink;
|
|
925
|
+
this.limits = limits;
|
|
926
|
+
}
|
|
927
|
+
push(record) {
|
|
928
|
+
if (this.stopped) return;
|
|
929
|
+
this.pending.push(record);
|
|
930
|
+
if (this.pending.length > this.limits.maxPendingRecords) {
|
|
931
|
+
this.stop();
|
|
932
|
+
queueMicrotask(this.sink.overflow);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
this.timer ??= setTimeout(() => this.flush(false), BATCH_MS);
|
|
936
|
+
}
|
|
937
|
+
flush(force) {
|
|
938
|
+
if (this.timer) clearTimeout(this.timer);
|
|
939
|
+
this.timer = null;
|
|
940
|
+
while (this.pending.length) {
|
|
941
|
+
if (!force && this.sink.bufferedBytes() >= this.limits.maxBufferedBytes) break;
|
|
942
|
+
this.sink.send(this.pending.splice(0, MAX_BATCH));
|
|
943
|
+
}
|
|
944
|
+
if (this.pending.length && !this.stopped) this.timer = setTimeout(() => this.flush(false), BATCH_MS);
|
|
945
|
+
}
|
|
946
|
+
stop() {
|
|
947
|
+
this.stopped = true;
|
|
948
|
+
if (this.timer) clearTimeout(this.timer);
|
|
949
|
+
this.timer = null;
|
|
950
|
+
this.pending = [];
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
//#endregion
|
|
954
|
+
//#region src/tailscale.ts
|
|
955
|
+
const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
956
|
+
const TIMEOUT_MS = 3e3;
|
|
957
|
+
/** The tailnet-only HTTPS port the setup command serves stim-server on. */
|
|
958
|
+
const SERVE_PORT = 7443;
|
|
959
|
+
function executable(path) {
|
|
960
|
+
try {
|
|
961
|
+
accessSync(path, constants.X_OK);
|
|
962
|
+
return true;
|
|
963
|
+
} catch {
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function findTailscale(env) {
|
|
968
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "tailscale"))) return join(dir, "tailscale");
|
|
969
|
+
return executable(MAC_APP_BINARY) ? MAC_APP_BINARY : null;
|
|
970
|
+
}
|
|
971
|
+
function parseTailscaleStatus(value) {
|
|
972
|
+
if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
|
|
973
|
+
state: "unavailable",
|
|
974
|
+
reason: "`tailscale status --json` printed no BackendState"
|
|
975
|
+
};
|
|
976
|
+
if (value.BackendState !== "Running") return {
|
|
977
|
+
state: "not-running",
|
|
978
|
+
backendState: value.BackendState
|
|
979
|
+
};
|
|
980
|
+
const self = isJsonObject(value.Self) ? value.Self : {};
|
|
981
|
+
return {
|
|
982
|
+
state: "running",
|
|
983
|
+
ips: Array.isArray(value.TailscaleIPs) ? value.TailscaleIPs.filter((ip) => typeof ip === "string") : [],
|
|
984
|
+
dnsName: typeof self.DNSName === "string" && self.DNSName ? self.DNSName.replace(/\.$/, "") : null,
|
|
985
|
+
hostName: typeof self.HostName === "string" && self.HostName ? self.HostName : null
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
function tailscaleStatus(binary, env) {
|
|
989
|
+
if (!binary) return {
|
|
990
|
+
state: "unavailable",
|
|
991
|
+
reason: "the tailscale command was not found"
|
|
992
|
+
};
|
|
993
|
+
try {
|
|
994
|
+
const output = execFileSync(binary, ["status", "--json"], {
|
|
995
|
+
env,
|
|
996
|
+
timeout: TIMEOUT_MS,
|
|
997
|
+
encoding: "utf8",
|
|
998
|
+
stdio: [
|
|
999
|
+
"ignore",
|
|
1000
|
+
"pipe",
|
|
1001
|
+
"ignore"
|
|
1002
|
+
]
|
|
1003
|
+
});
|
|
1004
|
+
return parseTailscaleStatus(JSON.parse(output));
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
return {
|
|
1007
|
+
state: "unavailable",
|
|
1008
|
+
reason: `\`tailscale status --json\` failed: ${error.message}`
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
function reaches(address, target, hosts) {
|
|
1013
|
+
if (typeof address !== "string") return null;
|
|
1014
|
+
let url;
|
|
1015
|
+
try {
|
|
1016
|
+
url = new URL(address.includes("://") ? address : `tcp://${address}`);
|
|
1017
|
+
} catch {
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
return hosts.has(url.hostname) && url.port === String(target) ? url : null;
|
|
1021
|
+
}
|
|
1022
|
+
function portOf(hostPort) {
|
|
1023
|
+
return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
|
|
1027
|
+
* `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
|
|
1028
|
+
* `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
|
|
1029
|
+
* foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
|
|
1030
|
+
* it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
|
|
1031
|
+
*/
|
|
1032
|
+
function parseServeStatus(value, target, ips) {
|
|
1033
|
+
if (!isJsonObject(value)) return {
|
|
1034
|
+
state: "unknown",
|
|
1035
|
+
reason: "it printed no serve config",
|
|
1036
|
+
port: SERVE_PORT
|
|
1037
|
+
};
|
|
1038
|
+
const hosts = /* @__PURE__ */ new Set([
|
|
1039
|
+
"127.0.0.1",
|
|
1040
|
+
"localhost",
|
|
1041
|
+
"[::1]",
|
|
1042
|
+
"0.0.0.0",
|
|
1043
|
+
"[::]",
|
|
1044
|
+
...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
|
|
1045
|
+
]);
|
|
1046
|
+
const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
|
|
1047
|
+
const used = /* @__PURE__ */ new Set();
|
|
1048
|
+
const funneled = /* @__PURE__ */ new Set();
|
|
1049
|
+
const reaching = /* @__PURE__ */ new Set();
|
|
1050
|
+
const routes = /* @__PURE__ */ new Set();
|
|
1051
|
+
for (const config of configs) {
|
|
1052
|
+
const tcp = isJsonObject(config.TCP) ? config.TCP : {};
|
|
1053
|
+
for (const [port, listener] of Object.entries(tcp)) {
|
|
1054
|
+
used.add(Number(port));
|
|
1055
|
+
if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
|
|
1056
|
+
}
|
|
1057
|
+
for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
|
|
1058
|
+
for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
|
|
1059
|
+
const port = portOf(hostPort);
|
|
1060
|
+
const listener = tcp[String(port)];
|
|
1061
|
+
const https = isJsonObject(listener) && listener.HTTPS === true;
|
|
1062
|
+
const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
|
|
1063
|
+
for (const [mount, handler] of Object.entries(handlers)) {
|
|
1064
|
+
const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
|
|
1065
|
+
if (!url) continue;
|
|
1066
|
+
reaching.add(port);
|
|
1067
|
+
if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
let free = SERVE_PORT;
|
|
1072
|
+
while (used.has(free) || funneled.has(free)) free++;
|
|
1073
|
+
const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
|
|
1074
|
+
if (exposed.length) return {
|
|
1075
|
+
state: "funneled",
|
|
1076
|
+
ports: exposed,
|
|
1077
|
+
port: free
|
|
1078
|
+
};
|
|
1079
|
+
const tailnet = [...routes].toSorted((a, b) => a - b);
|
|
1080
|
+
if (tailnet.length) return {
|
|
1081
|
+
state: "routed",
|
|
1082
|
+
port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
|
|
1083
|
+
};
|
|
1084
|
+
return {
|
|
1085
|
+
state: "missing",
|
|
1086
|
+
port: free
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
|
|
1090
|
+
if (!binary) return Promise.resolve({
|
|
1091
|
+
state: "unknown",
|
|
1092
|
+
reason: "the tailscale command was not found",
|
|
1093
|
+
port: SERVE_PORT
|
|
1094
|
+
});
|
|
1095
|
+
return new Promise((resolve) => {
|
|
1096
|
+
execFile(binary, [
|
|
1097
|
+
"serve",
|
|
1098
|
+
"status",
|
|
1099
|
+
"--json"
|
|
1100
|
+
], {
|
|
1101
|
+
env,
|
|
1102
|
+
timeout: timeoutMs,
|
|
1103
|
+
killSignal: "SIGKILL",
|
|
1104
|
+
encoding: "utf8"
|
|
1105
|
+
}, (error, stdout) => {
|
|
1106
|
+
let route;
|
|
1107
|
+
try {
|
|
1108
|
+
route = error ? {
|
|
1109
|
+
state: "unknown",
|
|
1110
|
+
reason: error.killed ? "it timed out" : error.message.split("\n")[0],
|
|
1111
|
+
port: SERVE_PORT
|
|
1112
|
+
} : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
|
|
1113
|
+
} catch {
|
|
1114
|
+
route = {
|
|
1115
|
+
state: "unknown",
|
|
1116
|
+
reason: "it printed output that is not JSON",
|
|
1117
|
+
port: SERVE_PORT
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
resolve(route);
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
function tailnetEndpoint(dnsName, port) {
|
|
1125
|
+
return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
|
|
1126
|
+
}
|
|
1127
|
+
function serveCommand(port, target) {
|
|
1128
|
+
return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
|
|
1129
|
+
}
|
|
1130
|
+
/** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
|
|
1131
|
+
function parseWhois(value) {
|
|
1132
|
+
if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
|
|
1133
|
+
const { StableID, Name } = value.Node;
|
|
1134
|
+
const login = isJsonObject(value.UserProfile) ? value.UserProfile.LoginName : void 0;
|
|
1135
|
+
if (typeof StableID !== "string" || !StableID) return null;
|
|
1136
|
+
return {
|
|
1137
|
+
kind: "tailnet",
|
|
1138
|
+
nodeId: StableID,
|
|
1139
|
+
nodeName: typeof Name === "string" ? Name.replace(/\.$/, "") : "",
|
|
1140
|
+
user: typeof login === "string" ? login : ""
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
function whois(binary, env, ip) {
|
|
1144
|
+
if (!binary) return Promise.resolve(null);
|
|
1145
|
+
return new Promise((resolve) => {
|
|
1146
|
+
execFile(binary, [
|
|
1147
|
+
"whois",
|
|
1148
|
+
"--json",
|
|
1149
|
+
ip
|
|
1150
|
+
], {
|
|
1151
|
+
env,
|
|
1152
|
+
timeout: TIMEOUT_MS,
|
|
1153
|
+
encoding: "utf8"
|
|
1154
|
+
}, (error, stdout) => {
|
|
1155
|
+
if (error) return resolve(null);
|
|
1156
|
+
try {
|
|
1157
|
+
resolve(parseWhois(JSON.parse(stdout)));
|
|
1158
|
+
} catch {
|
|
1159
|
+
resolve(null);
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
//#endregion
|
|
1165
|
+
//#region src/server.ts
|
|
1166
|
+
function healthTailscale(tailscale) {
|
|
1167
|
+
if (tailscale.state === "running") return {
|
|
1168
|
+
state: "running",
|
|
1169
|
+
dnsName: tailscale.dnsName
|
|
1170
|
+
};
|
|
1171
|
+
if (tailscale.state === "not-running") return {
|
|
1172
|
+
state: "not-running",
|
|
1173
|
+
backendState: tailscale.backendState
|
|
1174
|
+
};
|
|
1175
|
+
return {
|
|
1176
|
+
state: "unavailable",
|
|
1177
|
+
reason: tailscale.reason
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
/** Only a request made to this Mac's loopback name, so a DNS-rebound web page cannot read the health payload. */
|
|
1181
|
+
function localHealthRequest(request) {
|
|
1182
|
+
const host = request.headers.host?.replace(/:\d+$/, "");
|
|
1183
|
+
return request.method === "GET" && request.url === "/health" && (host === "127.0.0.1" || host === "localhost") && peerAddress(request) === null;
|
|
1184
|
+
}
|
|
1185
|
+
const CLOSE_UNAUTHORIZED = 4401;
|
|
1186
|
+
const CLOSE_BAD_REQUEST = 4400;
|
|
1187
|
+
const CLOSE_AUTH_TIMEOUT = 4408;
|
|
1188
|
+
const MAX_PAYLOAD = 65536;
|
|
1189
|
+
const MAX_SUBSCRIPTIONS = 32;
|
|
1190
|
+
const MAX_COMMANDS = 4;
|
|
1191
|
+
const LOG_LIMITS = {
|
|
1192
|
+
maxBufferedBytes: 4194304,
|
|
1193
|
+
maxPendingRecords: 2e4
|
|
1194
|
+
};
|
|
1195
|
+
const FRAME_BUFFER_BYTES = 1048576;
|
|
1196
|
+
const FRAME_RETRY_MS = 100;
|
|
1197
|
+
const STATUS_FEED = {
|
|
1198
|
+
args: [
|
|
1199
|
+
"status",
|
|
1200
|
+
"--watch",
|
|
1201
|
+
"--json"
|
|
1202
|
+
],
|
|
1203
|
+
cwd: homedir(),
|
|
1204
|
+
keep: 1,
|
|
1205
|
+
label: "stim status --watch"
|
|
1206
|
+
};
|
|
1207
|
+
const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
|
|
1208
|
+
const COMMAND_LIMITS = {
|
|
1209
|
+
timeoutMs: 6e4,
|
|
1210
|
+
maxOutputBytes: 33554432
|
|
1211
|
+
};
|
|
1212
|
+
const AUTH_REFUSALS = {
|
|
1213
|
+
"pairing-unknown": {
|
|
1214
|
+
code: "pairing-expired",
|
|
1215
|
+
message: "This pairing code is unknown or was already used. Pair again."
|
|
1216
|
+
},
|
|
1217
|
+
"pairing-expired": {
|
|
1218
|
+
code: "pairing-expired",
|
|
1219
|
+
message: "This pairing code expired. Pair again."
|
|
1220
|
+
},
|
|
1221
|
+
"device-unknown": {
|
|
1222
|
+
code: "unauthorized",
|
|
1223
|
+
message: "This Mac does not recognize this device token. Pair again."
|
|
1224
|
+
},
|
|
1225
|
+
"node-mismatch": {
|
|
1226
|
+
code: "unauthorized",
|
|
1227
|
+
message: "This device token was paired from a different tailnet node."
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
function isLoopback(address) {
|
|
1231
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The tailnet address of the peer. `tailscale serve` proxies from loopback and names the peer in
|
|
1235
|
+
* X-Forwarded-For; a loopback connection without that header comes from this Mac.
|
|
1236
|
+
*/
|
|
1237
|
+
function peerAddress(request) {
|
|
1238
|
+
const remote = request.socket.remoteAddress;
|
|
1239
|
+
if (!isLoopback(remote)) return remote ?? null;
|
|
1240
|
+
const forwarded = request.headers["x-forwarded-for"];
|
|
1241
|
+
return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
|
|
1242
|
+
}
|
|
1243
|
+
var FailureLimiter = class {
|
|
1244
|
+
failures = /* @__PURE__ */ new Map();
|
|
1245
|
+
max;
|
|
1246
|
+
windowMs;
|
|
1247
|
+
constructor(max, windowMs) {
|
|
1248
|
+
this.max = max;
|
|
1249
|
+
this.windowMs = windowMs;
|
|
1250
|
+
}
|
|
1251
|
+
recent(key, now) {
|
|
1252
|
+
const kept = (this.failures.get(key) ?? []).filter((at) => now - at < this.windowMs);
|
|
1253
|
+
if (kept.length) this.failures.set(key, kept);
|
|
1254
|
+
else this.failures.delete(key);
|
|
1255
|
+
return kept;
|
|
1256
|
+
}
|
|
1257
|
+
blocked(key, now = Date.now()) {
|
|
1258
|
+
return this.recent(key, now).length >= this.max;
|
|
1259
|
+
}
|
|
1260
|
+
record(key, now = Date.now()) {
|
|
1261
|
+
this.failures.set(key, [...this.recent(key, now), now]);
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
function send(socket, message) {
|
|
1265
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
1266
|
+
}
|
|
1267
|
+
function requestId(value) {
|
|
1268
|
+
return typeof value === "number" || typeof value === "string" ? value : null;
|
|
1269
|
+
}
|
|
1270
|
+
async function startServer(options) {
|
|
1271
|
+
const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
|
|
1272
|
+
const authTimeoutMs = options.authTimeoutMs ?? 5e3;
|
|
1273
|
+
const feeds = new FeedPool(options.stimCli, options.env);
|
|
1274
|
+
const frameLimits = {
|
|
1275
|
+
...DEFAULT_FRAME_LIMITS,
|
|
1276
|
+
...options.frameLimits
|
|
1277
|
+
};
|
|
1278
|
+
const frames = new FramePool(options.env, frameLimits);
|
|
1279
|
+
const running = /* @__PURE__ */ new Set();
|
|
1280
|
+
const logLimits = {
|
|
1281
|
+
...LOG_LIMITS,
|
|
1282
|
+
...options.logLimits
|
|
1283
|
+
};
|
|
1284
|
+
const commandLimits = {
|
|
1285
|
+
...COMMAND_LIMITS,
|
|
1286
|
+
...options.commandLimits
|
|
1287
|
+
};
|
|
1288
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1289
|
+
const wss = new WebSocketServer({
|
|
1290
|
+
noServer: true,
|
|
1291
|
+
maxPayload: MAX_PAYLOAD
|
|
1292
|
+
});
|
|
1293
|
+
mkdirSync(serverDir(), {
|
|
1294
|
+
recursive: true,
|
|
1295
|
+
mode: 448
|
|
1296
|
+
});
|
|
1297
|
+
let revocationCheck = null;
|
|
1298
|
+
const watcher = watch(serverDir(), () => {
|
|
1299
|
+
revocationCheck ??= setTimeout(() => {
|
|
1300
|
+
revocationCheck = null;
|
|
1301
|
+
const paired = new Set(readDevices().map((device) => device.id));
|
|
1302
|
+
for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
|
|
1303
|
+
}, 50);
|
|
1304
|
+
});
|
|
1305
|
+
function connection(socket, peer) {
|
|
1306
|
+
const limitKey = peer ?? "local";
|
|
1307
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
1308
|
+
const commands = /* @__PURE__ */ new Set();
|
|
1309
|
+
let nextSubscription = 1;
|
|
1310
|
+
let device = null;
|
|
1311
|
+
let queue = Promise.resolve();
|
|
1312
|
+
const refuse = (id, code, message, closeCode) => {
|
|
1313
|
+
if (!device) limiter.record(limitKey);
|
|
1314
|
+
send(socket, {
|
|
1315
|
+
id,
|
|
1316
|
+
error: {
|
|
1317
|
+
code,
|
|
1318
|
+
message
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
socket.close(closeCode, code);
|
|
1322
|
+
};
|
|
1323
|
+
const timer = setTimeout(() => {
|
|
1324
|
+
limiter.record(limitKey);
|
|
1325
|
+
socket.close(CLOSE_AUTH_TIMEOUT, "authentication timeout");
|
|
1326
|
+
}, authTimeoutMs);
|
|
1327
|
+
async function identify() {
|
|
1328
|
+
if (peer === null) return { kind: "local" };
|
|
1329
|
+
return isIP(peer) ? whois(options.tailscale, options.env, peer) : null;
|
|
1330
|
+
}
|
|
1331
|
+
async function hello(id, params) {
|
|
1332
|
+
if (device) return send(socket, {
|
|
1333
|
+
id,
|
|
1334
|
+
error: {
|
|
1335
|
+
code: "already-authenticated",
|
|
1336
|
+
message: "hello was already accepted."
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
const auth = isJsonObject(params) && isJsonObject(params.auth) ? params.auth : null;
|
|
1340
|
+
if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
|
|
1341
|
+
if (params.protocol !== 1) return refuse(id, "protocol-unsupported", `This server speaks protocol 1.`, CLOSE_BAD_REQUEST);
|
|
1342
|
+
clearTimeout(timer);
|
|
1343
|
+
const identity = await identify();
|
|
1344
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
1345
|
+
if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
|
|
1346
|
+
let outcome;
|
|
1347
|
+
if (typeof auth.pairingToken === "string" && typeof auth.deviceName === "string" && auth.deviceName.trim()) outcome = spendPairingToken(auth.pairingToken, auth.deviceName.trim(), identity);
|
|
1348
|
+
else if (typeof auth.deviceToken === "string") outcome = authenticateDevice(auth.deviceToken, identity);
|
|
1349
|
+
else return refuse(id, "bad-request", "auth needs pairingToken and deviceName, or deviceToken.", CLOSE_BAD_REQUEST);
|
|
1350
|
+
if (!outcome.ok) {
|
|
1351
|
+
const { code, message } = AUTH_REFUSALS[outcome.reason];
|
|
1352
|
+
return refuse(id, code, message, CLOSE_UNAUTHORIZED);
|
|
1353
|
+
}
|
|
1354
|
+
device = outcome.device;
|
|
1355
|
+
sessions.set(socket, device);
|
|
1356
|
+
send(socket, {
|
|
1357
|
+
id,
|
|
1358
|
+
result: {
|
|
1359
|
+
protocol: 1,
|
|
1360
|
+
server: {
|
|
1361
|
+
name: options.name,
|
|
1362
|
+
version: options.serverVersion,
|
|
1363
|
+
stim: options.stimVersion
|
|
1364
|
+
},
|
|
1365
|
+
capabilities: device.capabilities,
|
|
1366
|
+
device: {
|
|
1367
|
+
id: device.id,
|
|
1368
|
+
name: device.name
|
|
1369
|
+
},
|
|
1370
|
+
...outcome.deviceToken ? { deviceToken: outcome.deviceToken } : {}
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
function error(id, code, message) {
|
|
1375
|
+
send(socket, {
|
|
1376
|
+
id,
|
|
1377
|
+
error: {
|
|
1378
|
+
code,
|
|
1379
|
+
message
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
function workspaceDir(id, workspace, required) {
|
|
1384
|
+
if (workspace === void 0 && !required) return homedir();
|
|
1385
|
+
if (typeof workspace !== "string") {
|
|
1386
|
+
error(id, "bad-request", "params.workspace must be an environment path from a status payload.");
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
let registered;
|
|
1390
|
+
try {
|
|
1391
|
+
registered = Object.hasOwn(loadConfig()?.projects ?? {}, workspace);
|
|
1392
|
+
} catch (cause) {
|
|
1393
|
+
error(id, "stim-failed", cause.message);
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
if (!registered) {
|
|
1397
|
+
error(id, "unknown-workspace", `${workspace} is not a Stim workspace on this Mac.`);
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
if (!existsSync(workspace)) {
|
|
1401
|
+
error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
|
|
1402
|
+
return null;
|
|
1403
|
+
}
|
|
1404
|
+
return workspace;
|
|
1405
|
+
}
|
|
1406
|
+
function openSubscription(id) {
|
|
1407
|
+
if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
|
|
1408
|
+
error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
const subscription = `s${nextSubscription++}`;
|
|
1412
|
+
send(socket, {
|
|
1413
|
+
id,
|
|
1414
|
+
result: { subscription }
|
|
1415
|
+
});
|
|
1416
|
+
return subscription;
|
|
1417
|
+
}
|
|
1418
|
+
function subscribeStatus(id) {
|
|
1419
|
+
const subscription = openSubscription(id);
|
|
1420
|
+
if (!subscription) return;
|
|
1421
|
+
const unsubscribe = feeds.subscribe(STATUS_FEED, {
|
|
1422
|
+
item: (payload) => send(socket, {
|
|
1423
|
+
event: "status",
|
|
1424
|
+
subscription,
|
|
1425
|
+
payload
|
|
1426
|
+
}),
|
|
1427
|
+
failed: (message) => {
|
|
1428
|
+
subscriptions.delete(subscription);
|
|
1429
|
+
send(socket, {
|
|
1430
|
+
event: "error",
|
|
1431
|
+
subscription,
|
|
1432
|
+
error: {
|
|
1433
|
+
code: "status-failed",
|
|
1434
|
+
message
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
subscriptions.set(subscription, unsubscribe);
|
|
1440
|
+
}
|
|
1441
|
+
function subscribeLogs(id, params) {
|
|
1442
|
+
const parsed = parseLogFilter(params);
|
|
1443
|
+
if ("error" in parsed) return error(id, "bad-request", parsed.error);
|
|
1444
|
+
const cwd = workspaceDir(id, parsed.filter.workspace, true);
|
|
1445
|
+
if (!cwd) return;
|
|
1446
|
+
const subscription = openSubscription(id);
|
|
1447
|
+
if (!subscription) return;
|
|
1448
|
+
const end = () => {
|
|
1449
|
+
subscriptions.get(subscription)?.();
|
|
1450
|
+
subscriptions.delete(subscription);
|
|
1451
|
+
};
|
|
1452
|
+
const batcher = new LogBatcher({
|
|
1453
|
+
send: (records) => send(socket, {
|
|
1454
|
+
event: "logs",
|
|
1455
|
+
subscription,
|
|
1456
|
+
records
|
|
1457
|
+
}),
|
|
1458
|
+
bufferedBytes: () => socket.bufferedAmount,
|
|
1459
|
+
overflow: () => {
|
|
1460
|
+
end();
|
|
1461
|
+
send(socket, {
|
|
1462
|
+
event: "error",
|
|
1463
|
+
subscription,
|
|
1464
|
+
error: {
|
|
1465
|
+
code: "slow-client",
|
|
1466
|
+
message: "This client fell behind the log stream. Subscribe again."
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
}, logLimits);
|
|
1471
|
+
const unsubscribe = feeds.subscribe({
|
|
1472
|
+
args: logArgs(parsed.filter, true),
|
|
1473
|
+
cwd,
|
|
1474
|
+
keep: parsed.filter.tail,
|
|
1475
|
+
label: "stim logs --follow"
|
|
1476
|
+
}, {
|
|
1477
|
+
item: (record) => batcher.push(record),
|
|
1478
|
+
failed: (message) => {
|
|
1479
|
+
batcher.flush(true);
|
|
1480
|
+
batcher.stop();
|
|
1481
|
+
subscriptions.delete(subscription);
|
|
1482
|
+
send(socket, {
|
|
1483
|
+
event: "error",
|
|
1484
|
+
subscription,
|
|
1485
|
+
error: {
|
|
1486
|
+
code: "logs-failed",
|
|
1487
|
+
message
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
subscriptions.set(subscription, () => {
|
|
1493
|
+
batcher.stop();
|
|
1494
|
+
unsubscribe();
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
function subscribeFrames(id, params) {
|
|
1498
|
+
const { workspace, platform, slot } = isJsonObject(params) ? params : {};
|
|
1499
|
+
if (typeof workspace !== "string" || platform !== "ios" && platform !== "android") return error(id, "bad-request", "frames.subscribe needs params.workspace and params.platform (ios or android).");
|
|
1500
|
+
if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
|
|
1501
|
+
if (!workspaceDir(id, workspace, true)) return;
|
|
1502
|
+
const subscription = openSubscription(id);
|
|
1503
|
+
if (!subscription) return;
|
|
1504
|
+
const frameTarget = {
|
|
1505
|
+
workspace,
|
|
1506
|
+
platform,
|
|
1507
|
+
...slot ? { slot } : {}
|
|
1508
|
+
};
|
|
1509
|
+
let attached = null;
|
|
1510
|
+
let detach = null;
|
|
1511
|
+
let pending = null;
|
|
1512
|
+
let retry = null;
|
|
1513
|
+
let ended = false;
|
|
1514
|
+
const flush = () => {
|
|
1515
|
+
retry = null;
|
|
1516
|
+
if (!pending || ended) return;
|
|
1517
|
+
if (socket.bufferedAmount > FRAME_BUFFER_BYTES) {
|
|
1518
|
+
retry = setTimeout(flush, FRAME_RETRY_MS);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const frame = pending;
|
|
1522
|
+
pending = null;
|
|
1523
|
+
send(socket, {
|
|
1524
|
+
event: "frame",
|
|
1525
|
+
subscription,
|
|
1526
|
+
platform,
|
|
1527
|
+
slot: slot ?? "default",
|
|
1528
|
+
mime: "image/jpeg",
|
|
1529
|
+
...frame
|
|
1530
|
+
});
|
|
1531
|
+
};
|
|
1532
|
+
const cleanup = () => {
|
|
1533
|
+
ended = true;
|
|
1534
|
+
if (retry) clearTimeout(retry);
|
|
1535
|
+
detach?.();
|
|
1536
|
+
detach = null;
|
|
1537
|
+
unsubscribeStatus?.();
|
|
1538
|
+
};
|
|
1539
|
+
const end = (message) => {
|
|
1540
|
+
if (ended) return;
|
|
1541
|
+
cleanup();
|
|
1542
|
+
subscriptions.delete(subscription);
|
|
1543
|
+
send(socket, {
|
|
1544
|
+
event: "error",
|
|
1545
|
+
subscription,
|
|
1546
|
+
error: {
|
|
1547
|
+
code: "frames-failed",
|
|
1548
|
+
message
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
};
|
|
1552
|
+
const listener = {
|
|
1553
|
+
frame: (frame) => {
|
|
1554
|
+
pending = frame;
|
|
1555
|
+
if (!retry) flush();
|
|
1556
|
+
},
|
|
1557
|
+
delayed: (delayed) => {
|
|
1558
|
+
if (!ended) send(socket, {
|
|
1559
|
+
event: "frame-delayed",
|
|
1560
|
+
subscription,
|
|
1561
|
+
delayed
|
|
1562
|
+
});
|
|
1563
|
+
},
|
|
1564
|
+
failed: end
|
|
1565
|
+
};
|
|
1566
|
+
let unsubscribeStatus = null;
|
|
1567
|
+
unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
|
|
1568
|
+
item: (payload) => {
|
|
1569
|
+
if (ended) return;
|
|
1570
|
+
const resolved = ownedDevice(payload, frameTarget, attached);
|
|
1571
|
+
if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
|
|
1572
|
+
if (deviceKey(resolved) === attached) return;
|
|
1573
|
+
detach?.();
|
|
1574
|
+
attached = deviceKey(resolved);
|
|
1575
|
+
detach = frames.subscribe(resolved, listener);
|
|
1576
|
+
},
|
|
1577
|
+
failed: (message) => queueMicrotask(() => end(message))
|
|
1578
|
+
});
|
|
1579
|
+
subscriptions.set(subscription, cleanup);
|
|
1580
|
+
}
|
|
1581
|
+
function command(id, args, cwd, result) {
|
|
1582
|
+
if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
|
|
1583
|
+
const run = runStim(options.stimCli, options.env, args, cwd, commandLimits);
|
|
1584
|
+
commands.add(run.cancel);
|
|
1585
|
+
running.add(run.cancel);
|
|
1586
|
+
(async () => {
|
|
1587
|
+
const outcome = await run.outcome;
|
|
1588
|
+
commands.delete(run.cancel);
|
|
1589
|
+
running.delete(run.cancel);
|
|
1590
|
+
if (!outcome.ok) return error(id, "stim-failed", outcome.message);
|
|
1591
|
+
let value;
|
|
1592
|
+
try {
|
|
1593
|
+
value = result(outcome.stdout);
|
|
1594
|
+
} catch {
|
|
1595
|
+
return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
|
|
1596
|
+
}
|
|
1597
|
+
send(socket, {
|
|
1598
|
+
id,
|
|
1599
|
+
result: value
|
|
1600
|
+
});
|
|
1601
|
+
})();
|
|
1602
|
+
}
|
|
1603
|
+
function queryLogs(id, params) {
|
|
1604
|
+
const parsed = parseLogFilter(params);
|
|
1605
|
+
if ("error" in parsed) return error(id, "bad-request", parsed.error);
|
|
1606
|
+
const cwd = workspaceDir(id, parsed.filter.workspace, true);
|
|
1607
|
+
if (!cwd) return;
|
|
1608
|
+
command(id, logArgs(parsed.filter, false), cwd, (stdout) => ({ records: stdout.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)) }));
|
|
1609
|
+
}
|
|
1610
|
+
function workspaceCommand(id, method, params) {
|
|
1611
|
+
if (params !== void 0 && !isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
|
|
1612
|
+
const cwd = workspaceDir(id, params?.workspace, false);
|
|
1613
|
+
if (!cwd) return;
|
|
1614
|
+
command(id, [method === "stats.get" ? "stats" : "settings", "--json"], cwd, (stdout) => {
|
|
1615
|
+
const value = JSON.parse(stdout);
|
|
1616
|
+
if (!isJsonObject(value)) throw new Error("not an object");
|
|
1617
|
+
return value;
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
async function handle(raw) {
|
|
1621
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
1622
|
+
let message;
|
|
1623
|
+
try {
|
|
1624
|
+
message = JSON.parse(raw);
|
|
1625
|
+
} catch {
|
|
1626
|
+
message = null;
|
|
1627
|
+
}
|
|
1628
|
+
const id = isJsonObject(message) ? requestId(message.id) : null;
|
|
1629
|
+
if (!isJsonObject(message) || id === null || typeof message.method !== "string") {
|
|
1630
|
+
if (device) return send(socket, {
|
|
1631
|
+
id,
|
|
1632
|
+
error: {
|
|
1633
|
+
code: "bad-request",
|
|
1634
|
+
message: "Expected {id, method, params}."
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
|
|
1638
|
+
}
|
|
1639
|
+
if (message.method === "hello") return hello(id, message.params);
|
|
1640
|
+
if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
|
|
1641
|
+
if (message.method === "status.subscribe") return subscribeStatus(id);
|
|
1642
|
+
if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
|
|
1643
|
+
if (message.method === "logs.query") return queryLogs(id, message.params);
|
|
1644
|
+
if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
|
|
1645
|
+
if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
|
|
1646
|
+
if (message.method === "unsubscribe") {
|
|
1647
|
+
const name = isJsonObject(message.params) ? message.params.subscription : void 0;
|
|
1648
|
+
const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
|
|
1649
|
+
if (!unsubscribe) return send(socket, {
|
|
1650
|
+
id,
|
|
1651
|
+
error: {
|
|
1652
|
+
code: "unknown-subscription",
|
|
1653
|
+
message: `No subscription ${String(name)}.`
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
subscriptions.delete(name);
|
|
1657
|
+
unsubscribe();
|
|
1658
|
+
return send(socket, {
|
|
1659
|
+
id,
|
|
1660
|
+
result: {}
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
send(socket, {
|
|
1664
|
+
id,
|
|
1665
|
+
error: {
|
|
1666
|
+
code: "unknown-method",
|
|
1667
|
+
message: `Unknown method ${message.method}.`
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
socket.on("message", (data) => {
|
|
1672
|
+
const raw = data.toString();
|
|
1673
|
+
queue = queue.then(() => handle(raw)).catch(() => socket.close(1011, "internal error"));
|
|
1674
|
+
});
|
|
1675
|
+
socket.on("close", () => {
|
|
1676
|
+
clearTimeout(timer);
|
|
1677
|
+
sessions.delete(socket);
|
|
1678
|
+
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
1679
|
+
subscriptions.clear();
|
|
1680
|
+
for (const cancel of commands) {
|
|
1681
|
+
running.delete(cancel);
|
|
1682
|
+
cancel();
|
|
1683
|
+
}
|
|
1684
|
+
commands.clear();
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
function upgrade(request, socket, head) {
|
|
1688
|
+
const peer = peerAddress(request);
|
|
1689
|
+
if (limiter.blocked(peer ?? "local")) {
|
|
1690
|
+
socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
wss.handleUpgrade(request, socket, head, (ws) => connection(ws, peer));
|
|
1694
|
+
}
|
|
1695
|
+
const health = {
|
|
1696
|
+
server: "stim-server",
|
|
1697
|
+
name: options.name,
|
|
1698
|
+
version: options.serverVersion,
|
|
1699
|
+
stim: options.stimVersion,
|
|
1700
|
+
protocol: 1,
|
|
1701
|
+
stimHome: configDir(),
|
|
1702
|
+
tailscale: healthTailscale(options.tailscaleState)
|
|
1703
|
+
};
|
|
1704
|
+
const answerHealth = async (response) => {
|
|
1705
|
+
const tailscale = options.tailscaleState;
|
|
1706
|
+
const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
|
|
1707
|
+
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
|
1708
|
+
...health,
|
|
1709
|
+
route
|
|
1710
|
+
}));
|
|
1711
|
+
};
|
|
1712
|
+
const servers = [];
|
|
1713
|
+
const addresses = [];
|
|
1714
|
+
const close = async () => {
|
|
1715
|
+
watcher.close();
|
|
1716
|
+
if (revocationCheck) clearTimeout(revocationCheck);
|
|
1717
|
+
for (const client of wss.clients) client.terminate();
|
|
1718
|
+
await Promise.all([
|
|
1719
|
+
frames.close(),
|
|
1720
|
+
feeds.close(),
|
|
1721
|
+
...[...running].map((cancel) => cancel())
|
|
1722
|
+
]);
|
|
1723
|
+
wss.close();
|
|
1724
|
+
await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));
|
|
1725
|
+
};
|
|
1726
|
+
try {
|
|
1727
|
+
for (const host of options.hosts) {
|
|
1728
|
+
const server = createServer((request, response) => {
|
|
1729
|
+
if (localHealthRequest(request)) {
|
|
1730
|
+
answerHealth(response);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
|
|
1734
|
+
});
|
|
1735
|
+
server.on("upgrade", upgrade);
|
|
1736
|
+
servers.push(server);
|
|
1737
|
+
await new Promise((resolve, reject) => {
|
|
1738
|
+
server.once("error", reject);
|
|
1739
|
+
server.listen(options.port, host, () => {
|
|
1740
|
+
server.off("error", reject);
|
|
1741
|
+
resolve();
|
|
1742
|
+
});
|
|
1743
|
+
});
|
|
1744
|
+
addresses.push({
|
|
1745
|
+
host,
|
|
1746
|
+
port: server.address().port
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
await close();
|
|
1751
|
+
throw error;
|
|
1752
|
+
}
|
|
1753
|
+
return {
|
|
1754
|
+
addresses,
|
|
1755
|
+
close
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
//#endregion
|
|
1759
|
+
//#region bin/stim-server.ts
|
|
1760
|
+
const DEFAULT_PORT = 7787;
|
|
1761
|
+
const USAGE = `Usage:
|
|
1762
|
+
stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
|
|
1763
|
+
stim-server pair [--port <n>] [--json]
|
|
1764
|
+
print a single-use pairing payload for the QR code
|
|
1765
|
+
stim-server devices [list] [--json]
|
|
1766
|
+
list paired devices
|
|
1767
|
+
stim-server devices revoke <id> revoke a paired device`;
|
|
1768
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1769
|
+
function fail(message) {
|
|
1770
|
+
console.error(`stim-server: ${message}`);
|
|
1771
|
+
process.exit(1);
|
|
1772
|
+
}
|
|
1773
|
+
function tailscaleNote(tailscale) {
|
|
1774
|
+
const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
|
|
1775
|
+
if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
|
|
1776
|
+
if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
|
|
1777
|
+
return null;
|
|
1778
|
+
}
|
|
1779
|
+
function routeNote(route, port) {
|
|
1780
|
+
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)}\``;
|
|
1781
|
+
if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
|
|
1782
|
+
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)}\``;
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
function macName(tailscale) {
|
|
1786
|
+
return tailscale.state === "running" && tailscale.hostName || hostname();
|
|
1787
|
+
}
|
|
1788
|
+
async function serve(port) {
|
|
1789
|
+
const login = loginShellEnvironment();
|
|
1790
|
+
if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
|
|
1791
|
+
const env = { ...login ?? process.env };
|
|
1792
|
+
if (!process.env.STIM_HOME && env.STIM_HOME) process.env.STIM_HOME = env.STIM_HOME;
|
|
1793
|
+
if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
|
|
1794
|
+
const tailscaleBinary = findTailscale(env);
|
|
1795
|
+
const tailscale = tailscaleStatus(tailscaleBinary, env);
|
|
1796
|
+
const stim = bundledStim();
|
|
1797
|
+
const hosts = ["127.0.0.1", ...tailscale.state === "running" ? tailscale.ips : []];
|
|
1798
|
+
let server;
|
|
1799
|
+
try {
|
|
1800
|
+
server = await startServer({
|
|
1801
|
+
name: macName(tailscale),
|
|
1802
|
+
hosts,
|
|
1803
|
+
port,
|
|
1804
|
+
stimCli: stim.cli,
|
|
1805
|
+
stimVersion: stim.version,
|
|
1806
|
+
serverVersion: pkg.version,
|
|
1807
|
+
env,
|
|
1808
|
+
tailscale: tailscaleBinary,
|
|
1809
|
+
tailscaleState: tailscale
|
|
1810
|
+
});
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
if (error.code === "EADDRINUSE") fail(`port ${port} is in use; another stim-server may already be running. Pass --port to use another port.`);
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
|
|
1816
|
+
for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
|
|
1817
|
+
let note = tailscaleNote(tailscale);
|
|
1818
|
+
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
1819
|
+
const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
|
|
1820
|
+
if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
|
|
1821
|
+
note = routeNote(route, port);
|
|
1822
|
+
if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
|
|
1823
|
+
}
|
|
1824
|
+
if (note) console.error(note);
|
|
1825
|
+
const shutdown = () => {
|
|
1826
|
+
server.close().then(() => process.exit(0));
|
|
1827
|
+
};
|
|
1828
|
+
process.on("SIGINT", shutdown);
|
|
1829
|
+
process.on("SIGTERM", shutdown);
|
|
1830
|
+
process.on("SIGHUP", shutdown);
|
|
1831
|
+
}
|
|
1832
|
+
async function pair(port, json) {
|
|
1833
|
+
const binary = findTailscale(process.env);
|
|
1834
|
+
const tailscale = tailscaleStatus(binary, process.env);
|
|
1835
|
+
let endpoint = `ws://127.0.0.1:${port}`;
|
|
1836
|
+
let note = tailscaleNote(tailscale);
|
|
1837
|
+
if (note) note = `${note} The endpoint above only works on this Mac.`;
|
|
1838
|
+
if (tailscale.state === "running" && tailscale.dnsName) {
|
|
1839
|
+
const route = await serveRoute(binary, process.env, port, tailscale.ips);
|
|
1840
|
+
if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
|
|
1841
|
+
endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
|
|
1842
|
+
note = routeNote(route, port);
|
|
1843
|
+
}
|
|
1844
|
+
const { token, expiresAt } = createPairingToken();
|
|
1845
|
+
const payload = {
|
|
1846
|
+
v: 1,
|
|
1847
|
+
name: macName(tailscale),
|
|
1848
|
+
endpoint,
|
|
1849
|
+
pairingToken: token
|
|
1850
|
+
};
|
|
1851
|
+
if (json) return void console.log(JSON.stringify({
|
|
1852
|
+
qr: payload,
|
|
1853
|
+
expiresAt
|
|
1854
|
+
}));
|
|
1855
|
+
console.log(JSON.stringify(payload));
|
|
1856
|
+
console.error(`The pairing token is single use and expires at ${expiresAt}.`);
|
|
1857
|
+
if (note) console.error(note);
|
|
1858
|
+
}
|
|
1859
|
+
function describe(device) {
|
|
1860
|
+
const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
|
|
1861
|
+
return `${device.id} ${device.name} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
|
|
1862
|
+
}
|
|
1863
|
+
async function main() {
|
|
1864
|
+
const { values, positionals } = parseArgs({
|
|
1865
|
+
allowPositionals: true,
|
|
1866
|
+
options: {
|
|
1867
|
+
port: { type: "string" },
|
|
1868
|
+
json: { type: "boolean" },
|
|
1869
|
+
help: {
|
|
1870
|
+
type: "boolean",
|
|
1871
|
+
short: "h"
|
|
1872
|
+
},
|
|
1873
|
+
version: {
|
|
1874
|
+
type: "boolean",
|
|
1875
|
+
short: "V"
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
if (values.help) return void console.log(USAGE);
|
|
1880
|
+
if (values.version) return void console.log(pkg.version);
|
|
1881
|
+
const port = values.port === void 0 ? DEFAULT_PORT : Number(values.port);
|
|
1882
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
|
|
1883
|
+
const [command, sub, arg, ...rest] = positionals;
|
|
1884
|
+
if (command === void 0) return serve(port);
|
|
1885
|
+
if (command === "pair" && sub === void 0) return pair(port, values.json === true);
|
|
1886
|
+
if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
|
|
1887
|
+
const devices = readDevices();
|
|
1888
|
+
if (values.json) {
|
|
1889
|
+
const listed = devices.map(({ id, name, identity, pairedAt, lastSeenAt, capabilities }) => ({
|
|
1890
|
+
id,
|
|
1891
|
+
name,
|
|
1892
|
+
identity,
|
|
1893
|
+
pairedAt,
|
|
1894
|
+
lastSeenAt,
|
|
1895
|
+
capabilities
|
|
1896
|
+
}));
|
|
1897
|
+
console.log(JSON.stringify({ devices: listed }));
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (!devices.length) console.log("No paired devices.");
|
|
1901
|
+
for (const device of devices) console.log(describe(device));
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
if (command === "devices" && sub === "revoke" && arg !== void 0 && rest.length === 0) {
|
|
1905
|
+
if (!revokeDevice(arg)) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
|
|
1906
|
+
console.log(`Revoked ${arg}.`);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
fail(`unknown command.\n${USAGE}`);
|
|
1910
|
+
}
|
|
1911
|
+
await main();
|
|
1912
|
+
//#endregion
|
|
1913
|
+
export {};
|