@expo/serve-sim 0.1.35-canary.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.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +279 -0
  3. package/Sources/SimAXSettings/build.sh +21 -0
  4. package/Sources/SimAXSettings/sim-ax-settings.m +273 -0
  5. package/Sources/SimCameraHelper/build.sh +33 -0
  6. package/Sources/SimCameraHelper/main.m +955 -0
  7. package/Sources/SimCameraInjector/SimCamFakes.h +88 -0
  8. package/Sources/SimCameraInjector/SimCamFakes.m +704 -0
  9. package/Sources/SimCameraInjector/SimCamFrameSource.h +26 -0
  10. package/Sources/SimCameraInjector/SimCamFrameSource.m +577 -0
  11. package/Sources/SimCameraInjector/SimCamLog.h +5 -0
  12. package/Sources/SimCameraInjector/SimCamLog.m +9 -0
  13. package/Sources/SimCameraInjector/SimCamSwizzles.h +3 -0
  14. package/Sources/SimCameraInjector/SimCamSwizzles.m +1338 -0
  15. package/Sources/SimCameraInjector/SimCameraInjector.m +19 -0
  16. package/Sources/SimCameraInjector/build.sh +39 -0
  17. package/Sources/SimCameraInjector/include/SimCamShared.h +79 -0
  18. package/dist/bin/LiveKitWebRTC.framework/LiveKitWebRTC +0 -0
  19. package/dist/bin/LiveKitWebRTC.framework/Resources/Info.plist +36 -0
  20. package/dist/bin/LiveKitWebRTC.framework/Resources/LICENSE.webrtc +29 -0
  21. package/dist/bin/LiveKitWebRTC.framework/Resources/PrivacyInfo.xcprivacy +32 -0
  22. package/dist/bin/LiveKitWebRTC.framework/_CodeSignature/CodeResources +150 -0
  23. package/dist/middleware.cjs +2 -0
  24. package/dist/middleware.js +123 -0
  25. package/dist/native/serve-sim-native.node +0 -0
  26. package/dist/serve-sim.js +218 -0
  27. package/dist/simax/serve-sim-ax-settings +0 -0
  28. package/dist/simcam/libSimCameraInjector.dylib +0 -0
  29. package/dist/simcam/serve-sim-camera-helper +0 -0
  30. package/dist/state.js +1 -0
  31. package/package.json +99 -0
  32. package/src/ax-shared.ts +25 -0
  33. package/src/ax.ts +258 -0
  34. package/src/camera-helper.ts +150 -0
  35. package/src/connect-to-fetch.ts +239 -0
  36. package/src/middleware.ts +2208 -0
  37. package/src/native.ts +294 -0
  38. package/src/state.ts +86 -0
  39. package/src/stream-settings.ts +202 -0
