Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add go-ios client #2471

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading