@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/README.md +50 -0
- 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 +141 -68
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +196 -118
- package/package.json +7 -1
- package/src/middleware.ts +37 -1
- 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/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, {
|
|
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<
|
|
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();
|