@houwert/conductor 0.29.3 → 0.31.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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/assert-not-visible.js +4 -1
  3. package/dist/commands/assert-visible.js +5 -2
  4. package/dist/commands/back.js +2 -1
  5. package/dist/commands/capture-ui.js +7 -3
  6. package/dist/commands/clipboard.js +9 -0
  7. package/dist/commands/copy-text-from.js +2 -1
  8. package/dist/commands/crashes.js +5 -0
  9. package/dist/commands/delete-device.js +5 -0
  10. package/dist/commands/download-app.js +4 -0
  11. package/dist/commands/erase-text.js +4 -1
  12. package/dist/commands/focused.js +5 -2
  13. package/dist/commands/foreground-app.js +4 -1
  14. package/dist/commands/gestures.js +7 -0
  15. package/dist/commands/hide-keyboard.js +5 -0
  16. package/dist/commands/inspect.js +10 -3
  17. package/dist/commands/install-app.js +21 -4
  18. package/dist/commands/launch-app.js +3 -2
  19. package/dist/commands/list-apps.js +16 -0
  20. package/dist/commands/list-devices.js +37 -0
  21. package/dist/commands/memory.js +5 -0
  22. package/dist/commands/press-key.js +36 -3
  23. package/dist/commands/profile.js +4 -0
  24. package/dist/commands/screenshot.js +5 -2
  25. package/dist/commands/scroll-until-visible.js +5 -2
  26. package/dist/commands/scroll.js +4 -1
  27. package/dist/commands/start-device.js +27 -3
  28. package/dist/commands/stop-app.js +2 -1
  29. package/dist/commands/stop-device.js +25 -3
  30. package/dist/commands/swipe.js +8 -3
  31. package/dist/commands/tap.js +3 -2
  32. package/dist/commands/uninstall-app.js +4 -0
  33. package/dist/daemon/input-backends.js +26 -1
  34. package/dist/daemon/log-collector.js +6 -0
  35. package/dist/daemon/server.js +54 -11
  36. package/dist/drivers/bootstrap.js +263 -4
  37. package/dist/drivers/devicectl.js +243 -0
  38. package/dist/drivers/flow-runner.js +26 -4
  39. package/dist/drivers/ios.js +96 -4
  40. package/dist/drivers/roku/app-ui-parser.js +122 -0
  41. package/dist/drivers/roku/discovery.js +136 -0
  42. package/dist/drivers/roku/ecp-client.js +396 -0
  43. package/dist/drivers/roku/key-mapping.js +67 -0
  44. package/dist/drivers/roku.js +237 -0
  45. package/dist/drivers/vega/page-source-parser.js +5 -118
  46. package/dist/drivers/xml.js +128 -0
  47. package/dist/enum-options.js +3 -1
  48. package/dist/index.js +1 -1
  49. package/dist/runner.js +48 -8
  50. package/package.json +1 -1
  51. package/skills/conductor-device-interact/SKILL.md +14 -3
  52. package/skills/conductor-device-setup/SKILL.md +62 -2
  53. package/skills/conductor-profiler/SKILL.md +1 -1
