@stim-cli/server 1.19.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
@@ -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
@@ -7100,12 +7171,13 @@ var DeviceHost = class {
7100
7171
  appOffer(client, params) {
7101
7172
  if (!this.options.allowed(client)) return refused$1("forbidden", "Current device-host approval is required.");
7102
7173
  const offer = parseHostedAppOffer(params);
7103
- 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.");
7104
7175
  try {
7105
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.");
7106
7178
  if (record.platform !== "macos" && offer.arguments !== void 0) throw new Error("App arguments are supported only for hosted macOS sessions.");
7107
7179
  if (record.platform === "macos") {
7108
- 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.");
7109
7181
  if (offer.bundleId.startsWith("com.apple.")) throw new Error("Hosted macOS apps cannot use a com.apple. bundle identity.");
7110
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.");
7111
7183
  }
@@ -11539,16 +11611,28 @@ const INTERVAL_MS = 15e3;
11539
11611
  const POINTS = 40;
11540
11612
  /**
11541
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,
11542
- * 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
11543
11617
  * machine owner of its workspace; a device is a simulator or emulator owner, keyed by its UDID or AVD name.
11544
11618
  */
11545
11619
  var UsageRecorder = class {
11546
11620
  environments = /* @__PURE__ */ new Map();
11547
11621
  devices = /* @__PURE__ */ new Map();
11622
+ lastSlot = 0;
11623
+ liveEnvironments = /* @__PURE__ */ new Set();
11624
+ liveDevices = /* @__PURE__ */ new Set();
11548
11625
  record(payload, at = Date.now()) {
11549
- const owners = payload?.machine?.owners;
11550
- if (!Array.isArray(owners)) return;
11551
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
+ }
11552
11636
  const sums = /* @__PURE__ */ new Map();
11553
11637
  for (const owner of owners) {
11554
11638
  const reading = {
@@ -11578,20 +11662,24 @@ var UsageRecorder = class {
11578
11662
  meta,
11579
11663
  slots: slots.set(slot, reading)
11580
11664
  });
11665
+ this.liveDevices.add(key);
11581
11666
  }
11582
11667
  }
11583
- 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
+ }
11584
11672
  this.prune(slot);
11585
11673
  }
11586
11674
  /** The last 10 minutes ending at `now`'s slot, or null when no series has a reading in them. */
11587
11675
  history(now = Date.now()) {
11588
11676
  const end = Math.floor(now / INTERVAL_MS);
11589
11677
  this.prune(end);
11590
- const series = (slots) => {
11678
+ const series = (slots, live) => {
11591
11679
  const cpuPercent = [];
11592
11680
  const memoryMb = [];
11593
11681
  for (let slot = end - POINTS + 1; slot <= end; slot++) {
11594
- const reading = slots.get(slot);
11682
+ const reading = slots.get(slot) ?? (live && slot > this.lastSlot ? slots.get(this.lastSlot) : void 0);
11595
11683
  cpuPercent.push(reading ? Math.round(reading.cpuPercent * 10) / 10 : null);
11596
11684
  memoryMb.push(reading ? Math.round(reading.memoryMb) : null);
11597
11685
  }
@@ -11600,8 +11688,8 @@ var UsageRecorder = class {
11600
11688
  memoryMb
11601
11689
  };
11602
11690
  };
11603
- const environments = [...this.environments].map(([workspace, slots]) => Object.assign({ workspace }, series(slots)));
11604
- 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))));
11605
11693
  if (!environments.length && !devices.length) return null;
11606
11694
  return {
11607
11695
  intervalMs: INTERVAL_MS,
@@ -11610,13 +11698,27 @@ var UsageRecorder = class {
11610
11698
  devices
11611
11699
  };
11612
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
+ }
11613
11715
  prune(slot) {
11614
- const trim = (slots) => {
11615
- 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);
11616
11718
  return slots.size > 0;
11617
11719
  };
