@expo/serve-sim 0.2.3 → 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.3",
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"
@@ -53,6 +53,9 @@ export function connectToFetch(
53
53
 
54
54
  let status = 200;
55
55
  let responseHeaders = new Headers();
56
+ // Set before the status is written, the way `ServerResponse.setHeader` allows. `writeHead`
57
+ // values win, matching Node.
58
+ const presetHeaders = new Headers();
56
59
  let headersSent = false;
57
60
  let writableEnded = false;
58
61
  let writableFinished = false;
@@ -96,6 +99,9 @@ export function connectToFetch(
96
99
  const ensureResponse = () => {
97
100
  if (headersSent) return;
98
101
  headersSent = true;
102
+ for (const [name, value] of presetHeaders) {
103
+ if (!responseHeaders.has(name)) responseHeaders.set(name, value);
104
+ }
99
105
  resolveOnce(new Response(statusAllowsBody() ? body : null, { status, headers: responseHeaders }));
100
106
  };
101
107
  const writeChunk = (chunk: Buffer | string | Uint8Array) => {
@@ -122,6 +128,9 @@ export function connectToFetch(
122
128
  get writableNeedDrain() { return writableNeedDrain; },
123
129
  get statusCode() { return status; },
124
130
  set statusCode(nextStatus: number) { status = nextStatus; },
131
+ setHeader(name: string, value: string) {
132
+ presetHeaders.set(name, value);
133
+ },
125
134
  writeHead(nextStatus: number, headers?: Record<string, string | number | string[]>) {
126
135
  status = nextStatus;
127
136
  if (headers) {
package/src/middleware.ts CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  closeDeviceSession,
22
22
  getDeviceSession,
23
23
  peekDeviceSession,
24
- sendCorsPreflight,
25
24
  type HidSocket,
26
25
  } from "./device-session";
27
26
  import { assertPreviewAccess, assertUpgradeAccess } from "./session-auth";
@@ -40,6 +39,8 @@ import {
40
39
  serveDevicePlaceholderAsset,
41
40
  type DeviceKitChromeDescriptor,
42
41
  } from "./devicekit-chrome";
42
+ import { serveDeviceKitModelAsset } from "./devicekit-model";
43
+ import { validatePanelRoute } from "./panel-route";
43
44
  import { createExecWebSocketHandler, type UiRequestHandler } from "./exec-ws";
44
45
  import { claimHelperHidSocket, type UpgradeHandlerWebSocket } from "./middleware-utils";
45
46
  import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
@@ -179,11 +180,6 @@ function isSimulatorUdid(value: string): boolean {
179
180
  return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(value);
180
181
  }
181
182
 
182
- const SCREENSHOT_RESPONSE_HEADERS = {
183
- "Access-Control-Allow-Origin": "*",
184
- "Cache-Control": "no-store",
185
- };
186
-
187
183
  /** What to do with a persisted device state when reaping during a grid poll. */
188
184
  type StaleStateAction = "keep" | "recycle-self" | "recycle-helper";
189
185
 
@@ -558,6 +554,7 @@ function helperProxyTarget(rawUrl: string, prefix: string): { device: string | n
558
554
  "config",
559
555
  "foreground",
560
556
  "health",
557
+ "panel",
561
558
  "stream.avcc",
562
559
  "stream.mjpeg",
563
560
  "webrtc",
@@ -803,18 +800,20 @@ function serveHelperInProcess(
803
800
  ): boolean {
804
801
  if (!device) return false;
805
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
+ }
806
813
  if (endpoint === "/camera/status") {
807
814
  void handleCameraStatus(req, res, device);
808
815
  return true;
809
816
  }
810
- if (
811
- (endpoint === "/webrtc/offer" || endpoint === "/webrtc/close" || endpoint === "/webrtc/stats"
812
- || endpoint === "/stream-settings")
813
- && req.method === "OPTIONS"
814
- ) {
815
- sendCorsPreflight(res);
816
- return true;
817
- }
818
817
  // Polled once a second by the panel and the recorder, so creating a session here would start a
819
818
  // capture nothing asked for, and re-create one every tick after the simulator is shut down.
820
819
  if (endpoint === "/webrtc/stats") {
@@ -824,11 +823,25 @@ function serveHelperInProcess(
824
823
  return true;
825
824
  }
826
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
+ }
827
836
  try {
828
837
  session = getDeviceSession(device, initialStreamSettings);
829
838
  } catch {
830
839
  return false; // not booted / capture unavailable → 404
831
840
  }
841
+ if (panelRoute) {
842
+ void session.handlePanel(req, res, Number(panelRoute[1]), panelRoute[2]!);
843
+ return true;
844
+ }
832
845
  switch (endpoint) {
833
846
  case "/stream.mjpeg": session.handleMjpeg(req, res); return true;
834
847
  case "/stream.avcc": session.handleAvcc(req, res); return true;
@@ -1502,10 +1515,12 @@ export interface SimMiddlewareOptions {
1502
1515
  /** Stream transport and codec settings for the preview. */
1503
1516
  streamSettings?: StreamSettings;
1504
1517
  /**
1505
- * Origins allowed to read the `/metrics` SSE stream cross-origin (e.g. a
1506
- * hosted dashboard). Read-only telemetry only; the control routes stay
1507
- * same-origin + token-gated regardless. Loopback is always allowed.
1518
+ * Origins allowed to read this preview cross-origin (e.g. a hosted dashboard).
1519
+ * Every route answers with the policy, so the token gate stays the thing that
1520
+ * decides access. Loopback is always allowed.
1508
1521
  */
1522
+ corsOrigins?: string[];
1523
+ /** @deprecated Use `corsOrigins`. */
1509
1524
  metricsCorsOrigins?: string[];
1510
1525
  frameAncestors?: string[];
1511
1526
  /** Public page the Share button copies instead of this preview's address. */
@@ -1548,7 +1563,6 @@ export function handleMetricsRequest(
1548
1563
  res: SimRes,
1549
1564
  state: ServeSimState | null,
1550
1565
  samplerCache: MetricsSamplerCache = metricsSamplerCache,
1551
- corsOrigins: readonly string[] = [],
1552
1566
  tracker: ForegroundTrackerCache = foregroundTracker,
1553
1567
  ): void {
1554
1568
  if (!state) {
@@ -1561,7 +1575,6 @@ export function handleMetricsRequest(
1561
1575
  "Cache-Control": "no-cache",
1562
1576
  Connection: "keep-alive",
1563
1577
  "X-Accel-Buffering": "no",
1564
- ...corsAllowOriginHeaders(req.headers.origin, corsOrigins),
1565
1578
  });
1566
1579
  res.write(":\n\n");
1567
1580
  // Keep the foreground tail warm for this stream's lifetime so the sampler can scope to the
@@ -1594,9 +1607,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1594
1607
  // can't read this value (it's only injected into the preview page's config).
1595
1608
  const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
1596
1609
  const requirePreviewToken = options?.requirePreviewToken ?? false;
1597
- const metricsCorsOrigins = options?.metricsCorsOrigins ?? [];
1610
+ const corsOrigins = [...(options?.corsOrigins ?? []), ...(options?.metricsCorsOrigins ?? [])];
1598
1611
  const frameAncestors = options?.frameAncestors ?? [];
1599
1612
  const shareUrl = options?.shareUrl;
1613
+ // The proxied DevTools frontend sits behind the same cookie, so its document needs the policy too.
1614
+ const framePolicyHeaders: Record<string, string> = requirePreviewToken
1615
+ ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1616
+ : {};
1600
1617
 
1601
1618
  // Simulator-settings requests run in-process (just the underlying simctl /
1602
1619
  // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
@@ -1646,6 +1663,25 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1646
1663
  const rawUrl: string = req.url ?? "";
1647
1664
  const qIndex = rawUrl.indexOf("?");
1648
1665
  const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
1666
+ const ownPath = url === base || url.startsWith(`${base}/`);
1667
+ // Whole-middleware, so a new route is reachable cross-origin without opting in.
1668
+ if (ownPath) {
1669
+ // Unconditional, including when the origin is refused: a cached copy carrying no policy
1670
+ // would otherwise be replayed to an origin that is allowed one.
1671
+ res.setHeader("Vary", "Origin");
1672
+ const corsHeaders = corsAllowOriginHeaders(req.headers.origin, corsOrigins);
1673
+ for (const [name, value] of Object.entries(corsHeaders)) res.setHeader(name, value);
1674
+ }
1675
+ // A preflight carries no cookie and no token, so it has to be answered before the gate.
1676
+ if (ownPath && req.method === "OPTIONS") {
1677
+ res.writeHead(204, {
1678
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
1679
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
1680
+ "Access-Control-Max-Age": "600",
1681
+ });
1682
+ res.end();
1683
+ return;
1684
+ }
1649
1685
  const requestedDevice = queryDevice(rawUrl);
1650
1686
  const selectedDevice = requestedDevice ?? options?.device ?? null;
1651
1687
  const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
@@ -1653,7 +1689,11 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1653
1689
  // Gated as a whole rather than per route, so a new route is protected by default.
1654
1690
  if (
1655
1691
  !UNGATED_PATHS.some((path) => url === base + path)
1656
- && !assertPreviewAccess(req, res, execToken, { required: requirePreviewToken, basePath: base })
1692
+ && !assertPreviewAccess(req, res, execToken, {
1693
+ required: requirePreviewToken,
1694
+ basePath: base,
1695
+ htmlHeaders: framePolicyHeaders,
1696
+ })
1657
1697
  ) {
1658
1698
  return;
1659
1699
  }
@@ -1691,10 +1731,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1691
1731
  const upstream = await fetch(
1692
1732
  `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${upstreamSuffix}`,
1693
1733
  );
1734
+ const contentType = upstream.headers.get("content-type");
1735
+ const isDocument = contentType?.startsWith("text/html") ?? false;
1694
1736
  const headers: Record<string, string> = {
1695
- "Cache-Control": "public, max-age=604800",
1737
+ // A cached copy would otherwise outlive the policy it was fetched under.
1738
+ "Cache-Control": isDocument ? "no-store" : "public, max-age=604800",
1739
+ ...(isDocument ? framePolicyHeaders : {}),
1696
1740
  };
1697
- const contentType = upstream.headers.get("content-type");
1698
1741
  if (contentType) headers["Content-Type"] = contentType;
1699
1742
  res.writeHead(upstream.status, headers);
1700
1743
  res.end(Buffer.from(await upstream.arrayBuffer()));
@@ -1738,9 +1781,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1738
1781
  res.writeHead(200, {
1739
1782
  "Content-Type": "text/html; charset=utf-8",
1740
1783
  "Cache-Control": "no-store",
1741
- ...(requirePreviewToken
1742
- ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1743
- : {}),
1784
+ ...framePolicyHeaders,
1744
1785
  });
1745
1786
  res.end(html);
1746
1787
  return;
@@ -1766,6 +1807,11 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1766
1807
  return;
1767
1808
  }
1768
1809
 
1810
+ if (url === base + "/grid/api/devicekit-model") {
1811
+ serveDeviceKitModelAsset(req, res);
1812
+ return;
1813
+ }
1814
+
1769
1815
  // Static simulator metadata. The browser keeps this catalog in memory and
1770
1816
  // receives state/helper changes through the compact status feed below.
1771
1817
  if (url === base + "/grid/api/catalog") {
@@ -2185,7 +2231,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2185
2231
  if (url === base + "/api/screenshot") {
2186
2232
  if (req.method !== "POST") {
2187
2233
  res.writeHead(405, {
2188
- ...SCREENSHOT_RESPONSE_HEADERS,
2234
+ "Cache-Control": "no-store",
2189
2235
  "Content-Type": "text/plain; charset=utf-8",
2190
2236
  });
2191
2237
  res.end("method not allowed");
@@ -2194,7 +2240,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2194
2240
  let udid = selectedDevice;
2195
2241
  if (udid && !isSimulatorUdid(udid)) {
2196
2242
  res.writeHead(400, {
2197
- ...SCREENSHOT_RESPONSE_HEADERS,
2243
+ "Cache-Control": "no-store",
2198
2244
  "Content-Type": "application/json",
2199
2245
  });
2200
2246
  res.end(JSON.stringify({ ok: false, error: "Invalid simulator device ID" }));
@@ -2206,7 +2252,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2206
2252
  }
2207
2253
  if (!udid) {
2208
2254
  res.writeHead(400, {
2209
- ...SCREENSHOT_RESPONSE_HEADERS,
2255
+ "Cache-Control": "no-store",
2210
2256
  "Content-Type": "application/json",
2211
2257
  });
2212
2258
  res.end(JSON.stringify({ ok: false, error: "No booted simulator to screenshot" }));
@@ -2229,7 +2275,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2229
2275
  });
2230
2276
  const png = await readFile(file);
2231
2277
  res.writeHead(200, {
2232
- ...SCREENSHOT_RESPONSE_HEADERS,
2278
+ "Cache-Control": "no-store",
2233
2279
  "Content-Type": "image/png",
2234
2280
  });
2235
2281
  res.end(png);
@@ -2239,7 +2285,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2239
2285
  (typeof stderr === "string" && stderr.trim()) ||
2240
2286
  (err instanceof Error ? err.message : String(err));
2241
2287
  res.writeHead(500, {
2242
- ...SCREENSHOT_RESPONSE_HEADERS,
2288
+ "Cache-Control": "no-store",
2243
2289
  "Content-Type": "application/json",
2244
2290
  });
2245
2291
  res.end(JSON.stringify({ ok: false, error: message }));
@@ -2463,7 +2509,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2463
2509
  if (url === base + "/metrics") {
2464
2510
  const states = await readServeSimStates();
2465
2511
  const state = selectServeSimState(states, selectedDevice);
2466
- handleMetricsRequest(req, res, state, metricsSamplerCache, metricsCorsOrigins);
2512
+ handleMetricsRequest(req, res, state, metricsSamplerCache);
2467
2513
  return;
2468
2514
  }
2469
2515
 
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();