@houwert/conductor 0.30.0 → 0.32.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.
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.detectPlatform = detectPlatform;
7
+ exports.detectDeviceKind = detectDeviceKind;
8
+ exports.resolveDriverHost = resolveDriverHost;
7
9
  exports.getDriverPort = getDriverPort;
8
10
  exports.getInputPort = getInputPort;
9
11
  exports.getStreamPort = getStreamPort;
@@ -18,6 +20,9 @@ exports.stopIOSDriver = stopIOSDriver;
18
20
  exports.setupTvOSDriverCache = setupTvOSDriverCache;
19
21
  exports.startTvOSDriver = startTvOSDriver;
20
22
  exports.stopTvOSDriver = stopTvOSDriver;
23
+ exports.resolveTeamId = resolveTeamId;
24
+ exports.startDeviceDriver = startDeviceDriver;
25
+ exports.stopDeviceDriver = stopDeviceDriver;
21
26
  exports.startAndroidDriver = startAndroidDriver;
22
27
  exports.stopAndroidDriver = stopAndroidDriver;
23
28
  exports.webBrowserName = webBrowserName;
@@ -48,6 +53,7 @@ const path_1 = __importDefault(require("path"));
48
53
  const verbose_js_1 = require("../verbose.js");
49
54
  const utils_js_1 = require("../utils.js");
50
55
  const sdk_js_1 = require("../android/sdk.js");
56
+ const devicectl_js_1 = require("./devicectl.js");
51
57
  /** Cache: deviceId → platform */
52
58
  const _platformCache = new Map();
53
59
  async function detectPlatform(deviceId) {
@@ -68,9 +74,11 @@ async function detectPlatform(deviceId) {
68
74
  _platformCache.set(deviceId, 'roku');
69
75
  return 'roku';
70
76
  }
71
- // Check if it looks like an iOS/tvOS simulator UUID (8-4-4-4-12 hex chars)
72
- const iosUuidRe = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
73
- if (iosUuidRe.test(deviceId)) {
77
+ // Simulator UDIDs and CoreDevice identifiers share the 8-4-4-4-12 UUID shape;
78
+ // physical devices additionally answer to their 40-hex hardware UDID.
79
+ const appleUuidRe = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
80
+ const appleUdidRe = /^[0-9a-f]{40}$/i;
81
+ if (appleUuidRe.test(deviceId) || appleUdidRe.test(deviceId)) {
74
82
  // Query simctl to determine whether this UUID belongs to a tvOS runtime
75
83
  try {
76
84
  const out = await spawnCapture('xcrun', ['simctl', 'list', 'devices', '--json']);
@@ -79,12 +87,20 @@ async function detectPlatform(deviceId) {
79
87
  if (sims.some((s) => s.udid === deviceId)) {
80
88
  const platform = runtime.includes('tvOS') ? 'tvos' : 'ios';
81
89
  _platformCache.set(deviceId, platform);
90
+ _kindCache.set(deviceId, 'simulator');
82
91
  return platform;
83
92
  }
84
93
  }
85
94
  }
86
95
  catch {
87
- /* fall through to ios default */
96
+ /* fall through to devicectl */
97
+ }
98
+ // Not a simulator — ask CoreDevice whether it's a paired physical device.
99
+ const physical = await (0, devicectl_js_1.findPhysicalDevice)(deviceId);
100
+ if (physical) {
101
+ _platformCache.set(deviceId, physical.platform);
102
+ _kindCache.set(deviceId, 'physical');
103
+ return physical.platform;
88
104
  }
89
105
  _platformCache.set(deviceId, 'ios');
90
106
  return 'ios';
@@ -93,6 +109,40 @@ async function detectPlatform(deviceId) {
93
109
  _platformCache.set(deviceId, 'android');
94
110
  return 'android';
95
111
  }
112
+ /** Cache: deviceId → simulator vs physical. Populated as a side effect of detectPlatform. */
113
+ const _kindCache = new Map();
114
+ /**
115
+ * Whether the device is a real one rather than a simulator. Only meaningful for
116
+ * iOS/tvOS; everything else reports 'simulator' since the distinction is either
117
+ * absent (web) or already encoded in the driver (Android adb, Vega).
118
+ */
119
+ async function detectDeviceKind(deviceId) {
120
+ if (_kindCache.has(deviceId))
121
+ return _kindCache.get(deviceId);
122
+ const platform = await detectPlatform(deviceId);
123
+ if (platform !== 'ios' && platform !== 'tvos') {
124
+ _kindCache.set(deviceId, 'simulator');
125
+ return 'simulator';
126
+ }
127
+ // detectPlatform records the kind for Apple devices it recognised; anything
128
+ // still unset never matched simctl or devicectl, so treat it as a simulator.
129
+ const kind = _kindCache.get(deviceId) ?? 'simulator';
130
+ _kindCache.set(deviceId, kind);
131
+ return kind;
132
+ }
133
+ /**
134
+ * Host the XCTest driver for this device is reachable on. Simulators share the
135
+ * host's loopback; physical devices run the driver on their own loopback, so we
136
+ * reach them over the LAN instead.
137
+ */
138
+ async function resolveDriverHost(deviceId) {
139
+ if ((await detectDeviceKind(deviceId)) !== 'physical')
140
+ return '127.0.0.1';
141
+ const device = await (0, devicectl_js_1.findPhysicalDevice)(deviceId);
142
+ if (!device)
143
+ throw new Error(`Physical device ${deviceId} is no longer paired`);
144
+ return (0, devicectl_js_1.resolveDeviceHost)(device);
145
+ }
96
146
  // ── Port management ───────────────────────────────────────────────────────────
97
147
  const IOS_BASE_PORT = 1075;
98
148
  const TVOS_BASE_PORT = 2075;
@@ -723,6 +773,179 @@ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, restoreFocusAfte
723
773
  async function stopTvOSDriver(deviceId) {
724
774
  await spawnAndWait('xcrun', ['simctl', 'terminate', deviceId, TVOS_RUNNER_BUNDLE_ID]);
725
775
  }
776
+ // ── Physical device bootstrap ─────────────────────────────────────────────────
777
+ const DEVICE_STARTUP_TIMEOUT_MS = 300000;
778
+ const DEVICE_STARTUP_POLL_MS = 1000;
779
+ /**
780
+ * Resolve the Apple Developer team to sign the driver with.
781
+ *
782
+ * Unlike simulators, a real device only runs code signed for a team it's
783
+ * provisioned against, and we can't ship a signed driver — it has to be built
784
+ * on the user's machine with their credentials.
785
+ */
786
+ async function resolveTeamId() {
787
+ const fromEnv = process.env.CONDUCTOR_TEAM_ID?.trim();
788
+ if (fromEnv)
789
+ return fromEnv;
790
+ // Fall back to the keychain when there's exactly one development team, which
791
+ // is the common single-account setup.
792
+ let identities;
793
+ try {
794
+ identities = await spawnCapture('security', ['find-identity', '-v', '-p', 'codesigning']);
795
+ }
796
+ catch {
797
+ identities = '';
798
+ }
799
+ const teams = new Set([...identities.matchAll(/"Apple Development:[^"]*\(([A-Z0-9]{10})\)"/g)].map((m) => m[1]));
800
+ if (teams.size === 1)
801
+ return [...teams][0];
802
+ throw new Error(teams.size === 0
803
+ ? 'No "Apple Development" signing identity found. Running on a physical device needs one — ' +
804
+ 'sign in to Xcode ▸ Settings ▸ Accounts, then set CONDUCTOR_TEAM_ID=<team> if you have several teams.'
805
+ : `Multiple development teams found (${[...teams].join(', ')}). ` +
806
+ 'Set CONDUCTOR_TEAM_ID=<team> to pick one.');
807
+ }
808
+ /** Where the signed device driver for a given platform + team is cached. */
809
+ function deviceDriverCache(platform, teamId) {
810
+ return path_1.default.join(os_1.default.homedir(), '.conductor', `${platform}-driver-device`, teamId);
811
+ }
812
+ /**
813
+ * Build and sign the XCTest driver for a physical device.
814
+ *
815
+ * The bundled simulator builds are unsigned and the wrong slice, so the driver
816
+ * is compiled from the sources shipped alongside them. Building against the
817
+ * device (rather than a generic destination) lets Xcode register it with the
818
+ * team's provisioning profile on first run.
819
+ */
820
+ async function setupDeviceDriver(deviceId, platform, teamId) {
821
+ const driversDir = await getDriversDir();
822
+ const projectDir = path_1.default.join(driversDir, 'ios-driver-src');
823
+ const project = path_1.default.join(projectDir, 'conductor-driver-ios.xcodeproj');
824
+ if (!fs_1.default.existsSync(project)) {
825
+ throw new Error(`Conductor driver sources not found at ${projectDir}.\n` +
826
+ `Physical devices need a locally signed driver build. Run 'make package-drivers-tarball' ` +
827
+ `from the repo root, or reinstall conductor to fetch a driver bundle that includes sources.`);
828
+ }
829
+ const cache = deviceDriverCache(platform, teamId);
830
+ const scheme = platform === 'tvos' ? 'conductor-driver-tvos' : 'conductor-driver-ios';
831
+ // Rebuild when the sources or the signing team change; the xctestrun naming
832
+ // is derived from the SDK so glob for it rather than hardcoding a version.
833
+ const stamp = path_1.default.join(cache, '.version');
834
+ const sourceMtime = String(fs_1.default.statSync(path_1.default.join(project, 'project.pbxproj')).mtimeMs);
835
+ let cached = '';
836
+ try {
837
+ cached = fs_1.default.readFileSync(stamp, 'utf-8').trim();
838
+ }
839
+ catch {
840
+ /* first run */
841
+ }
842
+ if (cached === sourceMtime && findDeviceXctestrun(cache))
843
+ return findDeviceXctestrun(cache);
844
+ (0, verbose_js_1.log)(`Building signed ${platform} driver for team ${teamId} (first run takes a few minutes)...`);
845
+ const args = [
846
+ 'build-for-testing',
847
+ '-project',
848
+ project,
849
+ '-scheme',
850
+ scheme,
851
+ '-destination',
852
+ `id=${deviceId}`,
853
+ '-derivedDataPath',
854
+ cache,
855
+ '-allowProvisioningUpdates',
856
+ `DEVELOPMENT_TEAM=${teamId}`,
857
+ ];
858
+ // The first build for a team creates provisioning profiles as a side effect,
859
+ // and Xcode regularly references one before it lands on disk ("Build input
860
+ // file cannot be found: ….mobileprovision"). The profile exists by the retry.
861
+ try {
862
+ await spawnCaptureAll('xcodebuild', args);
863
+ }
864
+ catch (first) {
865
+ (0, verbose_js_1.log)(`Driver build failed, retrying once: ${first instanceof Error ? first.message : first}`);
866
+ try {
867
+ await spawnCaptureAll('xcodebuild', args);
868
+ }
869
+ catch (retry) {
870
+ throw new Error(`Could not build the ${platform} driver for team ${teamId}.\n` +
871
+ `${retry instanceof Error ? retry.message : String(retry)}\n` +
872
+ `Check that the device is registered to the team and that Xcode has an account for it.`);
873
+ }
874
+ }
875
+ const xctestrun = findDeviceXctestrun(cache);
876
+ if (!xctestrun) {
877
+ throw new Error(`Driver build for ${platform} produced no xctestrun under ${cache}`);
878
+ }
879
+ fs_1.default.writeFileSync(stamp, sourceMtime);
880
+ (0, verbose_js_1.log)(`${platform} device driver ready`);
881
+ return xctestrun;
882
+ }
883
+ /** Locate the xctestrun a device build produced — its name embeds the SDK version. */
884
+ function findDeviceXctestrun(cache) {
885
+ const products = path_1.default.join(cache, 'Build', 'Products');
886
+ try {
887
+ const match = fs_1.default.readdirSync(products).find((f) => f.endsWith('.xctestrun'));
888
+ return match ? path_1.default.join(products, match) : null;
889
+ }
890
+ catch {
891
+ return null;
892
+ }
893
+ }
894
+ /**
895
+ * Start the XCTest driver on a physical iOS/tvOS device.
896
+ *
897
+ * Mirrors startIOSDriver, with two differences forced by real hardware: the
898
+ * driver is built and signed locally, and it binds every interface because the
899
+ * device's loopback isn't shared with the host.
900
+ */
901
+ async function startDeviceDriver(deviceId, platform, port) {
902
+ const host = await resolveDriverHost(deviceId);
903
+ if (await isPortOpen(port, host)) {
904
+ (0, verbose_js_1.log)(`${platform} device driver already running on ${host}:${port}`);
905
+ return;
906
+ }
907
+ const device = await (0, devicectl_js_1.findPhysicalDevice)(deviceId);
908
+ if (device && !device.developerModeEnabled) {
909
+ throw new Error(`Developer Mode is disabled on "${device.name}". ` +
910
+ `Enable it in Settings ▸ Privacy & Security ▸ Developer Mode and re-pair the device.`);
911
+ }
912
+ const teamId = await resolveTeamId();
913
+ const xctestrun = await setupDeviceDriver(deviceId, platform, teamId);
914
+ const testTarget = platform === 'tvos' ? 'conductor-driver-tvosUITests' : 'conductor-driver-iosUITests';
915
+ await spawnAndWait('plutil', [
916
+ '-replace',
917
+ `${testTarget}.EnvironmentVariables.PORT`,
918
+ '-string',
919
+ String(port),
920
+ xctestrun,
921
+ ]);
922
+ // The host reaches the driver over the network, so it can't bind loopback-only.
923
+ await spawnAndWait('plutil', [
924
+ '-replace',
925
+ `${testTarget}.EnvironmentVariables.BIND_ALL`,
926
+ '-string',
927
+ '1',
928
+ xctestrun,
929
+ ]);
930
+ (0, verbose_js_1.log)(`Starting ${platform} driver on ${device?.name ?? deviceId} (${host}:${port})`);
931
+ const proc = (0, child_process_1.spawn)('xcodebuild', ['test-without-building', '-xctestrun', xctestrun, '-destination', `id=${deviceId}`], { detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
932
+ proc.unref();
933
+ const deadline = Date.now() + DEVICE_STARTUP_TIMEOUT_MS;
934
+ while (Date.now() < deadline) {
935
+ await (0, utils_js_1.sleep)(DEVICE_STARTUP_POLL_MS);
936
+ if (await isPortOpen(port, host)) {
937
+ (0, verbose_js_1.log)(`${platform} device driver ready on ${host}:${port}`);
938
+ return;
939
+ }
940
+ }
941
+ throw new Error(`${platform} XCTest driver did not start within ${DEVICE_STARTUP_TIMEOUT_MS / 1000}s ` +
942
+ `on ${host}:${port}. Check that the device is awake and on the same network.`);
943
+ }
944
+ /** Stop the driver on a physical device by killing the runner process. */
945
+ async function stopDeviceDriver(deviceId, platform) {
946
+ const bundleId = platform === 'tvos' ? TVOS_RUNNER_BUNDLE_ID : IOS_RUNNER_BUNDLE_ID;
947
+ await (0, devicectl_js_1.terminateApp)(deviceId, bundleId).catch(() => { });
948
+ }
726
949
  // ── Android bootstrap ─────────────────────────────────────────────────────────
727
950
  const ANDROID_STARTUP_TIMEOUT_MS = 30000;
728
951
  const ANDROID_STARTUP_POLL_MS = 500;
@@ -994,6 +1217,32 @@ function spawnAndWait(cmd, args) {
994
1217
  proc.on('error', reject);
995
1218
  });
996
1219
  }
1220
+ /**
1221
+ * Run a command, and on failure reject with the tail of its output. Used for
1222
+ * xcodebuild, where the exit code alone tells the user nothing actionable.
1223
+ */
1224
+ function spawnCaptureAll(cmd, args) {
1225
+ return new Promise((resolve, reject) => {
1226
+ const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
1227
+ let out = '';
1228
+ const append = (chunk) => {
1229
+ out += chunk.toString();
1230
+ };
1231
+ proc.stdout?.on('data', append);
1232
+ proc.stderr?.on('data', append);
1233
+ proc.on('close', (code) => {
1234
+ if (code === 0)
1235
+ return resolve(out);
1236
+ const errors = out
1237
+ .split('\n')
1238
+ .filter((l) => l.includes('error:'))
1239
+ .slice(0, 5)
1240
+ .join('\n');
1241
+ reject(new Error(`${cmd} exited ${code}${errors ? `:\n${errors}` : ''}`));
1242
+ });
1243
+ proc.on('error', reject);
1244
+ });
1245
+ }
997
1246
  function spawnCapture(cmd, args) {
998
1247
  return new Promise((resolve, reject) => {
999
1248
  const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseDevicectlDevices = parseDevicectlDevices;
7
+ exports.listPhysicalDevices = listPhysicalDevices;
8
+ exports.findPhysicalDevice = findPhysicalDevice;
9
+ exports.bonjourHostname = bonjourHostname;
10
+ exports.deviceHostCandidates = deviceHostCandidates;
11
+ exports.resolveDeviceHost = resolveDeviceHost;
12
+ exports.installApp = installApp;
13
+ exports.uninstallApp = uninstallApp;
14
+ exports.launchApp = launchApp;
15
+ exports.terminateApp = terminateApp;
16
+ exports.listApps = listApps;
17
+ /**
18
+ * `xcrun devicectl` wrapper — the physical-device counterpart to `simctl`.
19
+ *
20
+ * Simulators and real devices share the XCTest driver (same HTTP protocol), but
21
+ * everything around it differs: discovery, app install/launch, and reachability.
22
+ * This module owns the devicectl half so the rest of the CLI can stay generic.
23
+ */
24
+ const child_process_1 = require("child_process");
25
+ const fs_1 = __importDefault(require("fs"));
26
+ const os_1 = __importDefault(require("os"));
27
+ const path_1 = __importDefault(require("path"));
28
+ const promises_1 = __importDefault(require("dns/promises"));
29
+ const verbose_js_1 = require("../verbose.js");
30
+ function devicectl(args, timeoutMs = 30000) {
31
+ return new Promise((resolve, reject) => {
32
+ const proc = (0, child_process_1.spawn)('xcrun', ['devicectl', ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
33
+ let out = '';
34
+ let err = '';
35
+ const timer = setTimeout(() => {
36
+ proc.kill('SIGKILL');
37
+ reject(new Error(`devicectl ${args[0]} ${args[1] ?? ''} timed out after ${timeoutMs}ms`));
38
+ }, timeoutMs);
39
+ proc.stdout?.on('data', (c) => {
40
+ out += c.toString();
41
+ });
42
+ proc.stderr?.on('data', (c) => {
43
+ err += c.toString();
44
+ });
45
+ proc.on('close', (code) => {
46
+ clearTimeout(timer);
47
+ if (code === 0) {
48
+ resolve(out);
49
+ }
50
+ else {
51
+ reject(new Error(`devicectl ${args.join(' ')} failed: ${err.trim() || out.trim()}`));
52
+ }
53
+ });
54
+ proc.on('error', (e) => {
55
+ clearTimeout(timer);
56
+ reject(e);
57
+ });
58
+ });
59
+ }
60
+ /**
61
+ * Run a devicectl subcommand that reports its result as JSON. devicectl only
62
+ * writes structured output to a file, never stdout, so every call round-trips
63
+ * through a temp file.
64
+ */
65
+ async function devicectlJson(args, timeoutMs = 30000) {
66
+ const file = path_1.default.join(os_1.default.tmpdir(), `conductor-devicectl-${process.pid}-${Math.random().toString(36).slice(2)}.json`);
67
+ try {
68
+ await devicectl([...args, '--json-output', file], timeoutMs);
69
+ return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
70
+ }
71
+ finally {
72
+ fs_1.default.rmSync(file, { force: true });
73
+ }
74
+ }
75
+ function toPlatform(raw) {
76
+ if (!raw)
77
+ return null;
78
+ const v = raw.toLowerCase();
79
+ if (v === 'ios' || v === 'ipados')
80
+ return 'ios';
81
+ if (v === 'tvos')
82
+ return 'tvos';
83
+ return null;
84
+ }
85
+ /** Map devicectl's device list onto PhysicalDevice, dropping anything we can't drive. */
86
+ function parseDevicectlDevices(parsed) {
87
+ const devices = [];
88
+ for (const d of parsed.result?.devices ?? []) {
89
+ const platform = toPlatform(d.hardwareProperties?.platform);
90
+ // Skip simulators (reality: "simulator") — simctl already covers those.
91
+ if (!platform || d.hardwareProperties?.reality !== 'physical')
92
+ continue;
93
+ devices.push({
94
+ identifier: d.identifier,
95
+ udid: d.hardwareProperties?.udid ?? d.identifier,
96
+ name: d.deviceProperties?.name ?? d.identifier,
97
+ platform,
98
+ // Paired isn't enough: a device that's off or on another network stays
99
+ // paired but reports no transport, and tunnelState goes 'unavailable'.
100
+ available: d.connectionProperties?.pairingState === 'paired' &&
101
+ d.connectionProperties?.tunnelState !== 'unavailable' &&
102
+ !!d.connectionProperties?.transportType,
103
+ potentialHostnames: d.connectionProperties?.potentialHostnames ?? [],
104
+ osVersion: d.deviceProperties?.osVersionNumber ?? '',
105
+ marketingName: d.hardwareProperties?.marketingName ?? '',
106
+ developerModeEnabled: d.deviceProperties?.developerModeStatus === 'enabled',
107
+ });
108
+ }
109
+ return devices;
110
+ }
111
+ /** List every paired physical iOS/tvOS device known to CoreDevice. */
112
+ async function listPhysicalDevices() {
113
+ try {
114
+ return parseDevicectlDevices(await devicectlJson(['list', 'devices']));
115
+ }
116
+ catch (e) {
117
+ (0, verbose_js_1.log)(`devicectl list devices failed: ${e instanceof Error ? e.message : String(e)}`);
118
+ return [];
119
+ }
120
+ }
121
+ /** Cache: deviceId (identifier or udid) → device, or null when it isn't physical. */
122
+ const _deviceCache = new Map();
123
+ /** Look up a physical device by either its CoreDevice identifier or hardware UDID. */
124
+ async function findPhysicalDevice(deviceId) {
125
+ if (_deviceCache.has(deviceId))
126
+ return _deviceCache.get(deviceId);
127
+ const key = deviceId.toLowerCase();
128
+ const match = (await listPhysicalDevices()).find((d) => d.identifier.toLowerCase() === key || d.udid.toLowerCase() === key) ?? null;
129
+ _deviceCache.set(deviceId, match);
130
+ return match;
131
+ }
132
+ /**
133
+ * Resolve the address the host can reach the device's driver on.
134
+ *
135
+ * devicectl's `*.coredevice.local` hostnames only resolve inside Apple's
136
+ * CoreDevice tunnel, so they're useless for a plain TCP connect. The device's
137
+ * Bonjour name (`<name>.local`) is what actually resolves on the LAN, so we
138
+ * derive that from the device name and resolve it to an IP once, since mDNS
139
+ * lookups are slow enough to matter on every driver poll.
140
+ */
141
+ const _hostCache = new Map();
142
+ /**
143
+ * The mDNS name a device advertises itself under, derived from its display name
144
+ * the same way the device does: apostrophes are dropped outright (so "Douwe's
145
+ * iPhone" is "Douwes-iPhone", not "Douwe-s-iPhone") and every other run of
146
+ * non-alphanumerics collapses to a single dash.
147
+ */
148
+ function bonjourHostname(deviceName) {
149
+ return (deviceName
150
+ .replace(/['’]/g, '')
151
+ .replace(/[^A-Za-z0-9-]+/g, '-')
152
+ .replace(/-+/g, '-')
153
+ .replace(/^-|-$/g, '') + '.local');
154
+ }
155
+ /**
156
+ * Candidate LAN addresses, best first. devicectl's `potentialHostnames` already
157
+ * contain Apple's own sanitization of the device name, so re-pointing those at
158
+ * `.local` is more reliable than our derivation — we keep both because the
159
+ * hostname list is occasionally absent.
160
+ */
161
+ function deviceHostCandidates(device) {
162
+ const fromDevicectl = device.potentialHostnames
163
+ .filter((h) => h.endsWith('.coredevice.local'))
164
+ .map((h) => h.replace(/\.coredevice\.local$/, '.local'));
165
+ return [
166
+ ...new Set([bonjourHostname(device.name), ...fromDevicectl, ...device.potentialHostnames]),
167
+ ];
168
+ }
169
+ async function resolveDeviceHost(device) {
170
+ const cached = _hostCache.get(device.identifier);
171
+ if (cached)
172
+ return cached;
173
+ const candidates = deviceHostCandidates(device);
174
+ for (const host of candidates) {
175
+ try {
176
+ const { address } = await promises_1.default.lookup(host, { family: 4 });
177
+ (0, verbose_js_1.log)(`Resolved ${device.name} → ${host} (${address})`);
178
+ _hostCache.set(device.identifier, address);
179
+ return address;
180
+ }
181
+ catch {
182
+ /* try the next candidate */
183
+ }
184
+ }
185
+ throw new Error(`Could not resolve a network address for "${device.name}".\n` +
186
+ `Tried: ${candidates.join(', ')}\n` +
187
+ `The device must be on the same network as this Mac for conductor to reach its driver.`);
188
+ }
189
+ // ── App lifecycle ─────────────────────────────────────────────────────────────
190
+ async function installApp(deviceId, appPath) {
191
+ await devicectl(['device', 'install', 'app', '--device', deviceId, appPath], 300000);
192
+ }
193
+ async function uninstallApp(deviceId, bundleId) {
194
+ await devicectl(['device', 'uninstall', 'app', '--device', deviceId, bundleId], 60000);
195
+ }
196
+ /**
197
+ * Launch an app. `env` is passed as devicectl's JSON environment dictionary,
198
+ * which is the device-side equivalent of simctl's SIMCTL_CHILD_ vars.
199
+ */
200
+ async function launchApp(deviceId, bundleId, args = [], env) {
201
+ const flags = ['device', 'process', 'launch', '--device', deviceId, '--terminate-existing'];
202
+ if (env && Object.keys(env).length > 0) {
203
+ flags.push('--environment-variables', JSON.stringify(env));
204
+ }
205
+ await devicectl([...flags, bundleId, ...args], 120000);
206
+ }
207
+ /** Terminate an app by bundle id. devicectl only kills by PID, so resolve one first. */
208
+ async function terminateApp(deviceId, bundleId) {
209
+ const listed = await devicectlJson([
210
+ 'device',
211
+ 'info',
212
+ 'processes',
213
+ '--device',
214
+ deviceId,
215
+ ]);
216
+ const match = (listed.result?.runningProcesses ?? []).find((p) => p.executable?.includes(`${bundleId}`));
217
+ if (!match)
218
+ return;
219
+ await devicectl([
220
+ 'device',
221
+ 'process',
222
+ 'signal',
223
+ '--device',
224
+ deviceId,
225
+ '--signal',
226
+ 'SIGKILL',
227
+ '--pid',
228
+ String(match.processIdentifier),
229
+ ]);
230
+ }
231
+ async function listApps(deviceId) {
232
+ const listed = await devicectlJson([
233
+ 'device',
234
+ 'info',
235
+ 'apps',
236
+ '--device',
237
+ deviceId,
238
+ ]);
239
+ return (listed.result?.apps ?? []).map((a) => ({
240
+ id: a.bundleIdentifier,
241
+ name: a.name ?? a.bundleIdentifier,
242
+ }));
243
+ }
@@ -137,7 +137,16 @@ function parseFlowString(content, extraEnv) {
137
137
  else if (doc && typeof doc === 'object') {
138
138
  // Single-document flow: either a header-only or treat as single command
139
139
  const keys = Object.keys(doc);
140
- const headerKeys = new Set(['appId', 'url', 'env', 'tags', 'onFlowStart', 'onFlowComplete']);
140
+ const headerKeys = new Set([
141
+ 'appId',
142
+ 'url',
143
+ 'name',
144
+ 'env',
145
+ 'tags',
146
+ 'properties',
147
+ 'onFlowStart',
148
+ 'onFlowComplete',
149
+ ]);
141
150
  if (keys.every((k) => headerKeys.has(k))) {
142
151
  header = doc;
143
152
  rawCommands = [];