@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 CHANGED
@@ -38,6 +38,26 @@ Requires an Apple silicon (`arm64`) Mac with Xcode command line tools (`xcrun si
38
38
 
39
39
  The bundled native addon, simulator tools, and host helpers are arm64-only.
40
40
 
41
+ The iPhone Duo simulator supports touch input, scrolling, and touch gestures on both the folded cover screen and the half-open or fully open inner screen. Frames and input follow the active display and its orientation; an app can still restrict its supported orientations.
42
+
43
+ Three shortcuts below the phone select Fully folded, Partially open, and Fully open.
44
+ At the top of Simulator settings in the Tools sidebar, the Fold pose dropdown
45
+ adds Laptop and Tent. The next rows provide a live 0–180° hinge slider with
46
+ decimal angle input and Table Mode, using the same controls as other settings.
47
+ Partially open uses Book: it and Laptop use a 90° hinge angle with different
48
+ physical orientations; Tent uses 80°.
49
+ Duo defaults to 3D using Apple's model from the host's Xcode installation,
50
+ with live displays and smooth folding, unfolding, and pose transitions.
51
+ Display pixels stay attached to their
52
+ physical panels as the model moves. Closed and Open face the viewer directly,
53
+ with the open model following the screen's portrait or landscape layout. Laptop
54
+ rests level and Tent presents the outside cover screen. The original `V68.usdz`
55
+ is loaded locally, without bundling Apple's model or downloading it. The view
56
+ respects the browser's reduced-motion preference. **Simulator → Preview mode**
57
+ switches between 2D and 3D; AX inspection also uses the flat view. See [hinge controls and display
58
+ selection](packages/serve-sim/docs/hinge-controls.md) for Device Hub's hidden
59
+ controls and the simulator APIs behind them.
60
+
41
61
  ## CLI
42
62
 
43
63
  ```
@@ -50,6 +70,8 @@ serve-sim type <text> [-d udid] Type text via the simulator keyboard
50
70
  serve-sim rotate <orientation> [-d udid]
51
71
  portrait | portrait_upside_down |
52
72
  landscape_left | landscape_right
73
+ serve-sim hinge <fold|half|unfold|degrees> [-d udid]
74
+ Set the hinge angle (0° folded, 180° unfolded)
53
75
  serve-sim ca-debug <option> <on|off> [-d udid]
54
76
  Toggle a CoreAnimation debug flag
55
77
  (blended|copies|misaligned|offscreen|slow-animations)
@@ -95,6 +117,13 @@ Options:
95
117
  H.264/WebRTC target bitrate
96
118
  --video-fps <fps>
97
119
  H.264/WebRTC frame rate (1-140)
120
+ --launch-app-identifier <id>
121
+ Bundle identifier of an installed app to launch once the
122
+ simulator boots
123
+ --launch-arg <arg>
124
+ Argument passed to the app when it launches (repeatable)
125
+ --open-url <url>
126
+ URL to open in the app after it launches
98
127
  --list [device] List running streams
99
128
  --kill [device] Kill running stream(s)
100
129
 
@@ -171,6 +200,26 @@ Supervisors can probe `GET /healthz` to confirm that the preview server is
171
200
  listening and `GET /readyz` to wait until the selected simulator and native
172
201
  capture session are ready. Both endpoints return JSON and disable caching.
173
202
 
203
+ ### Launching an app
204
+
205
+ `--launch-app-identifier <bundle-id>` launches an app that is already installed on the
206
+ simulator, before the stream starts. Install it yourself first (`xcrun simctl install`);
207
+ `serve-sim` only launches.
208
+
209
+ ```sh
210
+ serve-sim --launch-app-identifier host.exp.Exponent \
211
+ --launch-arg -EXDevMenuIsOnboardingFinished --launch-arg 1 \
212
+ --open-url exp://127.0.0.1:8081
213
+ ```
214
+
215
+ `--launch-arg` is repeatable and maps to `simctl launch` process arguments. `--open-url`
216
+ runs `simctl openurl` after the app is up, and pre-approves custom URL schemes so the
217
+ Simulator does not ask for confirmation. This matters for `exp://` deep links on a headless host.
218
+
219
+ Launching from `serve-sim` rather than beforehand matters because a second `simctl launch`
220
+ on a running app is a no-op: it neither restarts the app nor applies new arguments. Owning
221
+ the launch is what lets `serve-sim` attach to the process from the start.
222
+
174
223
  ### Camera
175
224
 
176
225
  `serve-sim camera <bundle-id>` replaces the simulator's camera feed for a single app. A small host-side helper writes BGRA frames into a POSIX shared-memory region; an injected dylib (`DYLD_INSERT_LIBRARIES`) swizzles AVFoundation inside the simulator process so the app reads from that region instead of the simulator's stub camera.
@@ -259,7 +308,7 @@ server.onUpgrade((request, socket) => {
259
308
  });
260
309
  ```
261
310
 
262
- The middleware reads the helper's state from `$TMPDIR/serve-sim/` and points the browser at the helper's stream, interaction WebSocket, and WebKit DevTools endpoints. By default those URLs target the helper's own port directly (CORS is wide-open on the helper), so a plain `app.use(...)` mount works without touching your server's WebSocket handling.
311
+ The middleware reads the helper's state from `$TMPDIR/serve-sim/` and points the browser at the helper's stream, interaction WebSocket, and WebKit DevTools endpoints. By default those URLs target the helper's own port directly (the helper answers loopback origins), so a plain `app.use(...)` mount works without touching your server's WebSocket handling.
263
312
 
264
313
  ### Single-port / remote proxying
265
314
 
@@ -296,6 +345,7 @@ The npm package ships the native capture addon and LiveKit WebRTC framework alon
296
345
  ```sh
297
346
  bun install
298
347
  bun run packages/serve-sim/build.ts # full production build
348
+ bash packages/serve-sim/Sources/build-test-fixtures.sh # simulator test fixtures
299
349
  packages/serve-sim/Sources/SimNative/build.sh # native addon only
300
350
  bun run --filter @expo/serve-sim dev # watch mode
301
351
  bun run --filter @expo/serve-sim tart-dev # guest preview at localhost:3200
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ HERE="$(cd "$(dirname "$0")" && pwd)"
5
+ OUT_DIR="${1:-$HERE/../../dist/capability-loader}"
6
+ mkdir -p "$OUT_DIR"
7
+
8
+ SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
9
+ DYLIB="$OUT_DIR/libServeSimCapabilityLoader.dylib"
10
+
11
+ xcrun --sdk iphonesimulator clang \
12
+ -arch arm64 \
13
+ -mios-simulator-version-min=15.0 \
14
+ -isysroot "$SDK" \
15
+ -dynamiclib \
16
+ -O2 \
17
+ -Wall -Wextra -Werror -Wconversion -Wshadow \
18
+ -install_name "@rpath/libServeSimCapabilityLoader.dylib" \
19
+ -o "$DYLIB" \
20
+ "$HERE/serve-sim-capability-loader.c"
21
+
22
+ # This image is inserted into every process in the simulator. Anything beyond
23
+ # libSystem crash-loops system daemons, so fail the build rather than ship it.
24
+ LINKED="$(otool -L "$DYLIB" | grep $'^\t' | awk '{print $1}' | sort -u)"
25
+ UNEXPECTED="$(echo "$LINKED" | grep -v -e '^@rpath/libServeSimCapabilityLoader\.dylib$' -e '^/usr/lib/libSystem\.B\.dylib$' || true)"
26
+ if [ -n "$UNEXPECTED" ]; then
27
+ echo "CapabilityLoader links more than libSystem:" >&2
28
+ echo "$UNEXPECTED" >&2
29
+ exit 1
30
+ fi
31
+
32
+ echo "$DYLIB"
@@ -0,0 +1,322 @@
1
+ // Inserted into every simulator process via launchd DYLD_INSERT_LIBRARIES, so
2
+ // it links libSystem only: a Foundation-linked insert crash-loops GSSCred, and
3
+ // loading UIKit from the constructor crashes Safari on headless boots. Capability
4
+ // dylibs load on the main queue after the constructor returns.
5
+ //
6
+ // Config format, one capability per line, written by the launch manager:
7
+ // <user|all>\t<dylib>\t[KEY=VALUE;KEY=VALUE]\t<delay-ms>
8
+ //
9
+ // The launch manager sets SERVE_SIM_CAPABILITIES_CONFIG alongside the insert,
10
+ // per simulator, so the config can live with the rest of serve-sim's state
11
+ // rather than inside the installed package.
12
+
13
+ #include <dispatch/dispatch.h>
14
+ #include <dlfcn.h>
15
+ #include <errno.h>
16
+ #include <fcntl.h>
17
+ #include <mach-o/dyld.h>
18
+ #include <sys/stat.h>
19
+ #include <stdio.h>
20
+ #include <stdlib.h>
21
+ #include <string.h>
22
+ #include <unistd.h>
23
+
24
+ #define CONFIG_VAR "SERVE_SIM_CAPABILITIES_CONFIG"
25
+ #define MAX_CONFIG_BYTES (64 * 1024)
26
+ // A capability that links UIKit asks to be loaded late; one that links only
27
+ // libSystem does not have to wait for it.
28
+ #define MAX_LOAD_DELAY_MS 10000
29
+ #define MAX_CAPABILITIES 64
30
+
31
+ // 0 read the whole file, 1 the file did not fit, -1 could not read it.
32
+ static int read_config(const char *path, char *out, size_t cap) {
33
+ int fd;
34
+ do {
35
+ fd = open(path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW);
36
+ } while (fd < 0 && errno == EINTR);
37
+ if (fd < 0) return -1;
38
+
39
+ // Only a real file. A fifo or device would give this thread a read that never ends.
40
+ struct stat info;
41
+ if (fstat(fd, &info) != 0 || !S_ISREG(info.st_mode)) {
42
+ close(fd);
43
+ return -1;
44
+ }
45
+
46
+ size_t used = 0;
47
+ int truncated = 0;
48
+ for (;;) {
49
+ ssize_t n = read(fd, out + used, cap - 1 - used);
50
+ if (n < 0) {
51
+ if (errno == EINTR) continue;
52
+ close(fd);
53
+ return -1;
54
+ }
55
+ if (n == 0) break;
56
+ used += (size_t)n;
57
+ if (used >= cap - 1) { truncated = 1; break; }
58
+ }
59
+ close(fd);
60
+ out[used] = '\0';
61
+ return truncated;
62
+ }
63
+
64
+ static int apply_env(char *pairs) {
65
+ int failed = 0;
66
+ char *pair, *rest = pairs;
67
+ while ((pair = strsep(&rest, ";")) != NULL) {
68
+ if (*pair == '\0') continue;
69
+ char *eq = strchr(pair, '=');
70
+ if (eq == NULL) continue;
71
+ *eq = '\0';
72
+ if (setenv(pair, eq + 1, 1) != 0) {
73
+ fprintf(stderr, "[serve-sim] could not set %s for a capability\n", pair);
74
+ failed = 1;
75
+ }
76
+ }
77
+ return failed;
78
+ }
79
+
80
+ // An app the user installed lives under the device's own Bundle container; an
81
+ // Apple app ships inside the runtime, under RuntimeRoot.
82
+ #define USER_APP_MARKER "/Containers/Bundle/Application/"
83
+
84
+ static unsigned parse_delay_ms(const char *text) {
85
+ if (text == NULL || *text == '\0') return 0;
86
+ char *end = NULL;
87
+ errno = 0;
88
+ long value = strtol(text, &end, 10);
89
+ if (errno != 0 || end == text || *end != '\0' || value < 0) return 0;
90
+ if (value > MAX_LOAD_DELAY_MS) return MAX_LOAD_DELAY_MS;
91
+ return (unsigned)value;
92
+ }
93
+
94
+ static int capability_applies(char *line, const char *exec_path, char **dylib_out,
95
+ char **env_out, unsigned *delay_ms_out) {
96
+ if (*line == '\0' || *line == '#') return 0;
97
+
98
+ char *fields = line;
99
+ char *scope = strsep(&fields, "\t");
100
+ char *dylib = strsep(&fields, "\t");
101
+ char *env = strsep(&fields, "\t");
102
+ char *delay = strsep(&fields, "\t");
103
+ if (dylib == NULL || *dylib == '\0') return 0;
104
+ if (*dylib != '/') {
105
+ fprintf(stderr, "[serve-sim] ignoring capability path that is not absolute: %s\n", dylib);
106
+ return 0;
107
+ }
108
+
109
+ // `all` needs no check of its own: the constructor already refused anything
110
+ // that is not an app. An unknown scope loads nowhere.
111
+ if (strcmp(scope, "user") == 0) {
112
+ if (strstr(exec_path, USER_APP_MARKER) == NULL) return 0;
113
+ } else if (strcmp(scope, "all") != 0) {
114
+ fprintf(stderr, "[serve-sim] ignoring capability with unknown scope '%s': %s\n", scope, dylib);
115
+ return 0;
116
+ }
117
+
118
+ *dylib_out = dylib;
119
+ *env_out = env;
120
+ *delay_ms_out = parse_delay_ms(delay);
121
+ return 1;
122
+ }
123
+
124
+ struct CapabilityLoad {
125
+ char *dylib;
126
+ char *env;
127
+ unsigned delay_ms;
128
+ uint64_t generation;
129
+ int loaded;
130
+ int pending;
131
+ };
132
+
133
+ struct Load {
134
+ char exec_path[1024];
135
+ char config_path[1024];
136
+ uint64_t next_generation;
137
+ struct CapabilityLoad capabilities[MAX_CAPABILITIES];
138
+ };
139
+
140
+ static void load_capabilities(struct Load *load);
141
+
142
+ static void load_one(struct CapabilityLoad *capability) {
143
+ capability->pending = 0;
144
+ char *env = strdup(capability->env);
145
+ if (env == NULL) return;
146
+ int failed = apply_env(env);
147
+ free(env);
148
+ if (failed) {
149
+ fprintf(stderr, "[serve-sim] not loading %s: its environment is incomplete\n",
150
+ capability->dylib);
151
+ return;
152
+ }
153
+ if (dlopen(capability->dylib, RTLD_NOW | RTLD_LOCAL) == NULL) {
154
+ fprintf(stderr, "[serve-sim] could not load %s: %s\n", capability->dylib, dlerror());
155
+ return;
156
+ }
157
+ capability->loaded = 1;
158
+ }
159
+
160
+ static void load_capabilities(struct Load *load) {
161
+ char *config = malloc(MAX_CONFIG_BYTES);
162
+ if (config == NULL) return;
163
+ int status = read_config(load->config_path, config, MAX_CONFIG_BYTES);
164
+ if (status != 0) {
165
+ if (status > 0) {
166
+ fprintf(stderr, "[serve-sim] %s exceeds %d bytes; no capabilities loaded.\n",
167
+ load->config_path, MAX_CONFIG_BYTES);
168
+ }
169
+ config[0] = '\0';
170
+ }
171
+
172
+ struct CapabilityLoad desired[MAX_CAPABILITIES];
173
+ size_t count = 0;
174
+ char *line, *lines = config;
175
+ while ((line = strsep(&lines, "\n")) != NULL) {
176
+ char *dylib, *env;
177
+ unsigned delay_ms;
178
+ if (!capability_applies(line, load->exec_path, &dylib, &env, &delay_ms)) continue;
179
+ if (count == MAX_CAPABILITIES) {
180
+ fprintf(stderr, "[serve-sim] more than %d capabilities apply; ignoring the rest.\n",
181
+ MAX_CAPABILITIES);
182
+ break;
183
+ }
184
+ desired[count++] = (struct CapabilityLoad){
185
+ .dylib = dylib, .env = env ? env : "", .delay_ms = delay_ms,
186
+ };
187
+ }
188
+ for (size_t i = 0; i < MAX_CAPABILITIES; i++) {
189
+ struct CapabilityLoad *capability = &load->capabilities[i];
190
+ if (capability->dylib == NULL || capability->loaded) continue;
191
+ size_t j = 0;
192
+ while (j < count && strcmp(capability->dylib, desired[j].dylib) != 0) j++;
193
+ if (j == count) {
194
+ free(capability->dylib);
195
+ free(capability->env);
196
+ *capability = (struct CapabilityLoad){0};
197
+ }
198
+ }
199
+ for (size_t entry = 0; entry < count; entry++) {
200
+ char *dylib = desired[entry].dylib;
201
+ char *env = desired[entry].env;
202
+ unsigned delay_ms = desired[entry].delay_ms;
203
+ struct CapabilityLoad *capability = NULL;
204
+ for (size_t i = 0; i < MAX_CAPABILITIES; i++) {
205
+ if (load->capabilities[i].dylib && strcmp(load->capabilities[i].dylib, dylib) == 0) {
206
+ capability = &load->capabilities[i];
207
+ break;
208
+ }
209
+ }
210
+ if (capability && (capability->loaded ||
211
+ (capability->pending && capability->delay_ms == delay_ms && strcmp(capability->env, env) == 0))) {
212
+ continue;
213
+ }
214
+ if (capability == NULL) {
215
+ for (size_t i = 0; i < MAX_CAPABILITIES; i++) {
216
+ if (load->capabilities[i].dylib == NULL) {
217
+ capability = &load->capabilities[i];
218
+ break;
219
+ }
220
+ }
221
+ }
222
+ if (capability == NULL) {
223
+ fprintf(stderr, "[serve-sim] capability load limit reached; ignoring %s\n", dylib);
224
+ continue;
225
+ }
226
+ char *dylib_copy = strdup(dylib);
227
+ char *env_copy = strdup(env);
228
+ if (dylib_copy == NULL || env_copy == NULL) {
229
+ free(dylib_copy);
230
+ free(env_copy);
231
+ continue;
232
+ }
233
+ free(capability->dylib);
234
+ free(capability->env);
235
+ *capability = (struct CapabilityLoad){
236
+ .dylib = dylib_copy, .env = env_copy, .delay_ms = delay_ms,
237
+ .generation = ++load->next_generation,
238
+ };
239
+ if (delay_ms == 0) {
240
+ load_one(capability);
241
+ } else {
242
+ capability->pending = 1;
243
+ uint64_t generation = capability->generation;
244
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)delay_ms * (int64_t)NSEC_PER_MSEC),
245
+ dispatch_get_main_queue(), ^{
246
+ load_capabilities(load);
247
+ if (capability->generation == generation && capability->dylib && !capability->loaded)
248
+ load_one(capability);
249
+ });
250
+ }
251
+ }
252
+ free(config);
253
+ }
254
+
255
+ static int config_dir(const char *path, char *out, size_t cap) {
256
+ int n = snprintf(out, cap, "%s", path);
257
+ if (n < 0 || (size_t)n >= cap) return -1;
258
+ char *slash = strrchr(out, '/');
259
+ if (slash == NULL || slash == out) return -1;
260
+ *slash = '\0';
261
+ return 0;
262
+ }
263
+
264
+ // Watch the directory because config updates replace the file by rename.
265
+ static void watch_config(struct Load *load) {
266
+ char dir[sizeof load->config_path];
267
+ if (config_dir(load->config_path, dir, sizeof dir) != 0) return;
268
+
269
+ int fd = open(dir, O_EVTONLY);
270
+ if (fd < 0) return;
271
+
272
+ dispatch_source_t source = dispatch_source_create(
273
+ DISPATCH_SOURCE_TYPE_VNODE, (uintptr_t)fd, DISPATCH_VNODE_WRITE, dispatch_get_main_queue());
274
+ if (source == NULL) {
275
+ close(fd);
276
+ return;
277
+ }
278
+ dispatch_source_set_event_handler(source, ^{ load_capabilities(load); });
279
+ dispatch_source_set_cancel_handler(source, ^{ close(fd); });
280
+ dispatch_resume(source);
281
+ }
282
+
283
+ // getenv and access are safe in a constructor; the dlopen is not.
284
+ __attribute__((constructor))
285
+ static void serve_sim_capability_loader_init(void) {
286
+ const char *tmp = getenv("TMPDIR");
287
+ if (tmp == NULL || strstr(tmp, "/Containers/Data/Application/") == NULL) return;
288
+
289
+ // Absolute only. A relative path would resolve against the app's working
290
+ // directory, which is not ours to guess.
291
+ const char *config = getenv(CONFIG_VAR);
292
+ if (config == NULL || *config != '/') return;
293
+
294
+ struct Load *load = calloc(1, sizeof *load);
295
+ if (load == NULL) return;
296
+ char dir[sizeof load->config_path];
297
+
298
+ int n = snprintf(load->config_path, sizeof load->config_path, "%s", config);
299
+ if (n < 0 || (size_t)n >= sizeof load->config_path) {
300
+ free(load);
301
+ return;
302
+ }
303
+ // The file need not exist yet: a capability turned on later creates it, and
304
+ // the watch is on the directory. Requiring the file here would mean an app
305
+ // started before the first capability could never receive one.
306
+ if (config_dir(load->config_path, dir, sizeof dir) != 0 || access(dir, R_OK | X_OK) != 0) {
307
+ free(load);
308
+ return;
309
+ }
310
+ uint32_t exec_size = sizeof load->exec_path;
311
+ if (_NSGetExecutablePath(load->exec_path, &exec_size) != 0) {
312
+ free(load);
313
+ return;
314
+ }
315
+
316
+ // Concurrent dlopen during construction can invert the dyld/ObjC load locks.
317
+ // Defer capability loads to the main queue after construction completes.
318
+ dispatch_async(dispatch_get_main_queue(), ^{
319
+ watch_config(load);
320
+ load_capabilities(load);
321
+ });
322
+ }