Binary file
package/dist/state.js ADDED
@@ -0,0 +1 @@
1
+ import{tmpdir as O}from"os";import{join as G}from"path";import{readdirSync as P,mkdirSync as Q,writeFileSync as U,renameSync as V}from"fs";var B=G(O(),"serve-sim"),$=G(B,"server.json");function W(q){return G(B,`server-${q}.json`)}function k(q,z,C="/",H="127.0.0.1",K){let J=H==="0.0.0.0"||H==="::"?"127.0.0.1":H,M=C.replace(/^\/+/,"").replace(/\/+$/,""),N=M===""?"":`/${M}`;return{pid:process.pid,port:z,device:q,url:`http://${J}:${z}`,streamUrl:`http://${J}:${z}${N}/helper/${q}/stream.mjpeg`,wsUrl:`ws://${J}:${z}${N}/helper/${q}/ws`,...K?{streamSettings:K}:{}}}function w(q){Q(B,{recursive:!0});let z=W(q.device),C=`${z}.${process.pid}.tmp`;U(C,JSON.stringify(q,null,2),{mode:384}),V(C,z)}function L(){try{return P(B).filter((q)=>q.startsWith("server-")&&q.endsWith(".json")).map((q)=>G(B,q))}catch{return[]}}export{w as writeServeSimState,W as stateFileForDevice,L as listStateFiles,k as inProcessServeSimState,$ as STATE_FILE,B as STATE_DIR};
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "@expo/serve-sim",
3
+ "version": "0.1.35-canary.0",
4
+ "type": "module",
5
+ "author": {
6
+ "name": "Evan Bacon",
7
+ "email": "baconbrix@gmail.com",
8
+ "url": "https://evanbacon.dev"
9
+ },
10
+ "license": "Apache-2.0",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/expo/serve-sim.git",
14
+ "directory": "packages/serve-sim"
15
+ },
16
+ "homepage": "https://github.com/expo/serve-sim#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/expo/serve-sim/issues"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "provenance": true
23
+ },
24
+ "bin": {
25
+ "serve-sim": "dist/serve-sim.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "cpu": [
31
+ "arm64"
32
+ ],
33
+ "files": [
34
+ "LICENSE",
35
+ "dist/serve-sim.js",
36
+ "dist/middleware.js",
37
+ "dist/middleware.cjs",
38
+ "dist/state.js",
39
+ "dist/simcam/libSimCameraInjector.dylib",
40
+ "dist/simcam/serve-sim-camera-helper",
41
+ "dist/simax/serve-sim-ax-settings",
42
+ "src/ax-shared.ts",
43
+ "src/ax.ts",
44
+ "src/camera-helper.ts",
45
+ "src/connect-to-fetch.ts",
46
+ "src/middleware.ts",
47
+ "src/native.ts",
48
+ "src/state.ts",
49
+ "src/stream-settings.ts",
50
+ "Sources/SimCameraInjector/SimCameraInjector.m",
51
+ "Sources/SimCameraInjector/SimCam*.h",
52
+ "Sources/SimCameraInjector/SimCam*.m",
53
+ "Sources/SimCameraInjector/include/SimCamShared.h",
54
+ "Sources/SimCameraInjector/build.sh",
55
+ "Sources/SimCameraHelper/main.m",
56
+ "Sources/SimCameraHelper/build.sh",
57
+ "Sources/SimAXSettings/sim-ax-settings.m",
58
+ "Sources/SimAXSettings/build.sh",
59
+ "dist/native/serve-sim-native.node",
60
+ "dist/bin/LiveKitWebRTC.framework"
61
+ ],
62
+ "exports": {
63
+ "./middleware": {
64
+ "require": "./dist/middleware.cjs",
65
+ "import": "./dist/middleware.js",
66
+ "types": "./src/middleware.ts"
67
+ },
68
+ "./state": {
69
+ "import": "./dist/state.js",
70
+ "types": "./src/state.ts"
71
+ }
72
+ },
73
+ "scripts": {
74
+ "build": "bun run build.ts",
75
+ "dev": "bun run dev.ts"
76
+ },
77
+ "devDependencies": {
78
+ "@types/bun": "latest",
79
+ "@types/debug": "^4.1.13",
80
+ "@types/react": "^19.0.0",
81
+ "@types/react-dom": "^19.0.0",
82
+ "@types/ws": "^8.18.1",
83
+ "bun-plugin-tailwind": "^0.1.2",
84
+ "commander": "^14.0.1",
85
+ "debug": "^4.4.3",
86
+ "lucide-react": "^1.20.0",
87
+ "node-swift": "1.5.1",
88
+ "preact": "^10.29.1",
89
+ "react": "^19.0.0",
90
+ "react-dom": "^19.0.0",
91
+ "tailwindcss": "^4.1.7",
92
+ "typescript": "^5.7.0"
93
+ },
94
+ "dependencies": {
95
+ "inspect-webkit": "^0.0.5",
96
+ "sonner": "^2.0.7",
97
+ "ws": "^8.21.0"
98
+ }
99
+ }
@@ -0,0 +1,25 @@
1
+ export const AX_UNAVAILABLE_ERROR = "Accessibility unavailable on this simulator.";
2
+
3
+ export interface AxRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+
10
+ export interface AxElement {
11
+ id: string;
12
+ path: string;
13
+ label: string;
14
+ value: string;
15
+ role: string;
16
+ type: string;
17
+ enabled: boolean;
18
+ frame: AxRect;
19
+ }
20
+
21
+ export interface AxSnapshot {
22
+ screen: { width: number; height: number };
23
+ elements: AxElement[];
24
+ errors?: string[];
25
+ }
package/src/ax.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { AX_UNAVAILABLE_ERROR } from "./ax-shared";
2
+ import type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
3
+ import { axDescribeAsync } from "./native";
4
+
5
+ export type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
6
+
7
+ const MAX_ELEMENTS = 500;
8
+ const POLL_INTERVAL_MS = 500;
9
+ const MAX_POLL_INTERVAL_MS = 2000;
10
+ const UNAVAILABLE_RETRY_INTERVAL_MS = 15_000;
11
+
12
+ interface RawAxeNode {
13
+ AXUniqueId: string | null;
14
+ AXLabel: string | null;
15
+ AXValue: string | null;
16
+ enabled: boolean;
17
+ frame: AxRect;
18
+ role_description: string;
19
+ type: string;
20
+ children: RawAxeNode[];
21
+ }
22
+
23
+ function chooseScreenFrame(roots: RawAxeNode[]) {
24
+ return roots[0]?.frame ?? {
25
+ x: 0,
26
+ y: 0,
27
+ width: 1,
28
+ height: 1,
29
+ };
30
+ }
31
+
32
+ function sameRect(a: AxRect, b: AxRect) {
33
+ return (
34
+ Math.abs(a.x - b.x) < 0.5 &&
35
+ Math.abs(a.y - b.y) < 0.5 &&
36
+ Math.abs(a.width - b.width) < 0.5 &&
37
+ Math.abs(a.height - b.height) < 0.5
38
+ );
39
+ }
40
+
41
+ function normalizeAxTree(roots: RawAxeNode[]): AxSnapshot {
42
+ const screen = chooseScreenFrame(roots);
43
+ const elements: AxElement[] = [];
44
+
45
+ const visit = (node: RawAxeNode, path: string) => {
46
+ if (elements.length >= MAX_ELEMENTS) return;
47
+
48
+ const frame = node.frame;
49
+ const isScreenSized = sameRect(frame, screen);
50
+
51
+ if (!isScreenSized) {
52
+ elements.push({
53
+ id: node.AXUniqueId ?? path,
54
+ path,
55
+ label: node.AXLabel ?? "",
56
+ value: node.AXValue ?? "",
57
+ role: node.role_description,
58
+ type: node.type,
59
+ enabled: node.enabled !== false,
60
+ frame,
61
+ });
62
+ }
63
+
64
+ for (let index = 0; index < node.children.length && elements.length < MAX_ELEMENTS; index++) {
65
+ visit(node.children[index]!, `${path}.${index}`);
66
+ }
67
+ };
68
+
69
+ for (let index = 0; index < roots.length && elements.length < MAX_ELEMENTS; index++) {
70
+ visit(roots[index]!, String(index));
71
+ }
72
+
73
+ return {
74
+ screen: {
75
+ width: screen.width,
76
+ height: screen.height,
77
+ },
78
+ elements,
79
+ };
80
+ }
81
+
82
+ async function snapshotFromNative(udid: string): Promise<AxSnapshot> {
83
+ let raw: RawAxeNode[];
84
+ try {
85
+ raw = JSON.parse(await axDescribeAsync(udid)) as RawAxeNode[];
86
+ } catch {
87
+ // The in-process AX bridge throws when the simulator can't satisfy
88
+ // accessibility right now (framework missing, SpringBoard restarting,
89
+ // etc). Surface as the standard "unavailable" error so the streamer backs
90
+ // off and recovers automatically.
91
+ return {
92
+ screen: { width: 1, height: 1 },
93
+ elements: [],
94
+ errors: [AX_UNAVAILABLE_ERROR],
95
+ };
96
+ }
97
+ return normalizeAxTree(raw);
98
+ }
99
+
100
+ function isAxUnavailableSnapshot(snapshot: AxSnapshot | null) {
101
+ return snapshot?.errors?.includes(AX_UNAVAILABLE_ERROR) ?? false;
102
+ }
103
+
104
+ function isUsableAxSnapshot(snapshot: AxSnapshot) {
105
+ return (
106
+ snapshot.elements.length > 0 &&
107
+ snapshot.screen.width > 1 &&
108
+ snapshot.screen.height > 1
109
+ );
110
+ }
111
+
112
+ async function collectAxSnapshot(udid: string): Promise<AxSnapshot> {
113
+ const errors: string[] = [];
114
+
115
+ try {
116
+ const snapshot = await snapshotFromNative(udid);
117
+ if (snapshot.errors?.length) return snapshot;
118
+ if (!isUsableAxSnapshot(snapshot)) {
119
+ throw new Error(
120
+ `native AX returned ${snapshot.elements.length} elements in ${snapshot.screen.width}x${snapshot.screen.height} AX space`,
121
+ );
122
+ }
123
+ return {
124
+ ...snapshot,
125
+ errors,
126
+ };
127
+ } catch (error) {
128
+ errors.push((error as Error).message || String(error));
129
+ }
130
+
131
+ return {
132
+ screen: { width: 1, height: 1 },
133
+ elements: [],
134
+ errors,
135
+ };
136
+ }
137
+
138
+ function sseMessage(payload: unknown) {
139
+ return `data: ${JSON.stringify(payload)}\n\n`;
140
+ }
141
+
142
+ interface AxStreamer {
143
+ addClient(res: { write(chunk: string): void }): () => void;
144
+ dispose(): void;
145
+ }
146
+
147
+ function createAxStreamer({ udid }: { udid: string }): AxStreamer {
148
+ const clients = new Set<{ write(chunk: string): void }>();
149
+ let timer: ReturnType<typeof setTimeout> | null = null;
150
+ let latestMessage: string | null = null;
151
+ let pollIntervalMs = POLL_INTERVAL_MS;
152
+ let polling = false;
153
+ let disposed = false;
154
+
155
+ const schedule = () => {
156
+ if (disposed || clients.size === 0 || timer) return;
157
+ timer = setTimeout(poll, pollIntervalMs);
158
+ };
159
+
160
+ const poll = async () => {
161
+ timer = null;
162
+ if (disposed || polling || clients.size === 0) {
163
+ schedule();
164
+ return;
165
+ }
166
+
167
+ polling = true;
168
+ try {
169
+ const next = await collectAxSnapshot(udid);
170
+ const nextMessage = sseMessage(next);
171
+ if (nextMessage !== latestMessage) {
172
+ for (const client of clients) client.write(nextMessage);
173
+ pollIntervalMs = POLL_INTERVAL_MS;
174
+ } else {
175
+ pollIntervalMs = Math.min(pollIntervalMs * 2, MAX_POLL_INTERVAL_MS);
176
+ }
177
+ latestMessage = nextMessage;
178
+ // If the helper says AX is unavailable (framework missing, sim
179
+ // booting), keep polling but back off so we recover automatically
180
+ // without spamming requests.
181
+ if (isAxUnavailableSnapshot(next)) {
182
+ pollIntervalMs = UNAVAILABLE_RETRY_INTERVAL_MS;
183
+ }
184
+ } finally {
185
+ polling = false;
186
+ schedule();
187
+ }
188
+ };
189
+
190
+ return {
191
+ addClient(res) {
192
+ if (disposed) return () => {};
193
+ clients.add(res);
194
+ if (latestMessage) res.write(latestMessage);
195
+ void poll();
196
+ return () => {
197
+ clients.delete(res);
198
+ if (clients.size === 0 && timer) {
199
+ clearTimeout(timer);
200
+ timer = null;
201
+ }
202
+ };
203
+ },
204
+ dispose() {
205
+ if (disposed) return;
206
+ disposed = true;
207
+ if (timer) {
208
+ clearTimeout(timer);
209
+ timer = null;
210
+ }
211
+ clients.clear();
212
+ latestMessage = null;
213
+ },
214
+ };
215
+ }
216
+
217
+ export interface AxStreamerCache {
218
+ get(udid: string): AxStreamer;
219
+ prune(activeUdids: Iterable<string>): void;
220
+ size(): number;
221
+ }
222
+
223
+ export function createAxStreamerCache(): AxStreamerCache {
224
+ const streamers = new Map<string, AxStreamer>();
225
+
226
+ return {
227
+ /**
228
+ * Get (or create) the accessibility-snapshot streamer for a simulator.
229
+ * Snapshots come from the in-process native AX bridge keyed by udid.
230
+ */
231
+ get(udid: string) {
232
+ const existing = streamers.get(udid);
233
+ if (existing) return existing;
234
+
235
+ const streamer = createAxStreamer({ udid });
236
+ streamers.set(udid, streamer);
237
+ return streamer;
238
+ },
239
+ /**
240
+ * Drop streamers for simulators no longer present in `activeUdids`.
241
+ * Without this, the cache grew append-only across a server's lifetime
242
+ * as devices were booted/erased/reset, each entry holding a poll
243
+ * timer, last-snapshot buffer, and SSE client set.
244
+ */
245
+ prune(activeUdids) {
246
+ const active = activeUdids instanceof Set ? activeUdids : new Set(activeUdids);
247
+ for (const [udid, streamer] of streamers) {
248
+ if (!active.has(udid)) {
249
+ streamer.dispose();
250
+ streamers.delete(udid);
251
+ }
252
+ }
253
+ },
254
+ size() {
255
+ return streamers.size;
256
+ },
257
+ };
258
+ }
@@ -0,0 +1,150 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { STATE_DIR } from "./state";
5
+
6
+ export const CAMERA_STATE_DIR = join(STATE_DIR, "simcam");
7
+ const HELPER_TIMEOUT_MS = 3000;
8
+
9
+ interface InjectedBundlesState {
10
+ helperPid: number;
11
+ bundleIds: string[];
12
+ }
13
+
14
+ export interface CameraHelperReply {
15
+ [key: string]: unknown;
16
+ ok?: boolean;
17
+ source?: string;
18
+ arg?: string;
19
+ mirror?: string;
20
+ error?: string;
21
+ }
22
+
23
+ export interface CameraStatusReply extends CameraHelperReply {
24
+ udid: string;
25
+ alive: boolean;
26
+ helperPid?: number | null;
27
+ bundleIds?: string[];
28
+ }
29
+
30
+ export function cameraHelperPidFile(udid: string): string {
31
+ return join(CAMERA_STATE_DIR, `${udid}.pid`);
32
+ }
33
+
34
+ export function cameraHelperBundlesFile(udid: string): string {
35
+ return join(CAMERA_STATE_DIR, `${udid}.bundles.json`);
36
+ }
37
+
38
+ export function cameraHelperSocketFile(udid: string): string {
39
+ // POSIX sun_path is 104 chars on macOS, so keep this short.
40
+ const short = createHash("sha1").update(udid).digest("hex").slice(0, 12);
41
+ return `/tmp/serve-sim-cam-${short}.sock`;
42
+ }
43
+
44
+ function isProcessAlive(pid: number): boolean {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ function readCameraHelperPid(udid: string): number | null {
54
+ try {
55
+ const pid = Number(readFileSync(cameraHelperPidFile(udid), "utf-8").trim());
56
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function parseCameraHelperReply(value: unknown): CameraHelperReply {
63
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
64
+ throw new Error("invalid camera helper reply");
65
+ }
66
+ return value as CameraHelperReply;
67
+ }
68
+
69
+ export async function sendCameraHelperCommand(
70
+ udid: string,
71
+ command: object,
72
+ ): Promise<CameraHelperReply> {
73
+ const socketPath = cameraHelperSocketFile(udid);
74
+ if (!existsSync(socketPath)) throw new Error("camera helper socket not found");
75
+ const net = await import("net");
76
+ return new Promise((resolve, reject) => {
77
+ const socket = net.createConnection(socketPath);
78
+ socket.setEncoding("utf8");
79
+ let buffer = "";
80
+ let settled = false;
81
+ let timeout: ReturnType<typeof setTimeout> | undefined;
82
+
83
+ const settle = (error?: unknown, reply?: CameraHelperReply) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ if (timeout) clearTimeout(timeout);
87
+ if (error) reject(error);
88
+ else resolve(reply ?? {});
89
+ };
90
+
91
+ socket.on("data", (chunk) => {
92
+ buffer += chunk;
93
+ const newline = buffer.indexOf("\n");
94
+ if (newline < 0) return;
95
+ try {
96
+ settle(undefined, parseCameraHelperReply(JSON.parse(buffer.slice(0, newline)) as unknown));
97
+ } catch (error) {
98
+ settle(error);
99
+ }
100
+ socket.end();
101
+ });
102
+ socket.on("error", settle);
103
+ socket.on("close", () => settle(new Error("socket closed")));
104
+ timeout = setTimeout(() => {
105
+ socket.destroy();
106
+ settle(new Error("helper timeout"));
107
+ }, HELPER_TIMEOUT_MS);
108
+ socket.write(JSON.stringify(command) + "\n");
109
+ });
110
+ }
111
+
112
+ export function isCameraHelperAlive(udid: string): boolean {
113
+ const pid = readCameraHelperPid(udid);
114
+ return pid !== null && isProcessAlive(pid) && existsSync(cameraHelperSocketFile(udid));
115
+ }
116
+
117
+ export function readInjectedCameraBundles(udid: string): string[] {
118
+ let state: InjectedBundlesState;
119
+ try {
120
+ const value = JSON.parse(readFileSync(cameraHelperBundlesFile(udid), "utf-8")) as unknown;
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
122
+ const candidate = value as Partial<InjectedBundlesState>;
123
+ if (typeof candidate.helperPid !== "number" || !Array.isArray(candidate.bundleIds)) return [];
124
+ state = { helperPid: candidate.helperPid, bundleIds: candidate.bundleIds };
125
+ } catch {
126
+ return [];
127
+ }
128
+ const currentHelperPid = readCameraHelperPid(udid);
129
+ if (currentHelperPid === null || state.helperPid !== currentHelperPid) return [];
130
+ return state.bundleIds.filter((bundleId): bundleId is string => typeof bundleId === "string");
131
+ }
132
+
133
+ export async function readCameraStatus(udid: string): Promise<CameraStatusReply> {
134
+ if (!isCameraHelperAlive(udid)) return { udid, alive: false };
135
+
136
+ const helperPid = readCameraHelperPid(udid);
137
+ const bundleIds = readInjectedCameraBundles(udid);
138
+ try {
139
+ const reply = await sendCameraHelperCommand(udid, { action: "status" });
140
+ return { ...reply, udid, alive: true, helperPid, bundleIds };
141
+ } catch (error) {
142
+ return {
143
+ udid,
144
+ alive: true,
145
+ helperPid,
146
+ bundleIds,
147
+ error: error instanceof Error ? error.message : String(error),
148
+ };
149
+ }
150
+ }