@stim-cli/server 1.12.0 → 1.13.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
@@ -165,23 +165,31 @@ Events are `{ "event", "subscription", ... }`.
165
165
  `$STIM_HOME/server/helpers/`, named by a hash of the sources and the
166
166
  compiler version. For a simulator it renders the display's framebuffer
167
167
  (CoreSimulator's IOSurface) when the display reports damage, turned
168
- upright; for an emulator it keeps one gRPC `streamScreenshot` call open,
168
+ upright. An iPhone Duo lights one of its two panels, the cover while
169
+ folded and the inner panel while unfolded, and leaves the other black, so
170
+ the helper streams whichever panel is lit; its frames and video carry
171
+ `posture`, and the size changes with the panel. For an emulator it keeps one gRPC `streamScreenshot` call open,
169
172
  found through the discovery file and token the emulator writes when Stim
170
173
  boots it. It scales frames to fit the largest `maxEdge` and paces them to
171
174
  the highest `fps` its subscribers asked for. All subscribers of a device
172
- share one helper, which sends a new subscriber the latest frame and exits
173
- with the last subscriber or when the server's end of its stdin closes.
175
+ share one helper, which sends a new subscriber the latest frame. It keeps
176
+ running for 10 seconds after the last subscriber leaves, so a client that
177
+ subscribes for one frame at a time reuses it and gets the latest frame at
178
+ once, and exits after that or when the server's end of its stdin closes.
174
179
 
175
180
  A client that decodes H.264 adds `video: ["h264"]`. When the helper is
176
181
  built, the result carries `video: "h264"`, `fps` may go up to 60, and
177
182
  frames arrive as binary WebSocket messages instead of `frame` events: a
178
183
  big-endian header (u8 version 1, u8 flags with bit 0 set on a keyframe,
184
+ and on an iPhone Duo bit 1 while folded or bit 2 while unfolded,
179
185
  u16 header length, u32 sequence number of the messages sent on this
180
186
  subscription, f64 capture time in milliseconds since the epoch on the
181
187
  Mac's clock, u16 width, u16 height, u8 subscription id length and the
182
188
  ASCII id), then one Annex-B access unit. Every keyframe carries its SPS
183
189
  and PPS, and the stream has no B-frames, so each access unit is shown as
184
- it arrives. The helper encodes with VideoToolbox in real time: Main
190
+ it arrives. A change of size, such as a rotation or a Duo fold, restarts
191
+ the encoder, and the next access unit is a keyframe with the new SPS and
192
+ PPS. The helper encodes with VideoToolbox in real time: Main
185
193
  profile, a keyframe at least every 2 seconds, straight from the
186
194
  simulator's IOSurface or from the emulator's RGBA frames, only when the
187
195
  screen changed. A subscriber starts at a keyframe, and `frames.keyframe`
@@ -195,13 +203,13 @@ Events are `{ "event", "subscription", ... }`.
195
203
  subscribers of the same device still get at most the `fps` they asked
196
204
  for. Watching video needs only `read`. Without the helper, the result has
197
205
  no `video`, `fps` above 30 is lowered to 30, and `frame` events arrive as
198
- before; an iPhone Duo and a helper that fails before its first frame also
199
- fall back to `frame` events within a video subscription.
206
+ before; a helper that fails before its first frame also falls back to
207
+ `frame` events within a video subscription.
200
208
 
201
209
  Without the helper (the compiler is missing or fails, which the server
202
- retries every 5 minutes, or the helper fails before its first frame), for a
203
- subscription made while it is still being built, and for an iPhone Duo,
204
- frames come from screenshots, and `fps` and `maxEdge` only cap the rate. Simulators are
210
+ retries every 5 minutes, or the helper fails before its first frame), and
211
+ for a subscription made while it is still being built, frames come from
212
+ screenshots, and `fps` and `maxEdge` only cap the rate. Simulators are
205
213
  captured with `xcrun simctl io <udid> screenshot`, of the primary display,
206
214
  or of the default display when that `simctl` does not accept `primary`. An
207
215
  iPhone Duo lights one of two panels: the capture follows the lit one
@@ -365,7 +373,8 @@ streams its frames:
365
373
  through gRPC `setPhysicalModel` for rotation and `setPosture` for its hinge.
366
374
  An iPhone Duo folds with `sim-fold`, which the server builds from Stim
367
375
  Desktop's sources on the first fold and runs inside the simulator with
368
- `xcrun simctl spawn`; it answers `{}` once the fold finished. `sim-fold`
376
+ `xcrun simctl spawn`; it answers `{}` once the fold finished, and fails
377
+ with `action-failed` when the fold does not finish within 40 seconds. `sim-fold`
369
378
  swaps the posture, so the server runs it only when the Duo's latest frame
370
379
  shows the other posture, and refuses the request until a frame showed one.
371
380
 
@@ -67,6 +67,12 @@ const LOG_LEVELS = [
67
67
  "fatal"
68
68
  ];
69
69
  const MAX_LOG_TAIL = 5e3;
