@expo/serve-sim 0.2.4 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/serve-sim",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
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,6 +168,7 @@ function load(): NativeAddon {
144
168
  */
145
169
  export class NativeHid {
146
170
  private readonly handle: SimHIDHandle;
171
+ private inputUnavailable = false;
147
172
 
148
173
  constructor(udid: string) {
149
174
  this.handle = new (load().SimHID)(udid);
@@ -157,6 +182,7 @@ export class NativeHid {
157
182
  // the sim reboots. The spawned helper used to absorb this in its own process;
158
183
  // `guard` restores that isolation by swallowing malformed-input errors.
159
184
  private async guard<T>(op: string, fn: () => PromiseLike<T>, fallback: T): Promise<T> {
185
+ if (this.inputUnavailable) return fallback;
160
186
  try {
161
187
  return await fn();
162
188
  } catch (err) {
@@ -169,6 +195,38 @@ export class NativeHid {
169
195
  return this.guard("touch", () => this.handle.touch(type, x, y, w, h, edge), undefined);
170
196
  }
171
197
 
198
+ async setScreen(screenId: number): Promise<void> {
199
+ if (this.inputUnavailable) return;
200
+ try {
201
+ await this.handle.setScreen(screenId);
202
+ } catch (err) {
203
+ // Native setup is cached for this handle; a failed setup cannot recover
204
+ // until a new session. Keep capture running without calling partial HID state.
205
+ this.inputUnavailable = true;
206
+ console.error("[hid] Input setup failed; streaming will continue without input:", err instanceof Error ? err.message : err);
207
+ }
208
+ }
209
+
210
+ setHingeAngle(angle: number): Promise<boolean> {
211
+ return this.guard("setHingeAngle", () => this.handle.setHingeAngle(angle), false);
212
+ }
213
+
214
+ setHingePose(pose: string): Promise<boolean> {
215
+ return this.guard("setHingePose", () => this.handle.setHingePose(pose), false);
216
+ }
217
+
218
+ setTableMode(enabled: boolean): Promise<boolean> {
219
+ return this.guard("setTableMode", () => this.handle.setTableMode(enabled), false);
220
+ }
221
+
222
+ supportsHingeAngle(): Promise<boolean> {
223
+ return this.guard("supportsHingeAngle", () => this.handle.supportsHingeAngle(), false);
224
+ }
225
+
226
+ hingeState(): Promise<NativeHingeState> {
227
+ return this.guard("hingeState", () => this.handle.hingeState(), {});
228
+ }
229
+
172
230
  multiTouch(type: TouchType, x1: number, y1: number, x2: number, y2: number, w: number, h: number): Promise<void> {
173
231
  return this.guard("multiTouch", () => this.handle.multiTouch(type, x1, y1, x2, y2, w, h), undefined);
174
232
  }
@@ -220,7 +278,11 @@ export class NativeHid {
220
278
  export class NativeCapture {
221
279
  private readonly handle: SimCaptureHandle;
222
280
 
223
- constructor(udid: string, options: NativeCaptureOptions = DEFAULT_STREAM_ENCODER_SETTINGS) {
281
+ /** A fixed screen ID captures that panel independently of active-display/input routing. */
282
+ constructor(udid: string, options: NativeCaptureOptions = DEFAULT_STREAM_ENCODER_SETTINGS, screenId?: number) {
283
+ if (screenId !== undefined && (!Number.isInteger(screenId) || screenId < 0 || screenId > 0xffff_ffff)) {
284
+ throw new RangeError("Screen ID must be an unsigned 32-bit integer.");
285
+ }
224
286
  this.handle = new (load().SimCapture)(
225
287
  udid,
226
288
  options.mjpegFps,
@@ -228,6 +290,7 @@ export class NativeCapture {
228
290
  options.maxDimension,
229
291
  options.h264Fps,
230
292
  options.h264Bitrate,
293
+ screenId ?? 0,
231
294
  );
232
295
  }
233
296
 
@@ -281,10 +344,14 @@ export class NativeCapture {
281
344
  return readSenderStats(JSON.parse(await this.handle.webRTCSenderStats(sessionId ?? "")));
282
345
  }
283
346
 
284
- screenSize(): Promise<{ width: number; height: number }> {
347
+ screenSize(): Promise<NativeScreenInfo> {
285
348
  return this.handle.screenSize();
286
349
  }
287
350
 
351
+ subscribeScreenChanges(onChange: () => Promise<void>): Promise<NativeUnsubscribe> {
352
+ return this.handle.subscribeScreenChanges(onChange);
353
+ }
354
+
288
355
  /** Halt frame production. Full teardown happens when this object is GC'd. */
289
356
  stop(): Promise<void> {
290
357
  return this.handle.stop();