@stim-cli/server 1.18.0 → 1.20.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 CHANGED
@@ -37,7 +37,8 @@ runs (see [Tailscale](#tailscale)). Stim Desktop starts its own server with the
37
37
  unless it serves phones.
38
38
 
39
39
  `--env KEY=VALUE` and `--path-prepend <dir>` (each repeatable) apply to the
40
- serving command: see [Run as a service](#run-as-a-service).
40
+ serving command: see [Run as a service](#run-as-a-service). When serving, a bare
41
+ `--env KEY` takes the value from the server's own environment.
41
42
 
42
43
  `pair --json` prints `{ "qr": <payload>, "expiresAt": "<ISO time>" }`, and
43
44
  `devices --json` prints `{ "devices": [...] }` with each device's `id`, `name`,
@@ -280,6 +281,10 @@ loopback port: a forwarder that omits that header, such as `tailscale serve
280
281
 
281
282
  A connection must send `hello` within 5 seconds. Five failed attempts from the
282
283
  same peer within a minute block new connections from it for up to a minute.
284
+ The server answers 403 to a WebSocket upgrade that carries `Sec-Fetch-Site`, or
285
+ an `Origin` other than the requested host (`http:` on loopback, `https:`
286
+ through the route), and counts no failed attempt, so a web page can neither
287
+ connect nor block this Mac's clients.
283
288
 
284
289
  Paired devices live in `$STIM_HOME/server/devices.json`. Revoking a device
285
290
  closes its open connections. The server checks registrations on file changes
@@ -1048,9 +1053,15 @@ created a route to another `--port`, run `uninstall` first.
1048
1053
  the server. stim-server replaces its environment with the login shell's at
1049
1054
  start, so a `PATH` or `GEM_HOME` in the plist alone is lost and an entry in
1050
1055
  `~/.zshrc` changes the terminal too. These flags apply after that capture, to
1051
- the server and to the builds it starts, and the plist stores them as
1052
- `ProgramArguments`. `--path-prepend` puts directories in front of PATH in the
1053
- order given; `--env` cannot set `STIM_HOME`. The values sit in plain text in the plist and in the process arguments, so do not pass secrets. For a private CocoaPods:
1056
+ the server and to the builds it starts. The plist stores each `--env` value in
1057
+ `EnvironmentVariables`, so it stays out of the server's process arguments, and
1058
+ passes only the name as `--env KEY`; the server reads that variable from the
1059
+ environment launchd gives it. The values sit in plain text in the plist. A
1060
+ plist written by an earlier release keeps `--env KEY=VALUE` in its
1061
+ `ProgramArguments` until the next `service install`. `--path-prepend` puts
1062
+ directories in front of PATH in the order given, as `ProgramArguments`; `--env`
1063
+ cannot set `STIM_HOME` or `SHELL`, which the job takes from the installing
1064
+ process. For a private CocoaPods:
1054
1065
 
1055
1066
  ```bash
1056
1067
  stim-server service install --serve \
@@ -1119,7 +1130,9 @@ methods need that approval, not `read`:
1119
1130
  it does not run as a `stim-server service` and so cannot update itself),
1120
1131
  `acceptsClientBuilds`, `running` (the update in progress, with its `state`,
1121
1132
  `missing` package bytes and the last lines of its `log`) and `last` (how the
1122
- label's last `service update` ended, whoever ran it).
1133
+ label's last `service update` ended, whoever ran it, or how the update this
1134
+ server started ended when that is newer, such as one the update lock refused,
1135
+ with the last line of its log).
1123
1136
  - `server.update.start` takes `{ "release": "<version>" }`, an exact version
1124
1137
  that `stim-server service update --release` installs, or
1125
1138
  `{ "packages": [{ "name", "size", "sha256" }] }`, at most 8 `.tgz` packages
@@ -1189,7 +1202,11 @@ closes its connections and cancels its builds.
1189
1202
  while the worker root's volume has less than 10 GiB free, while `builds`
1190
1203
  reaches a non-zero `maxBuilds`, or while `loadPerCore` is at or above
1191
1204
  `maxLoadPerCore`. The toolchain is read at most once a minute per Ruby context; capacity on
1192
- every offer.
1205
+ every offer. With the `native-xcode-toolchain` feature, an offer can set
1206
+ `native: "xcode"` to collect only Stim, CPU, Xcode, iPhoneSimulator SDK and
1207
+ simulator-runtime facts. Unused report fields are null. This report has its own
1208
+ cache, ignores Ruby selection, and is reused for native Xcode build admission.
1209
+ Clients use the complete report on older native-build-capable hosts.
1193
1210
  - `build.sync` takes `repo`, `files` and `done`. `files` is one page of the
1194
1211
  manifest, each `{ "path", "kind": "file"|"exec"|"link", "size", "sha256" }`,
1195
1212
  a link's blob being its target. Pages accumulate until `done`; the next
@@ -1329,7 +1346,7 @@ Events are `{ "event", "subscription", ... }`.
1329
1346
  (none without `control`), the paired device, and the new `deviceToken` when
1330
1347
  the hello paired. `server.home` is the home folder
1331
1348
  of the user the server runs as, so clients can show paths under it as
1332
- `~/...`.
1349
+ `~/...`. A pending approval result omits it.
1333
1350
  - `status.subscribe` returns a subscription id. Each `status` event carries a
1334
1351
  full payload as `stim status --watch --json` prints it, including each
1335
1352
  environment's `physicalDevices`, the phones it leases, and its `agents`, the
@@ -120,7 +120,11 @@ function protocolJsonSchema() {
120
120
  session: { type: "string" },
121
121
  attempt: { type: "string" },
122
122
  bundleId: { type: "string" },
123
- mode: { enum: ["development", "release"] },
123
+ mode: { enum: [
124
+ "development",
125
+ "release",
126
+ "process"
127
+ ] },
124
128
  devClientScheme: {
125
129
  type: "string",
126
130
  pattern: "^[a-zA-Z][a-zA-Z0-9+.-]{0,127}$"
@@ -864,8 +868,7 @@ function protocolJsonSchema() {
864
868
  required: [
865
869
  "name",
866
870
  "version",
867
- "stim",
868
- "home"
871
+ "stim"
869
872
  ],
870
873
  additionalProperties: false,
871
874
  properties: {
@@ -1706,7 +1709,11 @@ function protocolJsonSchema() {
1706
1709
  pattern: "^[a-zA-Z0-9_-]{1,128}$"
1707
1710
  },
1708
1711
  bundleId: { type: "string" },
1709
- mode: { enum: ["development", "release"] },
1712
+ mode: { enum: [
1713
+ "development",
1714
+ "release",
1715
+ "process"
1716
+ ] },
1710
1717
  devClientScheme: {
1711
1718
  type: "string",
1712
1719
  pattern: "^[a-zA-Z][a-zA-Z0-9+.-]{0,127}$"
@@ -2217,7 +2224,8 @@ function protocolJsonSchema() {
2217
2224
  rubyVersion: {
2218
2225
  type: "string",
2219
2226
  pattern: BUILD_RUBY_VERSION_PATTERN
2220
- }
2227
+ },
2228
+ native: { enum: ["xcode", "gradle"] }
2221
2229
  }
2222
2230
  }),
2223
2231
  request("build.sync", {
@@ -2249,7 +2257,8 @@ function protocolJsonSchema() {
2249
2257
  kind: { enum: [
2250
2258
  "file",
2251
2259
  "exec",
2252
- "link"
2260
+ "link",
2261
+ "directory"
2253
2262
  ] },
2254
2263
  size: {
2255
2264
  type: "integer",
@@ -2264,6 +2273,18 @@ function protocolJsonSchema() {
2264
2273
  }),
2265
2274
  request("build.start", {
2266
2275
  type: "object",
2276
+ if: {
2277
+ required: ["native"],
2278
+ properties: { native: { properties: { provider: { const: "gradle" } } } }
2279
+ },
2280
+ then: { properties: {
2281
+ platform: { const: "android" },
2282
+ fingerprint: { type: "null" }
2283
+ } },
2284
+ else: { properties: { fingerprint: {
2285
+ type: "string",
2286
+ minLength: 1
2287
+ } } },
2267
2288
  required: [
2268
2289
  "repo",
2269
2290
  "project",
@@ -2307,9 +2328,75 @@ function protocolJsonSchema() {
2307
2328
  minLength: 1
2308
2329
  },
2309
2330
  fingerprint: {
2310
- type: "string",
2331
+ type: ["string", "null"],
2311
2332
  minLength: 1
2312
2333
  },
2334
+ native: { oneOf: [{
2335
+ type: "object",
2336
+ required: [
2337
+ "provider",
2338
+ "sourceDigest",
2339
+ "cacheKey",
2340
+ "arch",
2341
+ "parameters"
2342
+ ],
2343
+ additionalProperties: false,
2344
+ properties: {
2345
+ provider: { const: "xcode" },
2346
+ sourceDigest: sha256,
2347
+ cacheKey: {
2348
+ type: "string",
2349
+ minLength: 1
2350
+ },
2351
+ arch: { enum: [
2352
+ "arm64",
2353
+ "x86_64",
2354
+ null
2355
+ ] },
2356
+ parameters: {
2357
+ type: "object",
2358
+ description: "The non-file parameters of the client artifact identity, to name what differs."
2359
+ }
2360
+ }
2361
+ }, {
2362
+ type: "object",
2363
+ required: [
2364
+ "provider",
2365
+ "sourceDigest",
2366
+ "inputs"
2367
+ ],
2368
+ additionalProperties: false,
2369
+ properties: {
2370
+ provider: { const: "gradle" },
2371
+ sourceDigest: sha256,
2372
+ inputs: {
2373
+ type: "object",
2374
+ required: [
2375
+ "complete",
2376
+ "ignored",
2377
+ "outputs"
2378
+ ],
2379
+ additionalProperties: false,
2380
+ properties: {
2381
+ complete: { const: true },
2382
+ ignored: {
2383
+ type: "array",
2384
+ items: {
2385
+ type: "string",
2386
+ minLength: 1
2387
+ }
2388
+ },
2389
+ outputs: {
2390
+ type: "array",
2391
+ items: {
2392
+ type: "string",
2393
+ minLength: 1
2394
+ }
2395
+ }
2396
+ }
2397
+ }
2398
+ }
2399
+ }] },
2313
2400
  packageName: { type: ["string", "null"] },
2314
2401
  isExpo: { type: "boolean" },
2315
2402
  optimizations: { type: ["object", "null"] },
package/dist/protocol.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as protocolJsonSchema } from "./protocol-qo5TTavl.mjs";
1
+ import { t as protocolJsonSchema } from "./protocol-CjUChTWJ.mjs";
2
2
  export * from "@stim-cli/core/protocol";
3
3
  export { protocolJsonSchema };
@@ -70,7 +70,8 @@
70
70
  "mode": {
71
71
  "enum": [
72
72
  "development",
73
- "release"
73
+ "release",
74
+ "process"
74
75
  ]
75
76
  },
76
77
  "devClientScheme": {
@@ -1260,8 +1261,7 @@
1260
1261
  "required": [
1261
1262
  "name",
1262
1263
  "version",
1263
- "stim",
1264
- "home"
1264
+ "stim"
1265
1265
  ],
1266
1266
  "additionalProperties": false,
1267
1267
  "properties": {
@@ -1312,7 +1312,12 @@
1312
1312
  "workspace-diff",
1313
1313
  "hosted-congestion",
1314
1314
  "hosted-ios-data",
1315
+ "hosted-ios-process",
1316
+ "native-xcode-build",
1317
+ "native-xcode-toolchain",
1318
+ "native-gradle-build",
1315
1319
  "hosted-android-data",
1320
+ "hosted-android-process",
1316
1321
  "server-update"
1317
1322
  ]
1318
1323
  }
@@ -2778,7 +2783,8 @@
2778
2783
  "mode": {
2779
2784
  "enum": [
2780
2785
  "development",
2781
- "release"
2786
+ "release",
2787
+ "process"
2782
2788
  ]
2783
2789
  },
2784
2790
  "devClientScheme": {
@@ -5224,6 +5230,12 @@
5224
5230
  "rubyVersion": {
5225
5231
  "type": "string",
5226
5232
  "pattern": "^(?!\\.{1,2}$)[^/\\\\\\u0000\\r\\n]+$"
5233
+ },
5234
+ "native": {
5235
+ "enum": [
5236
+ "xcode",
5237
+ "gradle"
5238
+ ]
5227
5239
  }
5228
5240
  }
5229
5241
  }
@@ -5280,7 +5292,8 @@
5280
5292
  "enum": [
5281
5293
  "file",
5282
5294
  "exec",
5283
- "link"
5295
+ "link",
5296
+ "directory"
5284
5297
  ]
5285
5298
  },
5286
5299
  "size": {
@@ -5321,6 +5334,38 @@
5321
5334
  },
5322
5335
  "params": {
5323
5336
  "type": "object",
5337
+ "if": {
5338
+ "required": [
5339
+ "native"
5340
+ ],
5341
+ "properties": {
5342
+ "native": {
5343
+ "properties": {
5344
+ "provider": {
5345
+ "const": "gradle"
5346
+ }
5347
+ }
5348
+ }
5349
+ }
5350
+ },
5351
+ "then": {
5352
+ "properties": {
5353
+ "platform": {
5354
+ "const": "android"
5355
+ },
5356
+ "fingerprint": {
5357
+ "type": "null"
5358
+ }
5359
+ }
5360
+ },
5361
+ "else": {
5362
+ "properties": {
5363
+ "fingerprint": {
5364
+ "type": "string",
5365
+ "minLength": 1
5366
+ }
5367
+ }
5368
+ },
5324
5369
  "required": [
5325
5370
  "repo",
5326
5371
  "project",
@@ -5398,9 +5443,97 @@
5398
5443
  "minLength": 1
5399
5444
  },
5400
5445
  "fingerprint": {
5401
- "type": "string",
5446
+ "type": [
5447
+ "string",
5448
+ "null"
5449
+ ],
5402
5450
  "minLength": 1
5403
5451
  },
5452
+ "native": {
5453
+ "oneOf": [
5454
+ {
5455
+ "type": "object",
5456
+ "required": [
5457
+ "provider",
5458
+ "sourceDigest",
5459
+ "cacheKey",
5460
+ "arch",
5461
+ "parameters"
5462
+ ],
5463
+ "additionalProperties": false,
5464
+ "properties": {
5465
+ "provider": {
5466
+ "const": "xcode"
5467
+ },
5468
+ "sourceDigest": {
5469
+ "type": "string",
5470
+ "pattern": "^[0-9a-f]{64}$"
5471
+ },
5472
+ "cacheKey": {
5473
+ "type": "string",
5474
+ "minLength": 1
5475
+ },
5476
+ "arch": {
5477
+ "enum": [
5478
+ "arm64",
5479
+ "x86_64",
5480
+ null
5481
+ ]
5482
+ },
5483
+ "parameters": {
5484
+ "type": "object",
5485
+ "description": "The non-file parameters of the client artifact identity, to name what differs."
5486
+ }
5487
+ }
5488
+ },
5489
+ {
5490
+ "type": "object",
5491
+ "required": [
5492
+ "provider",
5493
+ "sourceDigest",
5494
+ "inputs"
5495
+ ],
5496
+ "additionalProperties": false,
5497
+ "properties": {
5498
+ "provider": {
5499
+ "const": "gradle"
5500
+ },
5501
+ "sourceDigest": {
5502
+ "type": "string",
5503
+ "pattern": "^[0-9a-f]{64}$"
5504
+ },
5505
+ "inputs": {
5506
+ "type": "object",
5507
+ "required": [
5508
+ "complete",
5509
+ "ignored",
5510
+ "outputs"
5511
+ ],
5512
+ "additionalProperties": false,
5513
+ "properties": {
5514
+ "complete": {
5515
+ "const": true
5516
+ },
5517
+ "ignored": {
5518
+ "type": "array",
5519
+ "items": {
5520
+ "type": "string",
5521
+ "minLength": 1
5522
+ }
5523
+ },
5524
+ "outputs": {
5525
+ "type": "array",
5526
+ "items": {
5527
+ "type": "string",
5528
+ "minLength": 1
5529
+ }
5530
+ }
5531
+ }
5532
+ }
5533
+ }
5534
+ }
5535
+ ]
5536
+ },
5404
5537
  "packageName": {
5405
5538
  "type": [
5406
5539
  "string",
@@ -66,6 +66,15 @@ private struct SkinNode {
66
66
 
67
67
  @MainActor
68
68
  enum EmulatorFrameArtwork {
69
+ private static var cache: [String: DeviceFrameArtwork] = [:]
70
+
71
+ static func cached(avdName: String) -> DeviceFrameArtwork? {
72
+ if let hit = cache[avdName] { return hit }
73
+ let artwork = load(avdName: avdName)
74
+ if let artwork { cache[avdName] = artwork }
75
+ return artwork
76
+ }
77
+
69
78
  static func load(avdName: String) -> DeviceFrameArtwork? {
70
79
  let environment = ProcessInfo.processInfo.environment
71
80
  let home = FileManager.default.homeDirectoryForCurrentUser
@@ -66,7 +66,10 @@ public enum CoreSimulator {
66
66
  }
67
67
 
68
68
  static func simulatorKitPath(_ developerDir: String) -> String {
69
- "\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit"
69
+ let shared = "\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit"
70
+ let legacy = "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"
71
+ return FileManager.default.fileExists(atPath: shared) || !FileManager.default.fileExists(atPath: legacy)
72
+ ? shared : legacy
70
73
  }
71
74
 
72
75
  static let deviceSet: NSObject? = {
@@ -7,6 +7,15 @@ import AppKit
7
7
 
8
8
  @MainActor
9
9
  enum SimulatorFrameArtwork {
10
+ private static var cache: [String: DeviceFrameArtwork] = [:]
11
+
12
+ static func cached(udid: String) -> DeviceFrameArtwork? {
13
+ if let hit = cache[udid] { return hit }
14
+ let artwork = load(udid: udid)
15
+ if let artwork { cache[udid] = artwork }
16
+ return artwork
17
+ }
18
+
10
19
  static func load(udid: String) -> DeviceFrameArtwork? {
11
20
  guard let resources = resources(udid: udid) else { return nil }
12
21
  let profile = plist(resources.appendingPathComponent("profile.plist"))
@@ -1,4 +1,4 @@
1
- import { n as protocol_exports } from "./protocol-qo5TTavl.mjs";
1
+ import { n as protocol_exports } from "./protocol-CjUChTWJ.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -187,6 +187,13 @@ const MAX_CLIENT_NAME = 64;
187
187
  function validClientName(name) {
188
188
  return name.length <= MAX_CLIENT_NAME && !/[\p{Cc}\p{Cf}]/u.test(name);
189
189
  }
190
+ /**
191
+ * A phone sends its OS device name, which the person cannot edit in the app, so pairing cleans it up instead of
192
+ * refusing it. U+200D stays because emoji sequences join with it.
193
+ */
194
+ function pairingName(name) {
195
+ return name.replace(/(?![\u200d\s])[\p{Cc}\p{Cf}]/gu, "").replace(/\s+/g, " ").trim().slice(0, MAX_CLIENT_NAME).replace(/[\ud800-\udbff]$/, "").trim() || "Phone";
196
+ }
190
197
  function newDeviceId() {
191
198
  const taken = new Set([
192
199
  ...readDevices(),
@@ -331,7 +338,7 @@ function spendPairingToken(token, name, identity, now = Date.now()) {
331
338
  const at = new Date(now).toISOString();
332
339
  const device = {
333
340
  id: newDeviceId(),
334
- name,
341
+ name: pairingName(name),
335
342
  tokenHash: hashToken(deviceToken),
336
343
  identity,
337
344
  pairedAt: at,
@@ -659,6 +666,7 @@ const DEFAULT_PORT = 7787;
659
666
  const THROTTLE_SECONDS = 30;
660
667
  const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
661
668
  const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
669
+ const JOB_KEYS = ["STIM_HOME", "SHELL"];
662
670
  function validateLabel(label) {
663
671
  return LABEL_PATTERN.test(label) ? null : `--label must be letters, digits, ".", "_" or "-", got "${label}".`;
664
672
  }
@@ -679,13 +687,17 @@ const illegal = (value) => [...value].some((char) => {
679
687
  const code = char.codePointAt(0);
680
688
  return code < 32 && code !== 9 && code !== 10 && code !== 13;
681
689
  });
682
- /** Returns the first problem in the `--env` and `--path-prepend` values, or null when both are usable. */
683
- function validateServeEnvironment(env, pathPrepend) {
690
+ /**
691
+ * Returns the first problem in the `--env` and `--path-prepend` values, or null when both are usable. `serving` also
692
+ * accepts a bare `KEY`, which the serving command reads from its own environment, and tolerates `STIM_HOME` and
693
+ * `SHELL`, which a plist written by an earlier release can carry and `applyServeEnvironment` ignores.
694
+ */
695
+ function validateServeEnvironment(env, pathPrepend, serving = false) {
684
696
  if ([...env, ...pathPrepend].some(illegal)) return "--env and --path-prepend values cannot contain control characters.";
685
697
  for (const entry of env) {
686
- const parsed = parseEnvAssignment(entry);
698
+ const parsed = serving && ENV_KEY_PATTERN.test(entry) ? { key: entry } : parseEnvAssignment(entry);
687
699
  if (typeof parsed === "string") return parsed;
688
- if (parsed.key === "STIM_HOME") return "--env cannot set STIM_HOME; start stim-server with STIM_HOME set instead.";
700
+ if (!serving && JOB_KEYS.includes(parsed.key)) return `--env cannot set ${parsed.key}; start stim-server with ${parsed.key} set instead.`;
689
701
  }
690
702
  for (const dir of pathPrepend) if (!isAbsolute(dir) || dir.includes(delimiter)) return `--path-prepend takes one absolute directory, got "${dir}".`;
691
703
  return null;
@@ -693,22 +705,43 @@ function validateServeEnvironment(env, pathPrepend) {
693
705
  /**
694
706
  * The environment stim-server runs with: the login shell's, then each `--env` entry, then the `--path-prepend`
695
707
  * directories in front of `PATH`, first directory first. Both flags apply after the login shell's environment
696
- * replaces the process environment, so they survive it.
708
+ * replaces the process environment, so they survive it. A bare `KEY` entry takes its value from `source`, the
709
+ * environment launchd gave the process; one that `source` lacks is skipped with a line on stderr. `STIM_HOME` and
710
+ * `SHELL` entries are skipped the same way: the job takes them from the installing process.
697
711
  */
698
- function applyServeEnvironment(base, env, pathPrepend) {
712
+ function applyServeEnvironment(base, env, pathPrepend, source = process.env) {
699
713
  const result = { ...base };
700
714
  for (const entry of env) {
701
715
  const parsed = parseEnvAssignment(entry);
702
- if (typeof parsed !== "string") result[parsed.key] = parsed.value;
716
+ const name = typeof parsed === "string" ? entry : parsed.key;
717
+ if (JOB_KEYS.includes(name)) console.error(`stim-server: ignoring --env ${name}; the service takes it from the process that installed it.`);
718
+ else if (typeof parsed !== "string") result[name] = parsed.value;
719
+ else if (ENV_KEY_PATTERN.test(entry)) {
720
+ const value = source[entry];
721
+ if (value === void 0) console.error(`stim-server: --env ${entry} has no value in the service environment; skipping it.`);
722
+ else result[entry] = value;
723
+ }
703
724
  }
704
725
  if (pathPrepend.length) result.PATH = [...pathPrepend, ...result.PATH ? [result.PATH] : []].join(delimiter);
705
726
  return result;
706
727
  }
728
+ function environmentValues(env) {
729
+ const values = /* @__PURE__ */ new Map();
730
+ for (const entry of env) {
731
+ const parsed = parseEnvAssignment(entry);
732
+ if (typeof parsed !== "string" && !JOB_KEYS.includes(parsed.key)) values.set(parsed.key, parsed.value);
733
+ }
734
+ return values;
735
+ }
736
+ /** `env` as the plist stores it: one `KEY=VALUE` per key, the last value winning, without the keys the job supplies. */
737
+ function normalizeEnv(env) {
738
+ return [...environmentValues(env)].map(([name, value]) => `${name}=${value}`);
739
+ }
707
740
  function serverArguments(spec) {
708
741
  return [
709
742
  "--port",
710
743
  String(spec.port),
711
- ...spec.env.flatMap((entry) => ["--env", entry]),
744
+ ...[...environmentValues(spec.env).keys()].flatMap((name) => ["--env", name]),
712
745
  ...spec.pathPrepend.flatMap((dir) => ["--path-prepend", dir])
713
746
  ];
714
747
  }
@@ -736,8 +769,9 @@ function renderPlist(spec) {
736
769
  ].map((argument) => string(argument, " ")),
737
770
  " </array>"
738
771
  ];
739
- const environment = Object.entries(spec.environment);
740
- if (environment.length) {
772
+ const environment = new Map(Object.entries(spec.environment));
773
+ for (const [name, value] of environmentValues(spec.env)) environment.set(name, value);
774
+ if (environment.size) {
741
775
  lines.push(key("EnvironmentVariables", " "), " <dict>");
742
776
  for (const [name, value] of environment) lines.push(key(name, " "), string(value, " "));
743
777
  lines.push(" </dict>");
@@ -758,6 +792,7 @@ function parseInstalledPlist(value) {
758
792
  const programArguments = strings(value.ProgramArguments);
759
793
  const host = programArguments[1] === "run" && programArguments[0]?.endsWith("/Contents/MacOS/stim-host") ? programArguments[0] : null;
760
794
  const [node = null, script = null, ...args] = host ? programArguments.slice(2) : programArguments;
795
+ const environment = isJsonObject(value.EnvironmentVariables) ? value.EnvironmentVariables : {};
761
796
  const env = [];
762
797
  const pathPrepend = [];
763
798
  let port = null;
@@ -766,10 +801,13 @@ function parseInstalledPlist(value) {
766
801
  const next = args[index + 1];
767
802
  if (next === void 0) break;
768
803
  if (flag === "--port") port = Number(next);
769
- if (flag === "--env") env.push(next);
804
+ if (flag === "--env") {
805
+ const stored = environment[next];
806
+ if (next.includes("=")) env.push(next);
807
+ else if (typeof stored === "string") env.push(`${next}=${stored}`);
808
+ }
770
809
  if (flag === "--path-prepend") pathPrepend.push(next);
771
810
  }
772
- const environment = isJsonObject(value.EnvironmentVariables) ? value.EnvironmentVariables : {};
773
811
  const meta = isJsonObject(value.StimService) ? value.StimService : {};
774
812
  const servePort = meta.ServePort;
775
813
  return {
@@ -1582,7 +1620,7 @@ async function installJob(options, setup) {
1582
1620
  };
1583
1621
  const path = plistPath(options.label);
1584
1622
  const tailscale = findTailscale(process.env) ?? "tailscale";
1585
- if (setup && previous?.script === script && previous.port === options.port && previous.host === spec.host && JSON.stringify(previous.env) === JSON.stringify(spec.env) && JSON.stringify(previous.pathPrepend) === JSON.stringify(spec.pathPrepend)) {
1623
+ if (setup && previous?.script === script && previous.port === options.port && previous.host === spec.host && JSON.stringify(normalizeEnv(previous.env)) === JSON.stringify(normalizeEnv(spec.env)) && JSON.stringify(previous.pathPrepend) === JSON.stringify(spec.pathPrepend)) {
1586
1624
  const health = await fetchHealth(options.port);
1587
1625
  if (health && health.startup?.state !== "pending" && health.startup?.state !== "degraded") return [`${options.label} already runs stim-server ${health.version}.`];
1588
1626
  }
@@ -2521,7 +2559,8 @@ function validBuildPath(path) {
2521
2559
  }
2522
2560
  function validFile(file) {
2523
2561
  if (!isJsonObject(file) || !validBuildPath(file.path)) return false;
2524
- if (file.kind !== "file" && file.kind !== "exec" && file.kind !== "link") return false;
2562
+ if (file.kind !== "file" && file.kind !== "exec" && file.kind !== "link" && file.kind !== "directory") return false;
2563
+ if (file.kind === "directory" && (file.size !== 0 || file.sha256 !== createHash("sha256").update("").digest("hex"))) return false;
2525
2564
  const size = file.size;
2526
2565
  if (typeof size !== "number" || !Number.isInteger(size) || size < 0 || size > MAX_FILE_BYTES) return false;
2527
2566
  return typeof file.sha256 === "string" && /^[0-9a-f]{64}$/.test(file.sha256);
@@ -2530,21 +2569,27 @@ const validRepo = (repo) => typeof repo === "string" && new RegExp(protocol_expo
2530
2569
  const gb = (bytes) => (bytes / 1024 ** 3).toFixed(1);
2531
2570
  const optional = (value) => typeof value === "string" ? value : null;
2532
2571
  const GRADLE_NAME = /^[A-Za-z0-9_-]{1,100}$/;
2572
+ const GRADLE_ROOT = /^[A-Za-z0-9._/ -]{1,200}$/;
2573
+ const GRADLE_MODULE = /^(:(?!\.\.?(?::|$))[A-Za-z0-9._-]+){1,20}$/;
2533
2574
  /** The Gradle choices of an Android `build.start`, or null when they are malformed. */
2534
2575
  function androidOptions(value) {
2535
2576
  if (!isJsonObject(value)) return null;
2536
- const { variant, abi, gradleBuildCache, pch, compilerCache } = value;
2577
+ const { variant, abi, gradleBuildCache, pch, compilerCache, gradleRoot = null, module = null } = value;
2537
2578
  if (variant !== null && !(typeof variant === "string" && GRADLE_NAME.test(variant))) return null;
2538
2579
  if (abi !== null && !(typeof abi === "string" && GRADLE_NAME.test(abi))) return null;
2539
2580
  if (typeof gradleBuildCache !== "boolean") return null;
2540
2581
  if (pch !== "auto" && pch !== "on" && pch !== "off") return null;
2541
2582
  if (compilerCache !== "ccache" && compilerCache !== "none") return null;
2583
+ if (gradleRoot !== null && !(typeof gradleRoot === "string" && GRADLE_ROOT.test(gradleRoot))) return null;
2584
+ if (module !== null && !(typeof module === "string" && GRADLE_MODULE.test(module))) return null;
2542
2585
  return {
2543
2586
  variant,
2544
2587
  abi,
2545
2588
  gradleBuildCache,
2546
2589
  pch,
2547
- compilerCache
2590
+ compilerCache,
2591
+ gradleRoot,
2592
+ module
2548
2593
  };
2549
2594
  }
2550
2595
  function freeBytes(path) {
@@ -2649,17 +2694,15 @@ var BuildHost = class {
2649
2694
  clientDir(client) {
2650
2695
  return join(this.root(), client);
2651
2696
  }
2652
- toolchain(rubyVersion = null) {
2697
+ toolchain(rubyVersion = null, native) {
2698
+ const key = JSON.stringify([native ?? null, native ? null : rubyVersion]);
2653
2699
  const now = Date.now();
2654
2700
  for (const [context, entry] of this.toolchains) if (now - entry.at >= this.limits.toolchainTtlMs) this.toolchains.delete(context);
2655
- const cached = this.toolchains.get(rubyVersion);
2701
+ const cached = this.toolchains.get(key);
2656
2702
  if (cached) return cached.value;
2657
2703
  const value = new Promise((resolve) => {
2658
- const child = spawn(process.execPath, [
2659
- this.options.worker,
2660
- "offer",
2661
- ...rubyVersion ? [rubyVersion] : []
2662
- ], {
2704
+ const args = native ? [`offer-native-${native}`] : ["offer", ...rubyVersion ? [rubyVersion] : []];
2705
+ const child = spawn(process.execPath, [this.options.worker, ...args], {
2663
2706
  env: this.options.env,
2664
2707
  stdio: [
2665
2708
  "ignore",
@@ -2682,19 +2725,20 @@ var BuildHost = class {
2682
2725
  }
2683
2726
  });
2684
2727
  });
2685
- this.toolchains.set(rubyVersion, {
2728
+ this.toolchains.set(key, {
2686
2729
  at: now,
2687
2730
  value
2688
2731
  });
2689
2732
  value.then((result) => {
2690
- if (!result && this.toolchains.get(rubyVersion)?.value === value) this.toolchains.delete(rubyVersion);
2733
+ if (!result && this.toolchains.get(key)?.value === value) this.toolchains.delete(key);
2691
2734
  });
2692
2735
  return value;
2693
2736
  }
2694
2737
  async offer(client, params) {
2695
2738
  if (!isJsonObject(params) || !validRepo(params.repo)) return refusal("bad-request", "build.offer needs params.repo.");
2696
2739
  if (params.rubyVersion !== void 0 && (typeof params.rubyVersion !== "string" || !new RegExp(protocol_exports.BUILD_RUBY_VERSION_PATTERN).test(params.rubyVersion))) return refusal("bad-request", "build.offer needs a Ruby installation name without path separators.");
2697
- const toolchain = await this.toolchain(params.rubyVersion ?? null);
2740
+ if (params.native !== void 0 && params.native !== "xcode" && params.native !== "gradle") return refusal("bad-request", "build.offer supports native Xcode or Gradle toolchains.");
2741
+ const toolchain = await this.toolchain(params.rubyVersion ?? null, params.native);
2698
2742
  if (!toolchain) return refusal("build-refused", "This Mac could not read its build toolchain.");
2699
2743
  const root = this.root();
2700
2744
  mkdirSync(root, {
@@ -2920,7 +2964,7 @@ var BuildHost = class {
2920
2964
  job: id,
2921
2965
  record: value.record
2922
2966
  });
2923
- else if (value.type === "result") entry.outcome = workerOutcome(value);
2967
+ else if (value.type === "result") entry.outcome = workerOutcome(value, isJsonObject(job.native) && job.native.provider === "gradle" ? "gradle" : void 0);
2924
2968
  });
2925
2969
  this.jobs.add(entry);
2926
2970
  this.owned.set(id, entry);
@@ -3079,18 +3123,20 @@ var BuildHost = class {
3079
3123
  await this.sweeping;
3080
3124
  }
3081
3125
  };
3082
- function workerOutcome(value) {
3126
+ function workerOutcome(value, native) {
3083
3127
  const timings = isJsonObject(value.timings) ? value.timings : {};
3084
3128
  if (value.ok === true && isJsonObject(value.artifact)) {
3085
3129
  const { name, size, sha256 } = value.artifact;
3086
- if (typeof name === "string" && typeof size === "number" && typeof sha256 === "string") return {
3130
+ if (typeof name === "string" && typeof size === "number" && typeof sha256 === "string" && (native !== "gradle" || value.fingerprint === null && typeof value.androidPackage === "string" && /^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+$/.test(value.androidPackage))) return {
3087
3131
  ok: true,
3088
3132
  artifact: {
3089
3133
  name,
3090
3134
  size,
3091
3135
  sha256
3092
3136
  },
3093
- fingerprint: String(value.fingerprint),
3137
+ fingerprint: native === "gradle" ? null : String(value.fingerprint),
3138
+ ...native === "gradle" ? { androidPackage: value.androidPackage } : {},
3139
+ ...typeof value.sourceDigest === "string" ? { sourceDigest: value.sourceDigest } : {},
3094
3140
  compilationCache: isJsonObject(value.compilationCache) ? value.compilationCache : {},
3095
3141
  timings
3096
3142
  };
@@ -3191,11 +3237,34 @@ var BuildSession = class {
3191
3237
  }
3192
3238
  async start(params) {
3193
3239
  if (!isJsonObject(params) || !validRepo(params.repo)) return refusal("bad-request", "build.start needs params.repo.");
3194
- const strings = ["fingerprint", "stimBuild"];
3240
+ const strings = ["stimBuild"];
3195
3241
  if (strings.some((key) => typeof params[key] !== "string" || !params[key])) return refusal("bad-request", `build.start needs params.${strings.join(", params.")}.`);
3196
3242
  const platform = params.platform;
3197
3243
  if (platform !== "ios" && platform !== "android" && platform !== "macos") return refusal("bad-request", "params.platform must be ios, android or macos.");
3198
3244
  if (platform === "ios" && (typeof params.runtime !== "string" || !params.runtime)) return refusal("bad-request", "An ios build needs params.runtime.");
3245
+ let native;
3246
+ if (params.native !== void 0) {
3247
+ const input = params.native;
3248
+ if (!isJsonObject(input) || typeof input.sourceDigest !== "string" || !/^[0-9a-f]{64}$/.test(input.sourceDigest)) return refusal("bad-request", "A native build needs its source transfer digest.");
3249
+ if (input.provider === "gradle") {
3250
+ const inputs = input.inputs;
3251
+ if (platform !== "android" || params.fingerprint !== null || !isJsonObject(inputs) || inputs.complete !== true || Object.keys(inputs).some((key) => ![
3252
+ "complete",
3253
+ "ignored",
3254
+ "outputs"
3255
+ ].includes(key)) || !Array.isArray(inputs.ignored) || !inputs.ignored.every(validBuildPath) || !Array.isArray(inputs.outputs) || !inputs.outputs.every(validBuildPath)) return refusal("bad-request", "A native Gradle build needs declared complete inputs and a null artifact fingerprint.");
3256
+ } else {
3257
+ const optimizations = params.optimizations;
3258
+ if (platform !== "ios" || input.provider !== "xcode" || typeof input.cacheKey !== "string" || !input.cacheKey || ![
3259
+ null,
3260
+ "arm64",
3261
+ "x86_64"
3262
+ ].includes(input.arch) || !isJsonObject(input.parameters) || !isJsonObject(optimizations) || typeof optimizations.compilationCache !== "boolean" || typeof optimizations.prefixMapping !== "boolean" || optimizations.swiftCompilationCache !== null && typeof optimizations.swiftCompilationCache !== "boolean") return refusal("bad-request", "A native Xcode build needs its source digest, cache key, architecture and compiler options.");
3263
+ }
3264
+ native = input;
3265
+ }
3266
+ if (native?.provider !== "gradle" && (typeof params.fingerprint !== "string" || !params.fingerprint)) return refusal("bad-request", "build.start needs params.fingerprint.");
3267
+ if (!native && [...this.files.values()].some((file) => file.kind === "directory")) return refusal("bad-request", "Directory entries require a native build request.");
3199
3268
  const android = platform === "android" ? androidOptions(params.android) : null;
3200
3269
  if (platform === "android" && !android) return refusal("bad-request", "An android build needs params.android: variant and abi (string or null), gradleBuildCache, pch and compilerCache.");
3201
3270
  let macos = null;
@@ -3215,7 +3284,7 @@ var BuildSession = class {
3215
3284
  if (!this.done || this.repo !== params.repo) return refusal("bad-request", "Send the whole manifest of this repo with build.sync first.");
3216
3285
  if (this.incoming || [...this.files.values()].some((file) => this.expected.has(file.sha256))) return refusal("bad-request", "Some files of the manifest were not uploaded.");
3217
3286
  for (const file of this.files.values()) if (!existsSync(this.blobPath(file.sha256))) return refusal("bad-request", `The blob of ${file.path} is missing; sync again.`);
3218
- const toolchain = await this.host.toolchain();
3287
+ const toolchain = await this.host.toolchain(null, native?.provider);
3219
3288
  if (this.closed) return refusal("build-refused", "The connection closed.");
3220
3289
  if (!toolchain || toolchain.stimBuild !== params.stimBuild) return refusal("build-refused", `This Mac runs Stim build ${toolchain?.stimBuild ?? "unknown"}.`);
3221
3290
  const launched = this.host.launch({
@@ -3226,6 +3295,7 @@ var BuildSession = class {
3226
3295
  job: {
3227
3296
  manifest: [...this.files.values()],
3228
3297
  platform,
3298
+ ...native ? { native } : {},
3229
3299
  android,
3230
3300
  macos,
3231
3301
  project: params.project,
@@ -3233,6 +3303,7 @@ var BuildSession = class {
3233
3303
  isExpo: params.isExpo === true,
3234
3304
  configuration: optional(params.configuration),
3235
3305
  scheme: optional(params.scheme),
3306
+ iosProjectPath: optional(params.iosProjectPath),
3236
3307
  runtime: optional(params.runtime),
3237
3308
  expectedFingerprint: params.fingerprint,
3238
3309
  optimizations: isJsonObject(params.optimizations) ? params.optimizations : null
@@ -6535,9 +6606,11 @@ var DeviceHost = class {
6535
6606
  reconciling;
6536
6607
  draining = null;
6537
6608
  limits;
6609
+ debug;
6538
6610
  options;
6539
6611
  constructor(options) {
6540
6612
  this.options = options;
6613
+ this.debug = createDebugLog("server", { env: options.env });
6541
6614
  this.limits = {
6542
6615
  prepareMs: 3e5,
6543
6616
  offerMs: 3e4,
@@ -7098,12 +7171,13 @@ var DeviceHost = class {
7098
7171
  appOffer(client, params) {
7099
7172
  if (!this.options.allowed(client)) return refused$1("forbidden", "Current device-host approval is required.");
7100
7173
  const offer = parseHostedAppOffer(params);
7101
- if (!offer) return refused$1("bad-request", "App offers need a bounded normalized bundle manifest, bundleId, development or release mode and, optionally, at most 32 arguments of 1024 characters (8192 in total) without NUL or line breaks.");
7174
+ if (!offer) return refused$1("bad-request", "App offers need a bounded normalized bundle manifest, bundleId, development, release or process mode and, optionally, at most 32 arguments of 1024 characters (8192 in total) without NUL or line breaks.");
7102
7175
  try {
7103
7176
  const record = this.appSession(client, offer);
7177
+ if (offer.mode === "process" && record.platform === "macos") throw new Error("Process-mode app offers require a hosted iOS or Android session.");
7104
7178
  if (record.platform !== "macos" && offer.arguments !== void 0) throw new Error("App arguments are supported only for hosted macOS sessions.");
7105
7179
  if (record.platform === "macos") {
7106
- if (offer.mode === "development" || offer.devClientScheme !== void 0) throw new Error("Hosted macOS apps require release mode without a development client scheme.");
7180
+ if (offer.mode !== "release" || offer.devClientScheme !== void 0) throw new Error("Hosted macOS apps require release mode without a development client scheme.");
7107
7181
  if (offer.bundleId.startsWith("com.apple.")) throw new Error("Hosted macOS apps cannot use a com.apple. bundle identity.");
7108
7182
  if (hostedMacosBundleId(offer.bundleId, record.appSlot).length + 6 > 255) throw new Error("The hosted macOS bundle identity exceeds 249 characters for its preferences plist.");
7109
7183
  }
@@ -7687,6 +7761,15 @@ var DeviceHost = class {
7687
7761
  if (options.claim) clearClaimChild(options.claim);
7688
7762
  throw error;
7689
7763
  }
7764
+ const started = performance.now();
7765
+ const mode = "mode" in options.input && typeof options.input.mode === "string" ? options.input.mode : void 0;
7766
+ const diagnostic = (event, fields = {}) => this.debug.log(`device-host.worker.${event}`, {
7767
+ mode,
7768
+ workerPid: child.pid,
7769
+ ms: Math.round(performance.now() - started),
7770
+ ...fields
7771
+ });
7772
+ diagnostic("start", { timeoutMs: options.timeoutMs });
7690
7773
  let identity = null;
7691
7774
  const output = [];
7692
7775
  let outputBytes = 0;
@@ -7709,7 +7792,14 @@ var DeviceHost = class {
7709
7792
  clearTimeout(killTimer);
7710
7793
  clearTimeout(finishTimer);
7711
7794
  clearTimeout(groupTimer);
7712
- const settled = closed && (!child.pid || !processGroupAlive(child.pid));
7795
+ const groupAlive = closed && child.pid ? processGroupAlive(child.pid) : null;
7796
+ const settled = closed && groupAlive !== true;
7797
+ diagnostic("settled", {
7798
+ closed,
7799
+ groupAlive,
7800
+ settled,
7801
+ cancelling
7802
+ });
7713
7803
  if (settled && options.claim) clearClaimChild(options.claim);
7714
7804
  let value = null;
7715
7805
  try {
@@ -7737,9 +7827,10 @@ var DeviceHost = class {
7737
7827
  process.kill(-child.pid, name);
7738
7828
  } catch {}
7739
7829
  };
7740
- const cancel = () => {
7830
+ const cancel = (reason = "requested") => {
7741
7831
  if (finished || cancelling) return;
7742
7832
  cancelling = true;
7833
+ diagnostic("cancel", { reason });
7743
7834
  notice ??= "Hosted worker was cancelled or exceeded its deadline.";
7744
7835
  signal("SIGTERM");
7745
7836
  killTimer = setTimeout(() => signal("SIGKILL"), this.limits.killGraceMs);
@@ -7749,15 +7840,15 @@ var DeviceHost = class {
7749
7840
  if (finished) return;
7750
7841
  if (!child.pid || !processGroupAlive(child.pid)) finish();
7751
7842
  else {
7752
- cancel();
7843
+ cancel("group-after-close");
7753
7844
  groupTimer = setTimeout(finishGroup, 25);
7754
7845
  }
7755
7846
  };
7756
- const timer = setTimeout(cancel, options.timeoutMs);
7847
+ const timer = setTimeout(() => cancel("deadline"), options.timeoutMs);
7757
7848
  child.stdout?.on("data", (chunk) => {
7758
7849
  if (outputBytes + chunk.length > options.maxOutputBytes) {
7759
7850
  notice = "Hosted worker output exceeded its bound.";
7760
- cancel();
7851
+ cancel("output-bound");
7761
7852
  } else {
7762
7853
  output.push(chunk);
7763
7854
  outputBytes += chunk.length;
@@ -7766,13 +7857,18 @@ var DeviceHost = class {
7766
7857
  child.stderr?.on("data", (chunk) => {
7767
7858
  stderr = (stderr + chunk.toString()).slice(-8192);
7768
7859
  });
7769
- child.stdin?.on("error", () => cancel());
7860
+ child.stdin?.on("error", () => cancel("stdin-error"));
7770
7861
  child.once("error", (error) => {
7862
+ diagnostic("error", { code: error.code });
7771
7863
  notice = error.message;
7772
7864
  closed = true;
7773
7865
  finishGroup();
7774
7866
  });
7775
- child.once("close", (code) => {
7867
+ child.once("close", (code, closeSignal) => {
7868
+ diagnostic("close", {
7869
+ code,
7870
+ signal: closeSignal
7871
+ });
7776
7872
  if (code !== 0) notice ??= stderr.trim() || `Hosted worker exited ${code}.`;
7777
7873
  closed = true;
7778
7874
  finishGroup();
@@ -7781,7 +7877,7 @@ var DeviceHost = class {
7781
7877
  if (!captured?.ok || child.pid === void 0) {
7782
7878
  notice = "The worker process identity could not be captured; no native request was sent.";
7783
7879
  child.kill("SIGKILL");
7784
- cancel();
7880
+ cancel("identity-unavailable");
7785
7881
  } else {
7786
7882
  identity = {
7787
7883
  pid: child.pid,
@@ -7792,7 +7888,7 @@ var DeviceHost = class {
7792
7888
  child.stdin?.end(JSON.stringify(options.input));
7793
7889
  } catch (error) {
7794
7890
  notice = error.message;
7795
- cancel();
7891
+ cancel("request-write");
7796
7892
  }
7797
7893
  }
7798
7894
  return {
@@ -11515,16 +11611,28 @@ const INTERVAL_MS = 15e3;
11515
11611
  const POINTS = 40;
11516
11612
  /**
11517
11613
  * Per-environment and per-device CPU and memory from the status payloads stim-server receives, in 15-second slots, the cadence at which `status --watch` rereads machine usage,
11518
- * over the last 10 minutes. A later payload in the same slot replaces the earlier one. An environment sums every
11614
+ * over the last 10 minutes. A later payload in the same slot replaces the earlier one. `status --watch` prints no line
11615
+ * while usage stays within a step, so the slots between two payloads repeat the readings of the series the earlier
11616
+ * payload held, and those series continue to now until a payload drops them. An environment sums every
11519
11617
  * machine owner of its workspace; a device is a simulator or emulator owner, keyed by its UDID or AVD name.
11520
11618
  */
11521
11619
  var UsageRecorder = class {
11522
11620
  environments = /* @__PURE__ */ new Map();
11523
11621
  devices = /* @__PURE__ */ new Map();
11622
+ lastSlot = 0;
11623
+ liveEnvironments = /* @__PURE__ */ new Set();
11624
+ liveDevices = /* @__PURE__ */ new Set();
11524
11625
  record(payload, at = Date.now()) {
11525
- const owners = payload?.machine?.owners;
11526
- if (!Array.isArray(owners)) return;
11527
11626
  const slot = Math.floor(at / INTERVAL_MS);
11627
+ const owners = payload?.machine?.owners;
11628
+ this.holdThrough(slot - 1);
11629
+ this.lastSlot = Math.max(this.lastSlot, slot);
11630
+ this.liveEnvironments = /* @__PURE__ */ new Set();
11631
+ this.liveDevices = /* @__PURE__ */ new Set();
11632
+ if (!Array.isArray(owners)) {
11633
+ this.prune(slot);
11634
+ return;
11635
+ }
11528
11636
  const sums = /* @__PURE__ */ new Map();
11529
11637
  for (const owner of owners) {
11530
11638
  const reading = {
@@ -11554,20 +11662,24 @@ var UsageRecorder = class {
11554
11662
  meta,
11555
11663
  slots: slots.set(slot, reading)
11556
11664
  });
11665
+ this.liveDevices.add(key);
11557
11666
  }
11558
11667
  }
11559
- for (const [workspace, reading] of sums) this.environments.set(workspace, (this.environments.get(workspace) ?? /* @__PURE__ */ new Map()).set(slot, reading));
11668
+ for (const [workspace, reading] of sums) {
11669
+ this.environments.set(workspace, (this.environments.get(workspace) ?? /* @__PURE__ */ new Map()).set(slot, reading));
11670
+ this.liveEnvironments.add(workspace);
11671
+ }
11560
11672
  this.prune(slot);
11561
11673
  }
11562
11674
  /** The last 10 minutes ending at `now`'s slot, or null when no series has a reading in them. */
11563
11675
  history(now = Date.now()) {
11564
11676
  const end = Math.floor(now / INTERVAL_MS);
11565
11677
  this.prune(end);
11566
- const series = (slots) => {
11678
+ const series = (slots, live) => {
11567
11679
  const cpuPercent = [];
11568
11680
  const memoryMb = [];
11569
11681
  for (let slot = end - POINTS + 1; slot <= end; slot++) {
11570
- const reading = slots.get(slot);
11682
+ const reading = slots.get(slot) ?? (live && slot > this.lastSlot ? slots.get(this.lastSlot) : void 0);
11571
11683
  cpuPercent.push(reading ? Math.round(reading.cpuPercent * 10) / 10 : null);
11572
11684
  memoryMb.push(reading ? Math.round(reading.memoryMb) : null);
11573
11685
  }
@@ -11576,8 +11688,8 @@ var UsageRecorder = class {
11576
11688
  memoryMb
11577
11689
  };
11578
11690
  };
11579
- const environments = [...this.environments].map(([workspace, slots]) => Object.assign({ workspace }, series(slots)));
11580
- const devices = [...this.devices.values()].map(({ meta, slots }) => Object.assign({}, meta, series(slots)));
11691
+ const environments = [...this.environments].map(([workspace, slots]) => Object.assign({ workspace }, series(slots, this.liveEnvironments.has(workspace))));
11692
+ const devices = [...this.devices].map(([key, { meta, slots }]) => Object.assign({}, meta, series(slots, this.liveDevices.has(key))));
11581
11693
  if (!environments.length && !devices.length) return null;
11582
11694
  return {
11583
11695
  intervalMs: INTERVAL_MS,
@@ -11586,13 +11698,27 @@ var UsageRecorder = class {
11586
11698
  devices
11587
11699
  };
11588
11700
  }
11701
+ /** Ends the live series where the last payload left them: nothing is recorded, or held, while nobody listens. */
11702
+ pause() {
11703
+ this.liveEnvironments = /* @__PURE__ */ new Set();
11704
+ this.liveDevices = /* @__PURE__ */ new Set();
11705
+ }
11706
+ holdThrough(slot) {
11707
+ const hold = (slots) => {
11708
+ const reading = slots?.get(this.lastSlot);
11709
+ if (!slots || !reading) return;
11710
+ for (let at = Math.max(this.lastSlot + 1, slot - POINTS + 1); at <= slot; at++) slots.set(at, reading);
11711
+ };
11712
+ for (const key of this.liveEnvironments) hold(this.environments.get(key));
11713
+ for (const key of this.liveDevices) hold(this.devices.get(key)?.slots);
11714
+ }
11589
11715
  prune(slot) {
11590
- const trim = (slots) => {
11591
- for (const at of slots.keys()) if (at <= slot - POINTS) slots.delete(at);
11716
+ const trim = (slots, live) => {
11717
+ for (const at of slots.keys()) if (at <= slot - POINTS && !(live && at === this.lastSlot)) slots.delete(at);
11592
11718
  return slots.size > 0;
11593
11719
  };
11594
- for (const [key, slots] of this.environments) if (!trim(slots)) this.environments.delete(key);
11595
- for (const [key, { slots }] of this.devices) if (!trim(slots)) this.devices.delete(key);
11720
+ for (const [key, slots] of this.environments) if (!trim(slots, this.liveEnvironments.has(key))) this.environments.delete(key);
11721
+ for (const [key, { slots }] of this.devices) if (!trim(slots, this.liveDevices.has(key))) this.devices.delete(key);
11596
11722
  }
11597
11723
  };
11598
11724
  //#endregion
@@ -12427,6 +12553,7 @@ var ServerUpdates = class {
12427
12553
  running = null;
12428
12554
  upload = null;
12429
12555
  idle = null;
12556
+ ended = null;
12430
12557
  constructor(options) {
12431
12558
  this.options = options;
12432
12559
  this.sweepIncoming();
@@ -12480,9 +12607,14 @@ var ServerUpdates = class {
12480
12607
  service: service ? label : null,
12481
12608
  acceptsClientBuilds: this.options.acceptsClientBuilds(),
12482
12609
  running: this.progress(),
12483
- last: service && label ? readLastUpdate(label) : null
12610
+ last: service && label ? this.latest(readLastUpdate(label)) : null
12484
12611
  };
12485
12612
  }
12613
+ latest(recorded) {
12614
+ if (!this.ended) return recorded;
12615
+ if (!recorded) return this.ended;
12616
+ return Date.parse(recorded.at) >= Date.parse(this.ended.at) ? recorded : this.ended;
12617
+ }
12486
12618
  async start(by, params) {
12487
12619
  const parsed = parseUpdateStart(params);
12488
12620
  if (typeof parsed === "string") return refused("bad-request", parsed);
@@ -12641,6 +12773,12 @@ var ServerUpdates = class {
12641
12773
  const settle = (ok, message) => {
12642
12774
  this.options.drain(null);
12643
12775
  this.clear();
12776
+ this.ended = {
12777
+ at: (/* @__PURE__ */ new Date()).toISOString(),
12778
+ target,
12779
+ ok,
12780
+ message
12781
+ };
12644
12782
  this.options.audit({
12645
12783
  by: running.by,
12646
12784
  target,
@@ -12653,7 +12791,11 @@ var ServerUpdates = class {
12653
12791
  child.once("exit", (code) => {
12654
12792
  const last = readLastUpdate(this.options.label);
12655
12793
  const fresh = last && Date.parse(last.at) >= Date.parse(running.startedAt);
12656
- settle(code === 0, fresh ? last.message : `The update exited with code ${String(code)}; see ${log}.`);
12794
+ let reason = "";
12795
+ try {
12796
+ reason = readFileSync(log, "utf8").split("\n").findLast((line) => line.trim() !== "") ?? "";
12797
+ } catch {}
12798
+ settle(code === 0, fresh ? last.message : `The update exited with code ${String(code)}${reason ? `: ${reason}` : ""}; see ${log}.`);
12657
12799
  });
12658
12800
  return null;
12659
12801
  }
@@ -13144,6 +13286,28 @@ function peerAddress(request) {
13144
13286
  const forwarded = request.headers["x-forwarded-for"];
13145
13287
  return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
13146
13288
  }
13289
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
13290
+ "127.0.0.1",
13291
+ "localhost",
13292
+ "[::1]"
13293
+ ]);
13294
+ /**
13295
+ * Whether a WebSocket upgrade comes from a web page. Browsers send `Sec-Fetch-Site` and the page's own `Origin`.
13296
+ * React Native derives `Origin` from the ws URL, so an app's `Origin` names the requested host: over `http:` on loopback,
13297
+ * and `https:` through `tailscale serve`. SocketRocket omits the brackets of an IPv6 origin, so an iOS app on
13298
+ * `ws://[::1]` is refused.
13299
+ */
13300
+ function upgradeFromWebPage(headers, local) {
13301
+ if (headers["sec-fetch-site"] !== void 0) return true;
13302
+ if (headers.origin === void 0) return false;
13303
+ const scheme = local ? "http:" : "https:";
13304
+ try {
13305
+ const own = new URL(`${scheme}//${headers.host ?? ""}`);
13306
+ return new URL(headers.origin).origin !== own.origin || local && !LOOPBACK_HOSTS.has(own.hostname);
13307
+ } catch {
13308
+ return true;
13309
+ }
13310
+ }
13147
13311
  var FailureLimiter = class {
13148
13312
  failures = /* @__PURE__ */ new Map();
13149
13313
  max;
@@ -13391,6 +13555,7 @@ async function startServer(options) {
13391
13555
  if (recorders > 0) return;
13392
13556
  stopRecording?.();
13393
13557
  stopRecording = null;
13558
+ usage.pause();
13394
13559
  };
13395
13560
  };
13396
13561
  const controllers = /* @__PURE__ */ new Map();
@@ -13750,8 +13915,7 @@ async function startServer(options) {
13750
13915
  server: {
13751
13916
  name: options.name,
13752
13917
  version: options.serverVersion,
13753
- stim: options.stimVersion,
13754
- home: homedir()
13918
+ stim: options.stimVersion
13755
13919
  },
13756
13920
  capabilities: [],
13757
13921
  features: [...protocol_exports.FEATURES],
@@ -15139,6 +15303,10 @@ async function startServer(options) {
15139
15303
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15140
15304
  return;
15141
15305
  }
15306
+ if (upgradeFromWebPage(request.headers, peer === null)) {
15307
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15308
+ return;
15309
+ }
15142
15310
  if (limiter.blocked(peer ?? "local")) {
15143
15311
  socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15144
15312
  return;
@@ -15736,7 +15904,7 @@ async function main() {
15736
15904
  const service = command === "service";
15737
15905
  if ((values.env || values["path-prepend"]) && command !== void 0 && !service) fail(`--env and --path-prepend apply only to serving and \`service install\`.\n${USAGE}`);
15738
15906
  if (values.env || values["path-prepend"]) {
15739
- const problem = validateServeEnvironment(extraEnv, pathPrepend);
15907
+ const problem = validateServeEnvironment(extraEnv, pathPrepend, command === void 0);
15740
15908
  if (problem) fail(problem);
15741
15909
  }
15742
15910
  if ((values.label !== void 0 || values.serve) && !service) fail(`--label and --serve apply only to \`service\`.\n${USAGE}`);
@@ -4,6 +4,6 @@ const encode = (version) => version.split(".", 3).reduce((sum, part) => sum * 1e
4
4
  if (encode(process.versions.node) < encode("22.12.0")) {
5
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
6
  process.exitCode = 1;
7
- } else import("./stim-server-BR9FPA63.mjs");
7
+ } else import("./stim-server-C5iKxjba.mjs");
8
8
  //#endregion
9
9
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/server",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "Serves Stim status to paired clients over Tailscale, and runs reload and stop for clients granted control.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,8 +28,8 @@
28
28
  "./protocol.schema.json": "./dist/protocol.schema.json"
29
29
  },
30
30
  "dependencies": {
31
- "@stim-cli/core": "^1.18.0",
32
- "stim": "1.18.0",
31
+ "@stim-cli/core": "^1.20.0",
32
+ "stim": "1.20.0",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {