@expo/serve-sim 0.2.4 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/serve-sim",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -37,6 +37,9 @@
37
37
  "dist/middleware.js",
38
38
  "dist/middleware.cjs",
39
39
  "dist/state.js",
40
+ "dist/capability-loader/libServeSimCapabilityLoader.dylib",
41
+ "Sources/ServeSimCapabilityLoader/serve-sim-capability-loader.c",
42
+ "Sources/ServeSimCapabilityLoader/build.sh",
40
43
  "dist/simcam/libSimCameraInjector.dylib",
41
44
  "dist/simcam/serve-sim-camera-helper",
42
45
  "dist/simax/serve-sim-ax-settings",
@@ -74,6 +77,7 @@
74
77
  "scripts": {
75
78
  "build": "bun run build.ts",
76
79
  "dev": "bun run dev.ts",
80
+ "test:capability-loader": "Sources/ServeSimCapabilityLoader/tests/run.sh",
77
81
  "tart": "bun scripts/tart/cli.ts",
78
82
  "tart-test": "bun scripts/tart/cli.ts test",
79
83
  "tart-dev": "bun scripts/tart/cli.ts dev"
@@ -83,6 +87,7 @@
83
87
  "@types/debug": "^4.1.13",
84
88
  "@types/react": "^19.0.0",
85
89
  "@types/react-dom": "^19.0.0",
90
+ "@types/three": "^0.186.0",
86
91
  "@types/ws": "^8.18.1",
87
92
  "bun-plugin-tailwind": "^0.1.2",
88
93
  "commander": "^14.0.1",
@@ -93,6 +98,7 @@
93
98
  "react": "^19.0.0",
94
99
  "react-dom": "^19.0.0",
95
100
  "tailwindcss": "^4.1.7",
101
+ "three": "^0.186.0",
96
102
  "typescript": "^5.7.0",
97
103
  "werift": "^0.24.4",
98
104
  "zod": "^4.5.4"
package/src/middleware.ts CHANGED
@@ -39,6 +39,8 @@ import {
39
39
  serveDevicePlaceholderAsset,
40
40
  type DeviceKitChromeDescriptor,
41
41
  } from "./devicekit-chrome";
42
+ import { serveDeviceKitModelAsset } from "./devicekit-model";
43
+ import { validatePanelRoute } from "./panel-route";
42
44
  import { createExecWebSocketHandler, type UiRequestHandler } from "./exec-ws";
43
45
  import { claimHelperHidSocket, type UpgradeHandlerWebSocket } from "./middleware-utils";
44
46
  import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
@@ -552,6 +554,7 @@ function helperProxyTarget(rawUrl: string, prefix: string): { device: string | n
552
554
  "config",
553
555
  "foreground",
554
556
  "health",
557
+ "panel",
555
558
  "stream.avcc",
556
559
  "stream.mjpeg",
557
560
  "webrtc",
@@ -797,6 +800,16 @@ function serveHelperInProcess(
797
800
  ): boolean {
798
801
  if (!device) return false;
799
802
  const endpoint = upstreamPath.split("?")[0];
803
+ const panelRoute = /^\/panel\/([^/]+)\/(.+)$/.exec(endpoint ?? "");
804
+ if (panelRoute) {
805
+ const route = validatePanelRoute(panelRoute[1]!, panelRoute[2]!, req.method);
806
+ if ("error" in route) {
807
+ res.writeHead(route.status, { "Content-Type": "application/json" });
808
+ res.end(JSON.stringify({ error: route.error }));
809
+ return true;
810
+ }
811
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return true; }
812
+ }
800
813
  if (endpoint === "/camera/status") {
801
814
  void handleCameraStatus(req, res, device);
802
815
  return true;
@@ -810,11 +823,25 @@ function serveHelperInProcess(
810
823
  return true;
811
824
  }
812
825
  let session;
826
+ if (panelRoute && (panelRoute[2] === "webrtc/stats" || panelRoute[2] === "webrtc/close")) {
827
+ const live = peekDeviceSession(device);
828
+ if (!live) {
829
+ res.writeHead(panelRoute[2] === "webrtc/close" ? 204 : 404);
830
+ res.end();
831
+ return true;
832
+ }
833
+ void live.handlePanel(req, res, Number(panelRoute[1]), panelRoute[2]!);
834
+ return true;
835
+ }
813
836
  try {
814
837
  session = getDeviceSession(device, initialStreamSettings);
815
838
  } catch {
816
839
  return false; // not booted / capture unavailable → 404
817
840
  }
841
+ if (panelRoute) {
842
+ void session.handlePanel(req, res, Number(panelRoute[1]), panelRoute[2]!);
843
+ return true;
844
+ }
818
845
  switch (endpoint) {
819
846
  case "/stream.mjpeg": session.handleMjpeg(req, res); return true;
820
847
  case "/stream.avcc": session.handleAvcc(req, res); return true;
@@ -1662,7 +1689,11 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1662
1689
  // Gated as a whole rather than per route, so a new route is protected by default.
1663
1690
  if (
1664
1691
  !UNGATED_PATHS.some((path) => url === base + path)
1665
- && !assertPreviewAccess(req, res, execToken, { required: requirePreviewToken, basePath: base })
1692
+ && !assertPreviewAccess(req, res, execToken, {
1693
+ required: requirePreviewToken,
1694
+ basePath: base,
1695
+ htmlHeaders: framePolicyHeaders,
1696
+ })
1666
1697
  ) {
1667
1698
  return;
1668
1699
  }
@@ -1776,6 +1807,11 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1776
1807
  return;
1777
1808
  }
1778
1809
 
1810
+ if (url === base + "/grid/api/devicekit-model") {
1811
+ serveDeviceKitModelAsset(req, res);
1812
+ return;
1813
+ }
1814
+
1779
1815
  // Static simulator metadata. The browser keeps this catalog in memory and
1780
1816
  // receives state/helper changes through the compact status feed below.
1781
1817
  if (url === base + "/grid/api/catalog") {
package/src/native.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type StreamEncoderSettings,
17
17
  } from "./stream-settings";
18
18
  import { readSenderStats, type SenderStats } from "./webrtc-sender-stats";
19
+ import type { HingePhysicalOrientation } from "./hinge-control";
19
20
 
20
21
  const require = createRequire(import.meta.url);
21
22
 
@@ -24,6 +25,12 @@ const require = createRequire(import.meta.url);
24
25
  // handle is garbage-collected (Swift `deinit`), so there are no explicit
25
26
  // destroy/free calls here.
26
27
  interface SimHIDHandle {
28
+ setScreen(screenId: number): Promise<void>;
29
+ supportsHingeAngle(): Promise<boolean>;
30
+ hingeState(): Promise<NativeHingeState>;
31
+ setHingeAngle(angle: number): Promise<boolean>;
32
+ setHingePose(pose: string): Promise<boolean>;
33
+ setTableMode(enabled: boolean): Promise<boolean>;
27
34
  touch(type: TouchType, x: number, y: number, w: number, hh: number, edge: number): Promise<void>;
28
35
  multiTouch(type: TouchType, x1: number, y1: number, x2: number, y2: number, w: number, hh: number): Promise<void>;
29
36
  button(button: string): Promise<void>;
@@ -49,7 +56,8 @@ interface SimCaptureHandle {
49
56
  handleWebRTCOffer(offerJson: string): Promise<string>;
50
57
  closeWebRTCSession(sessionId: string): Promise<void>;
51
58
  webRTCSenderStats(sessionId: string): Promise<string>;
52
- screenSize(): Promise<{ width: number; height: number }>;
59
+ screenSize(): Promise<NativeScreenInfo>;
60
+ subscribeScreenChanges(onChange: () => Promise<void>): Promise<NativeUnsubscribe>;
53
61
  stop(): Promise<void>;
54
62
  subscribe(codec: number, onFrame: RawFrameCallback): Promise<NativeUnsubscribe>;
55
63
  }
@@ -63,6 +71,7 @@ interface NativeAddon {
63
71
  maxDimension: number,
64
72
  h264Fps: number,
65
73
  h264Bitrate: number,
74
+ screenId?: number,
66
75
  ) => SimCaptureHandle;
67
76
  axDescribe(udid: string): Promise<string>;
68
77
  axFrontmost(udid: string): Promise<string>;
@@ -98,6 +107,21 @@ export type AvccFrame = {
98
107
 
99
108
  export type NativeCaptureOptions = StreamEncoderSettings;
100
109
 
110
+ export type NativeHingeState = {
111
+ hingeAngle?: number;
112
+ tableMode?: boolean;
113
+ physicalOrientation?: HingePhysicalOrientation;
114
+ };
115
+
116
+ export type NativeScreenInfo = {
117
+ width: number;
118
+ height: number;
119
+ /** Present when CoreSimulator exposes its active screen metadata. */
120
+ orientation?: "portrait" | "portrait_upside_down" | "landscape_left" | "landscape_right";
121
+ screenId?: number;
122
+ chromeIdentifier?: string;
123
+ };
124
+
101
125
  export type NativeUnsubscribe = () => Promise<void>;
102
126
 
103
127
  export type TouchType = "begin" | "move" | "end";
@@ -144,11 +168,17 @@ function load(): NativeAddon {
144
168
  */
145
169
  export class NativeHid {
146
170
  private readonly handle: SimHIDHandle;
171
+ private setupFailed = false;
147
172
 
148
173
  constructor(udid: string) {
149
174
  this.handle = new (load().SimHID)(udid);
150
175
  }
151
176
 
177
+ /** A failed native setup stays unavailable until this session is recreated. */
178
+ get inputUnavailable(): boolean {
179
+ return this.setupFailed;
180
+ }
181
+
152
182
  // The N-API bindings throw synchronously when a JS value can't be coerced to
153
183
  // the native parameter type (e.g. a touch with a non-string `type` →
154
184
  // "Could not convert parameter 0 to type String"). HID now runs in-process,
@@ -157,6 +187,7 @@ export class NativeHid {
157
187
  // the sim reboots. The spawned helper used to absorb this in its own process;
158
188
  // `guard` restores that isolation by swallowing malformed-input errors.
159
189
  private async guard<T>(op: string, fn: () => PromiseLike<T>, fallback: T): Promise<T> {
190
+ if (this.inputUnavailable) return fallback;
160
191
  try {
161
192
  return await fn();
162
193
  } catch (err) {
@@ -169,6 +200,38 @@ export class NativeHid {
169
200
  return this.guard("touch", () => this.handle.touch(type, x, y, w, h, edge), undefined);
170
201
  }
171
202
 
203
+ async setScreen(screenId: number): Promise<void> {
204
+ if (this.inputUnavailable) return;
205
+ try {
206
+ await this.handle.setScreen(screenId);
207
+ } catch (err) {
208
+ // Native setup is cached for this handle; a failed setup cannot recover
209
+ // until a new session. Keep capture running without calling partial HID state.
210
+ this.setupFailed = true;
211
+ console.error("[hid] Input setup failed; streaming will continue without input:", err instanceof Error ? err.message : err);
212
+ }
213
+ }
214
+
215
+ setHingeAngle(angle: number): Promise<boolean> {
216
+ return this.guard("setHingeAngle", () => this.handle.setHingeAngle(angle), false);
217
+ }
218
+
219
+ setHingePose(pose: string): Promise<boolean> {
220
+ return this.guard("setHingePose", () => this.handle.setHingePose(pose), false);
221
+ }
222
+
223
+ setTableMode(enabled: boolean): Promise<boolean> {
224
+ return this.guard("setTableMode", () => this.handle.setTableMode(enabled), false);
225
+ }
226
+
227
+ supportsHingeAngle(): Promise<boolean> {
228
+ return this.guard("supportsHingeAngle", () => this.handle.supportsHingeAngle(), false);
229
+ }
230
+
231
+ hingeState(): Promise<NativeHingeState> {
232
+ return this.guard("hingeState", () => this.handle.hingeState(), {});
233
+ }
234
+
172
235
  multiTouch(type: TouchType, x1: number, y1: number, x2: number, y2: number, w: number, h: number): Promise<void> {
173
236
  return this.guard("multiTouch", () => this.handle.multiTouch(type, x1, y1, x2, y2, w, h), undefined);
174
237
  }
@@ -220,7 +283,11 @@ export class NativeHid {
220
283
  export class NativeCapture {
221
284
  private readonly handle: SimCaptureHandle;
222
285
 
223
- constructor(udid: string, options: NativeCaptureOptions = DEFAULT_STREAM_ENCODER_SETTINGS) {
286
+ /** A fixed screen ID captures that panel independently of active-display/input routing. */
287
+ constructor(udid: string, options: NativeCaptureOptions = DEFAULT_STREAM_ENCODER_SETTINGS, screenId?: number) {
288
+ if (screenId !== undefined && (!Number.isInteger(screenId) || screenId < 0 || screenId > 0xffff_ffff)) {
289
+ throw new RangeError("Screen ID must be an unsigned 32-bit integer.");
290
+ }
224
291
  this.handle = new (load().SimCapture)(
225
292
  udid,
226
293
  options.mjpegFps,
@@ -228,6 +295,7 @@ export class NativeCapture {
228
295
  options.maxDimension,
229
296
  options.h264Fps,
230
297
  options.h264Bitrate,
298
+ screenId ?? 0,
231
299
  );
232
300
  }
233
301
 
@@ -281,10 +349,14 @@ export class NativeCapture {
281
349
  return readSenderStats(JSON.parse(await this.handle.webRTCSenderStats(sessionId ?? "")));
282
350
  }
283
351
 
284
- screenSize(): Promise<{ width: number; height: number }> {
352
+ screenSize(): Promise<NativeScreenInfo> {
285
353
  return this.handle.screenSize();
286
354
  }
287
355
 
356
+ subscribeScreenChanges(onChange: () => Promise<void>): Promise<NativeUnsubscribe> {
357
+ return this.handle.subscribeScreenChanges(onChange);
358
+ }
359
+
288
360
  /** Halt frame production. Full teardown happens when this object is GC'd. */
289
361
  stop(): Promise<void> {
290
362
  return this.handle.stop();