Skip to content

Commit

Permalink
feat: Add go-ios client
Browse files Browse the repository at this point in the history
  • Loading branch information
mykola-mokhnach committed Sep 16, 2024
1 parent b379fd2 commit 050a415
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 40 deletions.
27 changes: 11 additions & 16 deletions lib/commands/certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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}`);
}
}

Expand Down Expand Up @@ -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);
},

Expand All @@ -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<import('./types').CertificateList>} */ (await client.listProfiles());
},
};

Expand Down
11 changes: 6 additions & 5 deletions lib/commands/pcap.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand Down
17 changes: 9 additions & 8 deletions lib/device-log/ios-crash-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)';
Expand All @@ -28,10 +28,10 @@ export interface IOSCrashLogOptions {

export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
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) {
Expand All @@ -41,12 +41,7 @@ export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
});
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');
Expand All @@ -55,6 +50,12 @@ export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
}

override async startCapture(): Promise<void> {
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;
}
Expand Down
7 changes: 4 additions & 3 deletions lib/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}

Expand Down
25 changes: 25 additions & 0 deletions lib/real-device-clients/device-client.ts
Original file line number Diff line number Diff line change
@@ -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<BaseDeviceClient> {
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();
}
198 changes: 198 additions & 0 deletions lib/real-device-clients/go-ios-device-client.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<CertificateList> {
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<void> {
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<string> {
return (
await this.execute(['profiles', 'remove', name], {logStderr: true})
).stderr;
}

override async listCrashes(): Promise<string[]> {
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<void> {
await this.execute(['crash', 'cp', name, dstFolder]);
}

override async collectPcap(dstFile: string): Promise<SubProcess> {
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<T extends ExecuteOptions>(
args: string[],
opts?: T
): Promise<T extends ({asynchronous: true}) ? SubProcess : TeenProcessExecResult<string>> {
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}`);
}
}
}

Loading

0 comments on commit 050a415

Please sign in to comment.