appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
1,062 lines (964 loc) ⢠36.5 kB
JavaScript
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {strongbox, BaseItem} from '@appium/strongbox';
import {
AppleTVTunnelService,
TunnelManager,
TunnelReadinessCoordinator,
createLockdownServiceByUDID,
createUsbmux,
discoverServices,
servicesToCatalog,
startCoreDeviceProxyTcp,
startTunnelRegistryServer,
TUNNEL_CONTAINER_NAME,
watchTunnelRegistryOnDead,
} from 'appium-ios-remotexpc';
/**
* Test script for creating lockdown service, starting CoreDeviceProxy, and creating tunnel
* This script demonstrates the tunnel creation workflow for all connected devices.
*
* Must be run as root (e.g. sudo appium driver run xcuitest tunnel-creation).
*/
import {logger} from 'appium/support.js';
import {Command} from 'commander';
import {parsePositiveIntegerOption} from './lib/options.mjs';
import {startTimeoutProgressLogger} from './lib/progress.mjs';
import {assertRoot} from './lib/root.mjs';
const log = logger.getLogger('TunnelCreation');
const TUNNEL_REGISTRY_PORT = 'tunnelRegistryPort';
const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
const WIRELESS_APPLETV_DISCOVERY_PROGRESS_INTERVAL_MS = 1000;
const DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS = 10_000;
const WIRELESS_APPLETV_DISCOVERY_PROGRESS_BAR_WIDTH = 24;
/**
* TunnelCreator class for managing tunnel creation and related operations (USB and optional Apple TV over WiFi).
*/
class TunnelCreator {
/**
* @param {{appleTVDiscoveryTimeoutMs?: number}} [opts]
*/
constructor(opts = {}) {
this._tunnelRegistryPort = DEFAULT_TUNNEL_REGISTRY_PORT;
this._appleTVDiscoveryTimeoutMs = opts.appleTVDiscoveryTimeoutMs ?? DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS;
/** @type {import('appium-ios-remotexpc').TunnelRegistryServer | null} */
this._registryServer = null;
/** @type {import('appium-ios-remotexpc').TunnelReadinessCoordinator} */
this._readiness = new TunnelReadinessCoordinator();
/** @type {Map<string, import('appium-ios-remotexpc').UsbmuxDevice>} */
this._usbDevicesByUdid = new Map();
/** @type {Set<string>} */
this._appleTVDeviceIds = new Set();
/** @type {Map<string, EstablishedTunnel>} */
this._establishedTunnelsByUdid = new Map();
/** @type {Map<string, () => void>} */
this._lifecycleWatchStopByUdid = new Map();
/** @type {Array<() => void>} */
this._registryWatcherStops = [];
/** @type {Map<string, Promise<void>>} */
this._reconnectTasks = new Map();
/** @type {Map<string, import('appium-ios-remotexpc').AppleTVTunnelService>} */
this._appletvTunnelServicesByUdid = new Map();
this._isCleaningUp = false;
/** @type {number | null} */
this._disconnectRetryMaxAttempts = null;
this._disconnectRetryIntervalMs = 1000;
}
get tunnelRegistryPort() {
return this._tunnelRegistryPort;
}
get registryServer() {
return this._registryServer;
}
set tunnelRegistryPort(port) {
this._tunnelRegistryPort = port;
}
/**
* @param {number | null} maxAttempts - null disables retries; 0 means unlimited retries
* @param {number} intervalMs
*/
setDisconnectRetryPolicy(maxAttempts, intervalMs) {
this._disconnectRetryMaxAttempts = maxAttempts;
this._disconnectRetryIntervalMs = intervalMs;
}
/**
* @returns {Promise<void>}
*/
async startRegistryServer() {
const registry = {
tunnels: {},
metadata: {
lastUpdated: new Date().toISOString(),
totalTunnels: 0,
activeTunnels: 0,
},
};
this._registryServer = await startTunnelRegistryServer(registry, this._tunnelRegistryPort, {
readiness: this._readiness,
refreshServices: async (udid, entry) => this._refreshServiceCatalog(udid, entry),
});
}
/**
* @param {string} udid
* @param {import('appium-ios-remotexpc').TunnelRegistryEntry} entry
*/
async _refreshServiceCatalog(udid, entry) {
log.info(`Refreshing RSD service catalog for ${udid}...`);
const services = await discoverServices(udid, entry.address, entry.rsdPort);
const now = Date.now();
return {
...entry,
services: servicesToCatalog(services),
catalogUpdatedAt: now,
lastUpdated: now,
};
}
/**
* Cleanup resources for graceful shutdown
*/
async cleanup() {
this._isCleaningUp = true;
log.info('Cleaning up tunnel resources...');
/** @type {Error[]} */
const cleanupErrors = [];
const recordCleanupError = (message, err) => {
const wrapped = err instanceof Error ? err : new Error(String(err));
cleanupErrors.push(new Error(message, {cause: wrapped}));
log.warn(`${message}: ${wrapped.message}`);
};
while (this._registryWatcherStops.length > 0) {
const stop = this._registryWatcherStops.pop();
try {
stop?.();
} catch (err) {
recordCleanupError('Failed to stop tunnel registry watcher', err);
}
}
this._lifecycleWatchStopByUdid.clear();
if (this._registryServer) {
try {
await this._registryServer.stop();
} catch (err) {
recordCleanupError('Failed to stop tunnel registry server', err);
} finally {
this._registryServer = null;
}
}
for (const [udid, established] of this._establishedTunnelsByUdid.entries()) {
try {
if (established.tunnelConnection && typeof established.tunnelConnection.closer === 'function') {
await established.tunnelConnection.closer();
}
} catch (err) {
recordCleanupError(`Failed to close tunnel for ${udid}`, err);
}
}
this._establishedTunnelsByUdid.clear();
for (const [udid, tunnelService] of this._appletvTunnelServicesByUdid.entries()) {
try {
tunnelService.disconnect();
} catch (err) {
recordCleanupError(`Failed to disconnect Apple TV tunnel service for ${udid}`, err);
}
}
this._appletvTunnelServicesByUdid.clear();
try {
await TunnelManager.closeAllTunnels();
} catch (err) {
recordCleanupError('Failed to close managed tunnel(s)', err);
}
await Promise.allSettled([...this._reconnectTasks.values()]);
if (cleanupErrors.length > 0) {
throw new AggregateError(cleanupErrors, 'Tunnel cleanup encountered errors');
}
log.info('Cleanup completed.');
}
/**
* @param {string} udid
* @param {EstablishedTunnel} result
*/
_registerEstablishedTunnel(udid, result) {
this._stopLifecycleWatch(udid);
const previous = this._establishedTunnelsByUdid.get(udid);
if (previous?.tunnelConnection && previous.tunnelConnection !== result.tunnelConnection) {
void closeTunnelQuietly(previous.tunnelConnection);
}
this._establishedTunnelsByUdid.set(udid, result);
}
/**
* @param {string} udid
*/
_stopLifecycleWatch(udid) {
const stop = this._lifecycleWatchStopByUdid.get(udid);
if (stop) {
stop();
this._lifecycleWatchStopByUdid.delete(udid);
}
}
/**
* Create tunnel for a single USB device
* @param {import('appium-ios-remotexpc').UsbmuxDevice} device - Device object
* @returns {Promise<UsbTunnelResult>} Tunnel result
*/
async createUsbTunnelForDevice(device) {
const udid = device.Properties.SerialNumber;
this._usbDevicesByUdid.set(udid, device);
log.info(`\n--- Processing device: ${udid} ---`);
log.info(`Device ID: ${device.DeviceID}`);
log.info(`Connection Type: ${device.Properties.ConnectionType}`);
log.info(`Product ID: ${device.Properties.ProductID}`);
log.info('Creating lockdown service...');
const {lockdownService, device: lockdownDevice} = await createLockdownServiceByUDID(udid);
log.info(`Lockdown service created for device: ${lockdownDevice.Properties.SerialNumber}`);
log.info('Starting CoreDeviceProxy (raw TCP, native OpenSSL forwarder)...');
const {socket, cert, key} = await startCoreDeviceProxyTcp(
lockdownService,
lockdownDevice.DeviceID,
lockdownDevice.Properties.SerialNumber,
);
log.info('CoreDeviceProxy started successfully');
log.info('Creating tunnel...');
/** @type {{ notify: ((reason: string) => void) | null }} */
const lifecycle = {notify: null};
const tunnelConnection = await TunnelManager.getTunnel(
socket,
{cert, key},
{
onDead: (reason) => lifecycle.notify?.(reason),
},
);
log.info(`Tunnel created for address: ${tunnelConnection.Address} with RsdPort: ${tunnelConnection.RsdPort}`);
log.info(`ā
Tunnel creation completed successfully for device: ${udid}`);
log.info(` Tunnel Address: ${tunnelConnection.Address}`);
log.info(` Tunnel RsdPort: ${tunnelConnection.RsdPort}`);
/** @type {UsbTunnelSuccess} */
const result = {
kind: 'usb',
device,
tunnel: {
Address: tunnelConnection.Address,
RsdPort: tunnelConnection.RsdPort,
},
success: true,
tunnelConnection,
registerOnDead: (handler) => {
lifecycle.notify = handler;
},
};
this._registerEstablishedTunnel(udid, result);
return result;
}
/**
* Sets up tunnels for all connected USB devices.
* @param {import('appium-ios-remotexpc').Usbmux} usbmux - The usbmux object.
* @param {string[]|undefined} specificUdids - Specific UDIDs to process, or undefined for all devices.
* @returns {Promise<UsbTunnelResult[]>} USB tunnel results (may be empty).
*/
async setupUsbmuxTunnels(usbmux, specificUdids) {
log.info('Listing all connected devices...');
const devices = this._dedupeUsbmuxDevicesByUdid(await usbmux.listDevices());
if (devices.length === 0) {
log.info('No USB devices found.');
return [];
}
log.info(`Found ${devices.length} connected device(s):`);
devices.forEach((device, index) => {
log.info(` ${index + 1}. UDID: ${device.Properties.SerialNumber}`);
log.info(` Device ID: ${device.DeviceID}`);
log.info(` Connection: ${device.Properties.ConnectionType}`);
log.info(` Product ID: ${device.Properties.ProductID}`);
});
let devicesToProcess = devices;
const requestedUdids = specificUdids && specificUdids.length > 0 ? [...new Set(specificUdids)] : null;
if (requestedUdids) {
const requestedUdidSet = new Set(requestedUdids);
devicesToProcess = devices.filter((device) => requestedUdidSet.has(device.Properties.SerialNumber));
if (devicesToProcess.length === 0) {
log.error(`None of the requested UDID(s) were found: ${requestedUdids.join(', ')}`);
log.error('Available devices:');
devices.forEach((device) => {
log.error(` - ${device.Properties.SerialNumber}`);
});
process.exit(1);
}
const foundUdidSet = new Set(devicesToProcess.map((device) => device.Properties.SerialNumber));
const missingUdids = requestedUdids.filter((udid) => !foundUdidSet.has(udid));
if (missingUdids.length > 0) {
log.warn(`Some requested UDID(s) were not found and will be skipped: ${missingUdids.join(', ')}`);
}
}
log.info(`\nProcessing ${devicesToProcess.length} device(s)...`);
/** @type {UsbTunnelResult[]} */
const results = [];
for (const device of devicesToProcess) {
try {
const result = await this.createUsbTunnelForDevice(device);
results.push(result);
} catch (err) {
const errorMessage = `Failed to create tunnel for device ${device.Properties.SerialNumber}: ${err}`;
log.error(`ā ${errorMessage}`);
results.push({
kind: 'usb',
device,
tunnel: {Address: '', RsdPort: 0},
success: false,
error: errorMessage,
});
}
}
return results;
}
/**
* Sets up tunnel(s) for paired Apple TV device(s) over WiFi.
* @param {string[]|undefined} [specificDeviceIds] - Optional Apple TV device identifiers to target.
* @param {AppleTVDevice[] | null} [prefetchedDevices] - Optional prefetched Apple TV devices.
* @returns {Promise<AppleTVTunnelResult[]>} Apple TV tunnel results.
*/
async setupAppleTVTunnels(specificDeviceIds, prefetchedDevices = null) {
/** @type {AppleTVTunnelResult[]} */
const results = [];
try {
if ((!specificDeviceIds || specificDeviceIds.length === 0) && prefetchedDevices === null) {
log.info('Skipping Apple TV tunnel setup because wireless discovery did not find any devices.');
return results;
}
const prefetchedDevicesById = new Map((prefetchedDevices ?? []).map((device) => [device.identifier, device]));
const discoveredDeviceIds =
specificDeviceIds && specificDeviceIds.length > 0
? [...new Set(specificDeviceIds)]
: [...prefetchedDevicesById.keys()];
log.info('Starting Apple TV tunnel (WiFi)...');
const usbDiscoveredUdidSet = new Set(this._usbDevicesByUdid.keys());
const targetDeviceIds = discoveredDeviceIds.filter((udid) => !usbDiscoveredUdidSet.has(udid));
if (targetDeviceIds.length < discoveredDeviceIds.length) {
log.info(
`Filtered out ${discoveredDeviceIds.length - targetDeviceIds.length} Apple TV candidate(s) already discovered via usbmux.`,
);
}
if (!targetDeviceIds?.length) {
log.info('No paired Apple TV devices discovered after usbmux deduplication.');
return results;
}
for (const deviceId of targetDeviceIds) {
try {
const result = await this._createAppleTVTunnelForUdid(deviceId, prefetchedDevicesById.get(deviceId));
results.push(result);
log.info(`ā
Apple TV tunnel ready for ${deviceId}`);
} catch (err) {
log.warn(`Apple TV tunnel setup failed for ${deviceId}: ${err?.message ?? err}`);
results.push({
kind: 'appletv',
device: {identifier: deviceId},
tunnel: {Address: '', RsdPort: 0},
success: false,
error: String(err?.message ?? err),
});
}
}
return results;
} catch (err) {
log.warn('Apple TV tunnel setup failed (ensure device is paired and on same network):', err?.message ?? err);
return results;
}
}
/**
* @param {string[] | undefined} specificDeviceIds
* @returns {{startedAt: number, timeoutMs: number, promise: Promise<{devices: AppleTVDevice[] | null, error: unknown | null}>}}
*/
prefetchAppleTVDevices(specificDeviceIds) {
const startedAt = performance.now();
if (specificDeviceIds && specificDeviceIds.length > 0) {
return {
startedAt,
timeoutMs: this._appleTVDiscoveryTimeoutMs,
promise: Promise.resolve({devices: null, error: null}),
};
}
const tunnelService = new AppleTVTunnelService();
const promise = (async () => {
try {
const devices = await tunnelService.discoverDevices({
timeoutMs: this._appleTVDiscoveryTimeoutMs,
});
return {devices, error: null};
} catch (err) {
return {devices: null, error: err};
} finally {
try {
tunnelService.disconnect();
} catch {}
}
})();
return {startedAt, timeoutMs: this._appleTVDiscoveryTimeoutMs, promise};
}
/**
* @param {string} udid
* @param {AppleTVDevice | undefined} [prefetchedDevice]
* @returns {Promise<AppleTVTunnelResult>}
*/
async _createAppleTVTunnelForUdid(udid, prefetchedDevice) {
let tunnelService = this._appletvTunnelServicesByUdid.get(udid);
if (!tunnelService) {
tunnelService = new AppleTVTunnelService();
this._appletvTunnelServicesByUdid.set(udid, tunnelService);
}
const startResult = await tunnelService.startTunnel(undefined, udid, {
devices: prefetchedDevice ? [prefetchedDevice] : undefined,
discoveryTimeoutMs: this._appleTVDiscoveryTimeoutMs,
});
if (!startResult.tcpSocket) {
throw new Error('Apple TV TCP socket to listener port not established');
}
log.info(`Creating tunnel for Apple TV: ${startResult.device.identifier}`);
/** @type {{ notify: ((reason: string) => void) | null }} */
const lifecycle = {notify: null};
const tunnelConnection = await TunnelManager.getTunnelPsk(
startResult.tcpSocket,
{psk: startResult.psk},
{
onDead: (reason) => lifecycle.notify?.(reason),
},
);
this._appleTVDeviceIds.add(udid);
/** @type {AppleTVTunnelSuccess} */
const result = {
kind: 'appletv',
device: startResult.device,
tunnel: {
Address: tunnelConnection.Address,
RsdPort: tunnelConnection.RsdPort,
},
success: true,
tunnelConnection,
registerOnDead: (handler) => {
lifecycle.notify = handler;
},
tunnelService,
};
this._registerEstablishedTunnel(udid, result);
return result;
}
/**
* @param {EstablishedTunnel} result
* @returns {Promise<boolean>}
*/
async publishDiscoveredTunnelEntry(result) {
if (!this._registryServer) {
throw new Error('Registry server is not started');
}
const udid = getTunnelUdid(result);
const rsdPort = result.tunnel.RsdPort;
if (typeof rsdPort !== 'number' || rsdPort <= 0) {
log.warn(`Skipping registry entry for ${udid}: no valid RSD port (got ${String(rsdPort)})`);
return false;
}
this._registryServer.markTunnelPending(udid);
log.info(`Discovering RSD services for ${udid} at ${result.tunnel.Address}:${rsdPort}...`);
const services = await discoverServices(udid, result.tunnel.Address, rsdPort);
const now = Date.now();
const registry = this._registryServer.getRegistry();
const existing = registry.tunnels[udid];
const entry = buildTunnelRegistryEntry(result, existing, now);
entry.services = servicesToCatalog(services);
entry.catalogUpdatedAt = now;
this._registryServer.upsertReadyEntry(udid, entry);
log.info(`Published tunnel catalog for ${udid} (${Object.keys(entry.services).length} services)`);
return true;
}
/**
* @param {EstablishedTunnel} result
* @param {(ctx: {udid: string; address: string}) => Promise<void>} [onTunnelDead]
*/
attachTunnelRegistryLifecycleWatch(result, onTunnelDead) {
if (!this._registryServer) {
return false;
}
const udid = getTunnelUdid(result);
this._stopLifecycleWatch(udid);
const {stop} = watchTunnelRegistryOnDead({
registry: this._registryServer.getRegistry(),
watches: [
{
udid,
registerOnDead: result.registerOnDead,
},
],
onRemove: async (removedUdid) => {
this._registryServer?.removeTunnelEntry(removedUdid);
},
onTunnelDead: async ({udid: droppedUdid, address}) => {
if (typeof TunnelManager?.closeTunnelByAddress === 'function') {
await TunnelManager.closeTunnelByAddress(address).catch(() => {});
}
if (onTunnelDead) {
await onTunnelDead({udid: droppedUdid, address});
}
},
});
this._registryWatcherStops.push(stop);
this._lifecycleWatchStopByUdid.set(udid, stop);
return true;
}
/**
* @param {string} udid
*/
_reconnectTunnelByUdid(udid) {
if (this._isCleaningUp) {
return;
}
const existingTask = this._reconnectTasks.get(udid);
if (existingTask) {
return;
}
const task = (async () => {
try {
if (!this._isRetryEnabledForUdid(udid)) {
return;
}
const maxAttempts = this._disconnectRetryMaxAttempts;
if (maxAttempts === null) {
return;
}
const device = this._usbDevicesByUdid.get(udid);
const isAppleTV = this._appleTVDeviceIds.has(udid);
if (!device && !isAppleTV) {
return;
}
let attempt = 0;
while (!this._isCleaningUp) {
if (maxAttempts !== 0 && attempt >= maxAttempts) {
log.warn(`Retry limit reached for ${udid}; keeping it removed from the registry`);
return;
}
attempt += 1;
log.warn(
`Retrying tunnel creation for ${udid} (attempt ${attempt}${maxAttempts === 0 ? ', unlimited' : `/${maxAttempts}`}) in ${this._disconnectRetryIntervalMs}ms...`,
);
await sleep(this._disconnectRetryIntervalMs);
try {
this._registryServer?.markTunnelPending(udid);
const result = device
? await this.createUsbTunnelForDevice(device)
: await this._createAppleTVTunnelForUdid(udid);
this.attachTunnelRegistryLifecycleWatch(result, async ({udid: droppedUdid}) => {
this._reconnectTunnelByUdid(droppedUdid);
});
const published = await this.publishDiscoveredTunnelEntry(result);
if (published) {
log.info(`Successfully recreated tunnel for ${udid}`);
return;
}
} catch (retryErr) {
log.warn(`Failed to recreate tunnel for ${udid}: ${retryErr?.message ?? retryErr}`);
}
}
} catch (taskErr) {
log.warn(`Tunnel loss handling failed for ${udid}: ${taskErr?.message ?? taskErr}`);
} finally {
this._reconnectTasks.delete(udid);
}
})();
this._reconnectTasks.set(udid, task);
}
/**
* @param {string} udid
* @returns {boolean}
*/
_isRetryEnabledForUdid(udid) {
return (
(this._usbDevicesByUdid.has(udid) || this._appleTVDeviceIds.has(udid)) &&
this._disconnectRetryMaxAttempts !== null
);
}
/**
* Deduplicates usbmux responses by UDID (serial number).
* If both wired and wireless entries exist, wired is preferred.
*
* @param {import('appium-ios-remotexpc').UsbmuxDevice[]} devices
* @returns {import('appium-ios-remotexpc').UsbmuxDevice[]}
*/
_dedupeUsbmuxDevicesByUdid(devices) {
/** @type {Map<string, import('appium-ios-remotexpc').UsbmuxDevice>} */
const byUdid = new Map();
for (const device of devices) {
const udid = device?.Properties?.SerialNumber;
if (!udid) {
continue;
}
const existing = byUdid.get(udid);
if (!existing) {
byUdid.set(udid, device);
continue;
}
const existingConn = `${existing?.Properties?.ConnectionType ?? ''}`.toLowerCase();
const nextConn = `${device?.Properties?.ConnectionType ?? ''}`.toLowerCase();
const existingIsWired = existingConn === 'usb' || existingConn === 'wired';
const nextIsWired = nextConn === 'usb' || nextConn === 'wired';
if (!existingIsWired && nextIsWired) {
byUdid.set(udid, device);
}
}
if (byUdid.size !== devices.length) {
log.info(`Deduplicated USB device list by UDID: ${devices.length} -> ${byUdid.size}`);
}
return [...byUdid.values()];
}
}
/**
* @param {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
*/
async function closeTunnelQuietly(tunnelConnection) {
try {
await tunnelConnection.closer();
} catch {
// superseded tunnel may already be stopped
}
}
/**
* @param {EstablishedTunnel} result
* @param {import('appium-ios-remotexpc').TunnelRegistryEntry | undefined} existing
* @param {number} now
*/
function buildTunnelRegistryEntry(result, existing, now) {
if (result.kind === 'usb') {
const device = /** @type {import('appium-ios-remotexpc').UsbmuxDevice} */ (result.device);
return {
udid: device.Properties.SerialNumber,
deviceId: device.DeviceID,
address: result.tunnel.Address,
rsdPort: result.tunnel.RsdPort ?? 0,
services: {},
connectionType: device.Properties.ConnectionType,
productId: device.Properties.ProductID,
createdAt: existing?.createdAt ?? now,
lastUpdated: now,
};
}
const device = /** @type {{ identifier: string, name?: string }} */ (result.device);
return {
udid: device.identifier,
deviceId: 0,
address: result.tunnel.Address,
rsdPort: result.tunnel.RsdPort ?? 0,
services: {},
connectionType: 'WiFi',
productId: 0,
createdAt: existing?.createdAt ?? now,
lastUpdated: now,
};
}
/**
* @param {EstablishedTunnel} result
* @returns {string}
*/
function getTunnelUdid(result) {
if (result.kind === 'usb') {
return /** @type {import('appium-ios-remotexpc').UsbmuxDevice} */ (result.device).Properties.SerialNumber;
}
return /** @type {{ identifier: string }} */ (result.device).identifier;
}
/**
* @param {unknown} err
* @returns {boolean}
*/
function isNoAppleTVDevicesFoundError(err) {
return (err instanceof Error ? err.message : String(err)) === 'No devices found via discovery backend';
}
/**
* @param {{startedAt: number, timeoutMs: number, promise: Promise<{devices: AppleTVDevice[] | null, error: unknown | null}>}} discovery
* @returns {Promise<AppleTVDevice[] | null>}
*/
async function waitForAppleTVDiscovery(discovery) {
const wirelessDiscoveryProgress = startTimeoutProgressLogger({
log,
label: 'Waiting for wireless Apple TV discovery',
startedAt: discovery.startedAt,
timeoutMs: discovery.timeoutMs,
barWidth: WIRELESS_APPLETV_DISCOVERY_PROGRESS_BAR_WIDTH,
intervalMs: WIRELESS_APPLETV_DISCOVERY_PROGRESS_INTERVAL_MS,
});
const {devices, error} = await discovery.promise;
if (error) {
if (isNoAppleTVDevicesFoundError(error)) {
wirelessDiscoveryProgress.succeed('No wireless Apple TV devices found');
} else {
wirelessDiscoveryProgress.fail('Wireless Apple TV discovery failed');
log.warn(error);
}
return null;
}
wirelessDiscoveryProgress.succeed(`Wireless Apple TV discovery completed: ${devices?.length ?? 0} device(s) found`);
return devices;
}
/**
* @param {number} ms
* @returns {Promise<void>}
*/
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* @param {string} value
* @param {string} label
* @returns {number}
*/
function parsePortOption(value, label) {
const port = Number.parseInt(value, 10);
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid ${label}: ${value}. Expected an integer between 1 and 65535.`);
}
return port;
}
/**
* @param {string} value
* @param {string} label
* @returns {number}
*/
function parseNonNegativeIntegerOption(value, label) {
const count = Number.parseInt(value, 10);
if (!Number.isFinite(count) || count < 0) {
throw new Error(`Invalid ${label}: ${value}. Expected an integer >= 0.`);
}
return count;
}
/**
* @param {string} value
* @param {string[]} previous
* @returns {string[]}
*/
function collectStringValues(value, previous = []) {
previous.push(value);
return previous;
}
/**
* Sets up signal and error handlers to ensure tunnels are cleaned up exactly once
* and an appropriate process exit code is set on shutdown.
*
* @param {TunnelCreator} tunnelCreator
* @returns {() => Promise<void>} cleanup function that can be awaited in a finally block
*/
function setupCleanupHandlers(tunnelCreator) {
let shuttingDown = false;
const cleanupOnce = async () => {
if (shuttingDown) {
return;
}
shuttingDown = true;
try {
await tunnelCreator.cleanup();
} catch (err) {
log.warn(`Error during tunnel cleanup: ${err?.message ?? err}`);
if (!process.exitCode) {
process.exitCode = 1;
}
}
};
const cleanupAndExit = async () => {
await cleanupOnce();
process.exit(process.exitCode ?? 1);
};
const shutdownSignals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
for (const signal of shutdownSignals) {
process.on(signal, () => {
if (process.exitCode == null) {
if (signal === 'SIGINT') {
process.exitCode = 0;
} else if (signal === 'SIGTERM') {
process.exitCode = 143;
} else {
process.exitCode = 1;
}
}
void cleanupOnce();
});
}
process.on('unhandledRejection', (reason) => {
log.error('Unhandled promise rejection', reason);
if (process.exitCode == null) {
process.exitCode = 1;
}
void cleanupAndExit();
});
process.on('uncaughtException', (err) => {
log.error('Uncaught exception', err);
if (process.exitCode == null) {
process.exitCode = 1;
}
void cleanupAndExit();
});
return cleanupOnce;
}
async function main() {
const program = new Command();
program
.name('appium driver run xcuitest tunnel-creation')
.description('Create tunnels for connected iOS devices')
.option(
'--udid <udid>',
'UDID of the device to create tunnel for (repeatable). ' +
'If omitted, tunnels are created for all connected devices.',
collectStringValues,
[],
)
.option('--tunnel-registry-port <port>', 'Port for the tunnel registry API server (1-65535)', (value) =>
parsePortOption(value, 'tunnel registry port'),
)
.option(
'--appletv-device-id <identifier>',
'Apple TV device identifier to tunnel (repeatable, from pair-appletv; ' +
'omit to tunnel all discovered paired Apple TVs)',
collectStringValues,
[],
)
.option(
'--appletv-discovery-timeout-ms <ms>',
'Apple TV wireless discovery timeout in milliseconds',
(value) => parsePositiveIntegerOption(value, 'Apple TV discovery timeout'),
DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS,
)
.option(
'--disconnect-retry-max-attempts <count>',
'Max tunnel recreation attempts after unexpected disconnect: 0 = unlimited; omit to disable retries',
(value) => parseNonNegativeIntegerOption(value, 'disconnect retry max attempts'),
)
.option(
'--disconnect-retry-interval-ms <ms>',
'Delay between tunnel recreation attempts in milliseconds (default 1000)',
(value) => parsePositiveIntegerOption(value, 'disconnect retry interval'),
1000,
);
program.parse(process.argv);
const options = program.opts();
const requestedUdids = [...new Set([...(options.udid ?? [])])];
const requestedAppleTVIds = [...new Set([...(options.appletvDeviceId ?? [])])];
const hasRequestedUdids = requestedUdids.length > 0;
const hasRequestedAppleTVIds = requestedAppleTVIds.length > 0;
const shouldRunUsbFlow = !hasRequestedAppleTVIds || hasRequestedUdids;
const shouldRunAppleTVFlow = !hasRequestedUdids || hasRequestedAppleTVIds;
await assertRoot(path.parse(fileURLToPath(import.meta.url)).name);
const tunnelCreator = new TunnelCreator({
appleTVDiscoveryTimeoutMs: options.appletvDiscoveryTimeoutMs,
});
const cleanupOnce = setupCleanupHandlers(tunnelCreator);
try {
const isTunnelRegistryPortSet = options.tunnelRegistryPort !== undefined;
if (isTunnelRegistryPortSet) {
tunnelCreator.tunnelRegistryPort = options.tunnelRegistryPort;
}
const box = strongbox(TUNNEL_CONTAINER_NAME);
const item = new BaseItem(TUNNEL_REGISTRY_PORT, box);
try {
const value = await item.read();
if (!value || isTunnelRegistryPortSet) {
await item.write(String(tunnelCreator.tunnelRegistryPort));
} else {
const persistedPort = Number.parseInt(String(value), 10);
if (persistedPort > 0 && persistedPort < 65536) {
tunnelCreator.tunnelRegistryPort = persistedPort;
}
}
} catch (error) {
throw new Error(`Tunnel registry port cannot be persisted: ${error.message}`, {cause: error});
}
tunnelCreator.setDisconnectRetryPolicy(
options.disconnectRetryMaxAttempts ?? null,
options.disconnectRetryIntervalMs,
);
await tunnelCreator.startRegistryServer();
const appleTVDiscoveryPrefetch =
shouldRunAppleTVFlow && !hasRequestedAppleTVIds
? tunnelCreator.prefetchAppleTVDevices(requestedAppleTVIds)
: null;
if (appleTVDiscoveryPrefetch) {
log.info('Prefetching paired Apple TV devices...');
}
const usbmux = await createUsbmux();
/** @type {UsbTunnelResult[]} */
let usbResults = [];
try {
if (shouldRunUsbFlow) {
log.info('Connecting to usbmuxd...');
usbResults = await tunnelCreator.setupUsbmuxTunnels(usbmux, requestedUdids);
} else {
log.info('Skipping USB tunnel setup because only --appletv-device-id was provided.');
}
} finally {
await usbmux.close();
}
/** @type {AppleTVTunnelResult[]} */
let appletvResults = [];
if (shouldRunAppleTVFlow) {
const prefetchedAppleTVDevices = appleTVDiscoveryPrefetch
? await waitForAppleTVDiscovery(appleTVDiscoveryPrefetch)
: null;
appletvResults = await tunnelCreator.setupAppleTVTunnels(requestedAppleTVIds, prefetchedAppleTVDevices);
} else {
log.info('Skipping Apple TV tunnel setup because only --udid was provided.');
}
const successfulResults = [...usbResults.filter((r) => r.success), ...appletvResults.filter((r) => r.success)];
if (successfulResults.length === 0) {
log.warn('No tunnels created (no USB and no wireless Apple TV devices).');
await cleanupOnce();
return;
}
const publishedResults = [];
for (const result of successfulResults) {
tunnelCreator.attachTunnelRegistryLifecycleWatch(result, async ({udid}) => {
tunnelCreator._reconnectTunnelByUdid(udid);
});
const published = await tunnelCreator.publishDiscoveredTunnelEntry(result);
if (published) {
publishedResults.push(result);
}
}
const successfulUsb = usbResults.filter((r) => r.success);
const successfulAppleTV = appletvResults.filter((r) => r.success);
log.info('\n=== TUNNEL CREATION SUMMARY ===');
log.info(`USB tunnels: ${successfulUsb.length}`);
log.info(`Apple TV (WiFi) tunnels: ${successfulAppleTV.length}`);
log.info(`Published tunnels: ${publishedResults.length}`);
log.info('\nš Tunnel registry API:');
log.info(' The tunnel registry is now available through the API at:');
log.info(` http://localhost:${tunnelCreator.tunnelRegistryPort}/remotexpc/tunnels`);
log.info('\n Available endpoints:');
log.info(' - GET /remotexpc/tunnels - List all tunnels');
log.info(' - GET /remotexpc/tunnels/:udid?waitMs=15000 - Get tunnel (long-poll until catalog ready)');
log.info(' - POST /remotexpc/tunnels/:udid/refresh-services - Re-discover RSD catalog');
log.info(' - GET /remotexpc/tunnels/metadata - Get registry metadata');
const firstUdid = publishedResults.length > 0 ? getTunnelUdid(publishedResults[0]) : undefined;
if (firstUdid) {
log.info(
` curl "http://localhost:${tunnelCreator.tunnelRegistryPort}/remotexpc/tunnels/${firstUdid}?waitMs=15000"`,
);
}
} catch (err) {
log.error('Error during tunnel setup:', err);
process.exitCode = 1;
await cleanupOnce();
process.exit(process.exitCode);
}
}
await main();
/**
* @typedef {Object} EstablishedTunnel
* @property {'usb' | 'appletv'} kind
* @property {{ Properties: { SerialNumber: string }, DeviceID: number } | { identifier: string, name?: string }} device
* @property {{ Address: string, RsdPort?: number }} tunnel
* @property {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
* @property {(handler: (reason: string) => void) => void} registerOnDead
* @property {import('appium-ios-remotexpc').AppleTVTunnelService} [tunnelService]
*/
/**
* @typedef {EstablishedTunnel & { kind: 'usb', device: import('appium-ios-remotexpc').UsbmuxDevice, success: true }} UsbTunnelSuccess
* @typedef {Object & { kind: 'usb', device: import('appium-ios-remotexpc').UsbmuxDevice, success: false, error: string }} UsbTunnelFailure
* @typedef {UsbTunnelSuccess | UsbTunnelFailure} UsbTunnelResult
*/
/**
* @typedef {import('appium-ios-remotexpc').AppleTVDevice} AppleTVDevice
*/
/**
* @typedef {EstablishedTunnel & { kind: 'appletv', device: { identifier: string, name?: string }, success: true, tunnelService: import('appium-ios-remotexpc').AppleTVTunnelService }} AppleTVTunnelSuccess
* @typedef {Object & { kind: 'appletv', device: { identifier: string }, success: false, error: string }} AppleTVTunnelFailure
* @typedef {AppleTVTunnelSuccess | AppleTVTunnelFailure} AppleTVTunnelResult
*/