appium-ios-remotexpc 5.2.0 → 5.2.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
@@ -1,3 +1,9 @@
1
+ ## [5.2.1](https://github.com/appium/appium-ios-remotexpc/compare/v5.2.0...v5.2.1) (2026-06-24)
2
+
3
+ ### Miscellaneous Chores
4
+
5
+ * refactor tunnels creation and pairing scripts ([#247](https://github.com/appium/appium-ios-remotexpc/issues/247)) ([c614312](https://github.com/appium/appium-ios-remotexpc/commit/c614312dde31ce4019707ff3f079b57362a761ee))
6
+
1
7
  ## [5.2.0](https://github.com/appium/appium-ios-remotexpc/compare/v5.1.6...v5.2.0) (2026-06-22)
2
8
 
3
9
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-remotexpc",
3
- "version": "5.2.0",
3
+ "version": "5.2.1",
4
4
  "main": "build/src/index.js",
5
5
  "types": "build/src/index.d.ts",
6
6
  "type": "module",
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_APPLETV_PAIRING_DISCOVERY_TIMEOUT_MS =
2
+ Number(process.env.APPLETV_DISCOVERY_TIMEOUT) || 10_000;
3
+ export const DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS = 10_000;
4
+ export const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @param {string} value
3
+ * @param {string} label
4
+ * @returns {number}
5
+ */
6
+ export function parsePositiveIntegerOption(value, label) {
7
+ return parseIntegerOption(
8
+ value,
9
+ label,
10
+ (num) => num > 0,
11
+ 'a positive integer in milliseconds',
12
+ );
13
+ }
14
+
15
+ /**
16
+ * @param {string} value
17
+ * @param {string} label
18
+ * @returns {number}
19
+ */
20
+ export function parseNonNegativeIntegerOption(value, label) {
21
+ return parseIntegerOption(
22
+ value,
23
+ label,
24
+ (num) => num >= 0,
25
+ 'a non-negative integer (0 = unlimited)',
26
+ );
27
+ }
28
+
29
+ /**
30
+ * @param {string} value
31
+ * @param {string} label
32
+ * @returns {number}
33
+ */
34
+ export function parsePortOption(value, label) {
35
+ return parseIntegerOption(
36
+ value,
37
+ label,
38
+ (num) => num > 0 && num <= 65535,
39
+ 'an integer between 1 and 65535',
40
+ );
41
+ }
42
+
43
+ /**
44
+ * @param {string} value
45
+ * @param {string} label
46
+ * @param {(num: number) => boolean} isValid
47
+ * @param {string} expectation
48
+ * @returns {number}
49
+ */
50
+ function parseIntegerOption(value, label, isValid, expectation) {
51
+ const num = Number.parseInt(value, 10);
52
+ if (!Number.isFinite(num) || !isValid(num)) {
53
+ throw new Error(`Invalid ${label}: ${value}. Expected ${expectation}.`);
54
+ }
55
+ return num;
56
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Logs a timeout-based progress bar while an operation without native progress callbacks is running.
3
+ *
4
+ * @param {{log: {info: (message: string) => void}, label: string, startedAt: number, timeoutMs: number, barWidth: number, intervalMs: number}} opts
5
+ * @returns {{succeed: (message?: string) => void, fail: (message?: string) => void}}
6
+ */
7
+ export function startTimeoutProgressLogger({
8
+ log,
9
+ label,
10
+ startedAt,
11
+ timeoutMs,
12
+ barWidth,
13
+ intervalMs,
14
+ }) {
15
+ /** @type {NodeJS.Timeout | null} */
16
+ let timer = null;
17
+ let isStopped = false;
18
+
19
+ const logProgress = (status, isComplete = false) => {
20
+ const elapsedMs = performance.now() - startedAt;
21
+ const boundedElapsedMs = Math.min(elapsedMs, timeoutMs);
22
+ const progress = isComplete ? 1 : boundedElapsedMs / timeoutMs;
23
+ const filledWidth = Math.round(progress * barWidth);
24
+ const emptyWidth = barWidth - filledWidth;
25
+ const bar = `${'#'.repeat(filledWidth)}${'-'.repeat(emptyWidth)}`;
26
+ log.info(
27
+ `${label}: [${bar}]${status && status !== 'waiting' ? ` - ${status}` : ''}`,
28
+ );
29
+ };
30
+
31
+ const stop = (status, isComplete = false) => {
32
+ if (isStopped) {
33
+ return;
34
+ }
35
+ isStopped = true;
36
+ if (timer) {
37
+ clearInterval(timer);
38
+ timer = null;
39
+ }
40
+ logProgress(status, isComplete);
41
+ };
42
+
43
+ logProgress('waiting');
44
+ timer = setInterval(() => {
45
+ logProgress('waiting');
46
+ }, intervalMs);
47
+ timer.unref?.();
48
+
49
+ return {
50
+ succeed: (message = 'done') => stop(message, true),
51
+ fail: (message = 'failed') => stop(message),
52
+ };
53
+ }
@@ -0,0 +1,36 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ /**
7
+ * Ensures a helper script is run with elevated privileges.
8
+ *
9
+ * @param {string} relativeScriptPath
10
+ */
11
+ export async function assertRoot(relativeScriptPath) {
12
+ if (process.platform === 'win32') {
13
+ await assertWindowsAdmin(relativeScriptPath);
14
+ return;
15
+ }
16
+
17
+ if (typeof process.getuid !== 'function') {
18
+ return;
19
+ }
20
+ if (process.getuid() !== 0) {
21
+ throw new Error(`This script must be run as root/admin (e.g. sudo node "${relativeScriptPath}").`);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {string} relativeScriptPath
27
+ */
28
+ async function assertWindowsAdmin(relativeScriptPath) {
29
+ try {
30
+ await execFileAsync('net', ['session']);
31
+ } catch {
32
+ throw new Error(
33
+ `This script must be run as Administrator (e.g. from an elevated terminal: node "${relativeScriptPath}").`,
34
+ );
35
+ }
36
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @param {number} ms
3
+ * @returns {Promise<void>}
4
+ */
5
+ export function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
@@ -0,0 +1,81 @@
1
+ import { discoverServices, servicesToCatalog } from 'appium-ios-remotexpc';
2
+
3
+ /**
4
+ * @returns {import('appium-ios-remotexpc').TunnelRegistry}
5
+ */
6
+ export function createEmptyTunnelRegistry() {
7
+ return {
8
+ tunnels: {},
9
+ metadata: {
10
+ lastUpdated: new Date().toISOString(),
11
+ totalTunnels: 0,
12
+ activeTunnels: 0,
13
+ },
14
+ };
15
+ }
16
+
17
+ /**
18
+ * @param {string} udid
19
+ * @param {import('appium-ios-remotexpc').TunnelRegistryEntry} entry
20
+ * @param {{info: (message: string) => void}} log
21
+ */
22
+ export async function refreshServiceCatalog(udid, entry, log) {
23
+ log.info(`Refreshing RSD service catalog for ${udid}...`);
24
+ const services = await discoverServices(udid, entry.address, entry.rsdPort);
25
+ const now = Date.now();
26
+ return {
27
+ ...entry,
28
+ services: servicesToCatalog(services),
29
+ catalogUpdatedAt: now,
30
+ lastUpdated: now,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * @template T
36
+ * @param {object} opts
37
+ * @param {import('appium-ios-remotexpc').TunnelRegistryServer | null} opts.registryServer
38
+ * @param {T} opts.result
39
+ * @param {(result: T) => string} opts.getUdid
40
+ * @param {(result: T, existing: import('appium-ios-remotexpc').TunnelRegistryEntry | undefined, now: number) => import('appium-ios-remotexpc').TunnelRegistryEntry} opts.buildEntry
41
+ * @param {{info: (message: string) => void, warn: (message: string) => void}} opts.log
42
+ * @returns {Promise<boolean>}
43
+ */
44
+ export async function publishDiscoveredTunnelEntry({
45
+ registryServer,
46
+ result,
47
+ getUdid,
48
+ buildEntry,
49
+ log,
50
+ }) {
51
+ if (!registryServer) {
52
+ throw new Error('Registry server is not started');
53
+ }
54
+
55
+ const udid = getUdid(result);
56
+ const rsdPort = result.tunnel.RsdPort;
57
+ if (typeof rsdPort !== 'number' || rsdPort <= 0) {
58
+ log.warn(
59
+ `Skipping registry entry for ${udid}: no valid RSD port (got ${String(rsdPort)})`,
60
+ );
61
+ return false;
62
+ }
63
+
64
+ registryServer.markTunnelPending(udid);
65
+ log.info(
66
+ `Discovering RSD services for ${udid} at ${result.tunnel.Address}:${rsdPort}...`,
67
+ );
68
+
69
+ const services = await discoverServices(udid, result.tunnel.Address, rsdPort);
70
+ const now = Date.now();
71
+ const registry = registryServer.getRegistry();
72
+ const entry = buildEntry(result, registry.tunnels[udid], now);
73
+ entry.services = servicesToCatalog(services);
74
+ entry.catalogUpdatedAt = now;
75
+
76
+ registryServer.upsertReadyEntry(udid, entry);
77
+ log.info(
78
+ `Published tunnel catalog for ${udid} (${Object.keys(entry.services).length} services)`,
79
+ );
80
+ return true;
81
+ }
@@ -6,74 +6,20 @@
6
6
  * npm run pair-appletv -- [options]
7
7
  */
8
8
 
9
- import { logger } from '@appium/support';
9
+ import { logger, util } from '@appium/support';
10
10
  import { Command } from 'commander';
11
11
  import {
12
12
  AppleTVPairingService,
13
13
  UserInputService,
14
14
  } from 'appium-ios-remotexpc';
15
- import { DEFAULT_PAIRING_CONFIG } from '../build/src/lib/apple-tv/constants.js';
15
+ import { DEFAULT_APPLETV_PAIRING_DISCOVERY_TIMEOUT_MS } from './lib/constants.mjs';
16
+ import { parsePositiveIntegerOption } from './lib/options.mjs';
17
+ import { startTimeoutProgressLogger } from './lib/progress.mjs';
16
18
 
17
19
  const log = logger.getLogger('AppleTVPairing');
18
20
  const APPLETV_PAIRING_DISCOVERY_PROGRESS_INTERVAL_MS = 1000;
19
21
  const APPLETV_PAIRING_DISCOVERY_PROGRESS_BAR_WIDTH = 24;
20
22
 
21
- function parsePositiveInteger(value) {
22
- const count = Number.parseInt(value, 10);
23
- if (!Number.isFinite(count) || count <= 0) {
24
- throw new Error(
25
- `Invalid timeout: ${value}. Expected a positive integer in milliseconds.`,
26
- );
27
- }
28
- return count;
29
- }
30
-
31
- function startTimeoutProgressLogger({
32
- label,
33
- startedAt,
34
- timeoutMs,
35
- barWidth,
36
- intervalMs,
37
- }) {
38
- let timer = null;
39
- let isStopped = false;
40
-
41
- const logProgress = (status, isComplete = false) => {
42
- const elapsedMs = performance.now() - startedAt;
43
- const boundedElapsedMs = Math.min(elapsedMs, timeoutMs);
44
- const progress = isComplete ? 1 : boundedElapsedMs / timeoutMs;
45
- const filledWidth = Math.round(progress * barWidth);
46
- const emptyWidth = barWidth - filledWidth;
47
- const bar = `${'#'.repeat(filledWidth)}${'-'.repeat(emptyWidth)}`;
48
- log.info(
49
- `${label}: [${bar}]${status && status !== 'waiting' ? ` - ${status}` : ''}`,
50
- );
51
- };
52
-
53
- const stop = (status, isComplete = false) => {
54
- if (isStopped) {
55
- return;
56
- }
57
- isStopped = true;
58
- if (timer) {
59
- clearInterval(timer);
60
- timer = null;
61
- }
62
- logProgress(status, isComplete);
63
- };
64
-
65
- logProgress('waiting');
66
- timer = setInterval(() => {
67
- logProgress('waiting');
68
- }, intervalMs);
69
- timer.unref?.();
70
-
71
- return {
72
- succeed: (message = 'done') => stop(message, true),
73
- fail: (message = 'failed') => stop(message),
74
- };
75
- }
76
-
77
23
  function discoverAppleTVPairingDevices(pairingService, timeoutMs) {
78
24
  const startedAt = performance.now();
79
25
  const promise = pairingService.discoverDevices({ timeoutMs });
@@ -82,6 +28,7 @@ function discoverAppleTVPairingDevices(pairingService, timeoutMs) {
82
28
 
83
29
  async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
84
30
  const progress = startTimeoutProgressLogger({
31
+ log,
85
32
  label: 'Waiting for Apple TV pairing discovery',
86
33
  startedAt: discovery.startedAt,
87
34
  timeoutMs,
@@ -92,7 +39,7 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
92
39
  try {
93
40
  const devices = await discovery.promise;
94
41
  progress.succeed(
95
- `Apple TV pairing discovery completed: ${devices.length} device(s) found`,
42
+ `Apple TV pairing discovery completed: ${util.pluralize('device', devices.length, true)} found`,
96
43
  );
97
44
  return devices;
98
45
  } catch (err) {
@@ -101,6 +48,44 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
101
48
  }
102
49
  }
103
50
 
51
+ async function discoverAndPairWithProgress(
52
+ pairingService,
53
+ deviceSelector,
54
+ discoveryTimeoutMs,
55
+ ) {
56
+ const discovery = discoverAppleTVPairingDevices(
57
+ pairingService,
58
+ discoveryTimeoutMs,
59
+ );
60
+ const devices = await waitForAppleTVPairingDiscovery(
61
+ discovery,
62
+ discoveryTimeoutMs,
63
+ );
64
+ return await pairingService.discoverAndPair(deviceSelector, {
65
+ devices,
66
+ discoveryTimeoutMs,
67
+ });
68
+ }
69
+
70
+ /**
71
+ * @param {unknown} err
72
+ * @returns {boolean}
73
+ */
74
+ function isNoAppleTVPairingDevicesFoundError(err) {
75
+ return (
76
+ err instanceof Error &&
77
+ (err.message === getNoAppleTVPairingDevicesMessage() ||
78
+ ('code' in err && err.code === 'NO_DEVICES'))
79
+ );
80
+ }
81
+
82
+ /**
83
+ * @returns {string}
84
+ */
85
+ function getNoAppleTVPairingDevicesMessage() {
86
+ return 'No Apple TV pairing devices found. Please ensure your Apple TV is on the same network and in pairing mode.';
87
+ }
88
+
104
89
  async function main() {
105
90
  const program = new Command();
106
91
  program
@@ -113,31 +98,26 @@ async function main() {
113
98
  .option(
114
99
  '--discovery-timeout <ms>',
115
100
  'Apple TV pairing discovery timeout in milliseconds',
116
- parsePositiveInteger,
101
+ (value) => parsePositiveIntegerOption(value, 'discovery timeout'),
117
102
  );
118
103
 
119
104
  program.parse(process.argv);
120
105
  const options = program.opts();
121
106
  const discoveryTimeoutMs =
122
- options.discoveryTimeout ?? DEFAULT_PAIRING_CONFIG.discoveryTimeout;
107
+ options.discoveryTimeout ?? DEFAULT_APPLETV_PAIRING_DISCOVERY_TIMEOUT_MS;
123
108
 
124
109
  const userInput = new UserInputService();
125
110
  const pairingService = new AppleTVPairingService(userInput);
126
- const discovery = discoverAppleTVPairingDevices(
111
+ const result = await discoverAndPairWithProgress(
127
112
  pairingService,
113
+ options.device,
128
114
  discoveryTimeoutMs,
129
115
  );
130
- const devices = await waitForAppleTVPairingDiscovery(
131
- discovery,
132
- discoveryTimeoutMs,
133
- );
134
- const result = await pairingService.discoverAndPair(options.device, {
135
- devices,
136
- discoveryTimeoutMs,
137
- });
138
116
 
139
117
  if (result.success) {
140
118
  log.info(`Pairing successful! Record saved to: ${result.pairingFile}`);
119
+ } else if (isNoAppleTVPairingDevicesFoundError(result.error)) {
120
+ log.info(getNoAppleTVPairingDevicesMessage());
141
121
  } else {
142
122
  throw result.error ?? new Error('Pairing failed');
143
123
  }
@@ -11,15 +11,31 @@ import {
11
11
  AppleTVTunnelService,
12
12
  TunnelManager,
13
13
  TunnelReadinessCoordinator,
14
- discoverServices,
15
- servicesToCatalog,
16
14
  startTunnelRegistryServer,
17
15
  watchTunnelRegistryOnDead,
18
16
  } from 'appium-ios-remotexpc';
19
- import { DEFAULT_TUNNEL_REGISTRY_PORT } from '../build/src/lib/tunnel/tunnel-registry-server.js';
17
+ import {
18
+ parseNonNegativeIntegerOption,
19
+ parsePortOption,
20
+ parsePositiveIntegerOption,
21
+ } from './lib/options.mjs';
22
+ import { sleep } from './lib/timers.mjs';
23
+ import {
24
+ createEmptyTunnelRegistry,
25
+ publishDiscoveredTunnelEntry as publishTunnelRegistryEntry,
26
+ refreshServiceCatalog,
27
+ } from './lib/tunnel-registry.mjs';
28
+ import { DEFAULT_TUNNEL_REGISTRY_PORT, DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS } from './lib/constants.mjs';
29
+ import { assertRoot } from './lib/root.mjs';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { startTimeoutProgressLogger } from './lib/progress.mjs';
20
33
 
21
34
  const log = logger.getLogger('WiFiTunnel');
22
35
 
36
+ const APPLETV_TUNNEL_DISCOVERY_PROGRESS_INTERVAL_MS = 1000;
37
+ const APPLETV_TUNNEL_DISCOVERY_PROGRESS_BAR_WIDTH = 24;
38
+
23
39
  /** @type {import('appium-ios-remotexpc').TunnelRegistryServer | null} */
24
40
  let registryServer = null;
25
41
 
@@ -48,6 +64,54 @@ function stopLifecycleWatch(udid) {
48
64
  }
49
65
  }
50
66
 
67
+ /**
68
+ *
69
+ * @param {import('appium-ios-remotexpc').AppleTVTunnelService} tunnelService
70
+ * @param {number} timeoutMs
71
+ * @returns {Promise<number, Promise<AppleTVDevice[]>>}
72
+ */
73
+ function startDevicesDiscovery(tunnelService, timeoutMs) {
74
+ const startedAt = performance.now();
75
+ const promise = tunnelService.discoverDevices({ timeoutMs });
76
+ return { startedAt, promise };
77
+ }
78
+
79
+ /**
80
+ *
81
+ * @param {Promise<AppleTVDevice[]>} discovery
82
+ * @param {number} timeoutMs
83
+ * @returns {Promise<AppleTVDevice[]>}
84
+ */
85
+ async function waitForDevicesDiscovery(discovery, timeoutMs) {
86
+ const progress = startTimeoutProgressLogger({
87
+ log,
88
+ label: 'Waiting for wireless Apple TV devices discovery',
89
+ startedAt: discovery.startedAt,
90
+ timeoutMs,
91
+ barWidth: APPLETV_TUNNEL_DISCOVERY_PROGRESS_BAR_WIDTH,
92
+ intervalMs: APPLETV_TUNNEL_DISCOVERY_PROGRESS_INTERVAL_MS,
93
+ });
94
+
95
+ try {
96
+ const devices = await discovery.promise;
97
+ progress.succeed(
98
+ `Wireless Apple TV devices discovery completed: ${util.pluralize('device', devices.length, true)} found`,
99
+ );
100
+ return devices;
101
+ } catch (err) {
102
+ progress.fail('Wireless Apple TV devices discovery failed');
103
+ throw err;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * @param {unknown} err
109
+ * @returns {boolean}
110
+ */
111
+ function isNoDevicesFoundError(err) {
112
+ return err instanceof Error && ('code' in err && err.code === 'NO_DEVICES') || err.message?.includes('No devices found');
113
+ }
114
+
51
115
  function registerEstablishedTunnel(result) {
52
116
  const udid = result.device.identifier;
53
117
  stopLifecycleWatch(udid);
@@ -63,36 +127,6 @@ function registerEstablishedTunnel(result) {
63
127
  }
64
128
  let tunnelService = null;
65
129
 
66
- function parsePort(value) {
67
- const port = Number.parseInt(value, 10);
68
- if (!Number.isFinite(port) || port <= 0 || port > 65535) {
69
- throw new Error(
70
- `Invalid port: ${value}. Expected an integer between 1 and 65535.`,
71
- );
72
- }
73
- return port;
74
- }
75
-
76
- function parseNonNegativeInteger(value) {
77
- const count = Number.parseInt(value, 10);
78
- if (!Number.isFinite(count) || count < 0) {
79
- throw new Error(
80
- `Invalid retry count: ${value}. Expected a non-negative integer (0 = unlimited).`,
81
- );
82
- }
83
- return count;
84
- }
85
-
86
- function parsePositiveInteger(value) {
87
- const count = Number.parseInt(value, 10);
88
- if (!Number.isFinite(count) || count <= 0) {
89
- throw new Error(
90
- `Invalid timeout: ${value}. Expected a positive integer in milliseconds.`,
91
- );
92
- }
93
- return count;
94
- }
95
-
96
130
  /**
97
131
  * @param {object} registry
98
132
  * @param {AppleTvEstablishedTunnel[]} successfulResults
@@ -166,25 +200,27 @@ function setupCleanupHandlers() {
166
200
 
167
201
  process.on('SIGINT', async () => {
168
202
  await cleanup('SIGINT (Ctrl+C)');
169
- process.exit(0);
203
+ process.exit(process.exitCode || 0);
170
204
  });
171
205
  process.on('SIGTERM', async () => {
172
206
  await cleanup('SIGTERM');
173
- process.exit(0);
207
+ process.exit(process.exitCode || 0);
174
208
  });
175
209
  process.on('SIGHUP', async () => {
176
210
  await cleanup('SIGHUP');
177
- process.exit(0);
211
+ process.exit(process.exitCode || 0);
178
212
  });
179
213
 
180
214
  process.on('uncaughtException', async (error) => {
181
215
  log.error('Uncaught Exception:', error);
182
216
  await cleanup('Uncaught Exception');
217
+ process.exit(process.exitCode || 1);
183
218
  });
184
219
 
185
220
  process.on('unhandledRejection', async (reason, promise) => {
186
221
  log.error('Unhandled Rejection at:', promise, 'reason:', reason);
187
222
  await cleanup('Unhandled Rejection');
223
+ process.exit(process.exitCode || 1);
188
224
  });
189
225
  }
190
226
 
@@ -220,21 +256,8 @@ async function establishOneTunnel(startResult) {
220
256
  return result;
221
257
  }
222
258
 
223
- async function refreshServiceCatalog(udid, entry) {
224
- log.info(`Refreshing RSD service catalog for ${udid}...`);
225
- const services = await discoverServices(udid, entry.address, entry.rsdPort);
226
- const now = Date.now();
227
- return {
228
- ...entry,
229
- services: servicesToCatalog(services),
230
- catalogUpdatedAt: now,
231
- lastUpdated: now,
232
- };
233
- }
234
-
235
- function buildAppleTvTunnelEntryBase(result, existing) {
259
+ function buildAppleTvTunnelEntry(result, existing, now) {
236
260
  const udid = result.device.identifier;
237
- const now = Date.now();
238
261
  const entry = {
239
262
  udid,
240
263
  deviceId: 0,
@@ -250,44 +273,13 @@ function buildAppleTvTunnelEntryBase(result, existing) {
250
273
  }
251
274
 
252
275
  async function publishDiscoveredTunnelEntry(result) {
253
- if (!registryServer) {
254
- throw new Error('Registry server is not started');
255
- }
256
-
257
- const udid = result.device.identifier;
258
- const rsdPort = result.tunnel.RsdPort;
259
- if (typeof rsdPort !== 'number' || rsdPort <= 0) {
260
- log.warn(
261
- `Skipping registry entry for ${udid}: no valid RSD port (got ${String(rsdPort)})`,
262
- );
263
- return false;
264
- }
265
-
266
- registryServer.markTunnelPending(udid);
267
- log.info(
268
- `Discovering RSD services for ${udid} at ${result.tunnel.Address}:${rsdPort}...`,
269
- );
270
-
271
- const services = await discoverServices(
272
- udid,
273
- result.tunnel.Address,
274
- rsdPort,
275
- );
276
- const now = Date.now();
277
- const registry = registryServer.getRegistry();
278
- const entry = buildAppleTvTunnelEntryBase(result, registry.tunnels[udid]);
279
- entry.services = servicesToCatalog(services);
280
- entry.catalogUpdatedAt = now;
281
-
282
- registryServer.upsertReadyEntry(udid, entry);
283
- log.info(
284
- `Published tunnel catalog for ${udid} (${Object.keys(entry.services).length} services)`,
285
- );
286
- return true;
287
- }
288
-
289
- function sleep(ms) {
290
- return new Promise((resolve) => setTimeout(resolve, ms));
276
+ return await publishTunnelRegistryEntry({
277
+ registryServer,
278
+ result,
279
+ getUdid: (r) => r.device.identifier,
280
+ buildEntry: buildAppleTvTunnelEntry,
281
+ log,
282
+ });
291
283
  }
292
284
 
293
285
  async function runAppleTvReconnectAttempts({
@@ -382,17 +374,17 @@ async function main() {
382
374
  .option(
383
375
  '--tunnel-registry-port <port>',
384
376
  `Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
385
- parsePort,
377
+ (value) => parsePortOption(value, 'tunnel registry port'),
386
378
  )
387
379
  .option(
388
380
  '--reconnect-retries <count>',
389
381
  'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
390
- parseNonNegativeInteger,
382
+ (value) => parseNonNegativeIntegerOption(value, 'retry count'),
391
383
  )
392
384
  .option(
393
385
  '--discovery-timeout <ms>',
394
386
  'Apple TV discovery timeout in milliseconds',
395
- parsePositiveInteger,
387
+ (value) => parsePositiveIntegerOption(value, 'discovery timeout'),
396
388
  );
397
389
 
398
390
  program.parse(process.argv);
@@ -401,6 +393,8 @@ async function main() {
401
393
  const registryPort =
402
394
  options.tunnelRegistryPort ?? DEFAULT_TUNNEL_REGISTRY_PORT;
403
395
 
396
+ await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
397
+
404
398
  if (deviceIdentifier) {
405
399
  log.info(`Targeting a single Apple TV: ${deviceIdentifier}`);
406
400
  } else {
@@ -413,18 +407,12 @@ async function main() {
413
407
 
414
408
  try {
415
409
  const readiness = new TunnelReadinessCoordinator();
416
- const registry = {
417
- tunnels: {},
418
- metadata: {
419
- lastUpdated: new Date().toISOString(),
420
- totalTunnels: 0,
421
- activeTunnels: 0,
422
- },
423
- };
410
+ const registry = createEmptyTunnelRegistry();
424
411
 
425
412
  registryServer = await startTunnelRegistryServer(registry, registryPort, {
426
413
  readiness,
427
- refreshServices: refreshServiceCatalog,
414
+ refreshServices: async (udid, entry) =>
415
+ await refreshServiceCatalog(udid, entry, log),
428
416
  });
429
417
 
430
418
  const reconnectTunnelByUdid = createAppleTvReconnectTunnelByUdid({
@@ -460,11 +448,27 @@ async function main() {
460
448
  },
461
449
  );
462
450
  } else {
463
- const devices = await tunnelService.discoverDevices({
464
- timeoutMs: options.discoveryTimeout,
465
- });
451
+ const discoveryTimeoutMs = options.discoveryTimeout ?? DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS;
452
+ const discovery = startDevicesDiscovery(
453
+ tunnelService,
454
+ discoveryTimeoutMs,
455
+ );
456
+ /** @type {AppleTVDevice[]} */
457
+ let devices = [];
458
+ try {
459
+ devices = await waitForDevicesDiscovery(
460
+ discovery,
461
+ discoveryTimeoutMs,
462
+ );
463
+ } catch (err) {
464
+ if (isNoDevicesFoundError(err)) {
465
+ log.info('No wireless Apple TV devices found');
466
+ process.exit(process.exitCode || 0);
467
+ }
468
+ throw err;
469
+ }
466
470
  log.info(
467
- `Discovered ${devices.length} Apple TV device(s); establishing tunnels...`,
471
+ `Discovered ${util.pluralize('Apple TV device', devices.length, true)}; establishing ${util.pluralize('tunnel', devices.length)}...`,
468
472
  );
469
473
 
470
474
  for (let i = 0; i < devices.length; i++) {
@@ -536,8 +540,12 @@ async function main() {
536
540
  );
537
541
  }
538
542
 
539
- log.info('\n✅ Apple TV tunnel(s) ready.');
540
- log.info('\nPress Ctrl+C to close tunnel(s) and exit.');
543
+ log.info(
544
+ `\n✅ ${registryPublished.length} Apple TV ${util.pluralize('tunnel', registryPublished.length)} ready.`,
545
+ );
546
+ log.info(
547
+ `\nPress Ctrl+C to close ${util.pluralize('tunnel', registryPublished.length)} and exit.`,
548
+ );
541
549
 
542
550
  process.stdin.resume();
543
551
  } catch (error) {
@@ -3,21 +3,31 @@
3
3
  * Create lockdown + CoreDeviceProxy tunnels for connected USB devices and expose the tunnel registry API.
4
4
  */
5
5
 
6
- import { logger } from '@appium/support';
6
+ import { logger, util } from '@appium/support';
7
7
  import { Command } from 'commander';
8
-
8
+ import { fileURLToPath } from 'node:url';
9
+ import path from 'node:path';
9
10
  import {
10
11
  TunnelManager,
11
12
  TunnelReadinessCoordinator,
12
13
  createLockdownServiceByUDID,
13
14
  createUsbmux,
14
- discoverServices,
15
- servicesToCatalog,
16
15
  startCoreDeviceProxyTcp,
17
16
  startTunnelRegistryServer,
18
17
  watchTunnelRegistryOnDead,
19
18
  } from 'appium-ios-remotexpc';
20
- import { DEFAULT_TUNNEL_REGISTRY_PORT } from '../build/src/lib/tunnel/tunnel-registry-server.js';
19
+ import {
20
+ parseNonNegativeIntegerOption,
21
+ parsePortOption,
22
+ } from './lib/options.mjs';
23
+ import { sleep } from './lib/timers.mjs';
24
+ import {
25
+ createEmptyTunnelRegistry,
26
+ publishDiscoveredTunnelEntry as publishTunnelRegistryEntry,
27
+ refreshServiceCatalog,
28
+ } from './lib/tunnel-registry.mjs';
29
+ import { DEFAULT_TUNNEL_REGISTRY_PORT } from './lib/constants.mjs';
30
+ import { assertRoot } from './lib/root.mjs';
21
31
 
22
32
  const log = logger.getLogger('TunnelCreation');
23
33
 
@@ -94,41 +104,8 @@ function dedupeDevicesByUdid(devices) {
94
104
  return [...byUdid.values()];
95
105
  }
96
106
 
97
- function parsePort(value) {
98
- const port = Number.parseInt(value, 10);
99
- if (!Number.isFinite(port) || port <= 0 || port > 65535) {
100
- throw new Error(
101
- `Invalid port: ${value}. Expected an integer between 1 and 65535.`,
102
- );
103
- }
104
- return port;
105
- }
106
-
107
- function parseNonNegativeInteger(value) {
108
- const count = Number.parseInt(value, 10);
109
- if (!Number.isFinite(count) || count < 0) {
110
- throw new Error(
111
- `Invalid retry count: ${value}. Expected a non-negative integer (0 = unlimited).`,
112
- );
113
- }
114
- return count;
115
- }
116
-
117
- async function refreshServiceCatalog(udid, entry) {
118
- log.info(`Refreshing RSD service catalog for ${udid}...`);
119
- const services = await discoverServices(udid, entry.address, entry.rsdPort);
120
- const now = Date.now();
121
- return {
122
- ...entry,
123
- services: servicesToCatalog(services),
124
- catalogUpdatedAt: now,
125
- lastUpdated: now,
126
- };
127
- }
128
-
129
- function buildTunnelEntryBase(result, existing) {
107
+ function buildTunnelEntry(result, existing, now) {
130
108
  const udid = result.device.Properties.SerialNumber;
131
- const now = Date.now();
132
109
  const entry = {
133
110
  udid,
134
111
  deviceId: result.device.DeviceID,
@@ -144,40 +121,13 @@ function buildTunnelEntryBase(result, existing) {
144
121
  }
145
122
 
146
123
  async function publishDiscoveredTunnelEntry(result) {
147
- if (!registryServer) {
148
- throw new Error('Registry server is not started');
149
- }
150
-
151
- const udid = result.device.Properties.SerialNumber;
152
- const rsdPort = result.tunnel.RsdPort;
153
- if (typeof rsdPort !== 'number' || rsdPort <= 0) {
154
- log.warn(
155
- `Skipping registry entry for ${udid}: no valid RSD port (got ${String(rsdPort)})`,
156
- );
157
- return false;
158
- }
159
-
160
- registryServer.markTunnelPending(udid);
161
- log.info(
162
- `Discovering RSD services for ${udid} at ${result.tunnel.Address}:${rsdPort}...`,
163
- );
164
-
165
- const services = await discoverServices(
166
- udid,
167
- result.tunnel.Address,
168
- rsdPort,
169
- );
170
- const now = Date.now();
171
- const registry = registryServer.getRegistry();
172
- const entry = buildTunnelEntryBase(result, registry.tunnels[udid]);
173
- entry.services = servicesToCatalog(services);
174
- entry.catalogUpdatedAt = now;
175
-
176
- registryServer.upsertReadyEntry(udid, entry);
177
- log.info(
178
- `Published tunnel catalog for ${udid} (${Object.keys(entry.services).length} services)`,
179
- );
180
- return true;
124
+ return await publishTunnelRegistryEntry({
125
+ registryServer,
126
+ result,
127
+ getUdid: (r) => r.device.Properties.SerialNumber,
128
+ buildEntry: buildTunnelEntry,
129
+ log,
130
+ });
181
131
  }
182
132
 
183
133
  const registryWatcherStops = [];
@@ -222,10 +172,6 @@ function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}
222
172
  );
223
173
  }
224
174
 
225
- function sleep(ms) {
226
- return new Promise((resolve) => setTimeout(resolve, ms));
227
- }
228
-
229
175
  async function runReconnectAttempts({
230
176
  udid,
231
177
  maxRetries,
@@ -328,25 +274,27 @@ function setupCleanupHandlers() {
328
274
 
329
275
  process.on('SIGINT', async () => {
330
276
  await cleanup('SIGINT (Ctrl+C)');
331
- process.exit(0);
277
+ process.exit(process.exitCode || 0);
332
278
  });
333
279
  process.on('SIGTERM', async () => {
334
280
  await cleanup('SIGTERM');
335
- process.exit(0);
281
+ process.exit(process.exitCode || 0);
336
282
  });
337
283
  process.on('SIGHUP', async () => {
338
284
  await cleanup('SIGHUP');
339
- process.exit(0);
285
+ process.exit(process.exitCode || 0);
340
286
  });
341
287
 
342
288
  process.on('uncaughtException', async (error) => {
343
289
  log.error('Uncaught Exception:', error);
344
290
  await cleanup('Uncaught Exception');
291
+ process.exit(process.exitCode || 1);
345
292
  });
346
293
 
347
294
  process.on('unhandledRejection', async (reason, promise) => {
348
295
  log.error('Unhandled Rejection at:', promise, 'reason:', reason);
349
296
  await cleanup('Unhandled Rejection');
297
+ process.exit(process.exitCode || 1);
350
298
  });
351
299
  }
352
300
 
@@ -433,18 +381,20 @@ async function main() {
433
381
  .option(
434
382
  '--tunnel-registry-port <port>',
435
383
  `Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
436
- parsePort,
384
+ (value) => parsePortOption(value, 'tunnel registry port'),
437
385
  )
438
386
  .option(
439
387
  '--reconnect-retries <count>',
440
388
  'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
441
- parseNonNegativeInteger,
389
+ (value) => parseNonNegativeIntegerOption(value, 'retry count'),
442
390
  );
443
391
 
444
392
  program.parse(process.argv);
445
393
  const options = program.opts();
446
394
  const specificUdid = options.udid ?? program.args[0] ?? undefined;
447
395
 
396
+ await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
397
+
448
398
  if (specificUdid) {
449
399
  log.info(
450
400
  `Starting tunnel creation test for specific UDID: ${specificUdid}`,
@@ -476,7 +426,7 @@ async function main() {
476
426
  process.exit(0);
477
427
  }
478
428
 
479
- log.info(`Found ${devices.length} connected device(s):`);
429
+ log.info(`Found ${util.pluralize('connected device', devices.length, true)}:`);
480
430
  devices.forEach((device, index) => {
481
431
  log.info(` ${index + 1}. UDID: ${device.Properties.SerialNumber}`);
482
432
  log.info(` Device ID: ${device.DeviceID}`);
@@ -506,25 +456,19 @@ async function main() {
506
456
  devicesToProcess = dedupeDevicesByUdid(devicesToProcess);
507
457
  if (devicesToProcess.length < beforeDedupe) {
508
458
  log.info(
509
- `Deduped ${beforeDedupe} usbmux entries to ${devicesToProcess.length} device(s) (USB preferred over Network)`,
459
+ `Deduped ${util.pluralize('usbmux entry', beforeDedupe, true)} to ${util.pluralize('device', devicesToProcess.length, true)} (USB preferred over Network)`,
510
460
  );
511
461
  }
512
462
 
513
- log.info(`\nProcessing ${devicesToProcess.length} device(s)...`);
463
+ log.info(`\nProcessing ${util.pluralize('device', devicesToProcess.length, true)}...`);
514
464
 
515
465
  const readiness = new TunnelReadinessCoordinator();
516
- const registry = {
517
- tunnels: {},
518
- metadata: {
519
- lastUpdated: new Date().toISOString(),
520
- totalTunnels: 0,
521
- activeTunnels: 0,
522
- },
523
- };
466
+ const registry = createEmptyTunnelRegistry();
524
467
 
525
468
  registryServer = await startTunnelRegistryServer(registry, registryPort, {
526
469
  readiness,
527
- refreshServices: refreshServiceCatalog,
470
+ refreshServices: async (udid, entry) =>
471
+ await refreshServiceCatalog(udid, entry, log),
528
472
  });
529
473
 
530
474
  const reconnectRetries = options.reconnectRetries;
@@ -560,19 +504,19 @@ async function main() {
560
504
  }
561
505
 
562
506
  if (devicesToProcess.length > 1) {
563
- await new Promise((resolve) => setTimeout(resolve, 1000));
507
+ await sleep(1000);
564
508
  }
565
509
  }
566
510
 
567
511
  log.info('\n=== TUNNEL CREATION SUMMARY ===');
568
512
  const failed = results.filter((r) => !r.success);
569
513
 
570
- log.info(`Total devices processed: ${results.length}`);
571
- log.info(`Successful tunnels: ${successful.length}`);
572
- log.info(`Failed tunnels: ${failed.length}`);
514
+ log.info(`Total ${util.pluralize('device', results.length, true)} processed`);
515
+ log.info(`Successful ${util.pluralize('tunnel', successful.length, true)}`);
516
+ log.info(`Failed ${util.pluralize('tunnel', failed.length, true)}`);
573
517
 
574
518
  if (successful.length > 0) {
575
- log.info('\n✅ Successful tunnels:');
519
+ log.info(`\n✅ Successful ${util.pluralize('tunnel', successful.length)}:`);
576
520
  log.info('\n📁 Tunnel registry API:');
577
521
  log.info(' The tunnel registry is now available through the API at:');
578
522
  log.info(` http://localhost:${registryPort}/remotexpc/tunnels`);