70
+ /** `reload` also reaches the workspace's Stim-owned Chrome page. */
71
+ const RELOAD_PLATFORMS = [
72
+ "ios",
73
+ "android",
74
+ "web"
75
+ ];
70
76
  const FRAME_FPS = {
71
77
  default: 5,
72
78
  max: 30,
@@ -81,6 +87,9 @@ const VIDEO_CODECS = ["h264"];
81
87
  const VIDEO_HEADER_VERSION = 1;
82
88
  /** The keyframe bit of a {@link VideoPacket}'s flags. */
83
89
  const VIDEO_KEYFRAME = 1;
90
+ /** The flag bits of a {@link VideoPacket} that carry an iPhone Duo's posture, as `posture` on a `frame` event. */
91
+ const VIDEO_FOLDED = 2;
92
+ const VIDEO_UNFOLDED = 4;
84
93
  /** Each action runs one fixed `stim` command in the workspace. */
85
94
  const ACTIONS = ["reload", "stop"];
86
95
  const TOUCH_PHASES = [
@@ -263,7 +272,12 @@ function protocolJsonSchema() {
263
272
  ts: { type: "number" },
264
273
  src: { type: "string" },
265
274
  level: { type: "string" },
266
- msg: { type: "string" }
275
+ msg: { type: "string" },
276
+ context: {
277
+ type: "array",
278
+ items: { type: "string" },
279
+ description: "With `errors`: the code frame and stack lines Expo printed after this error."
280
+ }
267
281
  }
268
282
  },
269
283
  LogFilter: {
@@ -343,7 +357,7 @@ function protocolJsonSchema() {
343
357
  type: "string",
344
358
  description: "An environment path from a status payload."
345
359
  },
346
- platform: { enum: ["ios", "android"] }
360
+ platform: { enum: [...RELOAD_PLATFORMS] }
347
361
  }
348
362
  }, {
349
363
  type: "object",
@@ -805,4 +819,4 @@ function protocolJsonSchema() {
805
819
  };
806
820
  }
807
821
  //#endregion
808
- export { TOUCH_PHASES as _, ERROR_CODES as a, VIDEO_KEYFRAME as b, INPUT_BUTTONS as c, MAX_INPUT_TEXT as d, MAX_LOG_TAIL as f, ROTATE_DIRECTIONS as g, PROTOCOL_VERSION as h, DEVICE_POSTURES as i, LOG_LEVELS as l, PROTOCOL_SCHEMA_FILE as m, CAPABILITIES as n, FRAME_EDGE as o, METHODS as p, CONTROL_END_REASONS as r, FRAME_FPS as s, ACTIONS as t, LOG_SOURCES as u, VIDEO_CODECS as v, protocolJsonSchema as x, VIDEO_HEADER_VERSION as y };
822
+ export { VIDEO_UNFOLDED as C, VIDEO_KEYFRAME as S, ROTATE_DIRECTIONS as _, ERROR_CODES as a, VIDEO_FOLDED as b, INPUT_BUTTONS as c, MAX_INPUT_TEXT as d, MAX_LOG_TAIL as f, RELOAD_PLATFORMS as g, PROTOCOL_VERSION as h, DEVICE_POSTURES as i, LOG_LEVELS as l, PROTOCOL_SCHEMA_FILE as m, CAPABILITIES as n, FRAME_EDGE as o, METHODS as p, CONTROL_END_REASONS as r, FRAME_FPS as s, ACTIONS as t, LOG_SOURCES as u, TOUCH_PHASES as v, protocolJsonSchema as w, VIDEO_HEADER_VERSION as x, VIDEO_CODECS as y };
@@ -93,6 +93,9 @@ type StatsResult = Record<string, unknown>;
93
93
  /** `stim settings --json`; the CLI masks sensitive values. */
94
94
  type SettingsResult = Record<string, unknown>;
95
95
  type Platform = "ios" | "android";
96
+ /** `reload` also reaches the workspace's Stim-owned Chrome page. */
97
+ declare const RELOAD_PLATFORMS: readonly ["ios", "android", "web"];
98
+ type ReloadPlatform = (typeof RELOAD_PLATFORMS)[number];
96
99
  declare const FRAME_FPS: {
97
100
  readonly default: 5;
98
101
  readonly max: 30;
@@ -122,7 +125,7 @@ declare const VIDEO_CODECS: readonly ["h264"];
122
125
  type VideoCodec = (typeof VIDEO_CODECS)[number];
123
126
  /**
124
127
  * With `video`, frames arrive as binary WebSocket messages, one H.264 access unit each; see {@link VideoPacket}.
125
- * A subscription whose device cannot be encoded, such as an iPhone Duo, still sends JSON `frame` events.
128
+ * A subscription whose device falls back to screenshots still sends JSON `frame` events.
126
129
  */
127
130
  interface FramesSubscribeResult extends SubscribeResult {
128
131
  video?: VideoCodec;
@@ -134,9 +137,12 @@ interface KeyframeParams {
134
137
  declare const VIDEO_HEADER_VERSION = 1;
135
138
  /** The keyframe bit of a {@link VideoPacket}'s flags. */
136
139
  declare const VIDEO_KEYFRAME = 1;
140
+ /** The flag bits of a {@link VideoPacket} that carry an iPhone Duo's posture, as `posture` on a `frame` event. */
141
+ declare const VIDEO_FOLDED = 2;
142
+ declare const VIDEO_UNFOLDED = 4;
137
143
  /**
138
144
  * The layout of a binary video message, big-endian: u8 version ({@link VIDEO_HEADER_VERSION}), u8 flags
139
- * ({@link VIDEO_KEYFRAME}), u16 header length, u32 sequence number of the messages sent on this subscription, f64 capture time in milliseconds since the
145
+ * ({@link VIDEO_KEYFRAME}, and on an iPhone Duo {@link VIDEO_FOLDED} or {@link VIDEO_UNFOLDED}), u16 header length, u32 sequence number of the messages sent on this subscription, f64 capture time in milliseconds since the
140
146
  * epoch on the Mac's clock, u16 width, u16 height, u8 subscription id length N, N bytes of ASCII subscription
141
147
  * id. After the header comes one Annex-B H.264 access unit; a keyframe carries its SPS and PPS. The stream has
142
148
  * no B-frames, so each access unit is shown as it arrives.
@@ -148,19 +154,20 @@ interface VideoPacket {
148
154
  capturedAt: number;
149
155
  width: number;
150
156
  height: number;
157
+ posture?: "folded" | "unfolded";
151
158
  accessUnit: Uint8Array;
152
159
  }
153
160
  /** Each action runs one fixed `stim` command in the workspace. */
154
161
  declare const ACTIONS: readonly ["reload", "stop"];
155
162
  type ActionName = (typeof ACTIONS)[number];
156
163
  /**
157
- * `reload` runs `stim reload --json`, with `platform` when both platforms are live; `stop` runs
164
+ * `reload` runs `stim reload --json`, with `platform` when more than one platform is live; `stop` runs
158
165
  * `stim stop --json`. `workspace` is an environment `path` from a status payload. Needs `control`.
159
166
  */
160
167
  type ActionParams = {
161
168
  action: "reload";
162
169
  workspace: string;
163
- platform?: Platform;
170
+ platform?: ReloadPlatform;
164
171
  } | {
165
172
  action: "stop";
166
173
  workspace: string;
@@ -468,4 +475,4 @@ type JsonSchema = Record<string, unknown>;
468
475
  /** The JSON Schema of every message on the wire, written to `dist/protocol.schema.json` at build time. */
469
476
  declare function protocolJsonSchema(): JsonSchema;
470
477
  //#endregion
471
- export { ACTIONS, ActionName, ActionParams, ActionResult, BuildPlanParams, BuildPlanResult, CAPABILITIES, CONTROL_END_REASONS, Capability, ClientRequest, ControlBeginParams, ControlBeginResult, ControlEndParams, ControlEndedEvent, DEVICE_POSTURES, DeviceAuth, DevicePosture, ERROR_CODES, ErrorCode, ErrorEvent, FRAME_EDGE, FRAME_FPS, FrameDelayedEvent, FrameEvent, FrameTarget, FramesSubscribeResult, HelloParams, HelloResult, INPUT_BUTTONS, InputButton, InputButtonParams, InputPostureParams, InputRotateParams, InputTextParams, InputTouchParams, KeyframeParams, LOG_LEVELS, LOG_SOURCES, LogFilter, LogLevel, LogRecord, LogSource, LogsEvent, LogsQueryResult, MAX_INPUT_TEXT, MAX_LOG_TAIL, METHODS, MachineHistory, MachineHistoryParams, MachineUsage, MachineVolume, MemoryPressure, Method, Methods, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, PairingAuth, Platform, ProtocolError, ROTATE_DIRECTIONS, RequestId, RotateDirection, ServerEvent, ServerMessage, ServerResponse, SettingsResult, StatsResult, StatusEvent, SubscribeResult, TOUCH_PHASES, TouchPhase, UnsubscribeParams, UsageSample, VIDEO_CODECS, VIDEO_HEADER_VERSION, VIDEO_KEYFRAME, VideoCodec, VideoPacket, WorkspaceParams, protocolJsonSchema };
478
+ export { ACTIONS, ActionName, ActionParams, ActionResult, BuildPlanParams, BuildPlanResult, CAPABILITIES, CONTROL_END_REASONS, Capability, ClientRequest, ControlBeginParams, ControlBeginResult, ControlEndParams, ControlEndedEvent, DEVICE_POSTURES, DeviceAuth, DevicePosture, ERROR_CODES, ErrorCode, ErrorEvent, FRAME_EDGE, FRAME_FPS, FrameDelayedEvent, FrameEvent, FrameTarget, FramesSubscribeResult, HelloParams, HelloResult, INPUT_BUTTONS, InputButton, InputButtonParams, InputPostureParams, InputRotateParams, InputTextParams, InputTouchParams, KeyframeParams, LOG_LEVELS, LOG_SOURCES, LogFilter, LogLevel, LogRecord, LogSource, LogsEvent, LogsQueryResult, MAX_INPUT_TEXT, MAX_LOG_TAIL, METHODS, MachineHistory, MachineHistoryParams, MachineUsage, MachineVolume, MemoryPressure, Method, Methods, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, PairingAuth, Platform, ProtocolError, RELOAD_PLATFORMS, ROTATE_DIRECTIONS, ReloadPlatform, RequestId, RotateDirection, ServerEvent, ServerMessage, ServerResponse, SettingsResult, StatsResult, StatusEvent, SubscribeResult, TOUCH_PHASES, TouchPhase, UnsubscribeParams, UsageSample, VIDEO_CODECS, VIDEO_FOLDED, VIDEO_HEADER_VERSION, VIDEO_KEYFRAME, VIDEO_UNFOLDED, VideoCodec, VideoPacket, WorkspaceParams, protocolJsonSchema };
package/dist/protocol.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as TOUCH_PHASES, a as ERROR_CODES, b as VIDEO_KEYFRAME, c as INPUT_BUTTONS, d as MAX_INPUT_TEXT, f as MAX_LOG_TAIL, g as ROTATE_DIRECTIONS, h as PROTOCOL_VERSION, i as DEVICE_POSTURES, l as LOG_LEVELS, m as PROTOCOL_SCHEMA_FILE, n as CAPABILITIES, o as FRAME_EDGE, p as METHODS, r as CONTROL_END_REASONS, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES, v as VIDEO_CODECS, x as protocolJsonSchema, y as VIDEO_HEADER_VERSION } from "./protocol-BElQc2vm.mjs";
2
- export { ACTIONS, CAPABILITIES, CONTROL_END_REASONS, DEVICE_POSTURES, ERROR_CODES, FRAME_EDGE, FRAME_FPS, INPUT_BUTTONS, LOG_LEVELS, LOG_SOURCES, MAX_INPUT_TEXT, MAX_LOG_TAIL, METHODS, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, ROTATE_DIRECTIONS, TOUCH_PHASES, VIDEO_CODECS, VIDEO_HEADER_VERSION, VIDEO_KEYFRAME, protocolJsonSchema };
1
+ import { C as VIDEO_UNFOLDED, S as VIDEO_KEYFRAME, _ as ROTATE_DIRECTIONS, a as ERROR_CODES, b as VIDEO_FOLDED, c as INPUT_BUTTONS, d as MAX_INPUT_TEXT, f as MAX_LOG_TAIL, g as RELOAD_PLATFORMS, h as PROTOCOL_VERSION, i as DEVICE_POSTURES, l as LOG_LEVELS, m as PROTOCOL_SCHEMA_FILE, n as CAPABILITIES, o as FRAME_EDGE, p as METHODS, r as CONTROL_END_REASONS, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES, v as TOUCH_PHASES, w as protocolJsonSchema, x as VIDEO_HEADER_VERSION, y as VIDEO_CODECS } from "./protocol-rC-5KT1O.mjs";
2
+ export { ACTIONS, CAPABILITIES, CONTROL_END_REASONS, DEVICE_POSTURES, ERROR_CODES, FRAME_EDGE, FRAME_FPS, INPUT_BUTTONS, LOG_LEVELS, LOG_SOURCES, MAX_INPUT_TEXT, MAX_LOG_TAIL, METHODS, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, RELOAD_PLATFORMS, ROTATE_DIRECTIONS, TOUCH_PHASES, VIDEO_CODECS, VIDEO_FOLDED, VIDEO_HEADER_VERSION, VIDEO_KEYFRAME, VIDEO_UNFOLDED, protocolJsonSchema };
@@ -196,6 +196,13 @@
196
196
  },
197
197
  "msg": {
198
198
  "type": "string"
199
+ },
200
+ "context": {
201
+ "type": "array",
202
+ "items": {
203
+ "type": "string"
204
+ },
205
+ "description": "With `errors`: the code frame and stack lines Expo printed after this error."
199
206
  }
200
207
  }
201
208
  },
