dsh-mobilecode 0.1.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.
@@ -0,0 +1,870 @@
1
+ /**
2
+ * dsh-mobilecode — device-preview engine.
3
+ *
4
+ * Plain-JS port of `mobilecode/packages/core/src/device-preview.ts`
5
+ * (hsandhu/mobilecode). The Effect runtime is dropped entirely: the
6
+ * orchestration is a plain async class with the same state maps, the same
7
+ * lifecycle (park / halt / settle / execute), and the same process hooks.
8
+ *
9
+ * Lifecycle of one "run":
10
+ * runApp → park(other directories) → builds.set(key, active)
11
+ * → active.done = execute(...) (detached)
12
+ * execute → findProjects → preflight → resolveNode → prebuild (expo)
13
+ * → ensureBundler (Metro) → ensureDevice (sim/emulator boot)
14
+ * → pods (ios) → runIos / runAndroid → finish
15
+ *
16
+ * `info(directory)` is the read model the pane polls (~2s) and the agent
17
+ * tools summarize.
18
+ */
19
+
20
+ import os from "node:os"
21
+ import path from "node:path"
22
+ import * as DeviceBuild from "./device-build.js"
23
+
24
+ // serve-sim (iOS Simulator) and serve-avd (Android Emulator) both serve a browser preview UI and
25
+ // print its origin to stdout once the port is bound. Both default to 3200 and exit when it is
26
+ // taken, so each gets the first free port in its own range and the two can run side by side.
27
+ const COMMANDS = {
28
+ ios: { command: "npx", args: (port) => ["--yes", "serve-sim", "--port", String(port)] },
29
+ android: { command: "npx", args: (port) => ["--yes", "serve-avd", "--port", String(port)] },
30
+ }
31
+ const PORTS = { ios: 3200, android: 3250 }
32
+ const LOG_LIMIT = 200
33
+ const STOP_TIMEOUT_MS = 3000
34
+ const DEVICE_WAIT_MS = 180_000
35
+ const BUNDLER_WAIT_MS = 90_000
36
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]
37
+ const PREVIEW_URL = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d+/
38
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
39
+
40
+ const BUSY = ["building", "installing", "launching"]
41
+ const buildPrefix = (directory) => `${directory}\0`
42
+ const buildKey = (input) => `${buildPrefix(input.directory)}${input.platform}`
43
+
44
+ export class DevicePreviewEngine {
45
+ constructor() {
46
+ this.servers = new Map()
47
+ this.builds = new Map()
48
+ // One Metro per JavaScript root, shared by the iOS and Android apps built from it.
49
+ this.bundlers = new Map()
50
+ this.hooked = false
51
+ }
52
+
53
+ // ── process hooks ──────────────────────────────────────────────────────────
54
+
55
+ // Nothing reaps the children when this process dies without unwinding the engine (a signal,
56
+ // or process.exit from the CLI entrypoint), so ask them to stop from the process hooks too.
57
+ onExit = () => {
58
+ for (const active of this.servers.values()) terminate(active.process, active.state.status === "exited")
59
+ for (const bundler of this.bundlers.values()) terminate(bundler.process, bundler.state.status === "exited")
60
+ for (const build of this.builds.values()) terminate(build.process, false)
61
+ }
62
+
63
+ onSignal = (signal) => {
64
+ this.onExit()
65
+ // Keep the default termination when nothing else handles the signal (signal-exit pattern).
66
+ if (process.listenerCount(signal) > 1) return
67
+ this.unhook()
68
+ process.kill(process.pid, signal)
69
+ }
70
+
71
+ hook() {
72
+ if (this.hooked) return
73
+ this.hooked = true
74
+ process.on("exit", this.onExit)
75
+ if (process.platform === "win32") return
76
+ for (const signal of SIGNALS) process.on(signal, this.onSignal)
77
+ }
78
+
79
+ unhook() {
80
+ if (!this.hooked) return
81
+ this.hooked = false
82
+ process.off("exit", this.onExit)
83
+ for (const signal of SIGNALS) process.off(signal, this.onSignal)
84
+ }
85
+
86
+ /** Shut everything down. Safe to call on dispose and from the finalizer. */
87
+ async dispose() {
88
+ this.unhook()
89
+ for (const build of this.builds.values()) terminate(build.process, false)
90
+ this.builds.clear()
91
+ await Promise.all([...this.servers.values(), ...this.bundlers.values()].map(kill))
92
+ this.servers.clear()
93
+ this.bundlers.clear()
94
+ }
95
+
96
+ // ── read model ─────────────────────────────────────────────────────────────
97
+
98
+ async detect(input) {
99
+ return DeviceBuild.findProjects(input.directory).map((project) => project.platform)
100
+ }
101
+
102
+ async info(input) {
103
+ const projects = DeviceBuild.findProjects(input.directory)
104
+ const bundler = [...this.bundlers.values()].find((active) => within(active.state.directory, input.directory))
105
+ return {
106
+ platforms: projects.map((project) => project.platform),
107
+ framework: projects[0]?.framework,
108
+ bundler: bundler ? { ...bundler.state, log: [...bundler.state.log] } : undefined,
109
+ servers: [...this.servers.values()].map((active) => ({ ...active.state, log: [...active.state.log] })),
110
+ builds: [...this.builds.entries()]
111
+ .filter(([key]) => key.startsWith(buildPrefix(input.directory)))
112
+ .map(([, build]) => ({ ...build.state, log: [...build.state.log] })),
113
+ }
114
+ }
115
+
116
+ // ── preview servers ────────────────────────────────────────────────────────
117
+
118
+ async launchPreview(input) {
119
+ const current = this.servers.get(input.platform)
120
+ if (current && current.state.status !== "exited") return
121
+ const spec = COMMANDS[input.platform]
122
+ const state = {
123
+ platform: input.platform,
124
+ status: "starting",
125
+ command: [spec.command, ...spec.args(PORTS[input.platform])].join(" "),
126
+ log: [],
127
+ }
128
+ // Claim the slot before the first await so a second caller cannot start a duplicate.
129
+ const active = { state }
130
+ this.servers.set(input.platform, active)
131
+ const port = await DeviceBuild.freePort(PORTS[input.platform])
132
+ // A stop that landed during the lookup already removed the slot; spawning now would orphan.
133
+ if (this.servers.get(input.platform) !== active) {
134
+ state.status = "exited"
135
+ return
136
+ }
137
+ if (!port) {
138
+ push(state.log, `No free port found from ${PORTS[input.platform]} upward.`)
139
+ state.status = "exited"
140
+ return
141
+ }
142
+ const args = spec.args(port)
143
+ state.command = [spec.command, ...args].join(" ")
144
+ // Same process group as the server so a terminal Ctrl+C reaches npx and serve-* as well.
145
+ // stdin is the guard's lifeline: it closes when this process dies, however it dies.
146
+ const wrapped = DeviceBuild.guarded(spec.command, args)
147
+ const child = DeviceBuild.launch(wrapped.command, wrapped.args, {
148
+ cwd: input.directory,
149
+ env: { ...process.env, ...input.env, FORCE_COLOR: "0", NO_COLOR: "1" },
150
+ stdio: ["pipe", "pipe", "pipe"],
151
+ windowsHide: true,
152
+ })
153
+ state.pid = child.pid
154
+ active.process = child
155
+ this.hook()
156
+ const append = (line) => {
157
+ const text = clean(line)
158
+ if (!text) return
159
+ push(state.log, text)
160
+ if (state.status !== "starting") return
161
+ const match = PREVIEW_URL.exec(text)
162
+ if (!match) return
163
+ state.url = match[0]
164
+ state.status = "running"
165
+ }
166
+ lines(child.stdout, append)
167
+ lines(child.stderr, append)
168
+ child.once("error", (error) => {
169
+ append(error.message)
170
+ state.status = "exited"
171
+ state.url = undefined
172
+ })
173
+ child.once("exit", (code) => {
174
+ state.status = "exited"
175
+ state.exitCode = code ?? undefined
176
+ state.url = undefined
177
+ })
178
+ }
179
+
180
+ async start(input) {
181
+ await this.launchPreview(input)
182
+ return this.info(input)
183
+ }
184
+
185
+ async stop(input) {
186
+ const active = this.servers.get(input.platform)
187
+ if (active) {
188
+ this.servers.delete(input.platform)
189
+ await kill(active)
190
+ }
191
+ return this.info(input)
192
+ }
193
+
194
+ // ── devices and bundlers ───────────────────────────────────────────────────
195
+
196
+ // Pressing play with nothing booted should boot something, the way Xcode's Run does.
197
+ // serve-avd/serve-sim is what owns devices on macOS; on Windows serve-avd cannot find the
198
+ // SDK (it probes `emulator`/`adb` without the .exe extension), so boot the emulator directly.
199
+ async ensureDevice(platform, directory, env, active, report) {
200
+ const find = () => (platform === "ios" ? DeviceBuild.bootedSimulator() : DeviceBuild.androidDevice())
201
+ const existing = await find()
202
+ if (existing) return existing
203
+ report.step(platform === "ios" ? "Starting simulator" : "Starting emulator")
204
+ if (platform === "android") {
205
+ const serial = await this.bootEmulator(active)
206
+ if (serial) return serial
207
+ // No SDK emulator/AVDs: fall through to serve-avd (its own boot path may work on macOS).
208
+ }
209
+ await this.launchPreview({ directory, platform, env })
210
+ const deadline = Date.now() + DEVICE_WAIT_MS
211
+ while (Date.now() < deadline && !active.cancelled) {
212
+ await new Promise((resolve) => setTimeout(resolve, 2000))
213
+ const found = await find()
214
+ if (found) return found
215
+ // Stopped from the pane, or died: nothing is going to boot a device any more.
216
+ const server = this.servers.get(platform)
217
+ if (!server || server.state.status === "exited") return undefined
218
+ }
219
+ return undefined
220
+ }
221
+
222
+ /** Boot the first configured AVD and wait for it to come online. undefined when none can boot. */
223
+ async bootEmulator(active) {
224
+ const binary = DeviceBuild.emulatorBinary()
225
+ if (!binary) return undefined
226
+ const avds = await DeviceBuild.androidAvds()
227
+ const preferred = process.env["DSH_MOBILECODE_AVD"]
228
+ const avd = (preferred && avds.includes(preferred) ? preferred : avds[0])
229
+ if (!avd) return undefined
230
+ // Detached daemon, exactly like serve-avd boots it: the emulator outlives us and the user
231
+ // closes it when they are done (the way Xcode's Run leaves the Simulator open).
232
+ const child = DeviceBuild.launch(
233
+ binary,
234
+ ["-avd", avd, "-no-snapshot", "-no-boot-anim", "-no-audio", "-gpu", "swiftshader_indirect"],
235
+ { stdio: "ignore", detached: true, windowsHide: true },
236
+ )
237
+ child.unref?.()
238
+ const deadline = Date.now() + DEVICE_WAIT_MS
239
+ while (Date.now() < deadline && !active.cancelled) {
240
+ await new Promise((resolve) => setTimeout(resolve, 2000))
241
+ const serial = await DeviceBuild.androidDevice()
242
+ // The serial appears while Android is still booting; launching before
243
+ // sys.boot_completed=1 fails. Wait for a fully booted device (serve-avd
244
+ // does the same in its waitForBoot).
245
+ if (serial && (await DeviceBuild.androidBooted(serial))) return serial
246
+ }
247
+ return undefined
248
+ }
249
+
250
+ // Debug builds load their JavaScript from Metro at launch, so it must be up before the app is.
251
+ // Start it as early as possible and let the build overlap with its warm-up.
252
+ ensureBundler(root, framework, env) {
253
+ const current = this.bundlers.get(root)
254
+ if (current && current.state.status !== "exited") return current
255
+ const port = DeviceBuild.metroPort()
256
+ const spec = DeviceBuild.bundlerCommand(framework, port)
257
+ const state = {
258
+ framework,
259
+ directory: root,
260
+ status: "starting",
261
+ command: [spec.command, ...spec.args].join(" "),
262
+ log: [],
263
+ }
264
+ const active = { state }
265
+ active.ready = (async () => {
266
+ // Something (a terminal, an earlier session) may already be serving this port. Reuse it
267
+ // rather than have Expo offer to pick another port that the app would not know about.
268
+ if (await DeviceBuild.metroRunning(port)) {
269
+ const owner = await DeviceBuild.portOwner(port)
270
+ // Unknown owner (no lsof) is assumed to be ours, as before. A Metro serving another
271
+ // project would hand this app the wrong bundle, so replace it.
272
+ // Ours when started inside the project, or from a workspace root above it (not from
273
+ // somewhere as broad as the home directory).
274
+ const ours =
275
+ !owner?.cwd ||
276
+ within(owner.cwd, root) ||
277
+ (within(root, owner.cwd) && owner.cwd !== "/" && owner.cwd !== os.homedir())
278
+ if (ours) {
279
+ state.command = `Metro already running on port ${port}`
280
+ state.url = DeviceBuild.metroUrl(port)
281
+ state.status = "running"
282
+ return
283
+ }
284
+ push(state.log, `Stopping Metro for ${owner.cwd} (pid ${owner.pid}) to serve this project instead`)
285
+ try {
286
+ process.kill(owner.pid, "SIGTERM")
287
+ } catch {
288
+ /* process already gone */
289
+ }
290
+ const gone = Date.now() + STOP_TIMEOUT_MS
291
+ while (Date.now() < gone && (await DeviceBuild.metroRunning(port)))
292
+ await new Promise((resolve) => setTimeout(resolve, 200))
293
+ }
294
+ const wrapped = DeviceBuild.guarded(spec.command, spec.args)
295
+ const child = DeviceBuild.launch(wrapped.command, wrapped.args, {
296
+ cwd: root,
297
+ // Not CI mode: Expo disables file watching and reloads under CI=1.
298
+ env: { ...process.env, ...env, EXPO_NO_TELEMETRY: "1", FORCE_COLOR: "0", NO_COLOR: "1" },
299
+ stdio: ["pipe", "pipe", "pipe"],
300
+ windowsHide: true,
301
+ })
302
+ active.process = child
303
+ state.pid = child.pid
304
+ this.hook()
305
+ const append = (line) => {
306
+ const text = clean(line)
307
+ if (text) push(state.log, text)
308
+ }
309
+ lines(child.stdout, append)
310
+ lines(child.stderr, append)
311
+ child.once("error", (error) => {
312
+ append(error.message)
313
+ state.status = "exited"
314
+ state.url = undefined
315
+ })
316
+ child.once("exit", (code) => {
317
+ state.status = "exited"
318
+ state.exitCode = code ?? undefined
319
+ state.url = undefined
320
+ })
321
+ const deadline = Date.now() + BUNDLER_WAIT_MS
322
+ while (Date.now() < deadline && state.status === "starting") {
323
+ await new Promise((resolve) => setTimeout(resolve, 1000))
324
+ if (state.status !== "starting") break
325
+ if (await DeviceBuild.metroRunning(port)) {
326
+ state.url = DeviceBuild.metroUrl(port)
327
+ state.status = "running"
328
+ }
329
+ }
330
+ })()
331
+ this.bundlers.set(root, active)
332
+ return active
333
+ }
334
+
335
+ // Only the last app to leave turns Metro off.
336
+ async releaseBundler(root) {
337
+ if (!root) return
338
+ const others = [...this.builds.values()].some(
339
+ (build) => build.root === root && (BUSY.includes(build.state.status) || build.state.status === "running"),
340
+ )
341
+ if (others) return
342
+ const active = this.bundlers.get(root)
343
+ if (!active) return
344
+ this.bundlers.delete(root)
345
+ await kill(active)
346
+ }
347
+
348
+ // ── runs ───────────────────────────────────────────────────────────────────
349
+
350
+ async runApp(input) {
351
+ const key = buildKey(input)
352
+ const existing = this.builds.get(key)
353
+ if (existing && BUSY.includes(existing.state.status)) return this.info(input)
354
+ // One project at a time: the devices and the Metro port are shared.
355
+ await this.park(input.directory)
356
+ const active = {
357
+ state: {
358
+ platform: input.platform,
359
+ status: "building",
360
+ step: "Preparing",
361
+ log: [],
362
+ startedAt: Date.now(),
363
+ },
364
+ cancelled: false,
365
+ }
366
+ this.builds.set(key, active)
367
+ this.hook()
368
+ // Detached from the request: the UI polls `info` for progress.
369
+ active.done = execute(this, active, input.directory, input.platform, input.env, {
370
+ previous: input.relaunch ? existing : undefined,
371
+ })
372
+ return this.info(input)
373
+ }
374
+
375
+ // Cancel a build or terminate the app, then wait for the pipeline to unwind so a following
376
+ // play cannot start another xcodebuild or Gradle on the same project.
377
+ async halt(active) {
378
+ active.cancelled = true
379
+ const proc = active.process
380
+ active.process = undefined
381
+ if (proc) terminate(proc, false)
382
+ await quit(active)
383
+ active.state.status = "idle"
384
+ active.state.step = undefined
385
+ active.state.finishedAt = Date.now()
386
+ await this.releaseBundler(active.root)
387
+ await settle(active, proc)
388
+ }
389
+
390
+ live(build) {
391
+ return BUSY.includes(build.state.status) || build.state.status === "running"
392
+ }
393
+
394
+ ofDirectory(directory) {
395
+ return [...this.builds.entries()].filter(([key]) => key.startsWith(buildPrefix(directory))).map(([, build]) => build)
396
+ }
397
+
398
+ /** Stop every other location's apps, remembering that a switch back should revive them. */
399
+ async park(directory) {
400
+ for (const [key, build] of this.builds) {
401
+ if (key.startsWith(buildPrefix(directory)) || !this.live(build)) continue
402
+ await this.halt(build)
403
+ build.parked = true
404
+ }
405
+ }
406
+
407
+ async stopApp(input) {
408
+ const active = this.builds.get(buildKey(input))
409
+ if (!active) return this.info(input)
410
+ active.parked = false
411
+ await this.halt(active)
412
+ return this.info(input)
413
+ }
414
+
415
+ async focus(input) {
416
+ const mine = this.ofDirectory(input.directory)
417
+ const others = [...this.builds.entries()].some(
418
+ ([key, build]) => !key.startsWith(buildPrefix(input.directory)) && this.live(build),
419
+ )
420
+ const parked = mine.some((build) => build.parked)
421
+ // Merely opening a tab must not start a build; only a switch away from a running project,
422
+ // or back to one that a switch put away, changes what is on the devices.
423
+ if (!others && !parked) return this.info(input)
424
+ if (mine.some(this.live)) return this.info(input)
425
+ for (const platform of DeviceBuild.findProjects(input.directory).map((project) => project.platform))
426
+ await this.runApp({ directory: input.directory, platform, env: input.env, relaunch: true })
427
+ return this.info(input)
428
+ }
429
+ }
430
+
431
+ // ── module-level helpers ──────────────────────────────────────────────────────
432
+
433
+ /** Wait for a cancelled pipeline to unwind, killing its child outright if it does not. */
434
+ async function settle(active, proc) {
435
+ if (!active.done) return
436
+ const finished = await Promise.race([
437
+ active.done.then(() => true),
438
+ new Promise((resolve) => setTimeout(() => resolve(false), STOP_TIMEOUT_MS)),
439
+ ])
440
+ if (finished || !proc) return
441
+ for (const pid of [...descendants(proc.pid), ...(proc.pid ? [proc.pid] : [])]) {
442
+ try {
443
+ process.kill(pid, "SIGKILL")
444
+ } catch {
445
+ /* already gone */
446
+ }
447
+ }
448
+ await Promise.race([active.done, new Promise((resolve) => setTimeout(resolve, STOP_TIMEOUT_MS))])
449
+ }
450
+
451
+ /** Build, install and launch. Runs detached from the request that started it. */
452
+ async function execute(engine, active, directory, platform, env, runtime) {
453
+ const log = (line) => {
454
+ const text = clean(line)
455
+ if (text) push(active.state.log, text)
456
+ }
457
+ const fail = (message) => {
458
+ if (active.cancelled) return
459
+ active.state.status = "failed"
460
+ active.state.step = undefined
461
+ active.state.error = message
462
+ active.state.finishedAt = Date.now()
463
+ }
464
+ const step = (value, status) => {
465
+ active.state.step = value
466
+ if (status) active.state.status = status
467
+ }
468
+
469
+ try {
470
+ let project = DeviceBuild.findProjects(directory).find((candidate) => candidate.platform === platform)
471
+ if (!project) return fail(`No ${platform === "ios" ? "iOS" : "Android"} project found in this directory.`)
472
+ const problem = DeviceBuild.preflight(project, env)
473
+ if (problem) return fail(problem)
474
+ active.state.framework = project.framework
475
+ active.root = project.root
476
+ if (platform === "android") env = { ...DeviceBuild.androidEnv(), ...env }
477
+ const report = { log, fail, step }
478
+
479
+ // Expo and React Native pin a Node range; the login shell's default is often older. Find one
480
+ // that fits and put it first on PATH for every step below, or stop before wasting a build.
481
+ if (project.framework !== "native") {
482
+ const ranges = DeviceBuild.nodeRequirement(project.root)
483
+ if (ranges.length > 0) {
484
+ const node = await DeviceBuild.resolveNode(ranges, { ...process.env, ...env })
485
+ if (active.cancelled) return
486
+ if (node.problem) return fail(node.problem)
487
+ if (node.bin) {
488
+ env = { ...env, PATH: `${node.bin}${path.delimiter}${DeviceBuild.pathEnv(env)}` }
489
+ if (node.note) log(node.note)
490
+ }
491
+ }
492
+ }
493
+
494
+ if (project.needsPrebuild) {
495
+ const generated = await prebuild(engine, active, project, env, report)
496
+ if (active.cancelled || !generated) return
497
+ project = generated
498
+ }
499
+ active.state.directory = project.directory
500
+
501
+ // Metro next: its warm-up overlaps with the device boot and the native build, and the app
502
+ // needs it at launch. After prebuild, so it never watches folders being rewritten underneath it.
503
+ const bundler =
504
+ project.framework === "native" ? undefined : engine.ensureBundler(project.root, project.framework, env)
505
+
506
+ const device = await engine.ensureDevice(platform, project.directory, env, active, report)
507
+ if (active.cancelled) return
508
+ if (!device)
509
+ return fail(
510
+ platform === "ios"
511
+ ? "Could not start a simulator. Open the device pane and start one."
512
+ : "Could not start an emulator. Open the device pane and start one, or create an AVD in Android Studio.",
513
+ )
514
+ active.device = device
515
+
516
+ // Same device, app still installed from last time: bring it back without a build. Anything
517
+ // wrong with that (uninstalled, wiped emulator) falls through to the full pipeline.
518
+ const previous = runtime.previous
519
+ if (previous?.installed && previous.device === device) {
520
+ active.state.target = previous.state.target
521
+ active.state.appID = previous.installed.appID
522
+ const launched = await relaunch(active, platform, device, previous.installed, report, bundler)
523
+ if (active.cancelled || launched) return
524
+ report.log("Relaunch failed; rebuilding")
525
+ }
526
+
527
+ // Bare React Native ships a Podfile that nothing has installed yet on a fresh checkout.
528
+ if (platform === "ios" && !DeviceBuild.podsInstalled(project.directory)) {
529
+ report.step("Installing pods")
530
+ const pods = DeviceBuild.exec("pod", ["install"], { cwd: project.directory, env }, report.log)
531
+ active.process = pods.child
532
+ const code = await pods.exit
533
+ active.process = undefined
534
+ if (active.cancelled) return
535
+ if (code !== 0) return fail("`pod install` failed. Open the log for details.")
536
+ }
537
+
538
+ const beforeLaunch = bundler ? () => awaitBundler(bundler, report) : undefined
539
+ if (platform === "ios") return await runIos(active, project.directory, env, report, device, beforeLaunch)
540
+ return await runAndroid(
541
+ active,
542
+ project.directory,
543
+ env,
544
+ report,
545
+ device,
546
+ project.framework !== "native",
547
+ beforeLaunch,
548
+ )
549
+ } catch (error) {
550
+ fail(error instanceof Error ? error.message : String(error))
551
+ }
552
+ }
553
+
554
+ /** Generate the native project for an Expo app in place. Returns the project to build from. */
555
+ async function prebuild(engine, active, project, env, report) {
556
+ const note = DeviceBuild.ensureExpoAppIds(project.root, project.platform)
557
+ if (note) report.log(note)
558
+ report.step("Generating native project")
559
+ const run = DeviceBuild.exec(
560
+ "npx",
561
+ ["expo", "prebuild", "--platform", project.platform],
562
+ { cwd: project.root, env: { ...env, CI: "1", EXPO_NO_TELEMETRY: "1" } },
563
+ report.log,
564
+ )
565
+ active.process = run.child
566
+ const code = await run.exit
567
+ active.process = undefined
568
+ if (active.cancelled) return
569
+ if (code !== 0) {
570
+ report.fail(buildError(active.state.log) ?? "`expo prebuild` failed. Open the log for details.")
571
+ return
572
+ }
573
+ const directory = DeviceBuild.prebuiltDirectory(project.root, project.platform)
574
+ if (!directory) {
575
+ report.fail(`\`expo prebuild\` finished but produced no ${project.platform} project.`)
576
+ return
577
+ }
578
+ return { ...project, directory, needsPrebuild: false }
579
+ }
580
+
581
+ async function awaitBundler(bundler, report) {
582
+ if (bundler.state.status === "starting") report.step("Waiting for Metro")
583
+ await bundler.ready
584
+ if (bundler.state.status === "running") return true
585
+ const last = [...bundler.state.log].reverse().find((line) => /error|failed|EADDRINUSE|cannot/i.test(line))
586
+ report.fail(last ? `Metro did not start: ${last.slice(0, 250)}` : "Metro did not start. Open the log for details.")
587
+ return false
588
+ }
589
+
590
+ /** Launch the app a previous run installed. True on success, false to fall back to a build. */
591
+ async function relaunch(active, platform, device, installed, report, bundler) {
592
+ if (bundler && !(await awaitBundler(bundler, report))) return false
593
+ if (active.cancelled) return false
594
+ if (platform === "android" && bundler) {
595
+ const reversed = await reverseMetro(active, device, report)
596
+ if (active.cancelled || !reversed) return false
597
+ }
598
+ const ok =
599
+ platform === "ios"
600
+ ? await launchIos(active, device, installed.appID, report)
601
+ : await launchAndroid(active, device, installed, report)
602
+ if (!ok || active.cancelled) return false
603
+ finish(active, installed)
604
+ return true
605
+ }
606
+
607
+ async function launchIos(active, udid, bundleID, report) {
608
+ report.step("Launching", "launching")
609
+ // A previous run may still be on screen; launching over it is not a restart, so end it first.
610
+ await DeviceBuild.exec("xcrun", ["simctl", "terminate", udid, bundleID], {}).exit
611
+ if (active.cancelled) return false
612
+ const launched = DeviceBuild.exec("xcrun", ["simctl", "launch", udid, bundleID], {}, report.log)
613
+ active.process = launched.child
614
+ const code = await launched.exit
615
+ active.process = undefined
616
+ return code === 0
617
+ }
618
+
619
+ async function launchAndroid(active, serial, app, report) {
620
+ report.step("Launching", "launching")
621
+ const args = app.activity
622
+ ? ["-s", serial, "shell", "am", "start", "-n", `${app.appID}/${app.activity}`]
623
+ : ["-s", serial, "shell", "monkey", "-p", app.appID, "-c", "android.intent.category.LAUNCHER", "1"]
624
+ // `am start` reports a missing activity as "Error type 3" without a failing exit code.
625
+ let errored = false
626
+ const launched = DeviceBuild.exec(DeviceBuild.adb(), args, {}, (line) => {
627
+ if (/^Error/.test(line.trim())) errored = true
628
+ report.log(line)
629
+ })
630
+ active.process = launched.child
631
+ const code = await launched.exit
632
+ active.process = undefined
633
+ return code === 0 && !errored
634
+ }
635
+
636
+ /** The emulator cannot see the host's localhost; route the Metro port through adb. */
637
+ async function reverseMetro(active, serial, report) {
638
+ const port = String(DeviceBuild.metroPort())
639
+ const reverse = DeviceBuild.exec(DeviceBuild.adb(), ["-s", serial, "reverse", `tcp:${port}`, `tcp:${port}`], {}, report.log)
640
+ active.process = reverse.child
641
+ const code = await reverse.exit
642
+ active.process = undefined
643
+ if (code !== 0) report.log(`adb reverse failed; the app may not reach Metro on port ${port}.`)
644
+ return true
645
+ }
646
+
647
+ function finish(active, installed) {
648
+ active.installed = installed
649
+ active.parked = false
650
+ active.state.status = "running"
651
+ active.state.step = undefined
652
+ active.state.error = undefined
653
+ active.state.finishedAt = Date.now()
654
+ }
655
+
656
+ async function runIos(active, directory, env, report, udid, beforeLaunch) {
657
+ report.step("Reading project")
658
+ const target = await DeviceBuild.iosTarget(directory)
659
+ if (active.cancelled) return
660
+ if (typeof target === "string") return report.fail(target)
661
+ active.state.target = target.scheme
662
+ active.state.appID = target.bundleID
663
+
664
+ report.step(`Building ${target.scheme}`)
665
+ const build = DeviceBuild.exec("xcodebuild", DeviceBuild.iosBuildArgs(target, udid), { cwd: directory, env }, report.log)
666
+ active.process = build.child
667
+ const code = await build.exit
668
+ active.process = undefined
669
+ if (active.cancelled) return
670
+ if (code !== 0) return report.fail(buildError(active.state.log) ?? "Build failed.")
671
+
672
+ report.step("Installing", "installing")
673
+ const install = DeviceBuild.exec("xcrun", ["simctl", "install", udid, target.app], { cwd: directory }, report.log)
674
+ active.process = install.child
675
+ const installed = await install.exit
676
+ active.process = undefined
677
+ if (active.cancelled) return
678
+ if (installed !== 0) return report.fail("Could not install the app on the simulator.")
679
+
680
+ if (beforeLaunch && !(await beforeLaunch())) return
681
+ if (active.cancelled) return
682
+ const launched = await launchIos(active, udid, target.bundleID, report)
683
+ if (active.cancelled) return
684
+ if (!launched) return report.fail("Could not launch the app on the simulator.")
685
+ finish(active, { appID: target.bundleID })
686
+ }
687
+
688
+ async function runAndroid(active, directory, env, report, serial, reactNative, beforeLaunch) {
689
+ const wrapper = DeviceBuild.gradleWrapper(directory)
690
+ if (!wrapper) return report.fail("No Gradle wrapper found in this project.")
691
+ const module = DeviceBuild.androidModule(directory)
692
+ active.state.target = module
693
+
694
+ // Only the device's own ABI for React Native: a universal debug APK is ~250 MB and routinely
695
+ // fails to install on an emulator with a stock 6 GB data partition.
696
+ const abi = reactNative ? await DeviceBuild.androidAbi(serial) : undefined
697
+ if (active.cancelled) return
698
+ if (abi) report.log(`Building for ${abi} only`)
699
+
700
+ report.step(`Building ${module}`)
701
+ const build = DeviceBuild.exec(
702
+ wrapper,
703
+ [`:${module}:assembleDebug`, ...DeviceBuild.reactNativeArchitectureArgs(abi)],
704
+ { cwd: directory, env },
705
+ report.log,
706
+ )
707
+ active.process = build.child
708
+ const code = await build.exit
709
+ active.process = undefined
710
+ if (active.cancelled) return
711
+ if (code !== 0) return report.fail(buildError(active.state.log) ?? "Build failed.")
712
+
713
+ const apk = DeviceBuild.androidApk(directory, module)
714
+ if (!apk) return report.fail("Build finished but no debug APK was produced.")
715
+ const app = await DeviceBuild.androidApp(apk, directory, module)
716
+ if (active.cancelled) return
717
+ if (!app) return report.fail("Could not determine the application id for this project.")
718
+ active.state.appID = app.id
719
+
720
+ report.step("Installing", "installing")
721
+ const install = DeviceBuild.exec(DeviceBuild.adb(), ["-s", serial, "install", "-r", "-g", apk], { cwd: directory }, report.log)
722
+ active.process = install.child
723
+ const installed = await install.exit
724
+ active.process = undefined
725
+ if (active.cancelled) return
726
+ if (installed !== 0) return report.fail(await installError(active.state.log, serial))
727
+
728
+ if (beforeLaunch) {
729
+ await reverseMetro(active, serial, report)
730
+ if (active.cancelled) return
731
+ if (!(await beforeLaunch())) return
732
+ if (active.cancelled) return
733
+ }
734
+ const installedApp = { appID: app.id, ...(app.activity ? { activity: app.activity } : {}) }
735
+ const launched = await launchAndroid(active, serial, installedApp, report)
736
+ if (active.cancelled) return
737
+ if (!launched) return report.fail("Could not launch the app on the device.")
738
+ finish(active, installedApp)
739
+ }
740
+
741
+ /** Turn adb's install failure into something the user can act on. */
742
+ async function installError(log, serial) {
743
+ const reason = DeviceBuild.installFailure(log)
744
+ if (!reason) return "Could not install the APK on the device."
745
+ if (reason.startsWith("INSTALL_FAILED_INSUFFICIENT_STORAGE")) {
746
+ const free = await DeviceBuild.androidFreeMb(serial)
747
+ const space = free === undefined ? "" : ` (${free} MB free)`
748
+ return `The device is out of storage${space}. Uninstall apps or give the AVD a larger internal storage in Android Studio's Device Manager, then run again.`
749
+ }
750
+ return `Could not install the APK: ${reason}`
751
+ }
752
+
753
+ /** Ask the device to terminate the app that this build launched. */
754
+ async function quit(active) {
755
+ const appID = active.state.appID
756
+ const device = active.device
757
+ if (!appID || !device) return
758
+ if (active.state.platform === "ios") {
759
+ await DeviceBuild.exec("xcrun", ["simctl", "terminate", device, appID], {}).exit
760
+ return
761
+ }
762
+ await DeviceBuild.exec(DeviceBuild.adb(), ["-s", device, "shell", "am", "force-stop", appID], {}).exit
763
+ }
764
+
765
+ /** The most useful line from a failed build, for the status text and the toast. */
766
+ function buildError(log) {
767
+ const lines = log.map((line) => line.trim())
768
+ // Gradle prints the real cause under "* What went wrong:", usually prefixed with ">".
769
+ const wrong = lines.findIndex((line) => line.includes("What went wrong"))
770
+ if (wrong !== -1) {
771
+ const detail = lines.slice(wrong + 1, wrong + 6).find((line) => line && !line.startsWith("*"))
772
+ if (detail) return detail.replace(/^>\s*/, "").slice(0, 300)
773
+ }
774
+ const compiler = [...lines].reverse().find((line) => /(^|\s)error:/i.test(line))
775
+ if (compiler) return compiler.slice(0, 300)
776
+ return [...lines]
777
+ .reverse()
778
+ .find((line) => /FAILURE: Build failed/i.test(line))
779
+ ?.slice(0, 300)
780
+ }
781
+
782
+ async function kill(active) {
783
+ const proc = active.process
784
+ if (!proc || active.state.status === "exited") return
785
+ const exited = () => active.state.status === "exited"
786
+ if (process.platform === "win32") return killTree(proc, { exited })
787
+ // npm forwards SIGTERM to the serve-* process it launched, which shuts the stream down cleanly.
788
+ // Capture the tree first so stragglers (simctl, adb) can still be force-killed afterwards.
789
+ const tree = [...descendants(proc.pid), ...(proc.pid ? [proc.pid] : [])]
790
+ proc.kill("SIGTERM")
791
+ const deadline = Date.now() + STOP_TIMEOUT_MS
792
+ while (!exited() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 100))
793
+ for (const pid of tree) {
794
+ try {
795
+ process.kill(pid, "SIGKILL")
796
+ } catch {
797
+ /* already gone */
798
+ }
799
+ }
800
+ }
801
+
802
+ /** Windows: taskkill the whole tree, the way `Shell.killTree` does in mobilecode. */
803
+ function killTree(proc, opts) {
804
+ const pid = proc.pid
805
+ if (!pid || opts?.exited?.()) return Promise.resolve()
806
+ return new Promise((resolve) => {
807
+ const killer = DeviceBuild.launch("taskkill", ["/pid", String(pid), "/f", "/t"], {
808
+ stdio: "ignore",
809
+ windowsHide: true,
810
+ })
811
+ killer.once("exit", () => resolve())
812
+ killer.once("error", () => resolve())
813
+ })
814
+ }
815
+
816
+ /**
817
+ * Synchronous best-effort stop, safe to call from the process `exit` event. npx does not always
818
+ * forward the signal to the server it launched, and an orphaned serve-* keeps its port for days,
819
+ * so signal the descendants as well.
820
+ */
821
+ function terminate(proc, exited) {
822
+ if (!proc || exited) return
823
+ if (process.platform === "win32") {
824
+ proc.kill()
825
+ return
826
+ }
827
+ for (const pid of descendants(proc.pid)) {
828
+ try {
829
+ process.kill(pid, "SIGTERM")
830
+ } catch {
831
+ /* already gone */
832
+ }
833
+ }
834
+ proc.kill("SIGTERM")
835
+ }
836
+
837
+ // Direct and indirect child pids, deepest first. Only used on POSIX where pgrep is standard.
838
+ function descendants(pid) {
839
+ if (!pid) return []
840
+ const result = DeviceBuild.spawnPgrep(pid)
841
+ return result.flatMap((child) => [...descendants(child), child])
842
+ }
843
+
844
+ function within(child, parent) {
845
+ const relative = path.relative(parent, child)
846
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
847
+ }
848
+
849
+ function clean(line) {
850
+ return line.replace(ANSI, "").trimEnd()
851
+ }
852
+
853
+ function push(log, line) {
854
+ log.push(line)
855
+ if (log.length > LOG_LIMIT) log.splice(0, log.length - LOG_LIMIT)
856
+ }
857
+
858
+ function lines(stream, onLine) {
859
+ if (!stream) return
860
+ let rest = ""
861
+ stream.setEncoding("utf8")
862
+ stream.on("data", (chunk) => {
863
+ const parts = (rest + chunk).split(/\r?\n/)
864
+ rest = parts.pop() ?? ""
865
+ for (const part of parts) onLine(part)
866
+ })
867
+ stream.on("end", () => {
868
+ if (rest) onLine(rest)
869
+ })
870
+ }