UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

270 lines (251 loc) 9 kB
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, }); } } }