@@ -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) {
@@ -63,9 +69,16 @@ async function detectPlatform(deviceId) {
63
69
  _platformCache.set(deviceId, 'vega');
64
70
  return 'vega';
65
71
  }
66
- // Check if it looks like an iOS/tvOS simulator UUID (8-4-4-4-12 hex chars)
67
- const iosUuidRe = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
68
- if (iosUuidRe.test(deviceId)) {
72
+ // Roku: "roku:<host>" (e.g. "roku:192.168.1.100")
73
+ if (deviceId === 'roku' || deviceId.startsWith('roku:')) {
74
+ _platformCache.set(deviceId, 'roku');
75
+ return 'roku';
76
+ }
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)) {
69
82
  // Query simctl to determine whether this UUID belongs to a tvOS runtime
70
83
  try {
71
84
  const out = await spawnCapture('xcrun', ['simctl', 'list', 'devices', '--json']);
@@ -74,12 +87,20 @@ async function detectPlatform(deviceId) {
74
87
  if (sims.some((s) => s.udid === deviceId)) {
75
88
  const platform = runtime.includes('tvOS') ? 'tvos' : 'ios';
76
89
  _platformCache.set(deviceId, platform);
90
+ _kindCache.set(deviceId, 'simulator');
77
91
  return platform;
78
92
  }
79
93
  }
80
94
  }
81
95
  catch {
82
- /* 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;
83
104
  }
84
105
  _platformCache.set(deviceId, 'ios');
85
106
  return 'ios';
@@ -88,6 +109,40 @@ async function detectPlatform(deviceId) {
88
109
  _platformCache.set(deviceId, 'android');
89
110
  return 'android';
90
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
+ }
91
146
  // ── Port management ───────────────────────────────────────────────────────────
92
147
  const IOS_BASE_PORT = 1075;
93
148
  const TVOS_BASE_PORT = 2075;
@@ -174,6 +229,11 @@ async function getDriverPort(platform, deviceId) {
174
229
  else if (platform === 'vega') {
175
230
  port = state.nextVegaPort++;
176
231
  }
232
+ else if (platform === 'roku') {
233
+ // Roku is driven entirely over the network (ECP on the device's own port
234
+ // 8060) — there is no host-side driver process, so no port to reserve.
235
+ return 0;
236
+ }
177
237
  else {
178
238
  port = state.nextAndroidPort++;
179
239
  }
@@ -713,6 +773,179 @@ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, restoreFocusAfte
713
773
  async function stopTvOSDriver(deviceId) {
714
774
  await spawnAndWait('xcrun', ['simctl', 'terminate', deviceId, TVOS_RUNNER_BUNDLE_ID]);
715
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
+ }
716
949
  // ── Android bootstrap ─────────────────────────────────────────────────────────
717
950
  const ANDROID_STARTUP_TIMEOUT_MS = 30000;
718
951
  const ANDROID_STARTUP_POLL_MS = 500;
@@ -984,6 +1217,32 @@ function spawnAndWait(cmd, args) {
984
1217
  proc.on('error', reject);
985
1218
  });
986
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
+ }
987
1246
  function spawnCapture(cmd, args) {
988
1247
  return new Promise((resolve, reject) => {
989
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
+ }
@@ -21,6 +21,8 @@ const ios_js_1 = require("./ios.js");
21
21
  const android_js_1 = require("./android.js");
22
22
  const web_js_1 = require("./web.js");
23
23
  const vega_js_1 = require("./vega.js");
24
+ const roku_js_1 = require("./roku.js");
25
+ const key_mapping_js_1 = require("./roku/key-mapping.js");
24
26
  const wait_js_1 = require("./wait.js");
25
27
  const direct_ios_selector_js_1 = require("./direct-ios-selector.js");
26
28
  const perf_hooks_1 = require("perf_hooks");
@@ -135,7 +137,16 @@ function parseFlowString(content, extraEnv) {
135
137
  else if (doc && typeof doc === 'object') {
136
138
  // Single-document flow: either a header-only or treat as single command
137
139
  const keys = Object.keys(doc);
138
- 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
+ ]);
139
150
  if (keys.every((k) => headerKeys.has(k))) {
140
151
  header = doc;
141
152
  rawCommands = [];
@@ -552,7 +563,9 @@ function getConductorObj(driver, output) {
552
563
  ? 'web'
553
564
  : driver instanceof vega_js_1.VegaDriver
554
565
  ? 'vega'
555
- : 'android';
566
+ : driver instanceof roku_js_1.RokuDriver
567
+ ? 'roku'
568
+ : 'android';
556
569
  return {
557
570
  platform,
558
571
  copiedText: output['__copiedText'] ?? '',
@@ -684,7 +697,7 @@ async function executeCommandBody(key, val, driver, opts) {
684
697
  await driver.pressKey('delete');
685
698
  }
686
699
  else {
687
- // Android, web, and vega all expose eraseAllText.
700
+ // Android, web, vega, and roku all expose eraseAllText.
688
701
  await driver.eraseAllText(n);
689
702
  }
690
703
  break;
@@ -825,7 +838,7 @@ async function executeCommandBody(key, val, driver, opts) {
825
838
  await driver.back();
826
839
  else if (driver instanceof web_js_1.WebDriver)
827
840
  await driver.goBack();
828
- else if (driver instanceof vega_js_1.VegaDriver)
841
+ else if (driver instanceof vega_js_1.VegaDriver || driver instanceof roku_js_1.RokuDriver)
829
842
  await driver.back();
830
843
  // iOS has no hardware back button — noop
831
844
  break;
@@ -1113,6 +1126,11 @@ async function executeCommandBody(key, val, driver, opts) {
1113
1126
  throw new Error(`pressKey: key "${val}" is not supported on vega`);
1114
1127
  await driver.pressButton(button);
1115
1128
  }
1129
+ else if (driver instanceof roku_js_1.RokuDriver) {
1130
+ if (!(0, key_mapping_js_1.rokuEcpKey)(keyName))
1131
+ throw new Error(`pressKey: key "${val}" is not supported on roku`);
1132
+ await driver.pressKeyNamed(keyName);
1133
+ }
1116
1134
  else {
1117
1135
  const keycode = ANDROID_KEYCODES[keyName];
1118
1136
  if (keycode === undefined)
@@ -1133,6 +1151,10 @@ async function executeCommandBody(key, val, driver, opts) {
1133
1151
  else if (driver instanceof vega_js_1.VegaDriver) {
1134
1152
  // No reliable keyboard-hide primitive on vega — noop
1135
1153
  }
1154
+ else if (driver instanceof roku_js_1.RokuDriver) {
1155
+ // Roku dismisses its on-screen keyboard with Back.
1156
+ await driver.back();
1157
+ }
1136
1158
  else {
1137
1159
  await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
1138
1160
  }