appium-ios-remotexpc 5.2.0 → 5.2.2

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,15 @@
1
+ ## [5.2.2](https://github.com/appium/appium-ios-remotexpc/compare/v5.2.1...v5.2.2) (2026-06-24)
2
+
3
+ ### Miscellaneous Chores
4
+
5
+ * Add scripts to typescript checks ([#253](https://github.com/appium/appium-ios-remotexpc/issues/253)) ([f06a206](https://github.com/appium/appium-ios-remotexpc/commit/f06a2060a41f834ca049cb31d9f453c12d9fc013))
6
+
7
+ ## [5.2.1](https://github.com/appium/appium-ios-remotexpc/compare/v5.2.0...v5.2.1) (2026-06-24)
8
+
9
+ ### Miscellaneous Chores
10
+
11
+ * 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))
12
+
1
13
  ## [5.2.0](https://github.com/appium/appium-ios-remotexpc/compare/v5.1.6...v5.2.0) (2026-06-22)
2
14
 
3
15
  ### 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.2",
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,61 @@
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
+ /**
20
+ * @param {string} status
21
+ * @param {boolean} isComplete
22
+ */
23
+ const logProgress = (status, isComplete = false) => {
24
+ const elapsedMs = performance.now() - startedAt;
25
+ const boundedElapsedMs = Math.min(elapsedMs, timeoutMs);
26
+ const progress = isComplete ? 1 : boundedElapsedMs / timeoutMs;
27
+ const filledWidth = Math.round(progress * barWidth);
28
+ const emptyWidth = barWidth - filledWidth;
29
+ const bar = `${'#'.repeat(filledWidth)}${'-'.repeat(emptyWidth)}`;
30
+ log.info(
31
+ `${label}: [${bar}]${status && status !== 'waiting' ? ` - ${status}` : ''}`,
32
+ );
33
+ };
34
+
35
+ /**
36
+ * @param {string} status
37
+ * @param {boolean} isComplete
38
+ */
39
+ const stop = (status, isComplete = false) => {
40
+ if (isStopped) {
41
+ return;
42
+ }
43
+ isStopped = true;
44
+ if (timer) {
45
+ clearInterval(timer);
46
+ timer = null;
47
+ }
48
+ logProgress(status, isComplete);
49
+ };
50
+
51
+ logProgress('waiting');
52
+ timer = setInterval(() => {
53
+ logProgress('waiting');
54
+ }, intervalMs);
55
+ timer.unref?.();
56
+
57
+ return {
58
+ succeed: (message = 'done') => stop(message, true),
59
+ fail: (message = 'failed') => stop(message),
60
+ };
61
+ }
@@ -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 & { tunnel: { Address: string, RsdPort?: number } }} 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,82 +6,40 @@
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
-
23
+ /**
24
+ * @param {import('appium-ios-remotexpc').AppleTVPairingService} pairingService
25
+ * @param {number} timeoutMs
26
+ * @returns {{ startedAt: number, promise: Promise<AppleTVDevice[]> }}
27
+ */
77
28
  function discoverAppleTVPairingDevices(pairingService, timeoutMs) {
78
29
  const startedAt = performance.now();
79
30
  const promise = pairingService.discoverDevices({ timeoutMs });
80
31
  return { startedAt, promise };
81
32
  }
82
33
 
34
+ /**
35
+ *
36
+ * @param {{ startedAt: number, promise: Promise<AppleTVDevice[]> }} discovery
37
+ * @param {number} timeoutMs
38
+ * @returns {Promise<AppleTVDevice[]>}
39
+ */
83
40
  async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