11618
- for (const [key, slots] of this.environments) if (!trim(slots)) this.environments.delete(key);
11619
- 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);
11620
11722
  }
11621
11723
  };
11622
11724
  //#endregion
@@ -12451,6 +12553,7 @@ var ServerUpdates = class {
12451
12553
  running = null;
12452
12554
  upload = null;
12453
12555
  idle = null;
12556
+ ended = null;
12454
12557
  constructor(options) {
12455
12558
  this.options = options;
12456
12559
  this.sweepIncoming();
@@ -12504,9 +12607,14 @@ var ServerUpdates = class {
12504
12607
  service: service ? label : null,
12505
12608
  acceptsClientBuilds: this.options.acceptsClientBuilds(),
12506
12609
  running: this.progress(),
12507
- last: service && label ? readLastUpdate(label) : null
12610
+ last: service && label ? this.latest(readLastUpdate(label)) : null
12508
12611
  };
12509
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
+ }
12510
12618
  async start(by, params) {
12511
12619
  const parsed = parseUpdateStart(params);
12512
12620
  if (typeof parsed === "string") return refused("bad-request", parsed);
@@ -12665,6 +12773,12 @@ var ServerUpdates = class {
12665
12773
  const settle = (ok, message) => {
12666
12774
  this.options.drain(null);
12667
12775
  this.clear();
12776
+ this.ended = {
12777
+ at: (/* @__PURE__ */ new Date()).toISOString(),
12778
+ target,
12779
+ ok,
12780
+ message
12781
+ };
12668
12782
  this.options.audit({
12669
12783
  by: running.by,
12670
12784
  target,
@@ -12677,7 +12791,11 @@ var ServerUpdates = class {
12677
12791
  child.once("exit", (code) => {
12678
12792
  const last = readLastUpdate(this.options.label);
12679
12793
  const fresh = last && Date.parse(last.at) >= Date.parse(running.startedAt);
12680
- 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}.`);
12681
12799
  });
12682
12800
  return null;
12683
12801
  }
@@ -13168,6 +13286,28 @@ function peerAddress(request) {
13168
13286
  const forwarded = request.headers["x-forwarded-for"];
13169
13287
  return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
13170
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
+ }
13171
13311
  var FailureLimiter = class {
13172
13312
  failures = /* @__PURE__ */ new Map();
13173
13313
  max;
@@ -13415,6 +13555,7 @@ async function startServer(options) {
13415
13555
  if (recorders > 0) return;
13416
13556
  stopRecording?.();
13417
13557
  stopRecording = null;
13558
+ usage.pause();
13418
13559
  };
13419
13560
  };
13420
13561
  const controllers = /* @__PURE__ */ new Map();
@@ -13774,8 +13915,7 @@ async function startServer(options) {
13774
13915
  server: {
13775
13916
  name: options.name,
13776
13917
  version: options.serverVersion,
13777
- stim: options.stimVersion,
13778
- home: homedir()
13918
+ stim: options.stimVersion
13779
13919
  },
13780
13920
  capabilities: [],
13781
13921
  features: [...protocol_exports.FEATURES],
@@ -15163,6 +15303,10 @@ async function startServer(options) {
15163
15303
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15164
15304
  return;
15165
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
+ }
15166
15310
  if (limiter.blocked(peer ?? "local")) {
15167
15311
  socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15168
15312
  return;
@@ -15760,7 +15904,7 @@ async function main() {
15760
15904
  const service = command === "service";
15761
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}`);
15762
15906
  if (values.env || values["path-prepend"]) {
15763
- const problem = validateServeEnvironment(extraEnv, pathPrepend);
15907
+ const problem = validateServeEnvironment(extraEnv, pathPrepend, command === void 0);
15764
15908
  if (problem) fail(problem);
15765
15909
  }
15766
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-DThEHaj6.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.19.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.19.0",
32
- "stim": "1.19.0",
31
+ "@stim-cli/core": "^1.20.0",
32
+ "stim": "1.20.0",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {