From 050a415fc93c91695464ac5b40733d184c68f2d8 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Mon, 16 Sep 2024 17:37:52 +0200 Subject: [PATCH] feat: Add go-ios client --- lib/commands/certificate.js | 27 +-- lib/commands/pcap.js | 11 +- lib/device-log/ios-crash-log.ts | 17 +- lib/driver.js | 7 +- lib/real-device-clients/device-client.ts | 25 +++ .../go-ios-device-client.ts | 198 ++++++++++++++++++ .../py-ios-device-client.ts | 18 +- 7 files changed, 263 insertions(+), 40 deletions(-) create mode 100644 lib/real-device-clients/device-client.ts create mode 100644 lib/real-device-clients/go-ios-device-client.ts diff --git a/lib/commands/certificate.js b/lib/commands/certificate.js index baf9768e2..d761447dd 100644 --- a/lib/commands/certificate.js +++ b/lib/commands/certificate.js @@ -7,8 +7,8 @@ import path from 'path'; import http from 'http'; import {exec} from 'teen_process'; import {findAPortNotInUse, checkPortStatus} from 'portscanner'; -import {Pyidevice} from '../real-device-clients/py-ios-device-client'; import {errors} from 'appium/driver'; +import { selectDeviceClient } from '../real-device-clients/device-client'; const CONFIG_EXTENSION = 'mobileconfig'; const HOST_PORT_RANGE = [38200, 38299]; @@ -317,18 +317,15 @@ export default { ); } } else { - const client = new Pyidevice({ - udid: this.opts.udid, - log: this.log, - }); - if (await client.assertExists(false)) { + try { + const client = await selectDeviceClient({ + udid: this.opts.udid, + log: this.log, + }); await client.installProfile({payload: Buffer.from(content, 'base64')}); return; - } else { - this.log.info( - 'pyidevice is not installed on your system. ' + - 'Falling back to the (slow) UI-based installation', - ); + } catch (e) { + this.log.info(`Falling back to the (slow) UI-based installation: ${e.message}`); } } @@ -450,11 +447,10 @@ export default { if (!this.isRealDevice()) { throw new errors.NotImplementedError('This extension is only supported on real devices'); } - const client = new Pyidevice({ + const client = await selectDeviceClient({ udid: this.opts.udid, log: this.log, }); - await client.assertExists(true); return await client.removeProfile(name); }, @@ -472,12 +468,11 @@ export default { if (!this.isRealDevice()) { throw new errors.NotImplementedError('This extension is only supported on real devices'); } - const client = new Pyidevice({ + const client = await selectDeviceClient({ udid: this.opts.udid, log: this.log, }); - await client.assertExists(true); - return await client.listProfiles(); + return /** @type {Promise} */ (await client.listProfiles()); }, }; diff --git a/lib/commands/pcap.js b/lib/commands/pcap.js index 8b156d2e5..36a97255a 100644 --- a/lib/commands/pcap.js +++ b/lib/commands/pcap.js @@ -1,7 +1,7 @@ -import { Pyidevice } from '../real-device-clients/py-ios-device-client'; import {fs, tempDir, util} from 'appium/support'; import {encodeBase64OrUpload} from '../utils'; import {errors} from 'appium/driver'; +import { selectDeviceClient } from '../real-device-clients/device-client'; const MAX_CAPTURE_TIME_SEC = 60 * 60 * 12; const DEFAULT_EXT = '.pcap'; @@ -17,11 +17,12 @@ export class TrafficCapture { } async start(timeoutSeconds) { + const client = await selectDeviceClient({ + udid: this.udid, + log: this.log, + }); this.mainProcess = /** @type {import('teen_process').SubProcess} */ ( - await new Pyidevice({ - udid: this.udid, - log: this.log, - }).collectPcap(this.resultPath) + await client.collectPcap(this.resultPath) ); this.mainProcess.on('line-stderr', (line) => this.log.info(`[Pcap] ${line}`)); this.log.info( diff --git a/lib/device-log/ios-crash-log.ts b/lib/device-log/ios-crash-log.ts index cdbcede9b..c2d7bc6f6 100644 --- a/lib/device-log/ios-crash-log.ts +++ b/lib/device-log/ios-crash-log.ts @@ -2,13 +2,13 @@ import {fs, tempDir, util} from 'appium/support'; import B from 'bluebird'; import path from 'path'; import _ from 'lodash'; -import {Pyidevice} from '../real-device-clients/py-ios-device-client'; import IOSLog from './ios-log'; import { toLogEntry, grepFile } from './helpers'; import type { AppiumLogger } from '@appium/types'; import type { BaseDeviceClient } from '../real-device-clients/base-device-client'; import type { Simulator } from 'appium-ios-simulator'; import type { LogEntry } from '../commands/types'; +import { selectDeviceClient } from '../real-device-clients/device-client'; // The file format has been changed from '.crash' to '.ips' since Monterey. const CRASH_REPORTS_GLOB_PATTERN = '**/*.@(crash|ips)'; @@ -28,10 +28,10 @@ export interface IOSCrashLogOptions { export class IOSCrashLog extends IOSLog { private readonly _udid: string | undefined; - private readonly _realDeviceClient: BaseDeviceClient | null; private readonly _logDir: string | null; private readonly _sim: Simulator | undefined; private _recentCrashFiles: string[]; + private _realDeviceClient: BaseDeviceClient | null; private _started: boolean; constructor(opts: IOSCrashLogOptions) { @@ -41,12 +41,7 @@ export class IOSCrashLog extends IOSLog { }); this._udid = opts.udid; this._sim = opts.sim; - this._realDeviceClient = this._isRealDevice() - ? new Pyidevice({ - udid: this._udid as string, - log: opts.log, - }) - : null; + this._realDeviceClient = null; this._logDir = this._isRealDevice() ? null : path.resolve(process.env.HOME || '/', 'Library', 'Logs', 'DiagnosticReports'); @@ -55,6 +50,12 @@ export class IOSCrashLog extends IOSLog { } override async startCapture(): Promise { + if (this._isRealDevice() && !this._realDeviceClient) { + this._realDeviceClient = await selectDeviceClient({ + udid: this._udid as string, + log: this.log, + }); + } this._recentCrashFiles = await this._listCrashFiles(false); this._started = true; } diff --git a/lib/driver.js b/lib/driver.js index a02376a38..40db42e60 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -23,7 +23,6 @@ import {desiredCapConstraints} from './desired-caps'; import DEVICE_CONNECTIONS_FACTORY from './device-connections-factory'; import {executeMethodMap} from './execute-method-map'; import {newMethodMap} from './method-map'; -import { Pyidevice } from './real-device-clients/py-ios-device-client'; import { installToRealDevice, runRealDeviceReset, @@ -62,6 +61,7 @@ import { translateDeviceName, } from './utils'; import { AppInfosCache } from './app-infos-cache'; +import { selectDeviceClient } from './real-device-clients/device-client'; const SHUTDOWN_OTHER_FEAT_NAME = 'shutdown_other_sims'; const CUSTOMIZE_RESULT_BUNDLE_PATH = 'customize_result_bundle_path'; @@ -611,10 +611,11 @@ export class XCUITestDriver extends BaseDriver { await startLogCapture(); } } else if (this.opts.customSSLCert) { - await new Pyidevice({ + const client = await selectDeviceClient({ udid, log: this.log, - }).installProfile({payload: this.opts.customSSLCert}); + }); + await client.installProfile({payload: this.opts.customSSLCert}); this.logEvent('customCertInstalled'); } diff --git a/lib/real-device-clients/device-client.ts b/lib/real-device-clients/device-client.ts new file mode 100644 index 000000000..d42670a25 --- /dev/null +++ b/lib/real-device-clients/device-client.ts @@ -0,0 +1,25 @@ +import type { BaseDeviceClient, BaseDeviceClientOptions } from './base-device-client'; +import { Pyidevice } from './py-ios-device-client'; +import { GoIos } from './go-ios-device-client'; + +export async function selectDeviceClient(options: BaseDeviceClientOptions & {udid: string}): Promise { + let lastError: Error | null = null; + for (const client of [ + new Pyidevice(options), + new GoIos(options), + ]) { + try { + await client.assertExists(true); + options.log.debug(`Selected ${client.constructor.name} real device client`); + return client; + } catch (err) { + lastError = err; + continue; + } + } + if (lastError) { + throw lastError; + } + // This must never happen + throw new Error(); +} diff --git a/lib/real-device-clients/go-ios-device-client.ts b/lib/real-device-clients/go-ios-device-client.ts new file mode 100644 index 000000000..d85796886 --- /dev/null +++ b/lib/real-device-clients/go-ios-device-client.ts @@ -0,0 +1,198 @@ +import _ from 'lodash'; +import {exec, SubProcess} from 'teen_process'; +import {fs, util, tempDir} from 'appium/support'; +import path from 'path'; +import { BaseDeviceClient } from './base-device-client'; +import type { BaseDeviceClientOptions, InstallProfileArgs } from './base-device-client'; +import type { TeenProcessExecResult } from 'teen_process'; +import type { CertificateList } from '../commands/types'; + +// https://github.com/danielpaulus/go-ios + +const BINARY_NAME = 'ios'; +const CRASH_REPORT_EXT = '.ips'; +const PCAP_FILE_NAME_PATTERN = /Create pcap file: (dump-\\d+.pcap)/; + +export interface GoIosOptions extends BaseDeviceClientOptions { + udid: string; +} + +interface ExecuteOptions { + cwd?: string; + logStderr?: boolean; + asynchronous?: boolean; + autoStart?: boolean; +} + +export class GoIos extends BaseDeviceClient { + private readonly _udid: string; + private _binaryPath: string | null; + + constructor(opts: GoIosOptions) { + super({log: opts.log}); + this._udid = opts.udid; + this._binaryPath = null; + } + + override async assertExists(isStrict = true): Promise { + if (this._binaryPath) { + return true; + } + + try { + this._binaryPath = await fs.which(BINARY_NAME); + return true; + } catch (e) { + if (isStrict) { + throw new Error( + `${BINARY_NAME} binary cannot be found in PATH. ` + + `Please make sure go-ios is installed. Visit https://github.com/danielpaulus/go-ios for ` + + `more details.`, + ); + } + return false; + } + } + + override async listProfiles(): Promise { + const {stderr} = await this.execute(['profile', 'list']); + for (const line of stderr.split('\n')) { + const trimmedLine = line.trim(); + if (!trimmedLine.startsWith('[')) { + continue; + } + // TODO: Align payload + return JSON.parse(trimmedLine); + } + return { + OrderedIdentifiers: [], + ProfileManifest: {}, + ProfileMetadata: {}, + Status: 'Acknowledged', + }; + } + + override async installProfile(args: InstallProfileArgs): Promise { + const {profilePath, payload} = args; + if (!profilePath && !payload) { + throw new Error('Either the full path to the profile or its payload must be provided'); + } + + let tmpRoot: string | undefined; + let srcPath = profilePath; + try { + if (!srcPath) { + tmpRoot = await tempDir.openDir(); + srcPath = path.join(tmpRoot, 'cert.pem'); + if (Buffer.isBuffer(payload)) { + await fs.writeFile(srcPath, payload); + } else { + await fs.writeFile(srcPath, payload as string, 'utf8'); + } + } + await this.execute(['profile', 'add', srcPath], { + logStderr: true, + }); + } finally { + if (tmpRoot) { + await fs.rimraf(tmpRoot); + } + } + } + + override async removeProfile(name: string): Promise { + return ( + await this.execute(['profiles', 'remove', name], {logStderr: true}) + ).stderr; + } + + override async listCrashes(): Promise { + const {stderr} = await this.execute(['crash', 'ls']); + const crashFiles: string[] = []; + for (const line of stderr.split('\n')) { + if (!_.includes(line, '"files":')) { + continue; + } + crashFiles.push(...JSON.parse(line).files); + } + return crashFiles.filter((x: string) => x.endsWith(CRASH_REPORT_EXT)); + } + + override async exportCrash(name: string, dstFolder: string): Promise { + await this.execute(['crash', 'cp', name, dstFolder]); + } + + override async collectPcap(dstFile: string): Promise { + const tmpRoot = await tempDir.openDir(); + const process = await this.execute(['pcap'], { + asynchronous: true, + cwd: tmpRoot, + }); + let tmpPcapName: string | null = null; + const parseFileName = (line: string) => { + const match = PCAP_FILE_NAME_PATTERN.exec(line); + if (!match) { + return null; + } + tmpPcapName = match[1]; + this.log.debug(`Set the soure pcap log name to '${tmpPcapName}'`); + return tmpPcapName; + }; + process.on('line-stderr', (line: string) => { + if (parseFileName(line)) { + process.off('line-stderr', parseFileName); + } + }); + process.once('exit', async () => { + const fullPath = path.join(tmpRoot, tmpPcapName ?? ''); + try { + if (!tmpPcapName) { + this.log.warn(`The source pcap log name is unknown`); + return; + } + if (!await fs.exists(fullPath)) { + this.log.warn(`The pcap log at '${fullPath}' does not exist`); + return; + } + await fs.mv(fullPath, dstFile); + } catch (e) { + this.log.warn(`Cannot move pcap log from '${fullPath}' to '${dstFile}': ${e.message}`); + } finally { + await fs.rimraf(tmpRoot); + } + }); + return process; + } + + private async execute( + args: string[], + opts?: T + ): Promise> { + await this.assertExists(); + const {cwd, logStderr = false, asynchronous = false, autoStart} = opts ?? {}; + + const finalArgs = [...args, '--udid', this._udid]; + const binaryPath = this._binaryPath as string; + const cmdStr = util.quote([binaryPath, ...finalArgs]); + this.log.debug(`Executing ${cmdStr}`); + try { + if (asynchronous) { + const result = new SubProcess(binaryPath, finalArgs, {cwd}); + if (autoStart) { + await result.start(0); + } + //@ts-ignore This is ok + return result; + } + const result = await exec(binaryPath, finalArgs, {cwd}); + if (logStderr) { + this.log.debug(`Command output: ${result.stderr}`); + } + //@ts-ignore This is ok + return result; + } catch (e) { + throw new Error(`'${cmdStr}' failed. Original error: ${e.stderr || e.stdout || e.message}`); + } + } +} + diff --git a/lib/real-device-clients/py-ios-device-client.ts b/lib/real-device-clients/py-ios-device-client.ts index 8c1f03ca9..0bb77139e 100644 --- a/lib/real-device-clients/py-ios-device-client.ts +++ b/lib/real-device-clients/py-ios-device-client.ts @@ -53,7 +53,7 @@ export class Pyidevice extends BaseDeviceClient { } override async listProfiles(): Promise { - const {stdout} = await this.execute(['profiles', 'list']) as TeenProcessExecResult; + const {stdout} = await this.execute(['profiles', 'list']); return JSON.parse(stdout); } @@ -87,12 +87,12 @@ export class Pyidevice extends BaseDeviceClient { override async removeProfile(name: string): Promise { return ( - await this.execute(['profiles', 'remove', '--name', name], {logStdout: true}) as TeenProcessExecResult + await this.execute(['profiles', 'remove', '--name', name], {logStdout: true}) ).stdout; } override async listCrashes(): Promise { - const {stdout} = await this.execute(['crash', 'list']) as TeenProcessExecResult; + const {stdout} = await this.execute(['crash', 'list']); // Example output: // ['.', '..', 'SiriSearchFeedback-2023-12-06-144043.ips', ' // SiriSearchFeedback-2024-05-22-194219.ips', 'JetsamEvent-2024-05-23-225056.ips', @@ -114,15 +114,15 @@ export class Pyidevice extends BaseDeviceClient { return await this.execute(['pcapd', dstFile], { format: null, asynchronous: true, - }) as SubProcess; + }); } - private async execute( + private async execute( args: string[], - opts: ExecuteOptions = {} - ): Promise | SubProcess> { + opts?: T + ): Promise> { await this.assertExists(); - const {cwd, format = 'json', logStdout = false, asynchronous = false} = opts; + const {cwd, format = 'json', logStdout = false, asynchronous = false} = opts ?? {}; const finalArgs = [...args, '--udid', this._udid, '--network']; if (format) { @@ -135,12 +135,14 @@ export class Pyidevice extends BaseDeviceClient { if (asynchronous) { const result = new SubProcess(binaryPath, finalArgs, {cwd}); await result.start(0); + //@ts-ignore This is OK return result; } const result = await exec(binaryPath, finalArgs, {cwd}); if (logStdout) { this.log.debug(`Command output: ${result.stdout}`); } + //@ts-ignore This is OK return result; } catch (e) { throw new Error(`'${cmdStr}' failed. Original error: ${e.stderr || e.stdout || e.message}`);