84
41
  const progress = startTimeoutProgressLogger({
42
+ log,
85
43
  label: 'Waiting for Apple TV pairing discovery',
86
44
  startedAt: discovery.startedAt,
87
45
  timeoutMs,
@@ -92,7 +50,7 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
92
50
  try {
93
51
  const devices = await discovery.promise;
94
52
  progress.succeed(
95
- `Apple TV pairing discovery completed: ${devices.length} device(s) found`,
53
+ `Apple TV pairing discovery completed: ${util.pluralize('device', devices.length, true)} found`,
96
54
  );
97
55
  return devices;
98
56
  } catch (err) {
@@ -101,6 +59,50 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
101
59
  }
102
60
  }
103
61
 
62
+ /**
63
+ * @param {import('appium-ios-remotexpc').AppleTVPairingService} pairingService
64
+ * @param {string} deviceSelector
65
+ * @param {number} discoveryTimeoutMs
66
+ * @returns {Promise<{ success: boolean, deviceId: string, pairingFile?: string, error?: Error | null }>}
67
+ */
68
+ async function discoverAndPairWithProgress(
69
+ pairingService,
70
+ deviceSelector,
71
+ discoveryTimeoutMs,
72
+ ) {
73
+ const discovery = discoverAppleTVPairingDevices(
74
+ pairingService,
75
+ discoveryTimeoutMs,
76
+ );
77
+ const devices = await waitForAppleTVPairingDiscovery(
78
+ discovery,
79
+ discoveryTimeoutMs,
80
+ );
81
+ return await pairingService.discoverAndPair(deviceSelector, {
82
+ devices,
83
+ discoveryTimeoutMs,
84
+ });
85
+ }
86
+
87
+ /**
88
+ * @param {unknown} err
89
+ * @returns {boolean}
90
+ */
91
+ function isNoAppleTVPairingDevicesFoundError(err) {
92
+ return (
93
+ err instanceof Error &&
94
+ (err.message === getNoAppleTVPairingDevicesMessage() ||
95
+ ('code' in err && err.code === 'NO_DEVICES'))
96
+ );
97
+ }
98
+
99
+ /**
100
+ * @returns {string}
101
+ */
102
+ function getNoAppleTVPairingDevicesMessage() {
103
+ return 'No Apple TV pairing devices found. Please ensure your Apple TV is on the same network and in pairing mode.';
104
+ }
105
+
104
106
  async function main() {
105
107
  const program = new Command();
106
108
  program
@@ -113,34 +115,33 @@ async function main() {
113
115
  .option(
114
116
  '--discovery-timeout <ms>',
115
117
  'Apple TV pairing discovery timeout in milliseconds',
116
- parsePositiveInteger,
118
+ (value) => parsePositiveIntegerOption(value, 'discovery timeout'),
117
119
  );
118
120
 
119
121
  program.parse(process.argv);
120
122
  const options = program.opts();
121
123
  const discoveryTimeoutMs =
122
- options.discoveryTimeout ?? DEFAULT_PAIRING_CONFIG.discoveryTimeout;
124
+ options.discoveryTimeout ?? DEFAULT_APPLETV_PAIRING_DISCOVERY_TIMEOUT_MS;
123
125
 
124
126
  const userInput = new UserInputService();
125
127
  const pairingService = new AppleTVPairingService(userInput);
126
- const discovery = discoverAppleTVPairingDevices(
128
+ const result = await discoverAndPairWithProgress(
127
129
  pairingService,
130
+ options.device,
128
131
  discoveryTimeoutMs,
129
132
  );
130
- const devices = await waitForAppleTVPairingDiscovery(
131
- discovery,
132
- discoveryTimeoutMs,
133
- );
134
- const result = await pairingService.discoverAndPair(options.device, {
135
- devices,
136
- discoveryTimeoutMs,
137
- });
138
133
 
139
134
  if (result.success) {
140
135
  log.info(`Pairing successful! Record saved to: ${result.pairingFile}`);
136
+ } else if (isNoAppleTVPairingDevicesFoundError(result.error)) {
137
+ log.info(getNoAppleTVPairingDevicesMessage());
141
138
  } else {
142
139
  throw result.error ?? new Error('Pairing failed');
143
140
  }
144
141
  }
145
142
 
146
143
  await main();
144
+
145
+ /**
146
+ * @typedef {import('appium-ios-remotexpc').AppleTVDevice} AppleTVDevice
147
+ */
@@ -11,18 +11,35 @@ 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
 
42
+ /** @type {(() => void)[]} */
26
43
  const registryWatcherStops = [];
27
44
  /** @type {Map<string, Promise<void>>} */
28
45
  const reconnectingByUdid = new Map();
@@ -32,6 +49,10 @@ const establishedTunnelsByUdid = new Map();
32
49
  /** @type {Map<string, () => void>} */
33
50
  const lifecycleWatchStopByUdid = new Map();
34
51
 
52
+ /**
53
+ * @param {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
54
+ * @returns {Promise<void>}
55
+ */
35
56
  async function closeTunnelQuietly(tunnelConnection) {
36
57
  try {
37
58
  await tunnelConnection.closer();
@@ -40,6 +61,10 @@ async function closeTunnelQuietly(tunnelConnection) {
40
61
  }
41
62
  }
42
63
 
64
+ /**
65
+ * @param {string} udid
66
+ * @returns {void}
67
+ */
43
68
  function stopLifecycleWatch(udid) {
44
69
  const stop = lifecycleWatchStopByUdid.get(udid);
45
70
  if (stop) {
@@ -48,6 +73,58 @@ function stopLifecycleWatch(udid) {
48
73
  }
49
74
  }
50
75
 
76
+ /**
77
+ *
78
+ * @param {import('appium-ios-remotexpc').AppleTVTunnelService} tunnelService
79
+ * @param {number} timeoutMs
80
+ * @returns {{ startedAt: number, promise: Promise<AppleTVDevice[]> }}
81
+ */
82
+ function startDevicesDiscovery(tunnelService, timeoutMs) {
83
+ const startedAt = performance.now();
84
+ const promise = tunnelService.discoverDevices({ timeoutMs });
85
+ return { startedAt, promise };
86
+ }
87
+
88
+ /**
89
+ *
90
+ * @param {{ startedAt: number, promise: Promise<AppleTVDevice[]> }} discovery
91
+ * @param {number} timeoutMs
92
+ * @returns {Promise<AppleTVDevice[]>}
93
+ */
94
+ async function waitForDevicesDiscovery(discovery, timeoutMs) {
95
+ const progress = startTimeoutProgressLogger({
96
+ log,
97
+ label: 'Waiting for wireless Apple TV devices discovery',
98
+ startedAt: discovery.startedAt,
99
+ timeoutMs,
100
+ barWidth: APPLETV_TUNNEL_DISCOVERY_PROGRESS_BAR_WIDTH,
101
+ intervalMs: APPLETV_TUNNEL_DISCOVERY_PROGRESS_INTERVAL_MS,
102
+ });
103
+
104
+ try {
105
+ const devices = await discovery.promise;
106
+ progress.succeed(
107
+ `Wireless Apple TV devices discovery completed: ${util.pluralize('device', devices.length, true)} found`,
108
+ );
109
+ return devices;
110
+ } catch (err) {
111
+ progress.fail('Wireless Apple TV devices discovery failed');
112
+ throw err;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * @param {unknown} err
118
+ * @returns {boolean}
119
+ */
120
+ function isNoDevicesFoundError(err) {
121
+ return err instanceof Error && ('code' in err && err.code === 'NO_DEVICES') || /** @type {Error} */ (err).message?.includes('No devices found');
122
+ }
123
+
124
+ /**
125
+ * @param {AppleTvEstablishedTunnel} result
126
+ * @returns {void}
127
+ */
51
128
  function registerEstablishedTunnel(result) {
52
129
  const udid = result.device.identifier;
53
130
  stopLifecycleWatch(udid);
@@ -61,41 +138,15 @@ function registerEstablishedTunnel(result) {
61
138
  }
62
139
  establishedTunnelsByUdid.set(udid, result);
63
140
  }
64
- let tunnelService = null;
65
-
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
141
 
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
- }
142
+ /** @type {import('appium-ios-remotexpc').AppleTVTunnelService | null} */
143
+ let tunnelService = null;
95
144
 
96
145
  /**
97
- * @param {object} registry
146
+ * @param {import('appium-ios-remotexpc').TunnelRegistry} registry
98
147
  * @param {AppleTvEstablishedTunnel[]} successfulResults
148
+ * @param {object} callbacks
149
+ * @param {function({ udid: string, address: string }): Promise<void>} [callbacks.onTunnelDead]
99
150
  */
100
151
  function attachAppleTvTunnelRegistryLifecycleWatch(registry, successfulResults, callbacks = {}) {
101
152
  for (const result of successfulResults) {
@@ -129,7 +180,14 @@ function attachAppleTvTunnelRegistryLifecycleWatch(registry, successfulResults,
129
180
  );
130
181
  }
131
182
 
183
+ /**
184
+ * @returns {void}
185
+ */
132
186
  function setupCleanupHandlers() {
187
+ /**
188
+ * @param {string} signal
189
+ * @returns {Promise<void>}
190
+ */
133
191
  const cleanup = async (signal) => {
134
192
  log.warn(`\nCleaning up (${signal})...`);
135
193
 
@@ -166,25 +224,27 @@ function setupCleanupHandlers() {
166
224
 
167
225
  process.on('SIGINT', async () => {
168
226
  await cleanup('SIGINT (Ctrl+C)');
169
- process.exit(0);
227
+ process.exit(process.exitCode || 0);
170
228
  });
171
229
  process.on('SIGTERM', async () => {
172
230
  await cleanup('SIGTERM');
173
- process.exit(0);
231
+ process.exit(process.exitCode || 0);
174
232
  });
175
233
  process.on('SIGHUP', async () => {
176
234
  await cleanup('SIGHUP');
177
- process.exit(0);
235
+ process.exit(process.exitCode || 0);
178
236
  });
179
237
 
180
238
  process.on('uncaughtException', async (error) => {
181
239
  log.error('Uncaught Exception:', error);
182
240
  await cleanup('Uncaught Exception');
241
+ process.exit(process.exitCode || 1);
183
242
  });
184
243
 
185
244
  process.on('unhandledRejection', async (reason, promise) => {
186
245
  log.error('Unhandled Rejection at:', promise, 'reason:', reason);
187
246
  await cleanup('Unhandled Rejection');
247
+ process.exit(process.exitCode || 1);
188
248
  });
189
249
  }
190
250
 
@@ -194,6 +254,7 @@ function setupCleanupHandlers() {
194
254
  */
195
255
  async function establishOneTunnel(startResult) {
196
256
  const { tcpSocket, psk, device } = startResult;
257
+ /** @type {{ notify: ((reason: string) => void) | null }} */
197
258
  const lifecycle = { notify: null };
198
259
 
199
260
  const tunnelConnection = await TunnelManager.getTunnelPsk(
@@ -211,6 +272,10 @@ async function establishOneTunnel(startResult) {
211
272
  RsdPort: tunnelConnection.RsdPort,
212
273
  },
213
274
  tunnelConnection,
275
+ /**
276
+ * @param {(reason: string) => void} handler
277
+ * @returns {void}
278
+ */
214
279
  registerOnDead: (handler) => {
215
280
  lifecycle.notify = handler;
216
281
  },
@@ -220,21 +285,14 @@ async function establishOneTunnel(startResult) {
220
285
  return result;
221
286
  }
222
287
 
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) {
288
+ /**
289
+ * @param {AppleTvEstablishedTunnel} result
290
+ * @param {import('appium-ios-remotexpc').TunnelRegistryEntry | undefined} existing
291
+ * @param {number} now
292
+ * @returns {import('appium-ios-remotexpc').TunnelRegistryEntry}
293
+ */
294
+ function buildAppleTvTunnelEntry(result, existing, now) {
236
295
  const udid = result.device.identifier;
237
- const now = Date.now();
238
296
  const entry = {
239
297
  udid,
240
298
  deviceId: 0,
@@ -249,47 +307,31 @@ function buildAppleTvTunnelEntryBase(result, existing) {
249
307
  return entry;
250
308
  }
251
309
 
310
+ /**
311
+ *
312
+ * @param {AppleTvEstablishedTunnel} result
313
+ * @returns {Promise<boolean>}
314
+ */
252
315
  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));
316
+ return await publishTunnelRegistryEntry({
317
+ registryServer,
318
+ result,
319
+ getUdid: (r) => r.device.identifier,
320
+ buildEntry: buildAppleTvTunnelEntry,
321
+ log,
322
+ });
291
323
  }
292
324
 
325
+ /**
326
+ *
327
+ * @param {object} opts
328
+ * @param {string} opts.udid
329
+ * @param {number} opts.maxRetries
330
+ * @param {import('appium-ios-remotexpc').AppleTVTunnelService} opts.tunnelService
331
+ * @param {function(string): Promise<void>} opts.reconnectTunnelByUdid
332
+ * @param {number} opts.discoveryTimeoutMs
333
+ * @returns {Promise<void>}
334
+ */
293
335
  async function runAppleTvReconnectAttempts({
294
336
  udid,
295
337
  maxRetries,
@@ -335,6 +377,14 @@ async function runAppleTvReconnectAttempts({
335
377
  log.error(`Reconnect retries exhausted for ${udid}`);
336
378
  }
337
379
 
380
+ /**
381
+ *
382
+ * @param {object} opts
383
+ * @param {number} opts.reconnectRetries
384
+ * @param {import('appium-ios-remotexpc').AppleTVTunnelService} opts.tunnelService
385
+ * @param {number} opts.discoveryTimeoutMs
386
+ * @returns {function(string): Promise<void>}
387
+ */
338
388
  function createAppleTvReconnectTunnelByUdid({
339
389
  reconnectRetries,
340
390
  tunnelService,
@@ -382,17 +432,17 @@ async function main() {
382
432
  .option(
383
433
  '--tunnel-registry-port <port>',
384
434
  `Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
385
- parsePort,
435
+ (value) => parsePortOption(value, 'tunnel registry port'),
386
436
  )
387
437
  .option(
388
438
  '--reconnect-retries <count>',
389
439
  'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
390
- parseNonNegativeInteger,
440
+ (value) => parseNonNegativeIntegerOption(value, 'retry count'),
391
441
  )
392
442
  .option(
393
443
  '--discovery-timeout <ms>',
394
444
  'Apple TV discovery timeout in milliseconds',
395
- parsePositiveInteger,
445
+ (value) => parsePositiveIntegerOption(value, 'discovery timeout'),
396
446
  );
397
447
 
398
448
  program.parse(process.argv);
@@ -401,6 +451,8 @@ async function main() {
401
451
  const registryPort =
402
452
  options.tunnelRegistryPort ?? DEFAULT_TUNNEL_REGISTRY_PORT;
403
453
 
454
+ await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
455
+
404
456
  if (deviceIdentifier) {
405
457
  log.info(`Targeting a single Apple TV: ${deviceIdentifier}`);
406
458
  } else {
@@ -413,18 +465,12 @@ async function main() {
413
465
 
414
466
  try {
415
467
  const readiness = new TunnelReadinessCoordinator();
416
- const registry = {
417
- tunnels: {},
418
- metadata: {
419
- lastUpdated: new Date().toISOString(),
420
- totalTunnels: 0,
421
- activeTunnels: 0,
422
- },
423
- };
468
+ const registry = createEmptyTunnelRegistry();
424
469
 
425
470
  registryServer = await startTunnelRegistryServer(registry, registryPort, {
426
471
  readiness,
427
- refreshServices: refreshServiceCatalog,
472
+ refreshServices: async (udid, entry) =>
473
+ await refreshServiceCatalog(udid, entry, log),
428
474
  });
429
475
 
430
476
  const reconnectTunnelByUdid = createAppleTvReconnectTunnelByUdid({
@@ -460,11 +506,27 @@ async function main() {
460
506
  },
461
507
  );
462
508
  } else {
463
- const devices = await tunnelService.discoverDevices({
464
- timeoutMs: options.discoveryTimeout,
465
- });
509
+ const discoveryTimeoutMs = options.discoveryTimeout ?? DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS;
510
+ const discovery = startDevicesDiscovery(
511
+ tunnelService,
512
+ discoveryTimeoutMs,
513
+ );
514
+ /** @type {AppleTVDevice[]} */
515
+ let devices = [];
516
+ try {
517
+ devices = await waitForDevicesDiscovery(
518
+ discovery,
519
+ discoveryTimeoutMs,
520
+ );
521
+ } catch (err) {
522
+ if (isNoDevicesFoundError(err)) {
523
+ log.info('No wireless Apple TV devices found');
524
+ process.exit(process.exitCode || 0);
525
+ }
526
+ throw err;
527
+ }
466
528
  log.info(
467
- `Discovered ${devices.length} Apple TV device(s); establishing tunnels...`,
529
+ `Discovered ${util.pluralize('Apple TV device', devices.length, true)}; establishing ${util.pluralize('tunnel', devices.length)}...`,
468
530
  );
469
531
 
470
532
  for (let i = 0; i < devices.length; i++) {
@@ -536,8 +598,12 @@ async function main() {
536
598
  );
537
599
  }
538
600
 
539
- log.info('\n✅ Apple TV tunnel(s) ready.');
540
- log.info('\nPress Ctrl+C to close tunnel(s) and exit.');
601
+ log.info(
602
+ `\n✅ ${registryPublished.length} Apple TV ${util.pluralize('tunnel', registryPublished.length)} ready.`,
603
+ );
604
+ log.info(
605
+ `\nPress Ctrl+C to close ${util.pluralize('tunnel', registryPublished.length)} and exit.`,
606
+ );
541
607
 
542
608
  process.stdin.resume();
543
609
  } catch (error) {
@@ -557,3 +623,7 @@ await main();
557
623
  * @property {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
558
624
  * @property {(handler: (reason: string) => void) => void} registerOnDead
559
625
  */
626
+
627
+ /**
628
+ * @typedef {import('appium-ios-remotexpc').AppleTVDevice} AppleTVDevice
629
+ */
@@ -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
 
@@ -30,6 +40,10 @@ const establishedTunnelsByUdid = new Map();
30
40
  /** @type {Map<string, () => void>} */
31
41
  const lifecycleWatchStopByUdid = new Map();
32
42
 
43
+ /**
44
+ * @param {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
45
+ * @returns {Promise<void>}
46
+ */
33
47
  async function closeTunnelQuietly(tunnelConnection) {
34
48
  try {
35
49
  await tunnelConnection.closer();
@@ -38,6 +52,10 @@ async function closeTunnelQuietly(tunnelConnection) {
38
52
  }
39
53
  }
40
54
 
55
+ /**
56
+ * @param {string} udid
57
+ * @returns {void}
58
+ */
41
59
  function stopLifecycleWatch(udid) {
42
60
  const stop = lifecycleWatchStopByUdid.get(udid);
43
61
  if (stop) {
@@ -46,6 +64,10 @@ function stopLifecycleWatch(udid) {
46
64
  }
47
65
  }
48
66
 
67
+ /**
68
+ * @param {TunnelCreationSuccessResult} result
69
+ * @returns {void}
70
+ */
49
71
  function registerEstablishedTunnel(result) {
50
72
  const udid = result.device.Properties.SerialNumber;
51
73
  stopLifecycleWatch(udid);
@@ -60,6 +82,11 @@ function registerEstablishedTunnel(result) {
60
82
  establishedTunnelsByUdid.set(udid, result);
61
83
  }
62
84
 
85
+ /**
86
+ *
87
+ * @param {string} connectionType
88
+ * @returns {number}
89
+ */
63
90
  function connectionTypeRank(connectionType) {
64
91
  if (connectionType === 'USB') {
65
92
  return 0;
@@ -94,48 +121,25 @@ function dedupeDevicesByUdid(devices) {
94
121
  return [...byUdid.values()];
95
122
  }
96
123
 
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) {
124
+ /**
125
+ *
126
+ * @param {TunnelCreationSuccessResult} result
127
+ * @param {import('appium-ios-remotexpc').TunnelRegistryEntry | undefined} existing
128
+ * @param {number} now
129
+ * @returns {import('appium-ios-remotexpc').TunnelRegistryEntry}
130
+ */
131
+ function buildTunnelEntry(result, existing, now) {
130
132
  const udid = result.device.Properties.SerialNumber;
131
- const now = Date.now();
133
+ /** @type {import('appium-ios-remotexpc').TunnelRegistryEntry} */
132
134
  const entry = {
133
135
  udid,
134
136
  deviceId: result.device.DeviceID,
135
137
  address: result.tunnel.Address,
136
138
  rsdPort: result.tunnel.RsdPort,
137
139
  services: {},
140
+ // @ts-expect-error - connectionType is not typed
138
141
  connectionType: result.device.Properties.ConnectionType,
142
+ // @ts-expect-error - productId is not typed
139
143
  productId: result.device.Properties.ProductID,
140
144
  createdAt: existing?.createdAt ?? now,
141
145
  lastUpdated: now,
@@ -143,43 +147,21 @@ function buildTunnelEntryBase(result, existing) {
143
147
  return entry;
144
148
  }
145
149
 
150
+ /**
151
+ * @param {TunnelCreationSuccessResult} result
152
+ * @returns {Promise<boolean>}
153
+ */
146
154
  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;
155
+ return await publishTunnelRegistryEntry({
156
+ registryServer,
157
+ result,
158
+ getUdid: (r) => r.device.Properties.SerialNumber,
159
+ buildEntry: buildTunnelEntry,
160
+ log,
161
+ });
181
162
  }
182
163
 
164
+ /** @type {(() => void)[]} */
183
165
  const registryWatcherStops = [];
184
166
  /** @type {Map<string, Promise<void>>} */
185
167
  const reconnectingByUdid = new Map();
@@ -187,8 +169,11 @@ const reconnectingByUdid = new Map();
187
169
  /**
188
170
  * When the native forwarder exits, drop the UDID from the HTTP registry and tear down state.
189
171
  *
190
- * @param {object} registry
172
+ * @param {import('appium-ios-remotexpc').TunnelRegistry} registry
191
173
  * @param {TunnelCreationSuccessResult[]} successful
174
+ * @param {object} callbacks
175
+ * @param {function({ udid: string, address: string }): Promise<void>} [callbacks.onTunnelDead]
176
+ * @returns {void}
192
177
  */
193
178
  function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}) {
194
179
  for (const result of successful) {
@@ -222,10 +207,15 @@ function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}
222
207
  );
223
208
  }
224
209
 
225
- function sleep(ms) {
226
- return new Promise((resolve) => setTimeout(resolve, ms));
227
- }
228
-
210
+ /**
211
+ *
212
+ * @param {object} opts
213
+ * @param {string} opts.udid
214
+ * @param {number} opts.maxRetries
215
+ * @param {import('appium-ios-remotexpc').UsbmuxDevice} opts.device
216
+ * @param {function(string): Promise<void>} opts.reconnectTunnelByUdid
217
+ * @returns {Promise<void>}
218
+ */
229
219
  async function runReconnectAttempts({
230
220
  udid,
231
221
  maxRetries,
@@ -247,6 +237,7 @@ async function runReconnectAttempts({
247
237
  registryServer.getRegistry(),
248
238
  [result],
249
239
  {
240
+
250
241
  onTunnelDead: async ({ udid: droppedUdid }) => {
251
242
  await reconnectTunnelByUdid(droppedUdid);
252
243
  },
@@ -265,6 +256,12 @@ async function runReconnectAttempts({
265
256
  log.error(`Reconnect retries exhausted for ${udid}`);
266
257
  }
267
258
 
259
+ /**
260
+ * @param {object} opts
261
+ * @param {number} opts.reconnectRetries
262
+ * @param {Map<string, import('appium-ios-remotexpc').UsbmuxDevice>} opts.devicesByUdid
263
+ * @returns {function(string): Promise<void>}
264
+ */
268
265
  function createReconnectTunnelByUdid({
269
266
  reconnectRetries,
270
267
  devicesByUdid,
@@ -299,7 +296,14 @@ function createReconnectTunnelByUdid({
299
296
  };
300
297
  }
301
298
 
299
+ /**
300
+ * @returns {void}
301
+ */
302
302
  function setupCleanupHandlers() {
303
+ /**
304
+ * @param {string} signal
305
+ * @returns {Promise<void>}
306
+ */
303
307
  const cleanup = async (signal) => {
304
308
  log.warn(`\nCleaning up (${signal})...`);
305
309
 
@@ -328,28 +332,34 @@ function setupCleanupHandlers() {
328
332
 
329
333
  process.on('SIGINT', async () => {
330
334
  await cleanup('SIGINT (Ctrl+C)');
331
- process.exit(0);
335
+ process.exit(process.exitCode || 0);
332
336
  });
333
337
  process.on('SIGTERM', async () => {
334
338
  await cleanup('SIGTERM');
335
- process.exit(0);
339
+ process.exit(process.exitCode || 0);
336
340
  });
337
341
  process.on('SIGHUP', async () => {
338
342
  await cleanup('SIGHUP');
339
- process.exit(0);
343
+ process.exit(process.exitCode || 0);
340
344
  });
341
345
 
342
346
  process.on('uncaughtException', async (error) => {
343
347
  log.error('Uncaught Exception:', error);
344
348
  await cleanup('Uncaught Exception');
349
+ process.exit(process.exitCode || 1);
345
350
  });
346
351
 
347
352
  process.on('unhandledRejection', async (reason, promise) => {
348
353
  log.error('Unhandled Rejection at:', promise, 'reason:', reason);
349
354
  await cleanup('Unhandled Rejection');
355
+ process.exit(process.exitCode || 1);
350
356
  });
351
357
  }
352
358
 
359
+ /**
360
+ * @param {import('appium-ios-remotexpc').UsbmuxDevice} device
361
+ * @returns {Promise<TunnelCreationSuccessResult>}
362
+ */
353
363
  async function createTunnelForDevice(device) {
354
364
  const udid = device.Properties.SerialNumber;
355
365
 
@@ -375,6 +385,7 @@ async function createTunnelForDevice(device) {
375
385
  log.info('CoreDeviceProxy started successfully');
376
386
 
377
387
  log.info(`Creating tunnel...`);
388
+ /** @type {{ notify: ((reason: string) => void) | null }} */
378
389
  const lifecycle = { notify: null };
379
390
  const tunnelConnection = await TunnelManager.getTunnel(
380
391
  socket,
@@ -391,7 +402,9 @@ async function createTunnelForDevice(device) {
391
402
  log.info(` Tunnel Address: ${tunnelConnection.Address}`);
392
403
  log.info(` Tunnel RsdPort: ${tunnelConnection.RsdPort}`);
393
404
 
405
+ /** @type {TunnelCreationSuccessResult} */
394
406
  const result = {
407
+ // @ts-expect-error - device is not typed
395
408
  device,
396
409
  tunnel: {
397
410
  Address: tunnelConnection.Address,
@@ -399,6 +412,10 @@ async function createTunnelForDevice(device) {
399
412
  },
400
413
  success: true,
401
414
  tunnelConnection,
415
+ /**
416
+ * @param {(reason: string) => void} handler
417
+ * @returns {void}
418
+ */
402
419
  registerOnDead: (handler) => {
403
420
  lifecycle.notify = handler;
404
421
  },
@@ -410,6 +427,7 @@ async function createTunnelForDevice(device) {
410
427
  const errorMessage = `Failed to create tunnel for device ${udid}: ${error}`;
411
428
  log.error(`❌ ${errorMessage}`);
412
429
  return {
430
+ // @ts-expect-error - device is not typed
413
431
  device,
414
432
  tunnel: { Address: '', RsdPort: 0 },
415
433
  success: false,
@@ -418,6 +436,9 @@ async function createTunnelForDevice(device) {
418
436
  }
419
437
  }
420
438
 
439
+ /**
440
+ * @returns {Promise<void>}
441
+ */
421
442
  async function main() {
422
443
  setupCleanupHandlers();
423
444
 
@@ -433,18 +454,20 @@ async function main() {
433
454
  .option(
434
455
  '--tunnel-registry-port <port>',
435
456
  `Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
436
- parsePort,
457
+ (value) => parsePortOption(value, 'tunnel registry port'),
437
458
  )
438
459
  .option(
439
460
  '--reconnect-retries <count>',
440
461
  'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
441
- parseNonNegativeInteger,
462
+ (value) => parseNonNegativeIntegerOption(value, 'retry count'),
442
463
  );
443
464
 
444
465
  program.parse(process.argv);
445
466
  const options = program.opts();
446
467
  const specificUdid = options.udid ?? program.args[0] ?? undefined;
447
468
 
469
+ await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
470
+
448
471
  if (specificUdid) {
449
472
  log.info(
450
473
  `Starting tunnel creation test for specific UDID: ${specificUdid}`,
@@ -476,7 +499,7 @@ async function main() {
476
499
  process.exit(0);
477
500
  }
478
501
 
479
- log.info(`Found ${devices.length} connected device(s):`);
502
+ log.info(`Found ${util.pluralize('connected device', devices.length, true)}:`);
480
503
  devices.forEach((device, index) => {
481
504
  log.info(` ${index + 1}. UDID: ${device.Properties.SerialNumber}`);
482
505
  log.info(` Device ID: ${device.DeviceID}`);
@@ -506,25 +529,19 @@ async function main() {
506
529
  devicesToProcess = dedupeDevicesByUdid(devicesToProcess);
507
530
  if (devicesToProcess.length < beforeDedupe) {
508
531
  log.info(
509
- `Deduped ${beforeDedupe} usbmux entries to ${devicesToProcess.length} device(s) (USB preferred over Network)`,
532
+ `Deduped ${util.pluralize('usbmux entry', beforeDedupe, true)} to ${util.pluralize('device', devicesToProcess.length, true)} (USB preferred over Network)`,
510
533
  );
511
534
  }
512
535
 
513
- log.info(`\nProcessing ${devicesToProcess.length} device(s)...`);
536
+ log.info(`\nProcessing ${util.pluralize('device', devicesToProcess.length, true)}...`);
514
537
 
515
538
  const readiness = new TunnelReadinessCoordinator();
516
- const registry = {
517
- tunnels: {},
518
- metadata: {
519
- lastUpdated: new Date().toISOString(),
520
- totalTunnels: 0,
521
- activeTunnels: 0,
522
- },
523
- };
539
+ const registry = createEmptyTunnelRegistry();
524
540
 
525
541
  registryServer = await startTunnelRegistryServer(registry, registryPort, {
526
542
  readiness,
527
- refreshServices: refreshServiceCatalog,
543
+ refreshServices: async (udid, entry) =>
544
+ await refreshServiceCatalog(udid, entry, log),
528
545
  });
529
546
 
530
547
  const reconnectRetries = options.reconnectRetries;
@@ -548,6 +565,10 @@ async function main() {
548
565
  registryServer.getRegistry(),
549
566
  [result],
550
567
  {
568
+ /**
569
+ * @param {{ udid: string }} ctx
570
+ * @returns {Promise<void>}
571
+ */
551
572
  onTunnelDead: async ({ udid }) => {
552
573
  await reconnectTunnelByUdid(udid);
553
574
  },
@@ -560,19 +581,19 @@ async function main() {
560
581
  }
561
582
 
562
583
  if (devicesToProcess.length > 1) {
563
- await new Promise((resolve) => setTimeout(resolve, 1000));
584
+ await sleep(1000);
564
585
  }
565
586
  }
566
587
 
567
588
  log.info('\n=== TUNNEL CREATION SUMMARY ===');
568
589
  const failed = results.filter((r) => !r.success);
569
590
 
570
- log.info(`Total devices processed: ${results.length}`);
571
- log.info(`Successful tunnels: ${successful.length}`);
572
- log.info(`Failed tunnels: ${failed.length}`);
591
+ log.info(`Total ${util.pluralize('device', results.length, true)} processed`);
592
+ log.info(`Successful ${util.pluralize('tunnel', successful.length, true)}`);
593
+ log.info(`Failed ${util.pluralize('tunnel', failed.length, true)}`);
573
594
 
574
595
  if (successful.length > 0) {
575
- log.info('\n✅ Successful tunnels:');
596
+ log.info(`\n✅ Successful ${util.pluralize('tunnel', successful.length)}:`);
576
597
  log.info('\n📁 Tunnel registry API:');
577
598
  log.info(' The tunnel registry is now available through the API at:');
578
599
  log.info(` http://localhost:${registryPort}/remotexpc/tunnels`);
@@ -610,8 +631,9 @@ await main();
610
631
  * Successful tunnel row (USB lockdown + CoreDeviceProxy) used for the registry and lifecycle watch.
611
632
  *
612
633
  * @typedef {object} TunnelCreationSuccessResult
613
- * @property {{ Properties: { SerialNumber: string }, DeviceID: number }} device
634
+ * @property {{ Properties: { SerialNumber: string, [key: string]: unknown }, DeviceID: number }} device
614
635
  * @property {{ Address: string, RsdPort?: number }} tunnel
615
636
  * @property {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
616
637
  * @property {(handler: (reason: string) => void) => void} registerOnDead
638
+ * @property {boolean} success
617
639
  */