@expo/serve-sim 0.2.4 → 0.3.1
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 +75 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/serve-sim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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,11 +168,17 @@ function load(): NativeAddon {
|
|
|
144
168
|
*/
|
|
145
169
|
export class NativeHid {
|
|
146
170
|
private readonly handle: SimHIDHandle;
|
|
171
|
+
private setupFailed = false;
|
|
147
172
|
|
|
148
173
|
constructor(udid: string) {
|
|
149
174
|
this.handle = new (load().SimHID)(udid);
|
|
150
175
|
}
|
|
151
176
|
|
|
177
|
+
/** A failed native setup stays unavailable until this session is recreated. */
|
|
178
|
+
get inputUnavailable(): boolean {
|
|
179
|
+
return this.setupFailed;
|
|
180
|
+
}
|
|
181
|
+
|
|
152
182
|
// The N-API bindings throw synchronously when a JS value can't be coerced to
|
|
153
183
|
// the native parameter type (e.g. a touch with a non-string `type` →
|
|
154
184
|
// "Could not convert parameter 0 to type String"). HID now runs in-process,
|
|
@@ -157,6 +187,7 @@ export class NativeHid {
|
|
|
157
187
|
// the sim reboots. The spawned helper used to absorb this in its own process;
|
|
158
188
|
// `guard` restores that isolation by swallowing malformed-input errors.
|
|
159
189
|
private async guard<T>(op: string, fn: () => PromiseLike<T>, fallback: T): Promise<T> {
|
|
190
|
+
if (this.inputUnavailable) return fallback;
|
|
160
191
|
try {
|
|
161
192
|
return await fn();
|
|
162
193
|
} catch (err) {
|
|
@@ -169,6 +200,38 @@ export class NativeHid {
|
|
|
169
200
|
return this.guard("touch", () => this.handle.touch(type, x, y, w, h, edge), undefined);
|
|
170
201
|
}
|
|
171
202
|
|
|
203
|
+
async setScreen(screenId: number): Promise<void> {
|
|
204
|
+
if (this.inputUnavailable) return;
|
|
205
|
+
try {
|
|
206
|
+
await this.handle.setScreen(screenId);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Native setup is cached for this handle; a failed setup cannot recover
|
|
209
|
+
// until a new session. Keep capture running without calling partial HID state.
|
|
210
|
+
this.setupFailed = true;
|
|
211
|
+
console.error("[hid] Input setup failed; streaming will continue without input:", err instanceof Error ? err.message : err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
setHingeAngle(angle: number): Promise<boolean> {
|
|
216
|
+
return this.guard("setHingeAngle", () => this.handle.setHingeAngle(angle), false);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setHingePose(pose: string): Promise<boolean> {
|
|
220
|
+
return this.guard("setHingePose", () => this.handle.setHingePose(pose), false);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setTableMode(enabled: boolean): Promise<boolean> {
|
|
224
|
+
return this.guard("setTableMode", () => this.handle.setTableMode(enabled), false);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
supportsHingeAngle(): Promise<boolean> {
|
|
228
|
+
return this.guard("supportsHingeAngle", () => this.handle.supportsHingeAngle(), false);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
hingeState(): Promise<NativeHingeState> {
|
|
232
|
+
return this.guard("hingeState", () => this.handle.hingeState(), {});
|
|
233
|
+
}
|
|
234
|
+
|
|
172
235
|
multiTouch(type: TouchType, x1: number, y1: number, x2: number, y2: number, w: number, h: number): Promise<void> {
|
|
173
236
|
return this.guard("multiTouch", () => this.handle.multiTouch(type, x1, y1, x2, y2, w, h), undefined);
|
|
174
237
|
}
|
|
@@ -220,7 +283,11 @@ export class NativeHid {
|
|
|
220
283
|
export class NativeCapture {
|
|
221
284
|
private readonly handle: SimCaptureHandle;
|
|
222
285
|
|
|
223
|
-
|
|
286
|
+
/** A fixed screen ID captures that panel independently of active-display/input routing. */
|
|
287
|
+
constructor(udid: string, options: NativeCaptureOptions = DEFAULT_STREAM_ENCODER_SETTINGS, screenId?: number) {
|
|
288
|
+
if (screenId !== undefined && (!Number.isInteger(screenId) || screenId < 0 || screenId > 0xffff_ffff)) {
|
|
289
|
+
throw new RangeError("Screen ID must be an unsigned 32-bit integer.");
|
|
290
|
+
}
|
|
224
291
|
this.handle = new (load().SimCapture)(
|
|
225
292
|
udid,
|
|
226
293
|
options.mjpegFps,
|
|
@@ -228,6 +295,7 @@ export class NativeCapture {
|
|
|
228
295
|
options.maxDimension,
|
|
229
296
|
options.h264Fps,
|
|
230
297
|
options.h264Bitrate,
|
|
298
|
+
screenId ?? 0,
|
|
231
299
|
);
|
|
232
300
|
}
|
|
233
301
|
|
|
@@ -281,10 +349,14 @@ export class NativeCapture {
|
|
|
281
349
|
return readSenderStats(JSON.parse(await this.handle.webRTCSenderStats(sessionId ?? "")));
|
|
282
350
|
}
|
|
283
351
|
|
|
284
|
-
screenSize(): Promise<
|
|
352
|
+
screenSize(): Promise<NativeScreenInfo> {
|
|
285
353
|
return this.handle.screenSize();
|
|
286
354
|
}
|
|
287
355
|
|
|
356
|
+
subscribeScreenChanges(onChange: () => Promise<void>): Promise<NativeUnsubscribe> {
|
|
357
|
+
return this.handle.subscribeScreenChanges(onChange);
|
|
358
|
+
}
|
|
359
|
+
|
|
288
360
|
/** Halt frame production. Full teardown happens when this object is GC'd. */
|
|
289
361
|
stop(): Promise<void> {
|
|
290
362
|
return this.handle.stop();
|