@deeeed/metamask-harness 0.33.0 → 0.33.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.33.1 - 2026-08-04
6
+
7
+ ### Fixed
8
+
9
+ - Make Mobile onboarding deterministic by pinning provisioning, native builds, launch, and readiness to one simulator UDID and Metro port, while ensuring `doctor --fix` enables Perps without inventing `OVERRIDE_REMOTE_FEATURE_FLAGS`.
10
+
5
11
  ## 0.33.0 - 2026-08-03
6
12
 
7
13
  ### Added
@@ -23,7 +23,7 @@ set -uo pipefail
23
23
 
24
24
  PLATFORM="ios"
25
25
  TARGET="$PWD"
26
- SIMULATOR="${IOS_SIMULATOR:-${SIM_UDID:-booted}}"
26
+ SIMULATOR="${SIM_UDID:-${IOS_SIMULATOR:-booted}}"
27
27
  ADB_SERIAL_ARG="${ADB_SERIAL:-${ANDROID_SERIAL:-}}"
28
28
  ADB_BIN="${MM_HARNESS_ADB_PATH:-}"
29
29
  PREFLIGHT_MODE="${MOBILE_PREFLIGHT_MODE:-fast}"
@@ -89,15 +89,42 @@ ios_app_installed() {
89
89
  xcrun simctl get_app_container "$sim_target" "$bundle_id" app >/dev/null 2>&1
90
90
  }
91
91
 
92
- run_ios_native_build() {
93
- local sim_target="$1" reason="$2"
92
+ run_ios_native_build() (
93
+ local sim_target="$1" reason="$2" build_env=""
94
94
  printf '%s; installing with yarn start:ios (WATCHER_PORT=%s)\n' "$reason" "$PORT" >&2
95
+ build_env="$(mktemp "${TMPDIR:-/tmp}/mm-harness-ios-build-env.XXXXXX")" || return 1
96
+ chmod 600 "$build_env" || { rm -f "$build_env"; return 1; }
97
+ trap 'rm -f "$build_env"' EXIT HUP INT TERM
98
+ {
99
+ printf 'export MM_HARNESS_BUILD_WATCHER_PORT=%q\n' "$PORT"
100
+ printf 'export MM_HARNESS_BUILD_IOS_SIMULATOR=%q\n' "$sim_target"
101
+ cat <<'BUILD_ENV'
102
+ mm_harness_restore_build_target() {
103
+ export WATCHER_PORT="$MM_HARNESS_BUILD_WATCHER_PORT"
104
+ export METRO_PORT="$MM_HARNESS_BUILD_WATCHER_PORT"
105
+ export IOS_SIMULATOR="$MM_HARNESS_BUILD_IOS_SIMULATOR"
106
+ }
107
+ eval() {
108
+ builtin eval "$@"
109
+ local status=$?
110
+ mm_harness_restore_build_target
111
+ return "$status"
112
+ }
113
+ source() {
114
+ builtin source "$@"
115
+ local status=$?
116
+ mm_harness_restore_build_target
117
+ return "$status"
118
+ }
119
+ mm_harness_restore_build_target
120
+ BUILD_ENV
121
+ } > "$build_env"
95
122
  (
96
123
  cd "$TARGET"
97
124
  command -v activate_repo_ruby_best_effort >/dev/null 2>&1 && activate_repo_ruby_best_effort "$TARGET"
98
- WATCHER_PORT="$PORT" METRO_PORT="$PORT" IOS_SIMULATOR="$sim_target" EXPO_NO_TYPESCRIPT_SETUP=1 yarn start:ios 2>&1
125
+ BASH_ENV="$build_env" WATCHER_PORT="$PORT" METRO_PORT="$PORT" IOS_SIMULATOR="$sim_target" EXPO_NO_TYPESCRIPT_SETUP=1 yarn start:ios 2>&1
99
126
  )
100
- }
127
+ )
101
128
 
