UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

218 lines (198 loc) 7.47 kB
import type {AppiumLogger} from '@appium/types'; import {utilities} from 'appium-ios-device'; import type {LockdownService} from 'appium-ios-remotexpc'; import type {LockdownInfo} from '../commands/types'; import {log as defaultLogger} from '../logger'; import {isRemoteXPCUnavailableError, type RemoteXPCFacade} from './remote-xpc'; /** * 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; } interface CreateLockdownClientOptions { allowLegacyFallback?: boolean; facade?: RemoteXPCFacade | null; logger?: AppiumLogger; } /** * Unified lockdown access for real devices. * * On iOS/tvOS 18+ attempts a tunnel registry RemoteXPC connection and lockdown over RSD * (`createLockdownServiceForTunnel`). 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 strategy: 'ios-device' | 'remotexpc-usbmux' | 'remotexpc-tunnel', private readonly remoteXPCFacade: RemoteXPCFacade | null = null, ) {} /** * @param udid - Device UDID * @param opts - Creation options */ static async createForDevice(udid: string, opts: CreateLockdownClientOptions = {}): Promise<LockdownClient> { const {allowLegacyFallback = true, facade = null, logger = defaultLogger} = opts; if (!facade?.eligible) { if (!allowLegacyFallback) { throw new Error( `RemoteXPC lockdown is required for '${udid}', but this session is not eligible ` + `for RemoteXPC.`, ); } return new LockdownClient(udid, logger, 'ios-device'); } try { const strategy = await facade.resolveLockdownStrategy(); return new LockdownClient(udid, logger, strategy, facade); } catch (err) { if (!isRemoteXPCUnavailableError(err)) { throw err; } if (!allowLegacyFallback) { throw err; } logger.warn('RemoteXPC lockdown is not available. Using appium-ios-device lockdown (legacy fallback).'); return new LockdownClient(udid, logger, 'ios-device'); } } 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> { // Tunnel strategy opens lockdown per operation; nothing to close here. } /** * 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: LockdownService): 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'); } } private requireRemoteXPCFacade(): RemoteXPCFacade { if (!this.remoteXPCFacade) { throw new Error(`RemoteXPC lockdown is not configured for '${this.udid}'.`); } return this.remoteXPCFacade; } /** * 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: LockdownService) => Promise<T | undefined>, ): Promise<T | undefined> { return await this.requireRemoteXPCFacade().withUsbMuxLockdown(fn); } private async runWithRemotexpcLockdown<T>( fn: (lockdown: LockdownService) => 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: LockdownService) => 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'; } private async runWithTunnelLockdown<T>( fn: (lockdown: LockdownService) => Promise<T | undefined>, ): Promise<T | undefined> { return await this.requireRemoteXPCFacade().withTunnelLockdown(fn); } }