@@ -318,7 +325,8 @@
318
325
  "platform": {
319
326
  "enum": [
320
327
  "ios",
321
- "android"
328
+ "android",
329
+ "web"
322
330
  ]
323
331
  }
324
332
  }
@@ -21,8 +21,9 @@ import IOSurface
21
21
  // keyboard.
22
22
  // stdout carries messages framed as a 4-byte big-endian length, then a kind byte:
23
23
  // 1 is a frame (2-byte width, 2-byte height, JPEG bytes), 2 is a JSON notice
24
- // ({"error": message} before a failed exit, {"inputError": message}, or on an emulator
25
- // {"keyboard": "yes|no"} once it reports its hardware), 3 is an H.264 access unit (1-byte
24
+ // ({"error": message} before a failed exit, {"inputError": message}, on a simulator
25
+ // with several displays {"display": n} when display n is the one lit and streamed, or
26
+ // on an emulator {"keyboard": "yes|no"} once it reports its hardware), 3 is an H.264 access unit (1-byte
26
27
  // flags with bit 0 set on a keyframe, 8-byte big-endian float capture time in
27
28
  // milliseconds since the epoch, 2-byte width, 2-byte height, Annex-B bytes).
28
29
  // The helper exits when stdin closes.
@@ -88,7 +89,7 @@ enum Output {
88
89
  }
89
90
  }
90
91
 
