@stim-cli/server 1.19.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -130,7 +130,7 @@ function validTimeZone(timeZone) {
130
130
  return false;
131
131
  }
132
132
  }
133
- const minuteOfDay$1 = (value) => Number.isInteger(value) && value >= 0 && value < 1440;
133
+ const minuteOfDay$1 = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value < 1440;
134
134
  /** Quiet hours as `push.register` takes them, or null when `value` is not valid. */
135
135
  function parseQuietHours(value) {
136
136
  if (!isJsonObject(value)) return null;
@@ -144,7 +144,7 @@ function parseQuietHours(value) {
144
144
  }
145
145
  /** Whether `value` is a stuck threshold `push.register` takes: whole minutes from 1 to 240. */
146
146
  function validStuckMinutes(value) {
147
- return Number.isInteger(value) && value >= 1 && value <= 240;
147
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 240;
148
148
  }
149
149
  function serverDir() {
150
150
  return join(configDir(), "server");
@@ -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
  }
@@ -7312,7 +7384,7 @@ var DeviceHost = class {
7312
7384
  const value = outcome.value;
7313
7385
  const installed = !owned.stopping && this.options.allowed(record.client) && !outcome.notice && outcome.settled && isJsonObject(value) && value.state === "installed" && parseHostedPlatformDevice(value.device, record.platform) !== null && record.device !== null && hostedDeviceId(parseHostedPlatformDevice(value.device, record.platform)) === hostedDeviceId(record.device) && (value.launched === true || value.launched === "unverified");
7314
7386
  if (installed) owned.installedAttempt = attempt;
7315
- if (installed && (record.platform !== "macos" || Number.isSafeInteger(value.pid) && value.pid > 0)) await this.startAgent(record, attempt, value.pid);
7387
+ if (installed && (record.platform !== "macos" || typeof value.pid === "number" && Number.isSafeInteger(value.pid) && value.pid > 0)) await this.startAgent(record, attempt, value.pid);
7316
7388
  const app = changeHostedApp(record.id, attempt, (current) => {
7317
7389
  current.state = installed ? "installed" : "unknown";
7318
7390
  current.launched = installed ? value.launched : null;
@@ -7864,11 +7936,13 @@ function connect$1(url, timeoutMs) {
7864
7936
  } catch {
7865
7937
  return;
7866
7938
  }
7867
- const waiter = typeof message.id === "number" ? pending.get(message.id) : void 0;
7939
+ if (!isJsonObject(message) || typeof message.id !== "number") return;
7940
+ const waiter = pending.get(message.id);
7868
7941
  if (!waiter) return;
7869
7942
  pending.delete(message.id);
7870
- if (message.error) waiter.reject(new Error(message.error.message ?? "DevTools command failed."));
7871
- else waiter.resolve(message.result ?? {});
7943
+ const { error, result } = message;
7944
+ if (error) waiter.reject(new Error(isJsonObject(error) && typeof error.message === "string" ? error.message : "DevTools command failed."));
7945
+ else waiter.resolve(isJsonObject(result) ? result : {});
7872
7946
  });
7873
7947
  socket.on("close", () => {
7874
7948
  for (const waiter of pending.values()) waiter.reject(/* @__PURE__ */ new Error("The DevTools connection closed."));
@@ -9824,7 +9898,7 @@ function parseInput(method, params, targetOf) {
9824
9898
  code: "bad-request",
9825
9899
  message: "input.touch needs phase (down, move or up), and x and y from 0 to 1."
9826
9900
  };
9827
- if (display !== void 0 && (!Number.isInteger(display) || display < 0 || display > 3)) return {
9901
+ if (display !== void 0 && (typeof display !== "number" || !Number.isInteger(display) || display < 0 || display > 3)) return {
9828
9902
  code: "bad-request",
9829
9903
  message: "display must be a display index from 0 to 3."
9830
9904
  };
@@ -11256,7 +11330,7 @@ function parseLogFilter(params) {
11256
11330
  if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return { error: "slot must be 1-64 letters, digits, underscores or hyphens." };
11257
11331
  if (grep !== void 0 && (typeof grep !== "string" || grep.includes("\0") || compileGrep(grep).error)) return { error: "grep must be a valid regular expression without NUL characters." };
11258
11332
  if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
11259
- if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > protocol_exports.MAX_LOG_TAIL)) return { error: `tail must be an integer from 1 to ${protocol_exports.MAX_LOG_TAIL}.` };
11333
+ if (tail !== void 0 && (typeof tail !== "number" || !Number.isInteger(tail) || tail < 1 || tail > protocol_exports.MAX_LOG_TAIL)) return { error: `tail must be an integer from 1 to ${protocol_exports.MAX_LOG_TAIL}.` };
11260
11334
  return { filter: {
11261
11335
  ...typeof params.archive === "string" ? { archive: params.archive } : { workspace: params.workspace },
11262
11336
  ...sources ? { sources } : {},
@@ -11539,16 +11613,28 @@ const INTERVAL_MS = 15e3;
11539
11613
  const POINTS = 40;
11540
11614
  /**
11541
11615
  * 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
11616
+ * over the last 10 minutes. A later payload in the same slot replaces the earlier one. `status --watch` prints no line
11617
+ * while usage stays within a step, so the slots between two payloads repeat the readings of the series the earlier
11618
+ * payload held, and those series continue to now until a payload drops them. An environment sums every
11543
11619
  * machine owner of its workspace; a device is a simulator or emulator owner, keyed by its UDID or AVD name.
11544
11620
  */
11545
11621
  var UsageRecorder = class {
11546
11622
  environments = /* @__PURE__ */ new Map();
11547
11623
  devices = /* @__PURE__ */ new Map();
11624
+ lastSlot = 0;
11625
+ liveEnvironments = /* @__PURE__ */ new Set();
11626
+ liveDevices = /* @__PURE__ */ new Set();
11548
11627
  record(payload, at = Date.now()) {
11549
- const owners = payload?.machine?.owners;
11550
- if (!Array.isArray(owners)) return;
11551
11628
  const slot = Math.floor(at / INTERVAL_MS);
11629
+ const owners = payload?.machine?.owners;
11630
+ this.holdThrough(slot - 1);
11631
+ this.lastSlot = Math.max(this.lastSlot, slot);
11632
+ this.liveEnvironments = /* @__PURE__ */ new Set();
11633
+ this.liveDevices = /* @__PURE__ */ new Set();
11634
+ if (!Array.isArray(owners)) {
11635
+ this.prune(slot);
11636
+ return;
11637
+ }
11552
11638
  const sums = /* @__PURE__ */ new Map();
11553
11639
  for (const owner of owners) {
11554
11640
  const reading = {
@@ -11578,20 +11664,24 @@ var UsageRecorder = class {
11578
11664
  meta,
11579
11665
  slots: slots.set(slot, reading)
11580
11666
  });
11667
+ this.liveDevices.add(key);
11581
11668
  }
11582
11669
  }
11583
- for (const [workspace, reading] of sums) this.environments.set(workspace, (this.environments.get(workspace) ?? /* @__PURE__ */ new Map()).set(slot, reading));
11670
+ for (const [workspace, reading] of sums) {
11671
+ this.environments.set(workspace, (this.environments.get(workspace) ?? /* @__PURE__ */ new Map()).set(slot, reading));
11672
+ this.liveEnvironments.add(workspace);
11673
+ }
11584
11674
  this.prune(slot);
11585
11675
  }
11586
11676
  /** The last 10 minutes ending at `now`'s slot, or null when no series has a reading in them. */
11587
11677
  history(now = Date.now()) {
11588
11678
  const end = Math.floor(now / INTERVAL_MS);
11589
11679
  this.prune(end);
11590
- const series = (slots) => {
11680
+ const series = (slots, live) => {
11591
11681
  const cpuPercent = [];
11592
11682
  const memoryMb = [];
11593
11683
  for (let slot = end - POINTS + 1; slot <= end; slot++) {
11594
- const reading = slots.get(slot);
11684
+ const reading = slots.get(slot) ?? (live && slot > this.lastSlot ? slots.get(this.lastSlot) : void 0);
11595
11685
  cpuPercent.push(reading ? Math.round(reading.cpuPercent * 10) / 10 : null);
11596
11686
  memoryMb.push(reading ? Math.round(reading.memoryMb) : null);
11597
11687
  }
@@ -11600,8 +11690,8 @@ var UsageRecorder = class {
11600
11690
  memoryMb
11601
11691
  };
11602
11692
  };
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)));
11693
+ const environments = [...this.environments].map(([workspace, slots]) => Object.assign({ workspace }, series(slots, this.liveEnvironments.has(workspace))));
11694
+ const devices = [...this.devices].map(([key, { meta, slots }]) => Object.assign({}, meta, series(slots, this.liveDevices.has(key))));
11605
11695
  if (!environments.length && !devices.length) return null;
11606
11696
  return {
11607
11697
  intervalMs: INTERVAL_MS,
@@ -11610,13 +11700,27 @@ var UsageRecorder = class {
11610
11700
  devices
11611
11701
  };
11612
11702
  }
11703
+ /** Ends the live series where the last payload left them: nothing is recorded, or held, while nobody listens. */
11704
+ pause() {
11705
+ this.liveEnvironments = /* @__PURE__ */ new Set();
11706
+ this.liveDevices = /* @__PURE__ */ new Set();
11707
+ }
11708
+ holdThrough(slot) {
11709
+ const hold = (slots) => {
11710
+ const reading = slots?.get(this.lastSlot);
11711
+ if (!slots || !reading) return;
11712
+ for (let at = Math.max(this.lastSlot + 1, slot - POINTS + 1); at <= slot; at++) slots.set(at, reading);
11713
+ };
11714
+ for (const key of this.liveEnvironments) hold(this.environments.get(key));
11715
+ for (const key of this.liveDevices) hold(this.devices.get(key)?.slots);
11716
+ }
11613
11717
  prune(slot) {
11614
- const trim = (slots) => {
11615
- for (const at of slots.keys()) if (at <= slot - POINTS) slots.delete(at);
11718
+ const trim = (slots, live) => {
11719
+ for (const at of slots.keys()) if (at <= slot - POINTS && !(live && at === this.lastSlot)) slots.delete(at);
11616
11720
  return slots.size > 0;
11617
11721
  };
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);
11722
+ for (const [key, slots] of this.environments) if (!trim(slots, this.liveEnvironments.has(key))) this.environments.delete(key);
11723
+ for (const [key, { slots }] of this.devices) if (!trim(slots, this.liveDevices.has(key))) this.devices.delete(key);
11620
11724
  }
11621
11725
  };
11622
11726
  //#endregion
@@ -12451,6 +12555,7 @@ var ServerUpdates = class {
12451
12555
  running = null;
12452
12556
  upload = null;
12453
12557
  idle = null;
12558
+ ended = null;
12454
12559
  constructor(options) {
12455
12560
  this.options = options;
12456
12561
  this.sweepIncoming();
@@ -12504,9 +12609,14 @@ var ServerUpdates = class {
12504
12609
  service: service ? label : null,
12505
12610
  acceptsClientBuilds: this.options.acceptsClientBuilds(),
12506
12611
  running: this.progress(),
12507
- last: service && label ? readLastUpdate(label) : null
12612
+ last: service && label ? this.latest(readLastUpdate(label)) : null
12508
12613
  };
12509
12614
  }
12615
+ latest(recorded) {
12616
+ if (!this.ended) return recorded;
12617
+ if (!recorded) return this.ended;
12618
+ return Date.parse(recorded.at) >= Date.parse(this.ended.at) ? recorded : this.ended;
12619
+ }
12510
12620
  async start(by, params) {
12511
12621
  const parsed = parseUpdateStart(params);
12512
12622
  if (typeof parsed === "string") return refused("bad-request", parsed);
@@ -12665,6 +12775,12 @@ var ServerUpdates = class {
12665
12775
  const settle = (ok, message) => {
12666
12776
  this.options.drain(null);
12667
12777
  this.clear();
12778
+ this.ended = {
12779
+ at: (/* @__PURE__ */ new Date()).toISOString(),
12780
+ target,
12781
+ ok,
12782
+ message
12783
+ };
12668
12784
  this.options.audit({
12669
12785
  by: running.by,
12670
12786
  target,
@@ -12677,7 +12793,11 @@ var ServerUpdates = class {
12677
12793
  child.once("exit", (code) => {
12678
12794
  const last = readLastUpdate(this.options.label);
12679
12795
  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}.`);
12796
+ let reason = "";
12797
+ try {
12798
+ reason = readFileSync(log, "utf8").split("\n").findLast((line) => line.trim() !== "") ?? "";
12799
+ } catch {}
12800
+ settle(code === 0, fresh ? last.message : `The update exited with code ${String(code)}${reason ? `: ${reason}` : ""}; see ${log}.`);
12681
12801
  });
12682
12802
  return null;
12683
12803
  }
@@ -13168,6 +13288,28 @@ function peerAddress(request) {
13168
13288
  const forwarded = request.headers["x-forwarded-for"];
13169
13289
  return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
13170
13290
  }
13291
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
13292
+ "127.0.0.1",
13293
+ "localhost",
13294
+ "[::1]"
13295
+ ]);
13296
+ /**
13297
+ * Whether a WebSocket upgrade comes from a web page. Browsers send `Sec-Fetch-Site` and the page's own `Origin`.
13298
+ * React Native derives `Origin` from the ws URL, so an app's `Origin` names the requested host: over `http:` on loopback,
13299
+ * and `https:` through `tailscale serve`. SocketRocket omits the brackets of an IPv6 origin, so an iOS app on
13300
+ * `ws://[::1]` is refused.
13301
+ */
13302
+ function upgradeFromWebPage(headers, local) {
13303
+ if (headers["sec-fetch-site"] !== void 0) return true;
13304
+ if (headers.origin === void 0) return false;
13305
+ const scheme = local ? "http:" : "https:";
13306
+ try {
13307
+ const own = new URL(`${scheme}//${headers.host ?? ""}`);
13308
+ return new URL(headers.origin).origin !== own.origin || local && !LOOPBACK_HOSTS.has(own.hostname);
13309
+ } catch {
13310
+ return true;
13311
+ }
13312
+ }
13171
13313
  var FailureLimiter = class {
13172
13314
  failures = /* @__PURE__ */ new Map();
13173
13315
  max;
@@ -13415,6 +13557,7 @@ async function startServer(options) {
13415
13557
  if (recorders > 0) return;
13416
13558
  stopRecording?.();
13417
13559
  stopRecording = null;
13560
+ usage.pause();
13418
13561
  };
13419
13562
  };
13420
13563
  const controllers = /* @__PURE__ */ new Map();
@@ -13774,8 +13917,7 @@ async function startServer(options) {
13774
13917
  server: {
13775
13918
  name: options.name,
13776
13919
  version: options.serverVersion,
13777
- stim: options.stimVersion,
13778
- home: homedir()
13920
+ stim: options.stimVersion
13779
13921
  },
13780
13922
  capabilities: [],
13781
13923
  features: [...protocol_exports.FEATURES],
@@ -14071,8 +14213,8 @@ async function startServer(options) {
14071
14213
  if (video !== void 0 && (!Array.isArray(video) || !video.every((codec) => typeof codec === "string"))) return error(id, "bad-request", "video must be a list of codec names.");
14072
14214
  const wantsVideo = video?.includes("h264") === true;
14073
14215
  const maxFps = wantsVideo ? protocol_exports.FRAME_FPS.video : protocol_exports.FRAME_FPS.max;
14074
- if (fps !== void 0 && (!Number.isInteger(fps) || fps < 1 || fps > maxFps)) return error(id, "bad-request", `fps must be a whole number from 1 to ${maxFps}.`);
14075
- if (maxEdge !== void 0 && (!Number.isInteger(maxEdge) || maxEdge < protocol_exports.FRAME_EDGE.min || maxEdge > protocol_exports.FRAME_EDGE.max)) return error(id, "bad-request", `maxEdge must be a whole number of pixels from ${protocol_exports.FRAME_EDGE.min} to ${protocol_exports.FRAME_EDGE.max}.`);
14216
+ if (fps !== void 0 && (typeof fps !== "number" || !Number.isInteger(fps) || fps < 1 || fps > maxFps)) return error(id, "bad-request", `fps must be a whole number from 1 to ${maxFps}.`);
14217
+ if (maxEdge !== void 0 && (typeof maxEdge !== "number" || !Number.isInteger(maxEdge) || maxEdge < protocol_exports.FRAME_EDGE.min || maxEdge > protocol_exports.FRAME_EDGE.max)) return error(id, "bad-request", `maxEdge must be a whole number of pixels from ${protocol_exports.FRAME_EDGE.min} to ${protocol_exports.FRAME_EDGE.max}.`);
14076
14218
  const resolvedDir = hosted ? null : readDir(id, {
14077
14219
  workspace,
14078
14220
  archive
@@ -15084,7 +15226,7 @@ async function startServer(options) {
15084
15226
  if (message.method === "notifications.list") {
15085
15227
  const params = message.params ?? {};
15086
15228
  const since = isJsonObject(params) ? params.since : void 0;
15087
- if (!isJsonObject(params) || since !== void 0 && !(Number.isInteger(since) && since >= 0)) return error(id, "bad-request", "notifications.list takes an optional since, a cursor of 0 or more.");
15229
+ if (!isJsonObject(params) || since !== void 0 && !(typeof since === "number" && Number.isInteger(since) && since >= 0)) return error(id, "bad-request", "notifications.list takes an optional since, a cursor of 0 or more.");
15088
15230
  listeners.add(socket);
15089
15231
  return send(socket, {
15090
15232
  id,
@@ -15163,6 +15305,10 @@ async function startServer(options) {
15163
15305
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15164
15306
  return;
15165
15307
  }
15308
+ if (upgradeFromWebPage(request.headers, peer === null)) {
15309
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15310
+ return;
15311
+ }
15166
15312
  if (limiter.blocked(peer ?? "local")) {
15167
15313
  socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15168
15314
  return;
@@ -15760,7 +15906,7 @@ async function main() {
15760
15906
  const service = command === "service";
15761
15907
  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
15908
  if (values.env || values["path-prepend"]) {
15763
- const problem = validateServeEnvironment(extraEnv, pathPrepend);
15909
+ const problem = validateServeEnvironment(extraEnv, pathPrepend, command === void 0);
15764
15910
  if (problem) fail(problem);
15765
15911
  }
15766
15912
  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-Dy0DlR96.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.1",
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.1",
32
+ "stim": "1.20.1",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {