@expo/serve-sim 0.13.1 → 0.13.2

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.13.1",
3
+ "version": "0.13.2",
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
@@ -3,8 +3,10 @@ export { startDeviceInProcess } from "./device-lifecycle";
3
3
  export { gridDeviceState } from "./state";
4
4
  import { SingleDevicePolicy } from "./single-device";
5
5
  import { getScreenshotDisplayIdsAsync } from "./screenshot-display";
6
+ import { ScreenshotParams } from "./host-actions";
6
7
  import {
7
8
  captureScreenshotFileAsync,
9
+ captureNativeScreenshotAsync,
8
10
  ScreenshotDisplay,
9
11
  SCREENSHOT_DISPLAY_CHANGED,
10
12
  } from "./screenshot-store";
@@ -2128,6 +2130,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2128
2130
  const ALWAYS_GATED_PREFIX = "/network-capture";
2129
2131
 
2130
2132
  const connectMiddleware = (async (req: SimReq, res: SimRes, next?: SimNext) => {
2133
+ const requestStarted = performance.now();
2131
2134
  const rawUrl: string = req.url ?? "";
2132
2135
  const qIndex = rawUrl.indexOf("?");
2133
2136
  const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
@@ -2797,7 +2800,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2797
2800
  return;
2798
2801
  }
2799
2802
 
2800
- // Still-PNG capture via `simctl io <udid> screenshot`. Consumed by the
2803
+ // Still-PNG capture from native pixels (explicit displays use simctl). Consumed by the
2801
2804
  // Expo Device Hub dashboard's save-screenshot action and by the serve-sim web
2802
2805
  // UI when it is served through a tunnel (on loopback it uses the
2803
2806
  // screenshot.capture host action instead). Uses the
@@ -2842,18 +2845,46 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2842
2845
  }
2843
2846
  udid = udid.toUpperCase();
2844
2847
  // HTTP downloads avoid staging and Desktop copies, but share panel election.
2845
- const file = join(tmpdir(), `serve-sim-screenshot-${randomBytes(8).toString("hex")}.png`);
2848
+ let file: string | undefined;
2846
2849
  try {
2850
+ const captureStarted = performance.now();
2851
+ let nativeDuration = 0;
2847
2852
  const screenId = display?.data;
2848
- const capture = (id: number, read?: () => Promise<number>) =>
2849
- captureScreenshotFileAsync({ udid, file, display: id, timeoutMs: 5_000 }, read);
2850
- const result =
2853
+ const capture = async (id: number) => {
2854
+ file ??= join(tmpdir(), `serve-sim-screenshot-${randomBytes(8).toString("hex")}.png`);
2855
+ const result = await captureScreenshotFileAsync({
2856
+ udid,
2857
+ file,
2858
+ display: id,
2859
+ timeoutMs: 5_000,
2860
+ });
2861
+ if (result.exitCode !== 0)
2862
+ throw new Error(result.stderr || "Simulator screenshot failed");
2863
+ return await readFile(file);
2864
+ };
2865
+ const png =
2851
2866
  screenId !== undefined
2852
2867
  ? await capture(screenId)
2853
2868
  : await withScreenshotDeviceSession(udid, streamSettings, async (session) => {
2854
2869
  const read = () => session.activeScreenId();
2855
- return await capture(await read(), read);
2870
+ return Buffer.from(
2871
+ await captureNativeScreenshotAsync(
2872
+ await read(),
2873
+ read,
2874
+ async (id, timeoutMs) => {
2875
+ const started = performance.now();
2876
+ try {
2877
+ return await session.screenshot(id, timeoutMs);
2878
+ } finally {
2879
+ nativeDuration += performance.now() - started;
2880
+ }
2881
+ },
2882
+ 5_000,
2883
+ ),
2884
+ );
2856
2885
  }).catch(async (error) => {
2886
+ if (error instanceof Error && error.message === SCREENSHOT_DISPLAY_CHANGED)
2887
+ throw error;
2857
2888
  // A sole integrated display needs no native panel election. Never
2858
2889
  // fall back on an unknown profile or a multi-display device.
2859
2890
  if (!(await getBootedUdids())?.has(udid)) throw error;
@@ -2861,15 +2892,21 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2861
2892
  if (ids?.length !== 1) throw error;
2862
2893
  return await capture(ids[0]!);
2863
2894
  });
2864
- if (result.exitCode !== 0) throw new Error(result.stderr || "simctl screenshot failed");
2865
- const png = await readFile(file);
2895
+ const captureDuration = performance.now() - captureStarted;
2896
+ const artifactStarted = performance.now();
2866
2897
  const artifact = await saveScreenshotArtifact(png);
2867
2898
  recordScreenshotEvent(udid, artifact);
2899
+ const artifactDuration = performance.now() - artifactStarted;
2900
+ // Capture includes native queueing/PNG encoding; simctl also uses temporary-file I/O.
2901
+ // Total stops before response delivery; durations overlap, so do not add them.
2902
+ const serverTiming = `total;dur=${(performance.now() - requestStarted).toFixed(3)}, capture;dur=${captureDuration.toFixed(3)}, native;dur=${nativeDuration.toFixed(3)}, artifact;dur=${artifactDuration.toFixed(3)}`;
2868
2903
  res.writeHead(200, {
2869
2904
  "Cache-Control": "no-store",
2870
2905
  "Content-Type": "image/png",
2906
+ "Content-Length": String(png.byteLength),
2871
2907
  // The route allows configured cross-origin callers, and they can only read custom headers listed here.
2872
- "Access-Control-Expose-Headers": `${SCREENSHOT_ARTIFACT_HEADER}, ${SCREENSHOT_ARTIFACT_ERROR_HEADER}`,
2908
+ "Access-Control-Expose-Headers": `${SCREENSHOT_ARTIFACT_HEADER}, ${SCREENSHOT_ARTIFACT_ERROR_HEADER}, Server-Timing`,
2909
+ "Server-Timing": serverTiming,
2873
2910
  ...screenshotArtifactHeaders(artifact),
2874
2911
  });
2875
2912
  res.end(png);
@@ -2879,7 +2916,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2879
2916
  (typeof stderr === "string" && stderr.trim()) ||
2880
2917
  (err instanceof Error ? err.message : String(err));
2881
2918
  // The event log is browser-readable; simctl's stderr can name host paths, so it stays here.
2882
- console.error(`simctl screenshot failed for ${udid}:`, message);
2919
+ console.error(`Simulator screenshot failed for ${udid}:`, message);
2883
2920
  recordScreenshotEvent(udid, {
2884
2921
  status: "capture-failed",
2885
2922
  error: "simctl screenshot failed",
@@ -2899,7 +2936,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2899
2936
  );
2900
2937
  } finally {
2901
2938
  // Best-effort cleanup; the PNG is already in memory by now.
2902
- await unlink(file).catch(() => {});
2939
+ if (file) await unlink(file).catch(() => {});
2903
2940
  }
2904
2941
  return;
2905
2942
  }