91
- static func notice(_ object: [String: String]) {
92
+ static func notice(_ object: [String: Any]) {
92
93
  guard let json = try? JSONSerialization.data(withJSONObject: object) else { return }
93
94
  writer.sync { write(Data([2]) + json) }
94
95
  }
@@ -191,7 +192,9 @@ final class SimulatorSource {
191
192
  let inputQueue = DispatchQueue(label: "stim.frames.input")
192
193
  var hid: SimulatorHID?
193
194
  private let pacer: Pacer
194
- private var display: SimDisplay?
195
+ private var displays: [SimDisplay] = []
196
+ private var displayIndex = 0
197
+ private var reportedDisplay: Int?
195
198
  private let callbackID = NSUUID()
196
199
  private let video = videoEncoder()
197
200
  private let jpegGate = JpegGate()
@@ -206,37 +209,63 @@ final class SimulatorSource {
206
209
  var queue: DispatchQueue { pacer.queue }
207
210
 
208
211
  func start(deadline: Date = Date().addingTimeInterval(30)) {
209
- guard let display = CoreSimulator.displays(udid: udid).first else {
212
+ let found = CoreSimulator.displays(udid: udid)
213
+ guard !found.isEmpty else {
210
214
  if Date() > deadline { fail("Simulator \(udid) has no display with a framebuffer. Is it booted?") }
211
215
  queue.asyncAfter(deadline: .now() + 1) { self.start(deadline: deadline) }
212
216
  return
213
217
  }
214
- self.display = display
215
- display.registerDamageCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
216
- display.registerSurfacesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
217
- display.registerPropertiesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
218
+ displays = found
219
+ displayIndex = 0
220
+ for display in found {
221
+ display.registerDamageCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
222
+ display.registerSurfacesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
223
+ display.registerPropertiesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
224
+ }
218
225
  pacer.changed()
219
- watch(display)
226
+ watch()
220
227
  }
221
228
 
222
229
  // A simulator that shuts down and boots again gets new display objects, and
223
230
  // the old ones stop reporting damage; so does its HID client. CoreSimulator
224
231
  // returns a new proxy for the same display on every lookup, so the watch
225
232
  // follows the device state instead, where 3 is SimDeviceStateBooted.
226
- private func watch(_ display: SimDisplay) {
233
+ private func watch() {
227
234
  queue.asyncAfter(deadline: .now() + 2) {
228
235
  guard CoreSimulator.device(udid: self.udid)?.value(forKey: "state") as? Int != 3 else {
229
- return self.watch(display)
236
+ if self.displays.count > 1 { self.pacer.changed() }
237
+ return self.watch()
238
+ }
239
+ for display in self.displays {
240
+ display.unregisterDamageCallback(self.callbackID)
241
+ display.unregisterSurfacesCallback(self.callbackID)
242
+ display.unregisterPropertiesCallback(self.callbackID)
230
243
  }
231
- display.unregisterDamageCallback(self.callbackID)
232
- display.unregisterSurfacesCallback(self.callbackID)
233
- display.unregisterPropertiesCallback(self.callbackID)
234
- self.display = nil
244
+ self.displays = []
245
+ self.reportedDisplay = nil
235
246
  self.inputQueue.async { self.hid = nil }
236
247
  self.start()
237
248
  }
238
249
  }
239
250
 
251
+ // CoreSimulator keeps the built-in display an iPhone Duo's posture turned
252
+ // off, the cover or the inner one, all black, and sends no damage when it
253
+ // turns off, so watch() also re-renders a device with several displays.
254
+ private func litDisplay() -> SimDisplay? {
255
+ let panels = displays.indices.filter { displays[$0].screenProperties?.screenType == 0 }
256
+ guard panels.count > 1 else { return displays.first }
257
+ let lit = { (index: Int) in self.displays[index].framebufferSurface.map { !isBlack($0) } ?? false }
258
+ guard let index = lit(displayIndex) ? displayIndex : panels.first(where: lit) else {
259
+ return displays[displayIndex]
260
+ }
261
+ displayIndex = index
262
+ if reportedDisplay != index {
263
+ reportedDisplay = index
264
+ Output.notice(["display": index])
265
+ }
266
+ return displays[index]
267
+ }
268
+
240
269
  func configure(_ config: Config) {
241
270
  video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
242
271
  queue.async {
@@ -253,10 +282,10 @@ final class SimulatorSource {
253
282
  // uiOrientation is a UIInterfaceOrientation; the framebuffer stays in the
254
283
  // display's native portrait orientation, so the image is turned upright.
255
284
  private func render(_ config: Config) {
256
- guard let surface = display?.framebufferSurface else { return }
285
+ guard let display = litDisplay(), let surface = display.framebufferSurface else { return }
257
286
  let orientation: CGImagePropertyOrientation
258
287
  let quarterTurns: Int
259
- switch display?.screenProperties?.uiOrientation ?? 1 {
288
+ switch display.screenProperties?.uiOrientation ?? 1 {
260
289
  case 2: (orientation, quarterTurns) = (.down, 2)
261
290
  case 3: (orientation, quarterTurns) = (.right, 3)
262
291
  case 4: (orientation, quarterTurns) = (.left, 1)
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { _ as TOUCH_PHASES, c as INPUT_BUTTONS, f as MAX_LOG_TAIL, g as ROTATE_DIRECTIONS, l as LOG_LEVELS, n as CAPABILITIES, o as FRAME_EDGE, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES } from "./protocol-BElQc2vm.mjs";
2
+ import { _ as ROTATE_DIRECTIONS, c as INPUT_BUTTONS, f as MAX_LOG_TAIL, g as RELOAD_PLATFORMS, l as LOG_LEVELS, n as CAPABILITIES, o as FRAME_EDGE, s as FRAME_FPS, t as ACTIONS, u as LOG_SOURCES, v as TOUCH_PHASES } from "./protocol-rC-5KT1O.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { accessSync, appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, watch, writeFileSync } from "node:fs";
5
5
  import { availableParallelism, cpus, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
@@ -284,9 +284,9 @@ function parseAction(params) {
284
284
  action,
285
285
  workspace
286
286
  } };
287
- if (platform !== void 0 && platform !== "ios" && platform !== "android") return {
287
+ if (platform !== void 0 && !RELOAD_PLATFORMS.includes(platform)) return {
288
288
  code: "bad-request",
289
- message: "platform must be ios or android."
289
+ message: `platform must be one of ${RELOAD_PLATFORMS.join(", ")}.`
290
290
  };
291
291
  return { action: {
292
292
  action: "reload",
@@ -397,12 +397,20 @@ function runStim(stimCli, env, args, cwd, limits) {
397
397
  const chunks = [];
398
398
  let size = 0;
399
399
  let stderr = "";
400
+ const release = () => {
401
+ child.stdout.destroy();
402
+ child.stderr.destroy();
403
+ };
400
404
  const fail = (message) => {
401
405
  if (failure) return;
402
406
  failure = message;
407
+ release();
403
408
  terminate(child);
404
409
  };
405
- const timer = setTimeout(() => fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`), limits.timeoutMs);
410
+ const timer = setTimeout(() => {
411
+ if (child.exitCode !== null || child.signalCode !== null) return release();
412
+ fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`);
413
+ }, limits.timeoutMs);
406
414
  child.stdout.on("data", (chunk) => {
407
415
  size += chunk.length;
408
416
  if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
@@ -454,7 +462,8 @@ function videoPacket(subscription, sequence, unit) {
454
462
  const id = Buffer.from(subscription, "ascii");
455
463
  const header = Buffer.alloc(FIXED_HEADER_BYTES + id.length);
456
464
  header.writeUInt8(1, 0);
457
- header.writeUInt8(unit.keyframe ? 1 : 0, 1);
465
+ const posture = unit.posture === "folded" ? 2 : unit.posture === "unfolded" ? 4 : 0;
466
+ header.writeUInt8((unit.keyframe ? 1 : 0) | posture, 1);
458
467
  header.writeUInt16BE(header.length, 2);
459
468
  header.writeUInt32BE(sequence >>> 0, 4);
460
469
  header.writeDoubleBE(unit.capturedAt, 8);
@@ -670,9 +679,11 @@ async function compiled(name, inputs, version, compile) {
670
679
  return helper;
671
680
  }
672
681
  /**
673
- * One `stim-frames` process per device, shared by its subscribers and stopped with the last of them. It runs
682
+ * One `stim-frames` process per device, shared by its subscribers and stopped `lingerMs` after the last of them
683
+ * leaves, unchanged meanwhile, so a subscriber that comes back within that time gets the latest frame at once. It runs
674
684
  * at the highest fps and the largest edge any subscriber asked for; each subscriber paces its own frames. The
675
- * helper exits when its stdin closes, so it cannot outlive the server.
685
+ * helper exits when its stdin closes, so it cannot outlive the server. `lit` turns the display the helper
686
+ * reports it streams into a posture, which the frames and access units after it carry.
676
687
  */
677
688
  var HelperSource = class {
678
689
  listeners = /* @__PURE__ */ new Map();
@@ -687,8 +698,14 @@ var HelperSource = class {
687
698
  bitrate = new Bitrate(DEFAULT_VIDEO_LIMITS, Date.now());
688
699
  keyframeAt = -Infinity;
689
700
  keyframeTimer = null;
690
- constructor(helper, device, env, ended) {
701
+ lit;
702
+ posture;
703
+ lingerMs;
704
+ lingerTimer = null;
705
+ constructor(helper, device, env, ended, lingerMs, lit) {
691
706
  this.ended = ended;
707
+ this.lingerMs = lingerMs;
708
+ this.lit = lit;
692
709
  const id = device.platform === "ios" ? device.udid : device.serial;
693
710
  this.child = spawn(helper, [device.platform, id], {
694
711
  env,
@@ -712,20 +729,23 @@ var HelperSource = class {
712
729
  }
713
730
  /** A null `hint` keeps the helper running for input without asking for frames. */
714
731
  add(listener, hint) {
732
+ if (this.lingerTimer) clearTimeout(this.lingerTimer);
733
+ this.lingerTimer = null;
715
734
  this.listeners.set(listener, hint);
716
735
  this.configure();
717
736
  if (listener.video) this.keyframe();
718
737
  else if (this.last) listener.frame(this.last);
719
738
  return () => {
720
739
  if (!this.listeners.delete(listener)) return;
721
- if (this.listeners.size === 0) this.stop();
722
- else this.configure();
740
+ if (this.listeners.size > 0) this.configure();
741
+ else this.lingerTimer = setTimeout(() => void this.stop(), this.lingerMs);
723
742
  };
724
743
  }
725
744
  stop() {
726
745
  if (this.stopped) return Promise.resolve();
727
746
  this.stopped = true;
728
747
  if (this.keyframeTimer) clearTimeout(this.keyframeTimer);
748
+ if (this.lingerTimer) clearTimeout(this.lingerTimer);
729
749
  this.listeners.clear();
730
750
  this.ended();
731
751
  this.child.stdin.end();
@@ -752,6 +772,7 @@ var HelperSource = class {
752
772
  if (!this.stopped) this.child.stdin.write(`${JSON.stringify(command)}\n`);
753
773
  }
754
774
  configure() {
775
+ if (this.listeners.size === 0) return;
755
776
  const watching = [...this.listeners].flatMap(([listener, hint]) => hint ? [{
756
777
  listener,
757
778
  hint
@@ -802,7 +823,8 @@ var HelperSource = class {
802
823
  width: body.readUInt16BE(1),
803
824
  height: body.readUInt16BE(3),
804
825
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
805
- data: body.subarray(5).toString("base64")
826
+ data: body.subarray(5).toString("base64"),
827
+ ...this.posture ? { posture: this.posture } : {}
806
828
  };
807
829
  for (const listener of this.listeners.keys()) if (!listener.video) listener.frame(this.last);
808
830
  }
@@ -813,7 +835,8 @@ var HelperSource = class {
813
835
  capturedAt: body.readDoubleBE(2),
814
836
  width: body.readUInt16BE(10),
815
837
  height: body.readUInt16BE(12),
816
- data: body.subarray(VIDEO_HEADER_BYTES)
838
+ data: body.subarray(VIDEO_HEADER_BYTES),
839
+ ...this.posture ? { posture: this.posture } : {}
817
840
  };
818
841
  if (this.bitrate.tick(Date.now()) !== null) this.configure();
819
842
  for (const listener of this.listeners.keys()) listener.video?.(unit);
@@ -827,6 +850,8 @@ var HelperSource = class {
827
850
  if (keyboard === "yes" || keyboard === "no") this.keyboard = keyboard === "yes";
828
851
  const inputError = notice?.inputError;
829
852
  if (typeof inputError === "string") console.error(`stim-server: stim-frames: ${inputError}`);
853
+ const display = notice?.display;
854
+ if (this.lit && typeof display === "number") this.posture = this.lit(display);
830
855
  } catch {}
831
856
  }
832
857
  };
@@ -836,7 +861,8 @@ const DEFAULT_FRAME_LIMITS = {
836
861
  toolTimeoutMs: 3e4,
837
862
  slowCaptureMs: 3e3,
838
863
  failureBackoffMs: 3e3,
839
- maxConsecutiveFailures: 3
864
+ maxConsecutiveFailures: 3,
865
+ lingerMs: 1e4
840
866
  };
841
867
  const MIN_INTERVAL_MS = 200;
842
868
  const MAX_INTERVAL_MS = 1e3;
@@ -1044,6 +1070,8 @@ function simulatorCapturer(device, env, limits, litPanels) {
1044
1070
  }
1045
1071
  };
1046
1072
  }
1073
+ /** The Duo's panels in the order the helper indexes CoreSimulator's displays, by screen ID. */
1074
+ const DUO_PANELS = ["primary", "primary-1"];
1047
1075
  const POSTURES = {
1048
1076
  primary: "folded",
1049
1077
  "primary-1": "unfolded"
@@ -1401,12 +1429,15 @@ var FrameSource = class {
1401
1429
  }
1402
1430
  };
1403
1431
  /**
1404
- * One capture per device, shared by its subscribers and stopped with the last of them. With the `stim-frames`
1405
- * helper, a device streams frames as its screen changes, at the rate its subscribers ask for. Without it, for
1406
- * an iPhone Duo, and while the helper is still being built, a screenshot loop sends a frame only when the
1407
- * screen changed: up to 5 times a second while it changes, backing off to once a second while it does not,
1408
- * spending at most half of its time capturing, with at most two captures at once across all devices. A helper
1409
- * that fails before its first frame gives way to the screenshot loop.
1432
+ * One capture per device, shared by its subscribers. A `stim-frames` helper stops `lingerMs` after the last of
1433
+ * them leaves, so a client that takes one frame at a time reuses it; a screenshot loop stops with the last of
1434
+ * them. With the `stim-frames`
1435
+ * helper, a device streams frames as its screen changes, at the rate its subscribers ask for; an iPhone Duo
1436
+ * streams its lit panel, which is its posture. Without the helper, and while it is still being built, a
1437
+ * screenshot loop sends a frame only when the screen changed: up to 5 times a second while it changes, backing
1438
+ * off to once a second while it does not, spending at most half of its time capturing, with at most two
1439
+ * captures at once across all devices. A helper that fails before its first frame gives way to the screenshot
1440
+ * loop.
1410
1441
  */
1411
1442
  var FramePool = class {
1412
1443
  sources = /* @__PURE__ */ new Map();
@@ -1422,7 +1453,7 @@ var FramePool = class {
1422
1453
  }
1423
1454
  subscribe(device, listener, hint = DEFAULT_FRAME_HINT) {
1424
1455
  const helper = this.helper();
1425
- if (helper === null || device.platform === "ios" && device.foldable) return this.screenshots(device).add(listener);
1456
+ if (helper === null) return this.screenshots(device).add(listener);
1426
1457
  let streamed = false;
1427
1458
  let cancelled = false;
1428
1459
  const { video } = listener;
@@ -1490,9 +1521,15 @@ var FramePool = class {
1490
1521
  const key = `helper:${deviceKey(device)}`;
1491
1522
  const existing = this.sources.get(key);
1492
1523
  if (existing instanceof HelperSource) return existing;
1524
+ const lit = device.platform === "ios" && device.foldable ? (display) => {
1525
+ const panel = DUO_PANELS[display];
1526
+ if (!panel) return void 0;
1527
+ this.litPanels.set(device.udid, panel);
1528
+ return POSTURES[panel];
1529
+ } : void 0;
1493
1530
  const created = new HelperSource(helper, device, this.env, () => {
1494
1531
  if (this.sources.get(key) === created) this.sources.delete(key);
1495
- });
1532
+ }, this.limits.lingerMs, lit);
1496
1533
  this.sources.set(key, created);
1497
1534
  return created;
1498
1535
  }
@@ -1696,8 +1733,6 @@ function adbPath(env) {
1696
1733
  return "adb";
1697
1734
  }
1698
1735
  const ADB_TIMEOUT_MS = 1e4;
1699
- /** `sim-fold` waits up to 20 seconds for SpringBoard to finish the fold; Stim Desktop stops it after 40. */
1700
- const FOLD_TIMEOUT_MS = 4e4;
1701
1736
  const POSTURE_TIMEOUT_MS = 5e3;
1702
1737
  function runQuietly(env, file, args, label, timeoutMs) {
1703
1738
  return new Promise((resolve, reject) => {
@@ -1710,18 +1745,24 @@ function runQuietly(env, file, args, label, timeoutMs) {
1710
1745
  ]
1711
1746
  });
1712
1747
  let stderr = "";
1713
- const timer = setTimeout(() => void terminate(child), timeoutMs);
1748
+ const finish = (code) => {
1749
+ clearTimeout(timer);
1750
+ if (code === 0) resolve();
1751
+ else reject(/* @__PURE__ */ new Error(`${label} failed (code ${code}): ${stderr.trim()}`));
1752
+ };
1753
+ const timer = setTimeout(() => {
1754
+ child.stderr.destroy();
1755
+ if (child.exitCode !== null || child.signalCode !== null) return finish(child.exitCode);
1756
+ terminate(child);
1757
+ reject(/* @__PURE__ */ new Error(`${label} did not finish within ${timeoutMs / 1e3} s.`));
1758
+ }, timeoutMs);
1714
1759
  child.stderr.setEncoding("utf8");
1715
1760
  child.stderr.on("data", (chunk) => stderr = (stderr + chunk).slice(-500));
1716
1761
  child.on("error", (error) => {
1717
1762
  clearTimeout(timer);
1718
1763
  reject(/* @__PURE__ */ new Error(`${label} could not start (${error.message}).`));
1719
1764
  });
1720
- child.on("close", (code) => {
1721
- clearTimeout(timer);
1722
- if (code === 0) resolve();
1723
- else reject(/* @__PURE__ */ new Error(`${label} failed (code ${code}): ${stderr.trim()}`));
1724
- });
1765
+ child.on("close", finish);
1725
1766
  });
1726
1767
  }
1727
1768
  function runAdb(env, serial, args) {
@@ -1939,7 +1980,7 @@ var ControlHub = class {
1939
1980
  "spawn",
1940
1981
  udid,
1941
1982
  helper
1942
- ], "sim-fold", FOLD_TIMEOUT_MS);
1983
+ ], "sim-fold", this.options.foldTimeoutMs);
1943
1984
  this.options.frames.folded(udid, posture === "folded" ? "folded" : "unfolded");
1944
1985
  return null;
1945
1986
  } catch (cause) {
@@ -2101,9 +2142,12 @@ var Feed = class {
2101
2142
  return;
2102
2143
  }
2103
2144
  if (!isJsonObject(value)) return;
2104
- this.kept.push(value);
2145
+ this.kept.push({
2146
+ value,
2147
+ text: line
2148
+ });
2105
2149
  if (this.kept.length > spec.keep) this.kept.shift();
2106
- for (const listener of this.listeners) listener.item(value);
2150
+ for (const listener of this.listeners) listener.item(value, line);
2107
2151
  });
2108
2152
  const exited = (reason) => {
2109
2153
  if (this.child !== child) return;
@@ -2119,7 +2163,7 @@ var Feed = class {
2119
2163
  }
2120
2164
  add(listener) {
2121
2165
  this.listeners.add(listener);
2122
- for (const value of this.kept) listener.item(value);
2166
+ for (const { value, text } of this.kept) listener.item(value, text);
2123
2167
  return () => {
2124
2168
  if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
2125
2169
  };
@@ -2673,7 +2717,8 @@ const CONTROL_LIMITS = {
2673
2717
  leaseFor: "2m",
2674
2718
  inputPerSecond: 120,
2675
2719
  textCharsPerSecond: 40,
2676
- shapeChangesPerSecond: 2
2720
+ shapeChangesPerSecond: 2,
2721
+ foldTimeoutMs: 4e4
2677
2722
  };
2678
2723
  const LOCK_LIMITS = {
2679
2724
  timeoutMs: 3e4,
@@ -2768,7 +2813,7 @@ function take(bucket, cost, perSecond, capacity) {
2768
2813
  return true;
2769
2814
  }
2770
2815
  function send(socket, message) {
2771
- if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
2816
+ if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
2772
2817
  }
2773
2818
  function requestId(value) {
2774
2819
  return typeof value === "number" || typeof value === "string" ? value : null;
@@ -2871,7 +2916,8 @@ async function startServer(options) {
2871
2916
  idleMs: controlLimits.idleMs,
2872
2917
  renewMs: controlLimits.renewMs,
2873
2918
  leaseFor: controlLimits.leaseFor,
2874
- foldHelper
2919
+ foldHelper,
2920
+ foldTimeoutMs: controlLimits.foldTimeoutMs
2875
2921
  });
2876
2922
  const wss = new WebSocketServer({
2877
2923
  noServer: true,
@@ -3078,12 +3124,9 @@ async function startServer(options) {
3078
3124
  function subscribeStatus(id) {
3079
3125
  const subscription = openSubscription(id);
3080
3126
  if (!subscription) return;
3127
+ const envelope = `{"event":"status","subscription":${JSON.stringify(subscription)},"payload":`;
3081
3128
  const unsubscribe = feeds.subscribe(STATUS_FEED, {
3082
- item: (payload) => send(socket, {
3083
- event: "status",
3084
- subscription,
3085
- payload
3086
- }),
3129
+ item: (_payload, text) => send(socket, `${envelope}${text}}`),
3087
3130
  failed: (message) => {
3088
3131
  subscriptions.delete(subscription);
3089
3132
  send(socket, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/server",
3
- "version": "1.12.0",
3
+ "version": "1.13.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.12.0",
32
- "stim": "1.12.0",
31
+ "@stim-cli/core": "^1.13.0",
32
+ "stim": "1.13.0",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {