appium-ios-simulator
Version:
iOS Simulator interface for Appium.
349 lines (320 loc) • 12.8 kB
JavaScript
import _ from 'lodash';
import { fs, timing, util } from '@appium/support';
import { exec } from 'teen_process';
import path from 'path';
import B from 'bluebird';
import { waitForCondition } from 'asyncbox';
const STATUS = Object.freeze({
UNSET: 'unset',
NO: 'no',
YES: 'yes',
LIMITED: 'limited',
});
const SPRINGBOARD_BUNDLE_ID = 'com.apple.SpringBoard';
const SPOTLIGHT_BUNDLE_ID = 'com.apple.Spotlight';
const WIX_SIM_UTILS = 'applesimutils';
const SERVICES_NEED_SPRINGBOARD_RESTART = ['notifications'];
const SYSTEM_SERVICE_RESTART_TIMEOUT_MS = 15000;
// `location` permission does not work with WIX/applesimutils.
// Note that except for 'contacts', the Apple's privacy command sets
// permissions properly but it kills the app process while WIX/applesimutils does not.
// In the backward compatibility perspective,
// we'd like to keep the app process as possible.
const PERMISSIONS_APPLIED_VIA_SIMCTL = [
'location',
'location-always'
];
const SERVICES = Object.freeze({
calendar: 'kTCCServiceCalendar',
camera: 'kTCCServiceCamera',
contacts: 'kTCCServiceAddressBook',
homekit: 'kTCCServiceWillow',
microphone: 'kTCCServiceMicrophone',
photos: 'kTCCServicePhotos',
reminders: 'kTCCServiceReminders',
medialibrary: 'kTCCServiceMediaLibrary',
motion: 'kTCCServiceMotion',
health: 'kTCCServiceMSO',
siri: 'kTCCServiceSiri',
speech: 'kTCCServiceSpeechRecognition',
});
/**
* Sets the particular permission to the application bundle. See https://github.com/wix/AppleSimulatorUtils
* or `xcrun simctl privacy` for more details on the available service names and statuses.
*
* @this {CoreSimulatorWithAppPermissions}
* @param {string} bundleId - Application bundle identifier.
* @param {string} permission - Service name to be set.
* @param {string} value - The desired status for the service.
* @throws {Error} If there was an error while changing permission.
*/
export async function setPermission (bundleId, permission, value) {
await this.setPermissions(bundleId, {[permission]: value});
}
/**
* Sets the permissions for the particular application bundle.
*
* @this {CoreSimulatorWithAppPermissions}
* @param {string} bundleId - Application bundle identifier.
* @param {Object} permissionsMapping - A mapping where kays
* are service names and values are their corresponding status values.
* See https://github.com/wix/AppleSimulatorUtils or `xcrun simctl privacy`
* for more details on available service names and statuses.
* @throws {Error} If there was an error while changing permissions.
*/
export async function setPermissions (bundleId, permissionsMapping) {
this.log.debug(`Setting access for '${bundleId}': ${JSON.stringify(permissionsMapping, null, 2)}`);
await setAccess.bind(this)(bundleId, permissionsMapping);
}
/**
* Retrieves current permission status for the given application bundle.
*
* @this {CoreSimulatorWithAppPermissions}
* @param {string} bundleId - Application bundle identifier.
* @param {string} serviceName - One of available service names.
* @returns {Promise<string>}
* @throws {Error} If there was an error while retrieving permissions.
*/
export async function getPermission (bundleId, serviceName) {
const result = await getAccess.bind(this)(bundleId, serviceName);
this.log.debug(`Got ${serviceName} access status for '${bundleId}': ${result}`);
return result;
}
function toInternalServiceName (serviceName) {
if (_.has(SERVICES, _.toLower(serviceName))) {
return SERVICES[_.toLower(serviceName)];
}
throw new Error(
`'${serviceName}' is unknown. Only the following service names are supported: ${JSON.stringify(_.keys(SERVICES))}`
);
}
function formatStatus (status) {
return [STATUS.UNSET, STATUS.NO].includes(status) ? _.toUpper(status) : status;
}
/**
* Runs a command line sqlite3 query
*
* @this {CoreSimulatorWithAppPermissions}
* @param {string} db - Full path to sqlite database
* @param {string} query - The actual query string
* @returns {Promise<string>} sqlite command stdout
*/
async function execSQLiteQuery (db, query) {
this.log.debug(`Executing SQL query "${query}" on '${db}'`);
try {
return (await exec('sqlite3', ['-line', db, query])).stdout;
} catch (err) {
throw new Error(
`Cannot execute SQLite query "${query}" to '${db}'. Original error: ${err.stderr}`
);
}
}
/**
* @this {CoreSimulatorWithAppPermissions}
* @param {string[]} args
* @returns {Promise<string>}
*/
async function execWix (args) {
try {
await fs.which(WIX_SIM_UTILS);
} catch {
throw new Error(
`${WIX_SIM_UTILS} binary has not been found in your PATH. ` +
`Please install it ('brew tap wix/brew && brew install wix/brew/applesimutils') to ` +
`be able to change application permissions`
);
}
this.log.debug(`Executing: ${WIX_SIM_UTILS} ${util.quote(args)}`);
try {
const {stdout} = await exec(WIX_SIM_UTILS, args);
this.log.debug(`Command output: ${stdout}`);
return stdout;
} catch (e) {
throw new Error(`Cannot execute "${WIX_SIM_UTILS} ${util.quote(args)}". Original error: ${e.stderr || e.message}`);
}
}
/**
* Sets permissions for the given application
*
* @this {CoreSimulatorWithAppPermissions}
* @param {string} bundleId - bundle identifier of the target application.
* @param {Object} permissionsMapping - An object, where keys are service names
* and values are corresponding state values. Services listed in PERMISSIONS_APPLIED_VIA_SIMCTL
* will be set with `xcrun simctl privacy` command by Apple otherwise AppleSimulatorUtils by WIX.
* See the result of `xcrun simctl privacy` and https://github.com/wix/AppleSimulatorUtils
* for more details on available service names and statuses.
* Note that the `xcrun simctl privacy` command kill the app process.
* @throws {Error} If there was an error while changing permissions.
*/
async function setAccess (bundleId, permissionsMapping) {
const /** @type {Record<string, string>} */ wixPermissions = {};
const /** @type {string[]} */ grantPermissions = [];
const /** @type {string[]} */ revokePermissions = [];
const /** @type {string[]} */ resetPermissions = [];
for (const serviceName in permissionsMapping) {
if (!PERMISSIONS_APPLIED_VIA_SIMCTL.includes(serviceName)) {
wixPermissions[serviceName] = permissionsMapping[serviceName];
} else {
// xcrun simctl privacy expects to be lower case while AppleSimulatorUtils is upper case.
// To keep the compatibility, we should convert here to lower case explicitly.
switch (_.toLower(permissionsMapping[serviceName])) {
case STATUS.YES:
grantPermissions.push(serviceName);
break;
case STATUS.NO:
revokePermissions.push(serviceName);
break;
case STATUS.UNSET:
resetPermissions.push(serviceName);
break;
default:
throw this.log.errorWithException(
`${serviceName} does not support ${permissionsMapping[serviceName]}. Please specify 'yes', 'no' or 'unset'.`
);
}
}
}
/** @type {Promise[]} */
const permissionPromises = [];
if (!_.isEmpty(grantPermissions)) {
this.log.debug(`Granting ${util.pluralize('permission', grantPermissions.length, false)} for ${bundleId}: ${grantPermissions}`);
for (const action of grantPermissions) {
permissionPromises.push(this.simctl.grantPermission(bundleId, action));
}
}
if (!_.isEmpty(revokePermissions)) {
this.log.debug(`Revoking ${util.pluralize('permission', revokePermissions.length, false)} for ${bundleId}: ${revokePermissions}`);
for (const action of revokePermissions) {
permissionPromises.push(this.simctl.revokePermission(bundleId, action));
}
}
if (!_.isEmpty(resetPermissions)) {
this.log.debug(`Resetting ${util.pluralize('permission', resetPermissions.length, false)} for ${bundleId}: ${resetPermissions}`);
for (const action of resetPermissions) {
permissionPromises.push(this.simctl.resetPermission(bundleId, action));
}
}
if (!_.isEmpty(permissionPromises)) {
await B.all(permissionPromises);
}
if (!_.isEmpty(wixPermissions)) {
this.log.debug(`Setting permissions for ${bundleId} wit ${WIX_SIM_UTILS} as ${JSON.stringify(wixPermissions)}`);
const permissionsArg = _.toPairs(wixPermissions)
.map((x) => `${x[0]}=${formatStatus(x[1])}`)
.join(',');
const execWixFn = async () => await execWix.bind(this)([
'--byId', this.udid,
'--bundle', bundleId,
'--setPermissions', permissionsArg,
]);
const shouldWaitForSystemReadiness = !_.isEmpty(
_.intersection(SERVICES_NEED_SPRINGBOARD_RESTART, _.keys(wixPermissions))
);
if (shouldWaitForSystemReadiness) {
const [didTimeout] = await runAndWaitForSystemReadiness.bind(this)(
execWixFn, SYSTEM_SERVICE_RESTART_TIMEOUT_MS
);
if (didTimeout) {
this.log.warn(
`The required system services did not restart after ` +
`${SYSTEM_SERVICE_RESTART_TIMEOUT_MS}ms timeout. This might lead to unexpected consequences later.`
);
}
} else {
await execWixFn();
}
}
return true;
}
/**
* Waiting for springboard restart and applications process end/restart
* triggered by the springboard process restart.
*
* @this {import('../types').CoreSimulator}
* @template {any} T
* @param {() => Promise<T>} fn
* @param {number} timeoutMs
* @returns {Promise<[boolean, T]>}
*/
async function runAndWaitForSystemReadiness(fn, timeoutMs) {
const waitForNewPid = async (initialPid, bundleId, timeoutMs) => {
await waitForCondition(async () => {
try {
const pid = (await this.ps()).find(({name}) => bundleId === name)?.pid;
return _.isInteger(pid) && initialPid !== pid;
} catch {
return false;
}
}, {waitMs: timeoutMs, intervalMs: 500});
};
let initialProcesses = [];
try {
initialProcesses = await this.ps();
} catch {};
const [initialSpringboardPid, initialSpotlightPid] = [
SPRINGBOARD_BUNDLE_ID, SPOTLIGHT_BUNDLE_ID
].map((bundleId) => initialProcesses.find(({name}) => bundleId === name)?.pid);
const result = await fn();
if (!_.isInteger(initialSpringboardPid) || !_.isInteger(initialSpotlightPid)) {
// there is no point to wait if relevant processes were not running before
return [false, result];
}
try {
// Make sure the springboard process restarted first.
const timer = new timing.Timer().start();
await waitForNewPid(initialSpringboardPid, SPRINGBOARD_BUNDLE_ID, timeoutMs);
const remainingTimeoutMs = timeoutMs - timer.getDuration().asMilliSeconds;
if (remainingTimeoutMs <= 0) {
// no need to check the SPOTLIGHT_BUNDLE_ID
return [true, result];
}
// Then, checking if the new spring board process refreshes applications.
// Spotlight.app is widely used so the app process can be an indicator to check the refresh.
await waitForNewPid(initialSpotlightPid, SPOTLIGHT_BUNDLE_ID, remainingTimeoutMs);
} catch {
return [true, result];
}
return [false, result];
}
/**
* Retrieves the current permission status for the given service and application.
*
* @this {CoreSimulatorWithAppPermissions}
* @param {string} bundleId - bundle identifier of the target application.
* @param {string} serviceName - the name of the service. Should be one of
* `SERVICES` keys.
* @returns {Promise<string>} - The current status: yes/no/unset/limited
* @throws {Error} If there was an error while retrieving permissions.
*/
async function getAccess (bundleId, serviceName) {
const internalServiceName = toInternalServiceName(serviceName);
const dbPath = path.resolve(this.getDir(), 'Library', 'TCC', 'TCC.db');
const getAccessStatus = async (statusPairs, statusKey) => {
for (const [statusValue, status] of statusPairs) {
const sql = `SELECT count(*) FROM 'access' ` +
`WHERE client='${bundleId}' AND ${statusKey}=${statusValue} AND service='${internalServiceName}'`;
const count = await execSQLiteQuery.bind(this)(dbPath, sql);
if (parseInt(count.split('=')[1], 10) > 0) {
return status;
}
}
return STATUS.UNSET;
};
// 'auth_value' existence depends on the OS version rather than Xcode version.
// Thus here check the newer one first, then fallback to the older version way.
try {
// iOS 14+
return await getAccessStatus(
[['0', STATUS.NO], ['2', STATUS.YES], ['3', STATUS.LIMITED]],
'auth_value'
);
} catch {
return await getAccessStatus(
[['0', STATUS.NO], ['1', STATUS.YES]],
'allowed'
);
}
}
/**
* @typedef {import('../types').CoreSimulator & import('../types').SupportsAppPermissions} CoreSimulatorWithAppPermissions
*/