@@ -3449,6 +3486,29 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
3449
3486
  corsOrigins,
3450
3487
  ssePrefixes: sseStreamPaths(base),
3451
3488
  onUiRequest: handleUiRequest,
3489
+ async onScreenshotRequest(payload, request) {
3490
+ const parsed = ScreenshotParams.omit({ fileName: true })
3491
+ .partial({ udid: true })
3492
+ .strict()
3493
+ .safeParse(payload);
3494
+ if (!parsed.success)
3495
+ return Response.json({ error: "Invalid screenshot request" }, { status: 400 });
3496
+ const url = new URL(`${base}/api/screenshot`, request.url);
3497
+ if (parsed.data.udid !== undefined) {
3498
+ const { resolveBootedDeviceAsync } = await import("./device");
3499
+ // Enforce the same target policy as ordinary actions before resolving aliases.
3500
+ policy?.resolveTarget(parsed.data.udid);
3501
+ url.searchParams.set("device", await resolveBootedDeviceAsync(parsed.data.udid));
3502
+ }
3503
+ if (parsed.data.display !== undefined)
3504
+ url.searchParams.set("display", String(parsed.data.display));
3505
+ return (await fetchMiddleware(
3506
+ new Request(url, {
3507
+ method: "POST",
3508
+ headers: { authorization: `Bearer ${execToken}` },
3509
+ }),
3510
+ ))!;
3511
+ },
3452
3512
  get unavailable() {
3453
3513
  return configuringSession || awaitingAdoption;
3454
3514
  },
package/src/native.ts CHANGED
@@ -61,6 +61,7 @@ interface SimHIDHandle {
61
61
  }
62
62
 
63
63
  interface SimCaptureHandle {
64
+ screenshot(screenID: number): Promise<Uint8Array>;
64
65
  start(): Promise<void>;
65
66
  suspendForReboot(): Promise<void>;
66
67
  resumeAfterReboot(): Promise<void>;
@@ -394,6 +395,10 @@ export class NativeHid {
394
395
  * thread after being marshalled from the native encode thread.
395
396
  */
396
397
  export class NativeCapture {
398
+ screenshot(screenID: number): Promise<Uint8Array> {
399
+ return this.handle.screenshot(screenID);
400
+ }
401
+
397
402
  private readonly handle: SimCaptureHandle;
398
403
 
399
404
  /** A fixed screen ID captures that panel independently of active-display/input routing. */