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.
- package/LICENSE +26 -0
- package/README.md +164 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +828 -0
- package/lib/device-build.js +964 -0
- package/lib/device-preview.js +870 -0
- package/lib/index.js +976 -0
- package/lib/setup.js +243 -0
- package/package.json +68 -0
- package/scripts/ocr.py +111 -0
|
@@ -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
|
+
}
|