appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
270 lines (251 loc) • 9 kB
text/typescript
import type {AppiumLogger} from '@appium/types';
import {utilities} from 'appium-ios-device';
import type {RemoteXpcConnection} from 'appium-ios-remotexpc';
import type {LockdownInfo} from '../commands/types';
import type {XCUITestDriverOpts} from '../driver';
import {log as defaultLogger} from '../logger';
import {isIos18OrNewer} from '../utils';
import {
getLastRemoteXPCOptionalImportError,
tryGetRemoteXPCUsbMuxStrategy,
type RemoteXPCEsmModule,
} from './remotexpc-utils';
/**
* Shape returned by {@linkcode utilities.getDeviceTime} in appium-ios-device.
*/
export interface DeviceTimeLockdownFields {
/** UTC timestamp in seconds since 1970-01-01T00:00:00Z */
timestamp: number;
/** UTC offset in minutes */
utcOffset: number;
}
type LockdownServiceInstance = Awaited<
ReturnType<RemoteXPCEsmModule['createLockdownServiceByTunnel']>
>;
/**
* Unified lockdown access for real devices.
*
* On iOS/tvOS 18+ attempts a tunnel registry RemoteXPC connection and lockdown over RSD
* (`createLockdownServiceByTunnel`). When that path is unavailable, uses
* {@linkcode utilities} from `appium-ios-device` (USB/local usbmux).
*/
export class LockdownClient {
private constructor(
private readonly udid: string,
private readonly log: AppiumLogger,
private readonly remotexpc: RemoteXPCEsmModule | null,
private readonly strategy: 'ios-device' | 'remotexpc-usbmux' | 'remotexpc-tunnel',
private readonly remoteXpcConnection: RemoteXpcConnection | null,
) {}
/**
* @param udid - Device UDID
* @param opts - Driver options (used for iOS version gating)
* @param log - Logger
*/
static async createForDevice(
udid: string,
opts: XCUITestDriverOpts,
log: AppiumLogger = defaultLogger,
): Promise<LockdownClient> {
if (!isIos18OrNewer(opts)) {
return new LockdownClient(udid, log, null, 'ios-device', null);
}
const resolved = await tryGetRemoteXPCUsbMuxStrategy(udid, log);
if (!resolved) {
const err = getLastRemoteXPCOptionalImportError();
log.warn(
`appium-ios-remotexpc unavailable for lockdown on '${udid}': ${err?.message ?? 'unknown'}. ` +
`Using appium-ios-device lockdown (legacy fallback).`,
);
return new LockdownClient(udid, log, null, 'ios-device', null);
}
const {remotexpc, useUsbMuxPath} = resolved;
if (useUsbMuxPath) {
return new LockdownClient(udid, log, remotexpc, 'remotexpc-usbmux', null);
}
if (typeof remotexpc.createLockdownServiceByTunnel !== 'function') {
throw new Error(
`appium-ios-remotexpc does not provide createLockdownServiceByTunnel for tunnel-only ` +
`device '${udid}'. Please upgrade appium-ios-remotexpc.`,
);
}
const {remoteXPC} = await remotexpc.Services.createRemoteXPCConnection(udid);
return new LockdownClient(udid, log, remotexpc, 'remotexpc-tunnel', remoteXPC);
}
private static coerceFiniteNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'bigint') {
const converted = Number(value);
return Number.isFinite(converted) ? converted : undefined;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
async close(): Promise<void> {
if (this.remoteXpcConnection) {
try {
await this.remoteXpcConnection.close();
} catch {
// ignore
}
}
}
/**
* Full lockdown `GetValue` payload (`GetValue` with no key/domain).
*/
async getDeviceInfo(): Promise<LockdownInfo> {
if (this.strategy === 'ios-device') {
return await utilities.getDeviceInfo(this.udid);
}
return (await this.runWithRemotexpcLockdownRequiringValue(
(lockdown) => lockdown.getDeviceInfo(),
'device info payload',
)) as LockdownInfo;
}
/**
* Device ProductVersion from lockdown.
*
* Uses the same lockdown selection strategy as {@linkcode getDeviceInfo}.
* If a RemoteXPC lockdown payload does not include ProductVersion, throws.
*/
async getOSVersion(): Promise<string> {
if (this.strategy === 'ios-device') {
return await utilities.getOSVersion(this.udid);
}
return await this.runWithRemotexpcLockdownRequiringValue(
(lockdown) => lockdown.getProductVersion(),
'ProductVersion',
);
}
/**
* Fields needed to format device local time (same contract as {@linkcode utilities.getDeviceTime}).
*/
async getDeviceTimeFields(): Promise<DeviceTimeLockdownFields> {
const readTimeFromLockdown = async (
lockdown: LockdownServiceInstance,
): Promise<DeviceTimeLockdownFields | undefined> => {
const info = await lockdown.getDeviceInfo();
const timestamp = LockdownClient.coerceFiniteNumber(info.TimeIntervalSince1970);
const tzOffsetSeconds = LockdownClient.coerceFiniteNumber(info.TimeZoneOffsetFromUTC);
if (timestamp === undefined || tzOffsetSeconds === undefined) {
return undefined;
}
return {timestamp, utcOffset: tzOffsetSeconds / 60};
};
switch (this.strategy) {
case 'ios-device': {
const {timestamp, utcOffset, timeZone} = await utilities.getDeviceTime(this.udid);
return {
timestamp,
utcOffset: this.normalizeUtcOffsetMinutes(utcOffset, timeZone),
};
}
case 'remotexpc-usbmux':
case 'remotexpc-tunnel':
return await this.runWithRemotexpcLockdownRequiringValue(
readTimeFromLockdown,
'device time fields',
);
}
}
/**
* Legacy ios-device can provide inconsistent offset payloads. Normalize to a final offset in
* minutes for consumers.
*/
private normalizeUtcOffsetMinutes(utcOffset: number, timeZone: string | number): number {
// Normal/expected: offset already in minutes.
if (Math.abs(utcOffset) <= 12 * 60) {
return utcOffset;
}
// Sometimes `timeZone` is a numeric offset in seconds.
const offsetSeconds = typeof timeZone === 'number' ? timeZone : Number(timeZone);
if (Number.isFinite(offsetSeconds) && Math.abs(offsetSeconds) <= 12 * 60 * 60) {
return offsetSeconds / 60;
}
this.log.warn(
`Did not know how to apply UTC offset from lockdown (utcOffset=${utcOffset}, timeZone=${timeZone}). ` +
`Using UTC.`,
);
return 0;
}
private async runWithRemotexpcUsbmuxLockdown<T>(
fn: (lockdown: LockdownServiceInstance) => Promise<T | undefined>,
): Promise<T | undefined> {
if (!this.remotexpc) {
throw new Error(`appium-ios-remotexpc module is not initialized for '${this.udid}'.`);
}
try {
const {lockdownService} = await this.remotexpc.createLockdownServiceByUDID(this.udid);
try {
return await fn(lockdownService);
} finally {
lockdownService.close();
}
} catch (err) {
throw new Error(
`Failed to read lockdown via appium-ios-remotexpc USBMUX path for '${this.udid}': ` +
`${(err as Error).message}`,
{cause: err},
);
}
}
private async runWithRemotexpcLockdown<T>(
fn: (lockdown: LockdownServiceInstance) => Promise<T | undefined>,
): Promise<T | undefined> {
switch (this.strategy) {
case 'remotexpc-usbmux':
return await this.runWithRemotexpcUsbmuxLockdown(fn);
case 'remotexpc-tunnel':
return await this.runWithTunnelLockdown(fn);
default:
throw new Error(`RemoteXPC lockdown is not active for '${this.udid}'.`);
}
}
private async runWithRemotexpcLockdownRequiringValue<T>(
fn: (lockdown: LockdownServiceInstance) => Promise<T | undefined>,
valueName: string,
): Promise<T> {
const value = await this.runWithRemotexpcLockdown(fn);
if (!value) {
throw new Error(
`RemoteXPC ${this.getRemotexpcLockdownLabel()} lockdown did not return ${valueName} for '${this.udid}'.`,
);
}
return value;
}
private getRemotexpcLockdownLabel(): 'USB' | 'tunnel' {
return this.strategy === 'remotexpc-usbmux' ? 'USB' : 'tunnel';
}
/**
* Runs an operation with lockdown over the RSD tunnel.
*/
private async runWithTunnelLockdown<T>(
fn: (lockdown: LockdownServiceInstance) => Promise<T | undefined>,
): Promise<T | undefined> {
if (!this.remotexpc || !this.remoteXpcConnection) {
throw new Error(`RemoteXPC tunnel is not initialized for '${this.udid}'.`);
}
try {
const lockdown = await this.remotexpc.createLockdownServiceByTunnel(
this.remoteXpcConnection,
this.udid,
);
try {
return await fn(lockdown);
} finally {
lockdown.close();
}
} catch (err) {
throw new Error(`Tunnel lockdown failed for '${this.udid}': ${(err as Error).message}`, {
cause: err,
});
}
}
}