@expo/serve-sim 0.10.3 → 0.10.4

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.10.3",
3
+ "version": "0.10.4",
4
4
  "homepage": "https://github.com/expo/expo-device-hub/tree/main/packages/serve-sim/packages/serve-sim",
5
5
  "bugs": {
6
6
  "url": "https://github.com/expo/expo-device-hub/issues"
package/src/middleware.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import { getScreenshotDisplayIdsAsync } from "./screenshot-display";
2
+ import {
3
+ captureScreenshotFileAsync,
4
+ ScreenshotDisplay,
5
+ SCREENSHOT_DISPLAY_CHANGED,
6
+ } from "./screenshot-store";
1
7
  import { openSseStream } from "./sse-stream";
2
8
  import { parseCaptureProxy } from "./capture/upstream";
3
9
  import { execFile, execSync } from "child_process";
@@ -46,7 +52,14 @@ import {
46
52
  type ForegroundTrackerCache,
47
53
  } from "./foreground-tracker";
48
54
  import { corsAllowOriginHeaders, frameAncestorsPolicy, isAllowedOrigin } from "./middleware-utils";
49
- import { closeDeviceSession, getDeviceSession, peekDeviceSession } from "./device-session";
55
+ import {
56
+ closeDeviceSession,
57
+ getDeviceSession,
58
+ withScreenshotDeviceSession,
59
+ peekDeviceSession,
60
+ retainDeviceSession,
61
+ withDeviceSessionTask,
62
+ } from "./device-session";
50
63
  import {
51
64
  assertBearerAccess,
52
65
  assertCaptureAccess,
@@ -766,8 +779,29 @@ function serveHelperInProcess(
766
779
  void live.handlePanel(req, res, Number(panelRoute[1]), panelRoute[2]!);
767
780
  return true;
768
781
  }
782
+ // Unknown and one-shot helper reads must not claim screenshot-owned capture.
783
+ if (
784
+ !panelRoute &&
785
+ ![
786
+ "/stream.mjpeg",
787
+ "/stream.avcc",
788
+ "/stream-settings",
789
+ "/config",
790
+ "/health",
791
+ "/webrtc/offer",
792
+ "/webrtc/close",
793
+ "/recording/video",
794
+ "/ax",
795
+ "/foreground",
796
+ ].includes(endpoint ?? "")
797
+ )
798
+ return false;
799
+ const retainsCapture =
800
+ Boolean(panelRoute) ||
801
+ ((endpoint === "/stream.mjpeg" || endpoint === "/stream.avcc") && req.method === "GET") ||
802
+ ((endpoint === "/webrtc/offer" || endpoint === "/recording/video") && req.method === "POST");
769
803
  try {
770
- session = getDeviceSession(device, initialStreamSettings);
804
+ session = getDeviceSession(device, initialStreamSettings, retainsCapture);
771
805
  } catch {
772
806
  return false; // not booted / capture unavailable → 404
773
807
  }
@@ -783,7 +817,9 @@ function serveHelperInProcess(
783
817
  session.handleAvcc(req, res);
784
818
  return true;
785
819
  case "/stream-settings":
786
- void session.handleStreamSettings(req, res);
820
+ void withDeviceSessionTask(session, async () => {
821
+ if (await session.handleStreamSettings(req, res)) retainDeviceSession(session);
822
+ });
787
823
  return true;
788
824
  case "/config":
789
825
  session.handleConfig(req, res);
@@ -2765,21 +2801,36 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2765
2801
  res.end(JSON.stringify({ ok: false, error: "No booted simulator to screenshot" }));
2766
2802
  return;
2767
2803
  }
2768
- // simctl only writes to a file, so round-trip through a private tmp path
2769
- // instead of streaming; captures are a few MB at most.
2804
+ const displayValue = new URL(rawUrl, "http://localhost").searchParams.get("display");
2805
+ const display =
2806
+ displayValue === null ? undefined : ScreenshotDisplay.safeParse(Number(displayValue));
2807
+ if (display && !display.success) {
2808
+ res.writeHead(400, { "Cache-Control": "no-store", "Content-Type": "application/json" });
2809
+ res.end(JSON.stringify({ ok: false, error: "Invalid simulator display ID" }));
2810
+ return;
2811
+ }
2812
+ udid = udid.toUpperCase();
2813
+ // HTTP downloads avoid staging and Desktop copies, but share panel election.
2770
2814
  const file = join(tmpdir(), `serve-sim-screenshot-${randomBytes(8).toString("hex")}.png`);
2771
2815
  try {
2772
- await new Promise<void>((resolve, reject) => {
2773
- execFile(
2774
- "xcrun",
2775
- ["simctl", "io", udid, "screenshot", file],
2776
- { timeout: 5_000 },
2777
- (err, _stdout, stderr) => {
2778
- if (err) reject(Object.assign(err, { stderr: stderr?.toString() }));
2779
- else resolve();
2780
- },
2781
- );
2782
- });
2816
+ const screenId = display?.data;
2817
+ const capture = (id: number, read?: () => Promise<number>) =>
2818
+ captureScreenshotFileAsync({ udid, file, display: id, timeoutMs: 5_000 }, read);
2819
+ const result =
2820
+ screenId !== undefined
2821
+ ? await capture(screenId)
2822
+ : await withScreenshotDeviceSession(udid, streamSettings, async (session) => {
2823
+ const read = () => session.activeScreenId();
2824
+ return await capture(await read(), read);
2825
+ }).catch(async (error) => {
2826
+ // A sole integrated display needs no native panel election. Never
2827
+ // fall back on an unknown profile or a multi-display device.
2828
+ if (!(await getBootedUdids())?.has(udid)) throw error;
2829
+ const ids = await getScreenshotDisplayIdsAsync(udid);
2830
+ if (ids?.length !== 1) throw error;
2831
+ return await capture(ids[0]!);
2832
+ });
2833
+ if (result.exitCode !== 0) throw new Error(result.stderr || "simctl screenshot failed");
2783
2834
  const png = await readFile(file);
2784
2835
  const artifact = await saveScreenshotArtifact(png);
2785
2836
  recordScreenshotEvent(udid, artifact);
@@ -2806,7 +2857,15 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2806
2857
  "Cache-Control": "no-store",
2807
2858
  "Content-Type": "application/json",
2808
2859
  });
2809
- res.end(JSON.stringify({ ok: false, error: message }));
2860
+ res.end(
2861
+ JSON.stringify({
2862
+ ok: false,
2863
+ error:
2864
+ message === SCREENSHOT_DISPLAY_CHANGED
2865
+ ? SCREENSHOT_DISPLAY_CHANGED
2866
+ : "Simulator screenshot failed",
2867
+ }),
2868
+ );
2810
2869
  } finally {
2811
2870
  // Best-effort cleanup; the PNG is already in memory by now.
2812
2871
  await unlink(file).catch(() => {});
package/src/native.ts CHANGED
@@ -25,7 +25,7 @@ interface SimHIDHandle {
25
25
  setScreen(screenId: number): Promise<boolean | void>;
26
26
  supportsHingeAngle(): Promise<boolean>;
27
27
  hingeState(): Promise<NativeHingeState>;
28
- hingeControlEligibility(): Promise<NativeHingeState>;
28
+ hingeControlEligibility(): Promise<NativeHingeReadback>;
29
29
  setHingeAngle(angle: number): Promise<boolean>;
30
30
  setHingePose(pose: string): Promise<boolean>;
31
31
  supportsPhysicalOrientation(): Promise<boolean>;
@@ -77,6 +77,7 @@ interface SimCaptureHandle {
77
77
  closeWebRTCSession(sessionId: string): Promise<void>;
78
78
  webRTCSenderStats(sessionId: string): Promise<string>;
79
79
  screenSize(): Promise<NativeScreenInfo>;
80
+ activeScreenId(): Promise<number>;
80
81
  subscribeScreenChanges(onChange: () => Promise<void>): Promise<NativeUnsubscribe>;
81
82
  subscribeDeviceState(onSnapshot: (json: string) => Promise<void>): Promise<NativeUnsubscribe>;
82
83
  stop(): Promise<void>;
@@ -136,6 +137,9 @@ export type NativeHingeState = {
136
137
  physicalOrientation?: HingePhysicalOrientation;
137
138
  };
138
139
 
140
+ /** Command reconciliation never synthesizes native tabletop activity. */
141
+ export type NativeHingeReadback = NativeHingeState & { tableModeActive?: boolean };
142
+
139
143
  export type NativeDeviceStateSnapshot = {
140
144
  generation: number;
141
145
  revision: number;
@@ -289,13 +293,14 @@ export class NativeHid {
289
293
  return this.guard("hingeState", () => this.handle.hingeState(), {});
290
294
  }
291
295
 
292
- async hingeControlEligibility(): Promise<NativeHingeState> {
296
+ async hingeControlEligibility(): Promise<NativeHingeReadback | null> {
297
+ // An empty sample has unknown fields; null means the read failed.
293
298
  return (
294
- (await this.guard(
299
+ (await this.guard<NativeHingeReadback | null>(
295
300
  "hingeControlEligibility",
296
301
  () => this.handle.hingeControlEligibility(),
297
- {},
298
- )) ?? {}
302
+ null,
303
+ )) ?? null
299
304
  );
300
305
  }
301
306
 
@@ -484,6 +489,10 @@ export class NativeCapture {
484
489
  return this.handle.screenSize();
485
490
  }
486
491
 
492
+ activeScreenId(): Promise<number> {
493
+ return this.handle.activeScreenId();
494
+ }
495
+
487
496
  async subscribeDeviceState(
488
497
  onSnapshot: (snapshot: NativeDeviceStateSnapshot) => Promise<void>,
489
498
  ): Promise<NativeUnsubscribe> {