102
129
  ensure_ios_app_for_mode() {
103
130
  local sim_target="$1" bundle_id="$2" mode="$3"
@@ -107,7 +134,6 @@ ensure_ios_app_for_mode() {
107
134
  printf >&2 'open-device: fast mode requires an installed iOS dev client (%s) on %s.\n' \
108
135
  "$bundle_id" "$sim_target"
109
136
  printf >&2 'Fast mode only prewarms the bundle and opens the existing client.\n'
110
- printf >&2 'Next: use --preflight-mode auto to install if missing.\n'
111
137
  return 1
112
138
  ;;
113
139
  auto|default)
@@ -124,10 +150,6 @@ ensure_ios_app_for_mode() {
124
150
 
125
151
  sim_udid_for_target() {
126
152
  local target="$1"
127
- # Already a UDID
128
- if printf '%s' "$target" | grep -Eq '^[0-9A-Fa-f-]{36}$'; then
129
- printf '%s\n' "$target"; return 0
130
- fi
131
153
  xcrun simctl list devices available --json 2>/dev/null | TARGET_SIMULATOR="$target" node -e '
132
154
  let input = "";
133
155
  process.stdin.setEncoding("utf8");
@@ -136,34 +158,54 @@ process.stdin.on("end", () => {
136
158
  const target = process.env.TARGET_SIMULATOR || "";
137
159
  try {
138
160
  const parsed = JSON.parse(input);
139
- for (const devices of Object.values(parsed.devices || {})) {
140
- const found = Array.isArray(devices)
141
- ? devices.find((device) => device && device.isAvailable !== false && device.name === target)
142
- : null;
143
- if (found && found.udid) {
144
- process.stdout.write(`${found.udid}\n`);
145
- return;
161
+ const devices = Object.values(parsed.devices || {}).flatMap((value) => Array.isArray(value) ? value : [])
162
+ .filter((device) => device && device.isAvailable !== false && device.udid);
163
+ const matches = /^[0-9A-Fa-f-]{36}$/.test(target)
164
+ ? devices.filter((device) => device.udid.toLowerCase() === target.toLowerCase())
165
+ : target === "booted"
166
+ ? devices.filter((device) => device.state === "Booted")
167
+ : devices.filter((device) => device.name === target);
168
+ const booted = matches.filter((device) => device.state === "Booted");
169
+ const found = matches.length === 1 ? matches[0] : booted.length === 1 ? booted[0] : null;
170
+ if (found) {
171
+ process.stdout.write(`${found.udid}\n`);
172
+ return;
173
+ }
174
+ if (matches.length > 1) {
175
+ process.stderr.write(`open-device: iOS simulator target ${JSON.stringify(target)} is ambiguous; use one UDID:\n`);
176
+ for (const device of matches) {
177
+ process.stderr.write(` - ${device.udid} (${device.name}) [${device.state || "unknown"}]\n`);
146
178
  }
179
+ process.exit(2);
147
180
  }
181
+ process.exit(1);
148
182
  } catch {
149
- // Empty stdout means unresolved; callers print the teaching error.
183
+ process.exit(1);
150
184
  }
151
185
  });
152
186
  '
153
187
  }
154
188
 
189
+ print_missing_simulator_recovery() {
190
+ local target="$1"
191
+ if printf '%s' "$target" | grep -Eq '^[0-9A-Fa-f-]{36}$'; then
192
+ printf "Next: mm-harness status --adapter mobile --target '%s' --all-devices --json\n" "$TARGET" >&2
193
+ else
194
+ printf "Next: mm-harness provision runway ios --adapter mobile --target '%s' --device '%s'\n" "$TARGET" "$target" >&2
195
+ fi
196
+ }
197
+
155
198
  boot_simulator_if_needed() {
156
199
  local target="$1" state=""
157
- [ "$target" != "booted" ] || return 0
158
200
  local udid=""
159
201
  udid="$(sim_udid_for_target "$target")"
160
202
  if [ -z "$udid" ]; then
161
203
  printf "open-device: configured iOS simulator '%s' does not exist.\n" "$target" >&2
162
- printf "Next: mm-harness provision runway ios --adapter mobile --target '%s' --device '%s'\n" "$TARGET" "$target" >&2
204
+ print_missing_simulator_recovery "$target"
163
205
  return 1
164
206
  fi
165
207
  local line=""
166
- line="$(xcrun simctl list devices available 2>/dev/null | grep -F "${target} (" | head -1 || true)"
208
+ line="$(xcrun simctl list devices available 2>/dev/null | grep -F "(${udid})" | head -1 || true)"
167
209
  case "$line" in *"(Booted)"*) state="Booted" ;; *"(Shutdown)"*) state="Shutdown" ;; esac
168
210
  [ "$state" = "Booted" ] && return 0
169
211
  printf 'Booting iOS simulator %s%s\n' "$target" "${state:+ ($state)}" >&2
@@ -235,7 +277,6 @@ ensure_android_app_for_mode() {
235
277
  fast)
236
278
  if android_app_installed "$pkg" "$@"; then return 0; fi
237
279
  printf >&2 'open-device: fast mode requires an installed Android dev client (%s).\n' "$pkg"
238
- printf >&2 'Next: use --preflight-mode auto to install if missing.\n'
239
280
  return 1
240
281
  ;;
241
282
  auto|default)
@@ -264,7 +305,17 @@ BUNDLE_IDS=("${IOS_BUNDLE_ID:-io.metamask.MetaMask}" "io.metamask" "io.metamask.
264
305
  ANDROID_PKGS=("${ANDROID_PACKAGE_ID:-io.metamask}" "io.metamask.flask" "io.metamask.qa")
265
306
 
266
307
  if [ "$PLATFORM" = "ios" ]; then
267
- SIM_TARGET="$SIMULATOR"
308
+ SIM_TARGET="$(sim_udid_for_target "$SIMULATOR")"
309
+ SIM_RESOLVE_STATUS=$?
310
+ if [ "$SIM_RESOLVE_STATUS" -ne 0 ]; then
311
+ if [ "$SIM_RESOLVE_STATUS" -eq 1 ]; then
312
+ printf "open-device: configured iOS simulator '%s' does not exist.\n" "$SIMULATOR" >&2
313
+ print_missing_simulator_recovery "$SIMULATOR"
314
+ else
315
+ printf "Next: mm-harness status --adapter mobile --target '%s' --all-devices --json\n" "$TARGET" >&2
316
+ fi
317
+ exit 1
318
+ fi
268
319
  # Expo dev-client URL scheme — drives both the deep link and the scheme-approval key.
269
320
  DEV_CLIENT_SCHEME="${IOS_DEV_CLIENT_SCHEME:-expo-metamask}"
270
321
 
@@ -319,6 +370,7 @@ if [ "$PLATFORM" = "ios" ]; then
319
370
  if [ "$LAUNCHED" = false ]; then
320
371
  printf 'open-device: no MetaMask bundle found on simulator %s\n' "$SIM_TARGET" >&2
321
372
  printf ' tried: %s\n' "${BUNDLE_IDS[*]}" >&2
373
+ printf "Next: mm-harness provision runway ios --adapter mobile --target '%s' --device '%s' --force\n" "$TARGET" "$SIM_TARGET" >&2
322
374
  exit 1
323
375
  fi
324
376
 
@@ -356,6 +408,11 @@ else
356
408
  printf 'open-device: no MetaMask package found on Android device %s\n' \
357
409
  "${ADB_SERIAL_ARG:-<default>}" >&2
358
410
  printf ' tried: %s\n' "${ANDROID_PKGS[*]}" >&2
411
+ if [ -n "$ADB_SERIAL_ARG" ]; then
412
+ printf "Next: mm-harness launch android --build --target '%s' --device '%s'\n" "$TARGET" "$ADB_SERIAL_ARG" >&2
413
+ else
414
+ printf "Next: mm-harness launch android --build --target '%s'\n" "$TARGET" >&2
415
+ fi
359
416
  exit 1
360
417
  fi
361
418
  fi
@@ -2,6 +2,7 @@
2
2
  // Reads the orchestrator's pool JSON (the on-disk `pool/` directory) when a slot
3
3
  // repo matches, else the local-extension-N suffix formula.
4
4
 
5
+ import { execFileSync } from 'node:child_process';
5
6
  import fs from 'node:fs';
6
7
  import os from 'node:os';
7
8
  import path from 'node:path';
@@ -31,6 +32,7 @@ export function formatKvLines(kv) {
31
32
  if (kv.WATCHER_PORT !== undefined) lines.push(`WATCHER_PORT=${kv.WATCHER_PORT}`);
32
33
  if (kv.SLOT_ID) lines.push(`SLOT_ID=${kv.SLOT_ID}`);
33
34
  if (kv.IOS_SIMULATOR) lines.push(`IOS_SIMULATOR=${kv.IOS_SIMULATOR}`);
35
+ if (kv.SIM_UDID) lines.push(`SIM_UDID=${kv.SIM_UDID}`);
34
36
  if (kv.ADB_SERIAL) lines.push(`ADB_SERIAL=${kv.ADB_SERIAL}`);
35
37
  if (kv.ANDROID_DEVICE) lines.push(`ANDROID_DEVICE=${kv.ANDROID_DEVICE}`);
36
38
  return lines.length ? `${lines.join('\n')}\n` : '';
@@ -239,10 +241,15 @@ export function resolveMobileRuntimeContext(repo) {
239
241
  const c = JSON.parse(fs.readFileSync(ctxPath, 'utf8'));
240
242
  const adbSerial = c.adbSerial ?? c.androidSerial;
241
243
  const androidDevice = c.androidDevice;
242
- if (c.simulator || adbSerial || androidDevice || (c.metroPort != null && c.metroPort !== '')) {
244
+ const contextUdid = c.simulatorUdid && iosSimulatorExists(c.simulatorUdid)
245
+ ? c.simulatorUdid
246
+ : matchingProvisionedSimulatorUdid(repo, c.simulator);
247
+ const simulator = c.simulator ?? contextUdid;
248
+ if (simulator || adbSerial || androidDevice || (c.metroPort != null && c.metroPort !== '')) {
243
249
  return formatKvLines({
244
250
  ...(c.metroPort != null && c.metroPort !== '' ? { WATCHER_PORT: c.metroPort } : {}),
245
- ...(c.simulator ? { IOS_SIMULATOR: c.simulator } : {}),
251
+ ...(simulator ? { IOS_SIMULATOR: simulator } : {}),
252
+ ...(contextUdid ? { SIM_UDID: contextUdid } : {}),
246
253
  ...(adbSerial ? { ADB_SERIAL: adbSerial } : {}),
247
254
  ...(androidDevice ? { ANDROID_DEVICE: androidDevice } : {}),
248
255
  ...(c.slotId ? { SLOT_ID: c.slotId } : {}),
@@ -255,6 +262,21 @@ export function resolveMobileRuntimeContext(repo) {
255
262
  return resolveMobileProvisionBaseline(repo);
256
263
  }
257
264
 
265
+ function matchingProvisionedSimulatorUdid(repo, simulator) {
266
+ if (!simulator) return undefined;
267
+ const basePath = path.join(repo, recipeRuntimeDir(), 'runway-provision.json');
268
+ try {
269
+ const c = JSON.parse(fs.readFileSync(basePath, 'utf8'));
270
+ const recordedUdid = c.simulator?.udid;
271
+ const recordedName = c.simulator?.name;
272
+ const matches = simulator === recordedName ||
273
+ (recordedUdid && simulator.toLowerCase() === recordedUdid.toLowerCase());
274
+ return matches && recordedUdid && iosSimulatorExists(recordedUdid) ? recordedUdid : undefined;
275
+ } catch {
276
+ return undefined;
277
+ }
278
+ }
279
+
258
280
  // A provisioned-but-unprepared slot has no agentic-runtime.json yet, but the
259
281
  // provision baseline records the same authoritative simulator/port identity —
260
282
  // without it, a fresh slot's first launch degrades to the simctl `booted`
@@ -264,12 +286,16 @@ function resolveMobileProvisionBaseline(repo) {
264
286
  if (!fs.existsSync(basePath)) return null;
265
287
  try {
266
288
  const c = JSON.parse(fs.readFileSync(basePath, 'utf8'));
267
- const simulator = c.simulator?.name || c.simulator?.udid;
289
+ const recordedUdid = c.simulator?.udid;
290
+ const recordedName = c.simulator?.name;
291
+ const validUdid = recordedUdid && iosSimulatorExists(recordedUdid) ? recordedUdid : undefined;
292
+ const simulator = recordedName || validUdid || recordedUdid;
268
293
  const watcherPort = c.watcherPort != null && c.watcherPort !== '' ? c.watcherPort : undefined;
269
294
  if (!simulator && watcherPort === undefined) return null;
270
295
  return formatKvLines({
271
296
  ...(watcherPort !== undefined ? { WATCHER_PORT: watcherPort } : {}),
272
297
  ...(simulator ? { IOS_SIMULATOR: simulator } : {}),
298
+ ...(validUdid ? { SIM_UDID: validUdid } : {}),
273
299
  ...(c.slotId ? { SLOT_ID: c.slotId } : {}),
274
300
  });
275
301
  } catch {
@@ -277,6 +303,21 @@ function resolveMobileProvisionBaseline(repo) {
277
303
  }
278
304
  }
279
305
 
306
+ function iosSimulatorExists(udid) {
307
+ try {
308
+ const output = execFileSync(
309
+ 'xcrun',
310
+ ['simctl', 'list', 'devices', 'available', '--json'],
311
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 },
312
+ );
313
+ const devices = Object.values(JSON.parse(output).devices ?? {}).flat();
314
+ return devices.some((device) =>
315
+ device?.udid?.toLowerCase() === String(udid).toLowerCase() && device.isAvailable !== false);
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+
280
321
  export function resolveMobileRuntimePorts(repo) {
281
322
  return (
282
323
  resolveMobileRuntimeContext(repo)
@@ -118,6 +118,9 @@ apply_resolved_mobile_ports() {
118
118
  IOS_SIMULATOR)
119
119
  if [ "$from_pool" = true ] || [ -z "${IOS_SIMULATOR:-}" ]; then IOS_SIMULATOR="$val"; fi
120
120
  ;;
121
+ SIM_UDID)
122
+ if [ "$from_pool" = true ] || [ -z "${SIM_UDID:-}" ]; then SIM_UDID="$val"; fi
123
+ ;;
121
124
  SLOT_ID)
122
125
  if [ "$from_pool" = true ] || [ -z "${RECIPE_SLOT_ID:-}" ]; then RECIPE_SLOT_ID="$val"; fi
123
126
  ;;
@@ -2,8 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  const DEFAULTS = {
5
- MM_PERPS_ENABLED: "true",
6
- OVERRIDE_REMOTE_FEATURE_FLAGS: "true"
5
+ MM_PERPS_ENABLED: "true"
7
6
  };
8
7
  function mobilePerpsEnvironment(target) {
9
8
  const file = path.join(target, ".js.env");
@@ -14,16 +13,14 @@ function mobilePerpsEnvironment(target) {
14
13
  return {
15
14
  file: ".js.env",
16
15
  fileExists,
17
- status: perpsEnabled.status === "missing" || remoteFeatureFlagOverride.status === "missing" ? "incomplete" : "ready",
16
+ status: perpsEnabled.status === "missing" ? "incomplete" : "ready",
18
17
  perpsEnabled,
19
18
  remoteFeatureFlagOverride
20
19
  };
21
20
  }
22
21
  function ensureMobilePerpsEnvironment(target) {
23
22
  const current = mobilePerpsEnvironment(target);
24
- const missing = Object.keys(DEFAULTS).filter(
25
- (key) => settingForKey(current, key).status === "missing"
26
- );
23
+ const missing = current.perpsEnabled.status === "missing" ? ["MM_PERPS_ENABLED"] : [];
27
24
  if (missing.length === 0) return [];
28
25
  const file = path.join(fs.realpathSync(target), current.file);
29
26
  const snapshot = readEnvironmentSnapshot(file);
@@ -84,9 +81,6 @@ function readSetting(source, key) {
84
81
  if (value === "false") return { status: "disabled", value };
85
82
  return { status: "configured", value };
86
83
  }
87
- function settingForKey(environment, key) {
88
- return key === "MM_PERPS_ENABLED" ? environment.perpsEnabled : environment.remoteFeatureFlagOverride;
89
- }
90
84
  function normalizeValue(raw) {
91
85
  const value = raw.trim();
92
86
  if (value.length >= 2) {
@@ -402,6 +402,7 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
402
402
  }
403
403
  case "launch-mobile-runtime": {
404
404
  const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
405
+ const simulatorArgs = platform === "ios" ? ["--simulator", process.env.SIM_UDID || process.env.IOS_SIMULATOR || "booted"] : [];
405
406
  return spawnScriptStreaming(
406
407
  leaf,
407
408
  withWatcherPort(
@@ -412,6 +413,7 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
412
413
  cwd,
413
414
  "--preflight-mode",
414
415
  preflightMode,
416
+ ...simulatorArgs,
415
417
  ...action.argv ?? []
416
418
  ],
417
419
  watcherPort
@@ -422,10 +424,11 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
422
424
  }
423
425
  case "ensure-device-ui": {
424
426
  const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
427
+ const simulatorArgs = platform === "ios" ? ["--simulator", process.env.SIM_UDID || process.env.IOS_SIMULATOR || "booted"] : [];
425
428
  return spawnScriptStreaming(
426
429
  leaf,
427
430
  withWatcherPort(
428
- ["--platform", platform, "--target", cwd, "--ui-only"],
431
+ ["--platform", platform, "--target", cwd, ...simulatorArgs, "--ui-only"],
429
432
  watcherPort
430
433
  ),
431
434
  target,
@@ -12,6 +12,8 @@ const RUNWAY_IOS_METADATA = {
12
12
  appDirName: "MetaMask.app",
13
13
  fallbackRepo: "MetaMask/metamask-mobile"
14
14
  };
15
+ class SimulatorTargetError extends Error {
16
+ }
15
17
  function resolvePoolIdentity(target) {
16
18
  const out = resolveSlotPortsByRepo(target);
17
19
  const id = {};
@@ -35,7 +37,7 @@ async function provisionRunwayMobile(target, options) {
35
37
  if (platform !== "ios") {
36
38
  return fail(resolvedTarget, platform, "UNSUPPORTED_PLATFORM", "runway provisioning currently installs the iOS .app artifact only.", command, slot);
37
39
  }
38
- let simulator = options.simulator ?? slot.simulator ?? process.env.IOS_SIMULATOR;
40
+ let simulator = options.simulator ?? slot.simulatorUdid ?? slot.simulator ?? process.env.SIM_UDID ?? process.env.IOS_SIMULATOR;
39
41
  try {
40
42
  const pool = resolvePoolIdentity(resolvedTarget);
41
43
  if (!simulator && pool.simulator) simulator = pool.simulator;
@@ -133,6 +135,16 @@ async function provisionRunwayMobile(target, options) {
133
135
  reason
134
136
  };
135
137
  } catch (error) {
138
+ if (error instanceof SimulatorTargetError) {
139
+ return fail(
140
+ resolvedTarget,
141
+ platform,
142
+ "SIMULATOR_AMBIGUOUS",
143
+ error.message,
144
+ `mm-harness status --adapter mobile --target ${JSON.stringify(resolvedTarget)} --all-devices --json`,
145
+ slot
146
+ );
147
+ }
136
148
  return fail(resolvedTarget, platform, "PROVISION_FAILED", errorMessage(error), command, slot);
137
149
  }
138
150
  }
@@ -178,6 +190,7 @@ function readSlotContext(target, runtimeDir) {
178
190
  slotId: stringField(data, "slotId"),
179
191
  platform: stringField(data, "platform"),
180
192
  simulator: stringField(data, "simulator") ?? stringField(data, "iosSimulator"),
193
+ simulatorUdid: stringField(data, "simulatorUdid"),
181
194
  runtime: stringField(data, "runtime") ?? stringField(data, "iosRuntime"),
182
195
  deviceType: stringField(data, "deviceType") ?? stringField(data, "iosDeviceType"),
183
196
  watcherPort: stringField(data, "watcherPort") ?? stringField(data, "metroPort") ?? stringField(data, "devServerPort"),
@@ -456,7 +469,7 @@ function preapproveDeepLinkScheme(device, bundleId, options) {
456
469
  }
457
470
  function ensureSimulator(name, runtime, deviceType) {
458
471
  const existing = findSimulator(name);
459
- if (existing) return { name, udid: existing, created: false, runtime, deviceType };
472
+ if (existing) return { name: existing.name, udid: existing.udid, created: false, runtime, deviceType };
460
473
  const resolvedRuntime = runtime || latestIosRuntime();
461
474
  const resolvedDeviceType = deviceType || preferredIphoneDeviceType();
462
475
  if (!resolvedRuntime || !resolvedDeviceType) {
@@ -465,11 +478,23 @@ function ensureSimulator(name, runtime, deviceType) {
465
478
  const udid = execFileSync("xcrun", ["simctl", "create", name, resolvedDeviceType, resolvedRuntime], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
466
479
  return { name, udid: udid || name, created: true, runtime: resolvedRuntime, deviceType: resolvedDeviceType };
467
480
  }
468
- function findSimulator(name) {
469
- const data = execJson("xcrun", ["simctl", "list", "devices", "--json"]);
470
- for (const devices of Object.values(data.devices ?? {})) {
471
- const found = devices.find((device) => device.name === name || device.udid === name);
472
- if (found?.udid) return found.udid;
481
+ function findSimulator(target) {
482
+ const data = execJson(
483
+ "xcrun",
484
+ ["simctl", "list", "devices", "--json"]
485
+ );
486
+ const devices = Object.values(data.devices ?? {}).flat().filter((device) => device.isAvailable !== false && Boolean(device.name && device.udid));
487
+ const exact = devices.find((device) => device.udid.toLowerCase() === target.toLowerCase());
488
+ if (exact) return exact;
489
+ const matches = target === "booted" ? devices.filter((device) => device.state === "Booted") : devices.filter((device) => device.name === target);
490
+ if (matches.length === 1) return matches[0];
491
+ const booted = matches.filter((device) => device.state === "Booted");
492
+ if (booted.length === 1) return booted[0];
493
+ if (matches.length > 1) {
494
+ throw new SimulatorTargetError(
495
+ `iOS simulator target '${target}' is ambiguous; use one UDID:
496
+ ` + matches.map((device) => ` - ${device.udid} (${device.name}) [${device.state ?? "unknown"}]`).join("\n")
497
+ );
473
498
  }
474
499
  return void 0;
475
500
  }
@@ -78,7 +78,7 @@ function appRunningOnDevice(platform) {
78
78
  });
79
79
  return out2.includes("io.metamask");
80
80
  }
81
- const device = process.env.IOS_SIMULATOR || "booted";
81
+ const device = process.env.SIM_UDID || process.env.IOS_SIMULATOR || "booted";
82
82
  const out = execFileSync("xcrun", ["simctl", "spawn", device, "launchctl", "list"], {
83
83
  encoding: "utf8",
84
84
  stdio: ["ignore", "pipe", "ignore"],
@@ -22,7 +22,14 @@ function applyKVLines(output, overwrite) {
22
22
  }
23
23
  break;
24
24
  case "IOS_SIMULATOR":
25
- if (overwrite || !process.env["IOS_SIMULATOR"]) process.env["IOS_SIMULATOR"] = val;
25
+ if (overwrite || !process.env["IOS_SIMULATOR"]) {
26
+ process.env["IOS_SIMULATOR"] = val;
27
+ if (/^[0-9A-Fa-f-]{36}$/u.test(val)) process.env["SIM_UDID"] = val;
28
+ else delete process.env["SIM_UDID"];
29
+ }
30
+ break;
31
+ case "SIM_UDID":
32
+ if (overwrite || !process.env["SIM_UDID"]) process.env["SIM_UDID"] = val;
26
33
  break;
27
34
  case "ADB_SERIAL":
28
35
  if (overwrite || !process.env["ADB_SERIAL"]) {
@@ -269,6 +269,28 @@ async function handleLaunchLocked(argv, stream) {
269
269
  originalError: rebuildAttempt.output.trim() || void 0
270
270
  });
271
271
  }
272
+ const missingIosSimulator = adapter === "mobile" ? missingConfiguredIosSimulator(attempt.output) : void 0;
273
+ const ambiguousIosSimulator = adapter === "mobile" ? ambiguousConfiguredIosSimulator(attempt.output) : void 0;
274
+ if (ambiguousIosSimulator) {
275
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
276
+ code: "MOBILE_DEVICE_AMBIGUOUS",
277
+ message: `more than one iOS simulator matches ${ambiguousIosSimulator}.`,
278
+ recoverable: false,
279
+ userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
280
+ exitCode: EXIT.runtime,
281
+ originalError: attempt.output.trim() || void 0
282
+ });
283
+ }
284
+ if (missingIosSimulator && isIosSimulatorUdid(missingIosSimulator)) {
285
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
286
+ code: "MOBILE_DEVICE_NOT_FOUND",
287
+ message: `the configured iOS simulator ${missingIosSimulator} no longer exists.`,
288
+ recoverable: false,
289
+ userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
290
+ exitCode: EXIT.runtime,
291
+ originalError: attempt.output.trim() || void 0
292
+ });
293
+ }
272
294
  if (adapter === "mobile" && mobileProvisioningBlocked(attempt.output)) {
273
295
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
274
296
  code: "MOBILE_PROVISION_REQUIRED",
@@ -331,12 +353,18 @@ function extensionProductConfigMissing(output) {
331
353
  function mobileProvisioningBlocked(output) {
332
354
  return /open-device: configured iOS simulator '.+' does not exist|open-device: no MetaMask bundle found|fast mode requires an installed (?:iOS dev client|Android dev client)/u.test(output);
333
355
  }
356
+ function missingConfiguredIosSimulator(output) {
357
+ return /open-device: configured iOS simulator '([^']+)' does not exist/u.exec(output)?.[1];
358
+ }
359
+ function ambiguousConfiguredIosSimulator(output) {
360
+ return /open-device: iOS simulator target ("[^"]+") is ambiguous/u.exec(output)?.[1];
361
+ }
334
362
  function mobileBridgeTargetMissing(output) {
335
363
  return /no bridge target matched/iu.test(output);
336
364
  }
337
365
  function mobileProvisionCommand(target, mobileTarget) {
338
366
  const platform = mobileTarget === "android" ? "android" : "ios";
339
- const device = platform === "ios" ? process.env.IOS_SIMULATOR || process.env.SIM_UDID : process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE;
367
+ const device = platform === "ios" ? process.env.SIM_UDID || process.env.IOS_SIMULATOR : process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE;
340
368
  const parts = ["mm-harness", "provision", "runway", platform, "--adapter", "mobile", "--target", shellQuote(target)];
341
369
  if (device) parts.push("--device", shellQuote(device));
342
370
  return parts.join(" ");
@@ -46,19 +46,21 @@ async function handleProvision({ positional, options, rawArgv }) {
46
46
  resolveOnly: optionFlag(options, "resolveOnly"),
47
47
  rerunCommand
48
48
  });
49
- const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? platform)} --adapter mobile --target ${shellQuote(target)}` : void 0;
49
+ const provisionedSimulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
50
+ const provisionedDevice = typeof provisionedSimulator?.udid === "string" ? provisionedSimulator.udid : typeof provisionedSimulator?.name === "string" ? provisionedSimulator.name : void 0;
51
+ const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? platform)} --adapter mobile --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuoteArg(provisionedDevice)}` : "") : void 0;
50
52
  const output = next ? { ...result, next } : result;
51
53
  if (json) {
52
54
  console.log(JSON.stringify(output, null, 2));
53
55
  } else if (result.status === "pass") {
54
56
  const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
55
- const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
57
+ const simulator = provisionedSimulator;
56
58
  const artifact = typeof result.artifact === "object" && result.artifact ? result.artifact : void 0;
57
59
  if (result.resolveOnly) {
58
60
  console.error(`\u2713 resolved ${adapter} ${result.platform ?? ""} run=${artifact?.runId ?? "unknown"} revision=${artifact?.revision ?? "unknown"} artifact=${artifact?.artifactName ?? "unknown"}`);
59
61
  } else {
60
62
  const action = result.skipped ? "already provisioned" : "provisioned";
61
- console.error(`\u2713 ${action} ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "unknown"}`);
63
+ console.error(`\u2713 ${action} ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} udid=${simulator?.udid ?? "unknown"} cache=${cache?.status ?? "unknown"}`);
62
64
  }
63
65
  if (next) console.error(` Next: ${next}`);
64
66
  } else {
package/dist/harness.js CHANGED
@@ -428,19 +428,21 @@ async function handleRunwayInstall(adapter, target, forward, json) {
428
428
  resolveOnly: hasArg(forward, "--resolve-only"),
429
429
  rerunCommand
430
430
  });
431
- const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter mobile --target ${shellQuote(target)}` : void 0;
431
+ const provisionedSimulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
432
+ const provisionedDevice = typeof provisionedSimulator?.udid === "string" ? provisionedSimulator.udid : typeof provisionedSimulator?.name === "string" ? provisionedSimulator.name : void 0;
433
+ const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter mobile --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuote(provisionedDevice)}` : "") : void 0;
432
434
  const output = next ? { ...result, next } : result;
433
435
  if (json) {
434
436
  console.log(JSON.stringify(output, null, 2));
435
437
  } else if (result.status === "pass") {
436
438
  const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
437
- const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
439
+ const simulator = provisionedSimulator;
438
440
  const artifact = typeof result.artifact === "object" && result.artifact ? result.artifact : void 0;
439
441
  if (result.resolveOnly) {
440
442
  console.error(`\u2713 resolved Runway app for ${adapter} ${result.platform ?? ""} run=${artifact?.runId ?? "unknown"} revision=${artifact?.revision ?? "unknown"} artifact=${artifact?.artifactName ?? "unknown"}`);
441
443
  } else {
442
444
  const action = result.skipped ? "already provisioned" : "installed Runway app";
443
- console.error(`\u2713 ${action} for ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "skip"}`);
445
+ console.error(`\u2713 ${action} for ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} udid=${simulator?.udid ?? "unknown"} cache=${cache?.status ?? "skip"}`);
444
446
  }
445
447
  if (next) console.error(` Next: ${next}`);
446
448
  } else {
@@ -65,6 +65,7 @@ function validExistingContext(context, repoRoot, runtimeDir, adapter) {
65
65
  "machine",
66
66
  "runtimeOwner",
67
67
  "simulator",
68
+ "simulatorUdid",
68
69
  "adbSerial",
69
70
  "distDir",
70
71
  "extensionId",
@@ -90,7 +91,7 @@ function validExistingContext(context, repoRoot, runtimeDir, adapter) {
90
91
  if (runtimeStart[field] !== void 0 && (typeof runtimeStart[field] !== "string" || runtimeStart[field].length === 0)) return false;
91
92
  }
92
93
  }
93
- const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "adbSerial"] : ["cdpPort", "extensionId"];
94
+ const forbiddenResources = adapter === "core" ? ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"] : adapter === "extension" ? ["metroPort", "simulator", "simulatorUdid", "adbSerial"] : ["cdpPort", "extensionId"];
94
95
  if (forbiddenResources.some((field) => context[field] !== void 0)) return false;
95
96
  return true;
96
97
  }
@@ -168,6 +169,7 @@ function runtimeResources(adapter, existing, defaults) {
168
169
  } else {
169
170
  shared.metroPort = watcherPort;
170
171
  shared.simulator = process.env.IOS_SIMULATOR || existing.simulator;
172
+ shared.simulatorUdid = process.env.SIM_UDID || existing.simulatorUdid;
171
173
  shared.adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || existing.adbSerial;
172
174
  }
173
175
  return shared;
@@ -217,6 +219,7 @@ function hydrateRuntimeEnv(contextPath, context) {
217
219
  setEnv("RECIPE_WATCHER_PORT", context.watcherPort);
218
220
  setEnv("METRO_PORT", context.metroPort ?? context.watcherPort);
219
221
  setEnv("IOS_SIMULATOR", context.simulator);
222
+ setEnv("SIM_UDID", context.simulatorUdid);
220
223
  setEnv("ADB_SERIAL", context.adbSerial);
221
224
  setEnv("ANDROID_SERIAL", context.adbSerial);
222
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"