@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/README.md +51 -1
- package/Sources/ServeSimCapabilityLoader/build.sh +32 -0
- package/Sources/ServeSimCapabilityLoader/serve-sim-capability-loader.c +322 -0
- package/dist/capability-loader/libServeSimCapabilityLoader.dylib +0 -0
- package/dist/middleware.js +153 -80
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +201 -123
- package/package.json +7 -1
- package/src/connect-to-fetch.ts +9 -0
- package/src/middleware.ts +78 -32
- package/src/native.ts +70 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/serve-sim",
|
|
3
|
-
"version": "0.
|
|
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/connect-to-fetch.ts
CHANGED
|
@@ -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
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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();
|