rn-iso 0.4.0 → 0.4.4
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 +10 -2
- package/bin/cli.js +2 -0
- package/package.json +1 -1
- package/skill/SKILL.md +5 -3
- package/src/commands/android.js +132 -48
- package/src/commands/device.js +24 -3
- package/src/commands/ios.js +4 -2
- package/src/commands/release.js +3 -1
- package/src/commands/shutdown.js +120 -0
- package/src/commands/status.js +5 -1
- package/src/config.js +9 -2
- package/src/runner.js +29 -8
- package/src/sim/android.js +81 -17
package/README.md
CHANGED
|
@@ -27,6 +27,13 @@ Both run side by side. For non-interactive / agent use, pass `--auto` to skip th
|
|
|
27
27
|
npx rn-iso ios --auto
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Forward extra flags to the underlying build command with `--`:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx rn-iso ios -- --variant=release
|
|
34
|
+
npx rn-iso android -- --mode=diaRelease --terminal=Ghostty
|
|
35
|
+
```
|
|
36
|
+
|
|
30
37
|
For AI coding agents, install the skill so the agent knows how to drive the CLI:
|
|
31
38
|
|
|
32
39
|
```bash
|
|
@@ -39,8 +46,8 @@ All commands below take the same `npx rn-iso` prefix.
|
|
|
39
46
|
|
|
40
47
|
| Command | Purpose |
|
|
41
48
|
|---|---|
|
|
42
|
-
| `ios [--auto] [--device-type <name>] [--runtime <ver>] [--script <name>] [--pm <name>] [--no-script] [--no-install]` | Ensure iOS sim + Metro + build/install |
|
|
43
|
-
| `android [--auto] [--script <name>] [--pm <name>] [--no-script] [--no-install]` | Same for Android |
|
|
49
|
+
| `ios [--auto] [--device-type <name>] [--runtime <ver>] [--script <name>] [--pm <name>] [--no-script] [--no-install] [-- <extras...>]` | Ensure iOS sim + Metro + build/install. Extras after `--` are forwarded to the build command. |
|
|
50
|
+
| `android [--auto] [--script <name>] [--pm <name>] [--no-script] [--no-install] [-- <extras...>]` | Same for Android. |
|
|
44
51
|
| `start` | Start Metro detached, no platform action |
|
|
45
52
|
| `stop [<port>\|<shortcut>\|<path>]` | Kill Metro. No arg = current project; pass a port (e.g. 8083), a project shortcut (label or unique basename), or an absolute path. |
|
|
46
53
|
| `device [--platform ios\|android] [--json]` | Print the assigned device target |
|
|
@@ -48,6 +55,7 @@ All commands below take the same `npx rn-iso` prefix.
|
|
|
48
55
|
| `reserve [ios\|android]` | Lock a manually-started sim/emulator to the current project (no build) |
|
|
49
56
|
| `unreserve [ios\|android]` | Drop the current project's lock without shutting the sim down |
|
|
50
57
|
| `release [<port>\|<shortcut>\|<path>] [--platform <p>] [--shutdown]` | Free a project's assignment. Target can be a Metro port (`8083`), a shortcut (label or unique basename), or an absolute path. `--shutdown` also stops the sim. |
|
|
58
|
+
| `shutdown [<shortcut>\|<path>] [-y] [--keep-sims]` | Kill Metro, shut down sims/emulators, and clear device assignments. No arg = every registered project (end-of-day reset). Pass a shortcut or path to scope to one. |
|
|
51
59
|
| `config [<key> [<value>]] [--unset] [--project <target>]` | Get / set a per-project setting (`packageManager`, `ios.script`, `android.script`). |
|
|
52
60
|
|
|
53
61
|
## How it works
|
package/bin/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import statusCommand from '../src/commands/status.js';
|
|
|
10
10
|
import releaseCommand from '../src/commands/release.js';
|
|
11
11
|
import reserveCommand from '../src/commands/reserve.js';
|
|
12
12
|
import unreserveCommand from '../src/commands/unreserve.js';
|
|
13
|
+
import shutdownCommand from '../src/commands/shutdown.js';
|
|
13
14
|
import configCommand from '../src/commands/config.js';
|
|
14
15
|
|
|
15
16
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
@@ -29,6 +30,7 @@ statusCommand(program);
|
|
|
29
30
|
releaseCommand(program);
|
|
30
31
|
reserveCommand(program);
|
|
31
32
|
unreserveCommand(program);
|
|
33
|
+
shutdownCommand(program);
|
|
32
34
|
configCommand(program);
|
|
33
35
|
|
|
34
36
|
program.parse();
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -23,13 +23,14 @@ From the project root (or any subdirectory):
|
|
|
23
23
|
```json
|
|
24
24
|
{"platform":"ios","udid":"ABC-...","metroPort":8083}
|
|
25
25
|
```
|
|
26
|
-
Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` to use with `adb -s`.
|
|
26
|
+
Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` (or the hardware serial for a physical device) to use with `adb -s`. The Android JSON payload also includes `kind: "emulator" | "physical"`.
|
|
27
27
|
|
|
28
28
|
3. **Interact with the device** — pass the UDID/serial to your UI tools. Never call `simctl <verb>` without `<UDID>` — `booted` could be the wrong sim.
|
|
29
29
|
|
|
30
30
|
## CRITICAL rules
|
|
31
31
|
|
|
32
32
|
- **Pass `--auto` for non-interactive use** of `ios` or `android`. Without it, the command will prompt with an arrow-key picker if multiple unclaimed sims/AVDs exist. `--auto` is also implied automatically when stdin isn't a TTY (e.g., when an agent pipes the command), so under most agent harnesses you don't have to remember the flag — but passing it explicitly is harmless and clearer.
|
|
33
|
+
- **Forward extra flags to the build CLI with `--`.** `npx rn-iso ios -- --variant=release` (or `android -- --mode=diaRelease`) appends those flags to the underlying `react-native run-*` / `expo run:*` invocation. Useful for release-mode builds, custom terminals, etc. Last-wins semantics, so extras can override defaults rn-iso set earlier in the command.
|
|
33
34
|
- **`--auto` will NOT take over a claimed sim/AVD.** If every device is claimed by other rn-iso projects, `--auto` errors. To take one over, run the command interactively (no `--auto`, with a real TTY) and confirm at the prompt — only do this if the user explicitly asks.
|
|
34
35
|
- **Always use `npx rn-iso device` to discover your target.** Never assume `booted` is your sim — another project's simulator might be booted too.
|
|
35
36
|
- **Always pass the UDID/serial explicitly** to `xcrun simctl` and `adb -s`. Examples:
|
|
@@ -37,7 +38,7 @@ From the project root (or any subdirectory):
|
|
|
37
38
|
- `adb -s emulator-5556 shell input tap 100 200`
|
|
38
39
|
- **Don't call `release` or `release --shutdown`** unless the user explicitly asks. Other agents may be using neighboring sims; keep yours up so the user can come back to it.
|
|
39
40
|
- **Don't manually start Metro on a different port.** `npx rn-iso start` (or `npx rn-iso ios/android`) already handles port assignment.
|
|
40
|
-
- **rn-iso never auto-creates simulators.** It reuses existing unclaimed sims (booted or shutdown). If
|
|
41
|
+
- **rn-iso never auto-creates simulators.** It reuses existing unclaimed sims (booted or shutdown) and, on Android, also surfaces any physical device adb can see. If nothing is available, it errors. To create a new iOS sim explicitly, pass `--device-type "iPhone 17 Pro" [--runtime 26.2]`.
|
|
41
42
|
|
|
42
43
|
## Typical agent workflow
|
|
43
44
|
|
|
@@ -83,6 +84,7 @@ Reserve binds the sim to the current project the same way `ios` does, but skips
|
|
|
83
84
|
- `npx rn-iso start` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds.
|
|
84
85
|
- `npx rn-iso stop [<port>|<shortcut>|<path>]` — kill Metro. No arg = current project. Passing a port (e.g. `8083`) kills whatever is on it; a project shortcut (label or unique basename) or absolute path targets that project. Finds the process by port, so it works whether Metro was started by `npx rn-iso start` or by the build CLI.
|
|
85
86
|
- `npx rn-iso release [<port>|<shortcut>|<path>] [--platform <p>] [--shutdown]` — free a project's sim assignment. Defaults to the current project. Target can also be a Metro port (`8083`) or a shortcut (label / unique basename). `--shutdown` also stops the sim/emulator.
|
|
87
|
+
- `npx rn-iso shutdown [<shortcut>|<path>] [-y] [--keep-sims]` — kill Metro, shut down sims/emulators, and clear device assignments. With no arg, scopes to **every** registered project (end-of-day reset); pass a project shortcut (label or unique basename) or absolute path to scope to one. Note this does NOT default to the current project (deliberate — `shutdown` is the explicit "tear it all down" command). Prompts unless `-y` / non-TTY; `--keep-sims` only kills Metro and clears assignments without touching the sims. Project entries themselves stay registered, so `metroPort` allocations and labels survive.
|
|
86
88
|
- `npx rn-iso config [<key> [<value>]] [--unset] [--project <target>]` — persist per-project settings. Allowed keys: `packageManager` (npm|yarn|pnpm|bun), `ios.script`, `android.script`. Resolution order on `ios`/`android`: CLI flag > stored setting > inferred default. Useful when a project's build script is named differently (`dev:ios` instead of `ios`) or when a different package manager is used than the lockfile suggests.
|
|
87
89
|
|
|
88
90
|
### Project shortcuts (--label)
|
|
@@ -102,7 +104,7 @@ When the iOS picker fires, sims are sorted by:
|
|
|
102
104
|
3. Usage count (most-used floats up; tracked per UDID across all projects)
|
|
103
105
|
4. Name (alphabetical, stable tiebreak)
|
|
104
106
|
|
|
105
|
-
When the Android picker fires, AVDs are sorted by running state (running emulators first), then alphabetically.
|
|
107
|
+
When the Android picker fires, candidates include both AVDs on disk and physical devices currently visible to `adb`. They are sorted by running state (running emulators and connected physical devices first), then physical above AVDs within the same running group, then alphabetically. Physical devices show with a `[physical]` tag. Once selected, a physical device is claimed by serial just like an AVD; `release` clears the claim but never shuts the device down.
|
|
106
108
|
|
|
107
109
|
Sims/AVDs claimed by other rn-iso projects show in yellow with a `[claimed by ...]` tag. They're selectable but require a confirm prompt before being taken over.
|
|
108
110
|
|
package/src/commands/android.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
waitForBoot,
|
|
13
13
|
adbReverse,
|
|
14
14
|
nextConsolePort,
|
|
15
|
+
listAdbDevices,
|
|
15
16
|
} from '../sim/android.js';
|
|
16
17
|
import { buildAndroidCommand, detectPackageManager } from '../runner.js';
|
|
17
18
|
import { getExecutor } from '../exec.js';
|
|
@@ -20,14 +21,15 @@ import { resolveLabel } from '../labels.js';
|
|
|
20
21
|
export default function androidCommand(program) {
|
|
21
22
|
program
|
|
22
23
|
.command('android')
|
|
23
|
-
.description('Ensure a dedicated Android emulator + Metro for the current project; build/install if needed')
|
|
24
|
+
.description('Ensure a dedicated Android emulator + Metro for the current project; build/install if needed. Pass extra flags to the build CLI after `--`, e.g. `rn-iso android -- --mode=diaRelease`.')
|
|
25
|
+
.argument('[extras...]', 'Flags forwarded as-is to the underlying build command (after `--`)')
|
|
24
26
|
.option('--auto', 'Non-interactive: pick the first unclaimed AVD without prompting (also implied when stdin is not a TTY)')
|
|
25
27
|
.option('--label <name>', 'Optional shortcut name; refer to the project as <name> in stop / release / etc.')
|
|
26
28
|
.option('--script <name>', 'package.json script to invoke for build/install (default: project setting `android.script`, else `android`)')
|
|
27
29
|
.option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
|
|
28
30
|
.option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: project setting `packageManager`, else detected from lockfile)')
|
|
29
31
|
.option('--no-install', 'Skip the build/install step')
|
|
30
|
-
.action(async (opts) => {
|
|
32
|
+
.action(async (extras, opts) => {
|
|
31
33
|
const root = findProjectRoot(process.cwd());
|
|
32
34
|
if (!root) {
|
|
33
35
|
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
@@ -64,79 +66,145 @@ export default function androidCommand(program) {
|
|
|
64
66
|
const claimed = allClaimedDevices();
|
|
65
67
|
const myAvd = proj.platforms?.android?.avdName || null;
|
|
66
68
|
const myPort = proj.platforms?.android?.consolePort || null;
|
|
69
|
+
const mySerial = proj.platforms?.android?.serial || null;
|
|
67
70
|
const claimedAvds = claimed.androidAvds.filter(a => a !== myAvd);
|
|
68
71
|
const claimedPorts = claimed.androidConsolePorts.filter(p => p !== myPort);
|
|
72
|
+
const claimedSerials = claimed.androidPhysicalSerials.filter(s => s !== mySerial);
|
|
69
73
|
|
|
70
74
|
const selection = selectAndroidDevice({
|
|
71
75
|
existingAvd: myAvd,
|
|
76
|
+
existingSerial: mySerial,
|
|
72
77
|
existingConsolePort: myPort,
|
|
73
78
|
claimedAvds,
|
|
79
|
+
claimedSerials,
|
|
74
80
|
claimedConsolePorts: claimedPorts,
|
|
75
81
|
});
|
|
76
82
|
|
|
77
|
-
let avdName
|
|
83
|
+
let avdName = null;
|
|
84
|
+
let consolePort = null;
|
|
85
|
+
let serial = null;
|
|
86
|
+
let isRunning = false;
|
|
87
|
+
let isPhysical = false;
|
|
78
88
|
if (selection.kind === 'reuse') {
|
|
79
|
-
(
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
if (selection.deviceKind === 'physical') {
|
|
90
|
+
isPhysical = true;
|
|
91
|
+
serial = selection.serial;
|
|
92
|
+
isRunning = true;
|
|
93
|
+
console.log(chalk.dim(`Reusing physical device ${serial}`));
|
|
82
94
|
} else {
|
|
83
|
-
|
|
95
|
+
({ avdName, consolePort, isRunning } = selection);
|
|
96
|
+
if (isRunning) {
|
|
97
|
+
console.log(chalk.dim(`Reusing running ${avdName} (emulator-${consolePort})`));
|
|
98
|
+
} else {
|
|
99
|
+
console.log(chalk.dim(`Booting assigned ${avdName} (emulator-${consolePort})...`));
|
|
100
|
+
}
|
|
84
101
|
}
|
|
85
102
|
} else if (selection.kind === 'allocate') {
|
|
86
103
|
const picked = (selection.candidates.length === 1 || auto)
|
|
87
104
|
? { c: selection.candidates[0], prevClaim: null }
|
|
88
|
-
: await
|
|
105
|
+
: await pickAndroidDevice({
|
|
89
106
|
candidates: selection.candidates,
|
|
90
107
|
androidClaimsByAvd: claimed.androidClaimsByAvd,
|
|
108
|
+
androidPhysicalClaimsBySerial: claimed.androidPhysicalClaimsBySerial,
|
|
91
109
|
});
|
|
92
110
|
await releasePriorClaim(picked.prevClaim);
|
|
93
|
-
(
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
if (picked.c.kind === 'physical') {
|
|
112
|
+
isPhysical = true;
|
|
113
|
+
serial = picked.c.serial;
|
|
114
|
+
isRunning = true;
|
|
115
|
+
console.log(chalk.green(`Picked physical device ${serial}`));
|
|
116
|
+
} else {
|
|
117
|
+
({ avdName, isRunning, consolePort } = picked.c);
|
|
118
|
+
if (!isRunning) {
|
|
119
|
+
consolePort = nextConsolePort(claimedPorts);
|
|
120
|
+
}
|
|
121
|
+
console.log(isRunning
|
|
122
|
+
? chalk.green(`Picked ${avdName} (emulator-${consolePort}, running)`)
|
|
123
|
+
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
96
124
|
}
|
|
97
|
-
console.log(isRunning
|
|
98
|
-
? chalk.green(`Picked ${avdName} (emulator-${consolePort}, running)`)
|
|
99
|
-
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
100
125
|
} else if (selection.kind === 'allClaimed') {
|
|
101
126
|
if (auto) {
|
|
102
|
-
console.error(chalk.red('All Android
|
|
127
|
+
console.error(chalk.red('All Android devices are claimed by other rn-iso projects.'));
|
|
103
128
|
console.error(chalk.dim('Re-run without --auto to confirm taking one over, or create a new AVD via Android Studio.'));
|
|
104
129
|
process.exit(1);
|
|
105
130
|
}
|
|
106
|
-
const picked = await
|
|
131
|
+
const picked = await pickAndroidDevice({
|
|
107
132
|
candidates: selection.candidates,
|
|
108
133
|
androidClaimsByAvd: claimed.androidClaimsByAvd,
|
|
134
|
+
androidPhysicalClaimsBySerial: claimed.androidPhysicalClaimsBySerial,
|
|
109
135
|
allClaimed: true,
|
|
110
136
|
});
|
|
111
137
|
await releasePriorClaim(picked.prevClaim);
|
|
112
|
-
(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
if (picked.c.kind === 'physical') {
|
|
139
|
+
isPhysical = true;
|
|
140
|
+
serial = picked.c.serial;
|
|
141
|
+
isRunning = true;
|
|
142
|
+
console.log(chalk.green(`Took over physical device ${serial}`));
|
|
143
|
+
} else {
|
|
144
|
+
({ avdName, isRunning, consolePort } = picked.c);
|
|
145
|
+
if (!isRunning) {
|
|
146
|
+
// Prior owner's port is freed by releasePriorClaim, but compute fresh.
|
|
147
|
+
const fresh = allClaimedDevices().androidConsolePorts.filter(p => p !== myPort);
|
|
148
|
+
consolePort = nextConsolePort(fresh);
|
|
149
|
+
}
|
|
150
|
+
console.log(isRunning
|
|
151
|
+
? chalk.green(`Took over ${avdName} (emulator-${consolePort}, running)`)
|
|
152
|
+
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
117
153
|
}
|
|
118
|
-
console.log(isRunning
|
|
119
|
-
? chalk.green(`Took over ${avdName} (emulator-${consolePort}, running)`)
|
|
120
|
-
: chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
|
|
121
154
|
} else {
|
|
122
155
|
console.error(chalk.red(
|
|
123
|
-
'No AVDs available. Create
|
|
156
|
+
'No AVDs or physical devices available. Create an AVD via Android Studio (Tools -> Device Manager), or plug in a device with USB debugging enabled.'
|
|
124
157
|
));
|
|
125
158
|
process.exit(1);
|
|
126
159
|
}
|
|
127
160
|
|
|
128
|
-
|
|
129
|
-
if (!isRunning) {
|
|
161
|
+
if (!isPhysical) serial = `emulator-${consolePort}`;
|
|
162
|
+
if (!isPhysical && !isRunning) {
|
|
163
|
+
// Pre-spawn sanity check: an emulator may already be attached on
|
|
164
|
+
// this console port but in `unauthorized` / `offline` state, in
|
|
165
|
+
// which case enumerateAndroidCandidates marked isRunning=false.
|
|
166
|
+
// Spawning a second emulator on the same port would silently
|
|
167
|
+
// collide and the boot wait would poll forever.
|
|
168
|
+
const adb = listAdbDevices();
|
|
169
|
+
const stuck = adb.unhealthy.find(u => u.consolePort === consolePort);
|
|
170
|
+
if (stuck) {
|
|
171
|
+
console.error(chalk.red(
|
|
172
|
+
`adb sees ${stuck.serial} but its status is "${stuck.status}". rn-iso can't drive it in this state.`
|
|
173
|
+
));
|
|
174
|
+
if (stuck.status === 'unauthorized') {
|
|
175
|
+
console.error(chalk.dim('Likely cause: the emulator and your ~/.android/adbkey.pub are out of sync.'));
|
|
176
|
+
console.error(chalk.dim('Try: `adb kill-server && adb start-server` first; if it stays unauthorized,'));
|
|
177
|
+
console.error(chalk.dim('cold-boot the AVD from Android Studio Device Manager.'));
|
|
178
|
+
} else {
|
|
179
|
+
console.error(chalk.dim('Try: `adb kill-server && adb start-server`, then re-run.'));
|
|
180
|
+
}
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
130
183
|
bootAndroidEmulator(avdName, consolePort);
|
|
131
184
|
console.log(chalk.dim('Waiting for boot to complete (this can take 10-30s)...'));
|
|
132
|
-
const
|
|
133
|
-
if (!ok) {
|
|
185
|
+
const result = await waitForBoot(serial, 120000);
|
|
186
|
+
if (!result.ok) {
|
|
134
187
|
console.error(chalk.red(`Emulator ${serial} did not finish booting within 2 minutes.`));
|
|
188
|
+
const d = result.diagnostic;
|
|
189
|
+
console.error(chalk.dim('adb devices ->'));
|
|
190
|
+
console.error(chalk.dim((d.devices || '<no output>').split('\n').map(l => ' ' + l).join('\n')));
|
|
191
|
+
console.error(chalk.dim(
|
|
192
|
+
`getprop sys.boot_completed=${d.sysBoot || '<empty>'} ` +
|
|
193
|
+
`dev.bootcomplete=${d.devBoot || '<empty>'} ` +
|
|
194
|
+
`init.svc.bootanim=${d.bootAnim || '<empty>'}`
|
|
195
|
+
));
|
|
196
|
+
if (!d.devices.includes(serial)) {
|
|
197
|
+
console.error(chalk.dim(`Hint: ${serial} is not in adb's device list. Try \`adb kill-server && adb start-server\`.`));
|
|
198
|
+
}
|
|
135
199
|
process.exit(1);
|
|
136
200
|
}
|
|
137
201
|
}
|
|
138
202
|
|
|
139
|
-
|
|
203
|
+
if (isPhysical) {
|
|
204
|
+
setDevice(root, 'android', { serial });
|
|
205
|
+
} else {
|
|
206
|
+
setDevice(root, 'android', { avdName, consolePort });
|
|
207
|
+
}
|
|
140
208
|
|
|
141
209
|
adbReverse(serial, proj.metroPort);
|
|
142
210
|
console.log(chalk.dim(`adb reverse tcp:${proj.metroPort} configured for ${serial}`));
|
|
@@ -157,6 +225,7 @@ export default function androidCommand(program) {
|
|
|
157
225
|
serial,
|
|
158
226
|
port: proj.metroPort,
|
|
159
227
|
useScript,
|
|
228
|
+
extras,
|
|
160
229
|
});
|
|
161
230
|
console.log(chalk.dim(`> ${cmd}`));
|
|
162
231
|
const exec = getExecutor();
|
|
@@ -166,39 +235,46 @@ export default function androidCommand(program) {
|
|
|
166
235
|
});
|
|
167
236
|
}
|
|
168
237
|
|
|
169
|
-
|
|
238
|
+
const readyLabel = isPhysical ? `physical device ${serial}` : `${avdName} (${serial})`;
|
|
239
|
+
console.log(chalk.green(`\nAndroid ready on ${readyLabel}, Metro port ${proj.metroPort}`));
|
|
170
240
|
});
|
|
171
241
|
}
|
|
172
242
|
|
|
173
|
-
async function
|
|
174
|
-
// Show every AVD on disk
|
|
175
|
-
// see what's claimed and optionally
|
|
176
|
-
// unclaimed set passed in by
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const
|
|
243
|
+
async function pickAndroidDevice({ candidates, androidClaimsByAvd = {}, androidPhysicalClaimsBySerial = {}, allClaimed = false }) {
|
|
244
|
+
// Show every AVD on disk plus every physical device adb sees (parallel
|
|
245
|
+
// to the iOS picker), so the user can see what's claimed and optionally
|
|
246
|
+
// take it over. `candidates` is the unclaimed set passed in by
|
|
247
|
+
// selectAndroidDevice; devices outside it are claimed and require a
|
|
248
|
+
// confirm prompt on selection.
|
|
249
|
+
const all = enumerateAndroidCandidates();
|
|
250
|
+
const candidateKeys = new Set(candidates.map(c => candidateKey(c)));
|
|
251
|
+
const sorted = sortAndroidCandidates(all);
|
|
181
252
|
|
|
182
|
-
const nameWidth = Math.max(...sorted.map(c => c.
|
|
253
|
+
const nameWidth = Math.max(...sorted.map(c => candidateDisplayName(c).length), 18);
|
|
183
254
|
const choices = sorted.map(c => {
|
|
184
|
-
const claim =
|
|
185
|
-
|
|
186
|
-
|
|
255
|
+
const claim = c.kind === 'physical'
|
|
256
|
+
? androidPhysicalClaimsBySerial[c.serial]
|
|
257
|
+
: androidClaimsByAvd[c.avdName];
|
|
258
|
+
const isCandidate = candidateKeys.has(candidateKey(c));
|
|
259
|
+
const runTag = c.kind === 'physical'
|
|
260
|
+
? chalk.green(' [physical]')
|
|
261
|
+
: (c.isRunning ? chalk.green(` [emulator-${c.consolePort}, running]`) : '');
|
|
262
|
+
const name = candidateDisplayName(c);
|
|
187
263
|
if (claim || !isCandidate) {
|
|
188
264
|
const tag = claim ? chalk.yellow(` [claimed by ${claim.label}]`) : '';
|
|
189
265
|
return {
|
|
190
|
-
title: chalk.yellow(`${
|
|
266
|
+
title: chalk.yellow(`${name.padEnd(nameWidth)}${tag}${runTag}`),
|
|
191
267
|
value: { c, claim: claim || null },
|
|
192
268
|
};
|
|
193
269
|
}
|
|
194
270
|
return {
|
|
195
|
-
title: `${
|
|
271
|
+
title: `${name.padEnd(nameWidth)}${runTag}`,
|
|
196
272
|
value: { c, claim: null },
|
|
197
273
|
};
|
|
198
274
|
});
|
|
199
275
|
const message = allClaimed
|
|
200
|
-
? 'All
|
|
201
|
-
: 'Pick an
|
|
276
|
+
? 'All Android devices are claimed. Pick one to take over:'
|
|
277
|
+
: 'Pick an Android device (claimed devices will prompt to confirm):';
|
|
202
278
|
const answer = await prompts({
|
|
203
279
|
type: 'select',
|
|
204
280
|
name: 'pick',
|
|
@@ -214,7 +290,7 @@ async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false
|
|
|
214
290
|
const ok = await prompts({
|
|
215
291
|
type: 'confirm',
|
|
216
292
|
name: 'ok',
|
|
217
|
-
message: `${c
|
|
293
|
+
message: `${candidateDisplayName(c)} is currently held by project "${claim.label}". Take it over?`,
|
|
218
294
|
initial: false,
|
|
219
295
|
});
|
|
220
296
|
if (!ok.ok) {
|
|
@@ -226,6 +302,14 @@ async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false
|
|
|
226
302
|
return { c, prevClaim: null };
|
|
227
303
|
}
|
|
228
304
|
|
|
305
|
+
function candidateKey(c) {
|
|
306
|
+
return c.kind === 'physical' ? `p:${c.serial}` : `a:${c.avdName}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function candidateDisplayName(c) {
|
|
310
|
+
return c.kind === 'physical' ? c.serial : c.avdName;
|
|
311
|
+
}
|
|
312
|
+
|
|
229
313
|
async function releasePriorClaim(prevClaim) {
|
|
230
314
|
if (!prevClaim?.path) return;
|
|
231
315
|
clearDevice(prevClaim.path, 'android');
|
package/src/commands/device.js
CHANGED
|
@@ -27,15 +27,36 @@ export default function deviceCommand(program) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (opts.json) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
let payload;
|
|
31
|
+
if (opts.platform === 'ios') {
|
|
32
|
+
payload = { platform: 'ios', udid: platformEntry.deviceUdid, metroPort: proj.metroPort };
|
|
33
|
+
} else if (platformEntry.serial && !platformEntry.avdName) {
|
|
34
|
+
payload = {
|
|
35
|
+
platform: 'android',
|
|
36
|
+
kind: 'physical',
|
|
37
|
+
serial: platformEntry.serial,
|
|
38
|
+
avdName: null,
|
|
39
|
+
consolePort: null,
|
|
40
|
+
metroPort: proj.metroPort,
|
|
41
|
+
};
|
|
42
|
+
} else {
|
|
43
|
+
payload = {
|
|
44
|
+
platform: 'android',
|
|
45
|
+
kind: 'emulator',
|
|
46
|
+
serial: `emulator-${platformEntry.consolePort}`,
|
|
47
|
+
avdName: platformEntry.avdName,
|
|
48
|
+
consolePort: platformEntry.consolePort,
|
|
49
|
+
metroPort: proj.metroPort,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
33
52
|
console.log(JSON.stringify(payload));
|
|
34
53
|
return;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
if (opts.platform === 'ios') {
|
|
38
57
|
console.log(platformEntry.deviceUdid);
|
|
58
|
+
} else if (platformEntry.serial && !platformEntry.avdName) {
|
|
59
|
+
console.log(platformEntry.serial);
|
|
39
60
|
} else {
|
|
40
61
|
console.log(`emulator-${platformEntry.consolePort}`);
|
|
41
62
|
}
|
package/src/commands/ios.js
CHANGED
|
@@ -12,7 +12,8 @@ import { resolveLabel } from '../labels.js';
|
|
|
12
12
|
export default function iosCommand(program) {
|
|
13
13
|
program
|
|
14
14
|
.command('ios')
|
|
15
|
-
.description('Ensure a dedicated iOS simulator + Metro server for the current project; build/install if needed')
|
|
15
|
+
.description('Ensure a dedicated iOS simulator + Metro server for the current project; build/install if needed. Pass extra flags to the build CLI after `--`, e.g. `rn-iso ios -- --variant=release`.')
|
|
16
|
+
.argument('[extras...]', 'Flags forwarded as-is to the underlying build command (after `--`)')
|
|
16
17
|
.option('--device-type <name>', 'Explicit opt-in: create a NEW sim of this device type (e.g. "iPhone 17 Pro")')
|
|
17
18
|
.option('--runtime <version>', 'iOS runtime version when creating a new sim (e.g. "26.2"); defaults to latest')
|
|
18
19
|
.option('--auto', 'Non-interactive: pick the first unclaimed sim without prompting (also implied when stdin is not a TTY)')
|
|
@@ -21,7 +22,7 @@ export default function iosCommand(program) {
|
|
|
21
22
|
.option('--no-script', 'Skip the package.json script lookup; run expo/react-native CLI directly')
|
|
22
23
|
.option('--pm <name>', 'Package manager: npm, yarn, pnpm, bun (default: project setting `packageManager`, else detected from lockfile)')
|
|
23
24
|
.option('--no-install', 'Skip the build/install step (assume app is already installed)')
|
|
24
|
-
.action(async (opts) => {
|
|
25
|
+
.action(async (extras, opts) => {
|
|
25
26
|
const root = findProjectRoot(process.cwd());
|
|
26
27
|
if (!root) {
|
|
27
28
|
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
@@ -158,6 +159,7 @@ export default function iosCommand(program) {
|
|
|
158
159
|
udid,
|
|
159
160
|
port: proj.metroPort,
|
|
160
161
|
useScript,
|
|
162
|
+
extras,
|
|
161
163
|
});
|
|
162
164
|
console.log(chalk.dim(`> ${cmd}`));
|
|
163
165
|
const exec = getExecutor();
|
package/src/commands/release.js
CHANGED
|
@@ -46,9 +46,11 @@ export default function releaseCommand(program) {
|
|
|
46
46
|
if (p === 'ios') {
|
|
47
47
|
shutdownIosSim(entry.deviceUdid);
|
|
48
48
|
console.log(chalk.green(`Shut down iOS sim ${formatIosLabel(entry.deviceUdid)}`));
|
|
49
|
-
} else {
|
|
49
|
+
} else if (entry.avdName && typeof entry.consolePort === 'number') {
|
|
50
50
|
shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
|
|
51
51
|
console.log(chalk.green(`Shut down ${entry.avdName} (emulator-${entry.consolePort})`));
|
|
52
|
+
} else if (entry.serial) {
|
|
53
|
+
console.log(chalk.dim(`Skipping shutdown for physical device ${entry.serial}`));
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
clearDevice(found, p);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/commands/shutdown.js
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import { resolveRegisteredProject } from '../project.js';
|
|
5
|
+
import { loadConfig, setMetro, clearDevice } from '../config.js';
|
|
6
|
+
import { killMetroByPid, findPidListeningOnPort } from '../metro.js';
|
|
7
|
+
import { shutdownIosSim, formatIosLabel } from '../sim/ios.js';
|
|
8
|
+
import { shutdownAndroidEmulator } from '../sim/android.js';
|
|
9
|
+
|
|
10
|
+
export default function shutdownCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('shutdown [target]')
|
|
13
|
+
.description('Stop Metro and shut down sims/emulators across rn-iso projects, then clear their device assignments. With no arg, targets every registered project; pass a project shortcut (label or unique basename) or absolute path to scope to one.')
|
|
14
|
+
.option('-y, --yes', 'Skip the confirmation prompt (also implied when stdin is not a TTY)')
|
|
15
|
+
.option('--keep-sims', "Don't shut down simulators/emulators; only kill Metro and clear assignments")
|
|
16
|
+
.action(async (target, opts) => {
|
|
17
|
+
const cfg = loadConfig();
|
|
18
|
+
let projects = cfg ? Object.entries(cfg.projects || {}) : [];
|
|
19
|
+
if (projects.length === 0) {
|
|
20
|
+
console.log(chalk.dim('No projects registered.'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Optional [target] narrows the scope to a single project. We
|
|
25
|
+
// intentionally do NOT default to the current project when no arg is
|
|
26
|
+
// given — `shutdown` is the explicit "tear everything down" command,
|
|
27
|
+
// so omitting the target means "all projects".
|
|
28
|
+
if (target) {
|
|
29
|
+
const { found, error } = resolveRegisteredProject(target);
|
|
30
|
+
if (!found) {
|
|
31
|
+
console.error(chalk.red(error));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
projects = projects.filter(([path]) => path === found);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Build the work plan up front so the prompt can show counts and so we
|
|
38
|
+
// do all the I/O in clearly separated phases.
|
|
39
|
+
const metros = []; // { path, port, pid }
|
|
40
|
+
const iosSims = []; // { path, udid }
|
|
41
|
+
const androidEmus = []; // { path, avdName, consolePort }
|
|
42
|
+
for (const [path, proj] of projects) {
|
|
43
|
+
if (typeof proj.metroPort === 'number') {
|
|
44
|
+
metros.push({ path, port: proj.metroPort, pid: proj.metroPid });
|
|
45
|
+
}
|
|
46
|
+
const ios = proj.platforms?.ios;
|
|
47
|
+
if (ios?.deviceUdid) iosSims.push({ path, udid: ios.deviceUdid });
|
|
48
|
+
const android = proj.platforms?.android;
|
|
49
|
+
if (android?.avdName || typeof android?.consolePort === 'number') {
|
|
50
|
+
androidEmus.push({ path, avdName: android.avdName, consolePort: android.consolePort });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hasDeviceAssignments = iosSims.length > 0 || androidEmus.length > 0;
|
|
55
|
+
if (metros.length === 0 && !hasDeviceAssignments) {
|
|
56
|
+
console.log(chalk.dim('Nothing to do (no Metro / device assignments tracked).'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const yes = opts.yes || !process.stdin.isTTY;
|
|
61
|
+
if (!yes) {
|
|
62
|
+
const summary = [];
|
|
63
|
+
if (metros.length) summary.push(`kill ${metros.length} Metro instance${metros.length === 1 ? '' : 's'}`);
|
|
64
|
+
if (!opts.keepSims) {
|
|
65
|
+
if (iosSims.length) summary.push(`shut down ${iosSims.length} iOS sim${iosSims.length === 1 ? '' : 's'}`);
|
|
66
|
+
if (androidEmus.length) summary.push(`shut down ${androidEmus.length} Android emulator${androidEmus.length === 1 ? '' : 's'}`);
|
|
67
|
+
}
|
|
68
|
+
if (hasDeviceAssignments) summary.push('clear device assignments');
|
|
69
|
+
const answer = await prompts({
|
|
70
|
+
type: 'confirm',
|
|
71
|
+
name: 'ok',
|
|
72
|
+
message: `About to ${summary.join(', ')} across ${projects.length} project${projects.length === 1 ? '' : 's'}. Proceed?`,
|
|
73
|
+
initial: false,
|
|
74
|
+
});
|
|
75
|
+
if (!answer.ok) {
|
|
76
|
+
console.error(chalk.red('Cancelled.'));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Phase 1: kill Metro instances. Try the recorded pid first; if that
|
|
82
|
+
// misses, look up whoever's listening on the port. Always clear the
|
|
83
|
+
// recorded metroPid so `status` reflects reality afterward.
|
|
84
|
+
for (const m of metros) {
|
|
85
|
+
let pid = m.pid;
|
|
86
|
+
if (!pid || !killMetroByPid(pid)) {
|
|
87
|
+
pid = findPidListeningOnPort(m.port);
|
|
88
|
+
if (pid) killMetroByPid(pid);
|
|
89
|
+
}
|
|
90
|
+
setMetro(m.path, m.port, null);
|
|
91
|
+
if (pid) {
|
|
92
|
+
console.log(chalk.green(`Killed Metro pid ${pid} on port ${m.port} ${chalk.dim(`(${m.path})`)}`));
|
|
93
|
+
} else {
|
|
94
|
+
console.log(chalk.dim(`No Metro running on port ${m.port} (${m.path})`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Phase 2: shut down sims / emulators. shutdownIosSim and
|
|
99
|
+
// shutdownAndroidEmulator both go through runQuiet so failures (e.g.
|
|
100
|
+
// sim already shut down, adb missing) don't throw.
|
|
101
|
+
if (!opts.keepSims) {
|
|
102
|
+
for (const s of iosSims) {
|
|
103
|
+
shutdownIosSim(s.udid);
|
|
104
|
+
console.log(chalk.green(`Shut down iOS sim ${formatIosLabel(s.udid)} ${chalk.dim(`(${s.path})`)}`));
|
|
105
|
+
}
|
|
106
|
+
for (const a of androidEmus) {
|
|
107
|
+
const serial = `emulator-${a.consolePort}`;
|
|
108
|
+
shutdownAndroidEmulator(serial);
|
|
109
|
+
console.log(chalk.green(`Shut down ${a.avdName ?? serial} (${serial}) ${chalk.dim(`(${a.path})`)}`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Phase 3: clear device assignments so subsequent `rn-iso ios/android`
|
|
114
|
+
// calls re-pick instead of trying to reuse a now-shutdown device.
|
|
115
|
+
for (const [path, proj] of projects) {
|
|
116
|
+
if (proj.platforms?.ios) clearDevice(path, 'ios');
|
|
117
|
+
if (proj.platforms?.android) clearDevice(path, 'android');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -53,7 +53,11 @@ export default function statusCommand(program) {
|
|
|
53
53
|
}
|
|
54
54
|
const android = proj.platforms?.android;
|
|
55
55
|
if (android) {
|
|
56
|
-
|
|
56
|
+
if (android.serial && !android.avdName) {
|
|
57
|
+
console.log(` android: ${chalk.cyan(android.serial)} ${chalk.dim('(physical)')}`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(` android: ${chalk.cyan(android.avdName)} ${chalk.dim(`(emulator-${android.consolePort})`)}`);
|
|
60
|
+
}
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
console.log('');
|
package/src/config.js
CHANGED
|
@@ -174,13 +174,16 @@ export function allClaimedDevices() {
|
|
|
174
174
|
iosUdids: [],
|
|
175
175
|
androidAvds: [],
|
|
176
176
|
androidConsolePorts: [],
|
|
177
|
+
androidPhysicalSerials: [],
|
|
177
178
|
// iosClaims: udid -> { label, path }. androidClaims: consolePort ->
|
|
178
179
|
// { label, path, avdName }. androidClaimsByAvd: avdName -> { label,
|
|
179
|
-
// path, consolePort }.
|
|
180
|
-
//
|
|
180
|
+
// path, consolePort }. androidPhysicalClaimsBySerial: serial ->
|
|
181
|
+
// { label, path }. `path` is the absolute project path so take-over
|
|
182
|
+
// flows can call clearDevice on the owning project.
|
|
181
183
|
iosClaims: {},
|
|
182
184
|
androidClaims: {},
|
|
183
185
|
androidClaimsByAvd: {},
|
|
186
|
+
androidPhysicalClaimsBySerial: {},
|
|
184
187
|
};
|
|
185
188
|
if (!cfg) return result;
|
|
186
189
|
for (const [path, proj] of Object.entries(cfg.projects || {})) {
|
|
@@ -207,6 +210,10 @@ export function allClaimedDevices() {
|
|
|
207
210
|
avdName: android.avdName,
|
|
208
211
|
};
|
|
209
212
|
}
|
|
213
|
+
if (android?.serial && !android.avdName) {
|
|
214
|
+
result.androidPhysicalSerials.push(android.serial);
|
|
215
|
+
result.androidPhysicalClaimsBySerial[android.serial] = { label, path };
|
|
216
|
+
}
|
|
210
217
|
}
|
|
211
218
|
return result;
|
|
212
219
|
}
|
package/src/runner.js
CHANGED
|
@@ -79,7 +79,10 @@ export function detectScriptCli(scriptBody) {
|
|
|
79
79
|
// iOS run command. Prefers the project's `ios` script if present (the most
|
|
80
80
|
// reliable: respects user customization, picks the right CLI). Falls back to
|
|
81
81
|
// expo run:ios / react-native run-ios when no script exists or --no-script.
|
|
82
|
-
|
|
82
|
+
// Any `extras` are appended last so they can override earlier flags (CLIs
|
|
83
|
+
// using commander/yargs are last-wins on repeated options).
|
|
84
|
+
export function buildIosCommand({ projectRoot, packageManager, scriptName, isExpo, udid, port, useScript = true, extras = [] }) {
|
|
85
|
+
const tail = (extras || []).map(shQuote);
|
|
83
86
|
if (useScript && scriptName) {
|
|
84
87
|
const script = getProjectScript(projectRoot, scriptName);
|
|
85
88
|
if (script) {
|
|
@@ -89,32 +92,50 @@ export function buildIosCommand({ projectRoot, packageManager, scriptName, isExp
|
|
|
89
92
|
return buildScriptCommand(packageManager, scriptName, [
|
|
90
93
|
deviceFlag,
|
|
91
94
|
`--port ${port}`,
|
|
95
|
+
...tail,
|
|
92
96
|
]);
|
|
93
97
|
}
|
|
94
98
|
}
|
|
99
|
+
const tailStr = tail.length ? ' ' + tail.join(' ') : '';
|
|
95
100
|
if (isExpo) {
|
|
96
|
-
return `npx expo run:ios --device ${udid} --port ${port}`;
|
|
101
|
+
return `npx expo run:ios --device ${udid} --port ${port}${tailStr}`;
|
|
97
102
|
}
|
|
98
|
-
return `npx react-native run-ios --udid ${udid} --port ${port}`;
|
|
103
|
+
return `npx react-native run-ios --udid ${udid} --port ${port}${tailStr}`;
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true }) {
|
|
106
|
+
export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true, extras = [] }) {
|
|
107
|
+
const tail = (extras || []).map(shQuote);
|
|
108
|
+
// Expo `--device <id>` accepts either an AVD name (emulators) or a
|
|
109
|
+
// hardware serial (physical devices). When no AVD name is available
|
|
110
|
+
// (physical), fall back to the serial.
|
|
111
|
+
const expoDeviceArg = avdName ?? serial;
|
|
102
112
|
if (useScript && scriptName) {
|
|
103
113
|
const script = getProjectScript(projectRoot, scriptName);
|
|
104
114
|
if (script) {
|
|
105
115
|
const cli = detectScriptCli(script);
|
|
106
|
-
// Expo: --device <AVD name>; RN: --deviceId <serial>.
|
|
107
|
-
const deviceFlag = cli === 'expo' ? `--device "${
|
|
116
|
+
// Expo: --device <AVD name | serial>; RN: --deviceId <serial>.
|
|
117
|
+
const deviceFlag = cli === 'expo' ? `--device "${expoDeviceArg}"` : `--deviceId ${serial}`;
|
|
108
118
|
return buildScriptCommand(packageManager, scriptName, [
|
|
109
119
|
deviceFlag,
|
|
110
120
|
`--port ${port}`,
|
|
121
|
+
...tail,
|
|
111
122
|
]);
|
|
112
123
|
}
|
|
113
124
|
}
|
|
125
|
+
const tailStr = tail.length ? ' ' + tail.join(' ') : '';
|
|
114
126
|
if (isExpo) {
|
|
115
|
-
return `npx expo run:android --device "${
|
|
127
|
+
return `npx expo run:android --device "${expoDeviceArg}" --port ${port}${tailStr}`;
|
|
116
128
|
}
|
|
117
|
-
return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}`;
|
|
129
|
+
return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}${tailStr}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// POSIX-safe single-quote shell escape. Leaves "safe" tokens (alnum and a
|
|
133
|
+
// few harmless punctuation marks like `=`, `.`, `,`, `:`, `/`, `-`, `@`,
|
|
134
|
+
// `+`, `_`, `%`) alone, single-quotes everything else.
|
|
135
|
+
export function shQuote(s) {
|
|
136
|
+
if (s === '') return "''";
|
|
137
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s;
|
|
138
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
export function buildMetroCommand({ isExpo, port }) {
|
package/src/sim/android.js
CHANGED
|
@@ -10,15 +10,28 @@ export function parseAvdList(text) {
|
|
|
10
10
|
export function parseAdbDevices(text) {
|
|
11
11
|
const lines = text.split('\n').slice(1); // skip "List of devices attached"
|
|
12
12
|
const emulators = [];
|
|
13
|
+
const physical = []; // USB or adb-over-TCP serials (e.g. R5CR70XXX, 192.168.1.5:5555)
|
|
14
|
+
const unhealthy = []; // serials adb sees but can't talk to (unauthorized/offline)
|
|
13
15
|
for (const line of lines) {
|
|
14
16
|
const trimmed = line.trim();
|
|
15
17
|
if (!trimmed) continue;
|
|
16
18
|
const [serial, status] = trimmed.split(/\s+/);
|
|
17
|
-
if (status !== 'device') continue;
|
|
18
19
|
const m = serial.match(/^emulator-(\d+)$/);
|
|
19
|
-
if (m)
|
|
20
|
+
if (m) {
|
|
21
|
+
if (status === 'device') {
|
|
22
|
+
emulators.push({ serial, consolePort: parseInt(m[1], 10) });
|
|
23
|
+
} else {
|
|
24
|
+
unhealthy.push({ serial, kind: 'emulator', consolePort: parseInt(m[1], 10), status });
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
if (status === 'device') {
|
|
28
|
+
physical.push({ serial });
|
|
29
|
+
} else {
|
|
30
|
+
unhealthy.push({ serial, kind: 'physical', status });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
20
33
|
}
|
|
21
|
-
return { emulators };
|
|
34
|
+
return { emulators, physical, unhealthy };
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
export function listAvds() {
|
|
@@ -35,37 +48,66 @@ export function nextConsolePort(claimedPorts) {
|
|
|
35
48
|
return max + 2; // emulator console ports are even
|
|
36
49
|
}
|
|
37
50
|
|
|
38
|
-
// Build the full candidate list: every AVD on disk
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
51
|
+
// Build the full candidate list: every AVD on disk plus every physical
|
|
52
|
+
// device adb currently sees. AVDs are paired with whether they have a
|
|
53
|
+
// running emulator (and on which console port); physical devices are
|
|
54
|
+
// always treated as running. Returns [] when neither exist.
|
|
42
55
|
export function enumerateAndroidCandidates() {
|
|
43
56
|
const avds = listAvds();
|
|
44
|
-
if (avds.length === 0) return [];
|
|
45
|
-
|
|
46
57
|
const adbDevices = listAdbDevices();
|
|
58
|
+
|
|
47
59
|
const runningByAvd = {};
|
|
48
60
|
for (const e of adbDevices.emulators) {
|
|
49
61
|
const avdName = getAvdNameForSerial(e.serial);
|
|
50
62
|
if (avdName) runningByAvd[avdName] = e.consolePort;
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
const avdCandidates = avds.map(avdName => ({
|
|
66
|
+
kind: 'avd',
|
|
54
67
|
avdName,
|
|
55
68
|
isRunning: avdName in runningByAvd,
|
|
56
69
|
consolePort: runningByAvd[avdName] ?? null,
|
|
57
70
|
}));
|
|
71
|
+
|
|
72
|
+
const physicalCandidates = adbDevices.physical.map(p => ({
|
|
73
|
+
kind: 'physical',
|
|
74
|
+
serial: p.serial,
|
|
75
|
+
isRunning: true,
|
|
76
|
+
consolePort: null,
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
return [...avdCandidates, ...physicalCandidates];
|
|
58
80
|
}
|
|
59
81
|
|
|
60
|
-
export function selectAndroidDevice({
|
|
82
|
+
export function selectAndroidDevice({
|
|
83
|
+
existingAvd,
|
|
84
|
+
existingSerial,
|
|
85
|
+
existingConsolePort,
|
|
86
|
+
claimedAvds,
|
|
87
|
+
claimedSerials,
|
|
88
|
+
claimedConsolePorts,
|
|
89
|
+
}) {
|
|
61
90
|
const all = enumerateAndroidCandidates();
|
|
62
91
|
if (all.length === 0) return { kind: 'noAvd' };
|
|
63
92
|
|
|
93
|
+
if (existingSerial) {
|
|
94
|
+
const found = all.find(c => c.kind === 'physical' && c.serial === existingSerial);
|
|
95
|
+
if (found) {
|
|
96
|
+
return {
|
|
97
|
+
kind: 'reuse',
|
|
98
|
+
deviceKind: 'physical',
|
|
99
|
+
serial: existingSerial,
|
|
100
|
+
isRunning: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
if (existingAvd) {
|
|
65
|
-
const found = all.find(c => c.avdName === existingAvd);
|
|
106
|
+
const found = all.find(c => c.kind === 'avd' && c.avdName === existingAvd);
|
|
66
107
|
if (found) {
|
|
67
108
|
return {
|
|
68
109
|
kind: 'reuse',
|
|
110
|
+
deviceKind: 'avd',
|
|
69
111
|
avdName: existingAvd,
|
|
70
112
|
consolePort: found.consolePort ?? existingConsolePort ?? nextConsolePort(claimedConsolePorts),
|
|
71
113
|
isRunning: found.isRunning,
|
|
@@ -74,7 +116,10 @@ export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedA
|
|
|
74
116
|
}
|
|
75
117
|
|
|
76
118
|
const claimedAvdSet = new Set(claimedAvds);
|
|
77
|
-
const
|
|
119
|
+
const claimedSerialSet = new Set(claimedSerials || []);
|
|
120
|
+
const unclaimed = all.filter(c => c.kind === 'avd'
|
|
121
|
+
? !claimedAvdSet.has(c.avdName)
|
|
122
|
+
: !claimedSerialSet.has(c.serial));
|
|
78
123
|
|
|
79
124
|
if (unclaimed.length === 0) {
|
|
80
125
|
return { kind: 'allClaimed', candidates: sortAndroidCandidates(all) };
|
|
@@ -85,7 +130,11 @@ export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedA
|
|
|
85
130
|
export function sortAndroidCandidates(list) {
|
|
86
131
|
return [...list].sort((a, b) => {
|
|
87
132
|
if (a.isRunning !== b.isRunning) return a.isRunning ? -1 : 1;
|
|
88
|
-
|
|
133
|
+
// Mixed lists: physical devices float above AVDs within the same running state.
|
|
134
|
+
if (a.kind !== b.kind) return a.kind === 'physical' ? -1 : 1;
|
|
135
|
+
const an = a.kind === 'physical' ? a.serial : a.avdName;
|
|
136
|
+
const bn = b.kind === 'physical' ? b.serial : b.avdName;
|
|
137
|
+
return an.localeCompare(bn);
|
|
89
138
|
});
|
|
90
139
|
}
|
|
91
140
|
|
|
@@ -101,11 +150,26 @@ export async function waitForBoot(serial, timeoutMs = 60000) {
|
|
|
101
150
|
const exec = getExecutor();
|
|
102
151
|
const start = Date.now();
|
|
103
152
|
while (Date.now() - start < timeoutMs) {
|
|
104
|
-
|
|
105
|
-
|
|
153
|
+
// sys.boot_completed is the canonical "system fully up" signal; some
|
|
154
|
+
// older AVD images set dev.bootcomplete sooner. Either is fine.
|
|
155
|
+
const sysBoot = exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`).trim();
|
|
156
|
+
if (sysBoot === '1') return { ok: true };
|
|
157
|
+
const devBoot = exec.runQuiet(`adb -s ${serial} shell getprop dev.bootcomplete`).trim();
|
|
158
|
+
if (devBoot === '1') return { ok: true };
|
|
106
159
|
await new Promise(r => setTimeout(r, 1000));
|
|
107
160
|
}
|
|
108
|
-
|
|
161
|
+
// Diagnostic snapshot for the timeout error: shows the user exactly
|
|
162
|
+
// what adb sees and why the polling never resolved.
|
|
163
|
+
const devices = exec.runQuiet('adb devices').trim();
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
diagnostic: {
|
|
167
|
+
devices,
|
|
168
|
+
sysBoot: exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`).trim(),
|
|
169
|
+
devBoot: exec.runQuiet(`adb -s ${serial} shell getprop dev.bootcomplete`).trim(),
|
|
170
|
+
bootAnim: exec.runQuiet(`adb -s ${serial} shell getprop init.svc.bootanim`).trim(),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
109
173
|
}
|
|
110
174
|
|
|
111
175
|
export function shutdownAndroidEmulator(serial) {
|