Skip to content

Commit

Permalink
refactor: Improve error messages by Xcode location detection (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
mykola-mokhnach authored Jan 12, 2023
1 parent 29b4f51 commit 4d67e31
Showing 1 changed file with 97 additions and 83 deletions.
180 changes: 97 additions & 83 deletions lib/xcode.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import { util, fs, plist, logger } from '@appium/support';
import { fs, plist, logger } from '@appium/support';
import path from 'path';
import { retry } from 'asyncbox';
import _ from 'lodash';
import { parse as parsePlistData } from 'plist';
import { exec } from 'teen_process';
import semver from 'semver';


const env = process.env;
import B from 'bluebird';

const XCRUN_TIMEOUT = 15000;
const XCODE_SUBDIR = '/Contents/Developer';
const DEFAULT_NUMBER_OF_RETRIES = 3;
const XCODE_BUNDLE_ID = 'com.apple.dt.Xcode';

const log = logger.getLogger('Xcode');


function hasExpectedSubDir (path) {
return path.substring(path.length - XCODE_SUBDIR.length) === XCODE_SUBDIR;
}

async function runXcrunCommand (args, timeout = XCRUN_TIMEOUT) {
try {
const res = await exec('xcrun', args, {timeout});
Expand All @@ -37,97 +30,118 @@ async function runXcrunCommand (args, timeout = XCRUN_TIMEOUT) {
}
}

async function getPathFromSymlink (failMessage) {
// Node's invocation of xcode-select sometimes flakes and returns an empty string.
// Not clear why. As a workaround, Appium can reliably deduce the version in use by checking
// the locations xcode-select uses to store the selected version's path. This should be 100%
// reliable so long as the link locations remain the same. However, since we're relying on
// hardcoded paths, this approach will break the next time Apple changes the symlink location.
log.warn(`Finding XcodePath by symlink because ${failMessage}`);

const symlinkPath = '/var/db/xcode_select_link';
const legacySymlinkPath = '/usr/share/xcode-select/xcode_dir_link'; // Xcode < 5.x
let xcodePath = null;

// xcode-select allows users to override its settings with the DEVELOPER_DIR env var,
// so check that first
if (util.hasContent(env.DEVELOPER_DIR)) {
const customPath = hasExpectedSubDir(env.DEVELOPER_DIR)
? env.DEVELOPER_DIR
: env.DEVELOPER_DIR + XCODE_SUBDIR;

if (await fs.exists(customPath)) {
xcodePath = customPath;
} else {
let mesg = `Could not find path to Xcode, environment variable ` +
`DEVELOPER_DIR set to: ${env.DEVELOPER_DIR} ` +
`but no Xcode found`;
log.warn(mesg);
throw new Error(mesg);
}
} else if (await fs.exists(symlinkPath)) {
xcodePath = await fs.readlink(symlinkPath);
} else if (await fs.exists(legacySymlinkPath)) {
xcodePath = await fs.readlink(legacySymlinkPath);
/**
* Uses macOS Spotlight service to detect where the given app is installed
*
* @param {string} bundleId Bundle identifier of the target app
* @returns {Promise[string[]]} Full paths to where the app with the given bundle id is present.
*/
async function findAppPaths (bundleId) {
let stdout;
try {
({stdout} = await exec('/usr/bin/mdfind', [
`kMDItemCFBundleIdentifier=${bundleId}`
]));
} catch (e) {
return [];
}

if (xcodePath) {
return xcodePath.replace(new RegExp('/$'), '').trim();
const matchedPaths = _.trim(stdout)
.split('\n')
.map(_.trim)
.filter(Boolean);
if (_.isEmpty(matchedPaths)) {
return [];
}
const results = matchedPaths.map((p) => (async () => {
if (await fs.exists(p)) {
return p;
}
})());
return (await B.all(results)).filter(Boolean);
}

// We should only get here is we failed to capture xcode-select's stdout and our
// other checks failed. Either Apple has moved the symlink to a new location or the user
// is not using the default install. 99.999% chance it's the latter, so issue a warning
// should we ever hit the edge case.
let msg = `Could not find path to Xcode by symlinks located in ${symlinkPath}, or ${legacySymlinkPath}`;
log.warn(msg);
throw new Error(msg);
/**
* Finds and retrieves the content of the Xcode's Info.plist file
*
* @param {string} developerRoot Full path to the Contents/Developer folder under Xcode.app root
* @returns {Promise[object]} All plist entries as an object or an empty object if no plist was found
*/
async function readXcodePlist (developerRoot) {
const plistPath = path.resolve(developerRoot, '..', 'Info.plist');
return await fs.exists(plistPath)
? await plist.parsePlistFile(plistPath)
: {};
}

/**
* Retrieves the full path to Xcode Developer subfolder via xcode-select
*
* @param {number} timeout The maximum timeout for xcode-select execution
* @returns {Promise[string]} Full path to Xcode Developer subfolder
* @throws {Error} If it is not possible to retrieve a proper path
*/
async function getPathFromXcodeSelect (timeout = XCRUN_TIMEOUT) {
let {stdout} = await exec('xcode-select', ['--print-path'], {timeout});
const generateErrorMessage = async (prefix) => {
const xcodePaths = await findAppPaths(XCODE_BUNDLE_ID);
if (_.isEmpty(xcodePaths)) {
return `${prefix}. Consider installing Xcode to address this issue.`;
}

// trim and remove trailing slash
const xcodeFolderPath = stdout.replace(/\/$/, '').trim();
const proposals = xcodePaths.map((p) => ` sudo xcode-select -s "${path.join(p, 'Contents', 'Developer')}"`);
return `${prefix}. ` +
`Consider running${proposals.length > 1 ? ' any of' : ''}:\n${'\n'.join(proposals)}\nto address this issue.`;
};

if (!util.hasContent(xcodeFolderPath)) {
log.errorAndThrow('xcode-select returned an empty string');
let stdout;
try {
({stdout} = await exec('xcode-select', ['--print-path'], {timeout}));
} catch (e) {
log.errorAndThrow(`Cannot determine the path to Xcode by running 'xcode-select -p' command. ` +
`Original error: ${e.stderr || e.message}`);
}
// trim and remove trailing slash
const developerRoot = stdout.replace(/\/$/, '').trim();
if (!developerRoot) {
log.errorAndThrow(await generateErrorMessage(`'xcode-select -p' returned an empty string`));
}
// xcode-select might also return a path to command line tools
const {CFBundleIdentifier} = await readXcodePlist(developerRoot);
if (CFBundleIdentifier === XCODE_BUNDLE_ID) {
return developerRoot;
}

if (await fs.exists(xcodeFolderPath)) {
return xcodeFolderPath;
} else {
const msg = `xcode-select could not find xcode. Path '${xcodeFolderPath}' does not exist.`;
log.errorAndThrow(msg);
log.errorAndThrow(await generateErrorMessage(`'${developerRoot}' is not a valid Xcode path`));
}

/**
* Retrieves the full path to Xcode Developer subfolder via DEVELOPER_DIR environment variable
*
* @returns {Promise[string]} Full path to Xcode Developer subfolder
* @throws {Error} If it is not possible to retrieve a proper path
*/
async function getPathFromDeveloperDir () {
const developerRoot = process.env.DEVELOPER_DIR;
const {CFBundleIdentifier} = await readXcodePlist(developerRoot);
if (CFBundleIdentifier === XCODE_BUNDLE_ID) {
return developerRoot;
}

log.errorAndThrow(`The path to Xcode Developer dir '${developerRoot}' provided in DEVELOPER_DIR ` +
`environment variable is not a valid path`);
}

const getPath = _.memoize(function getPath (timeout = XCRUN_TIMEOUT) {
// first we try using xcode-select to find the path
// then we try using the symlinks that Apple has by default
return (async () => {
try {
return await getPathFromXcodeSelect(timeout);
} catch (e) {
return await getPathFromSymlink(e.message);
}
})();
return process.env.DEVELOPER_DIR
? (async () => await getPathFromDeveloperDir())()
: (async () => await getPathFromXcodeSelect(timeout))();
});


async function getVersionWithoutRetry (timeout = XCRUN_TIMEOUT) {
const xcodePath = await getPath(timeout);

const developerPath = await getPath(timeout);
// we want to read the CFBundleShortVersionString from Xcode's plist.
// It should be in /[root]/XCode.app/Contents/
const plistPath = path.resolve(xcodePath, '..', 'Info.plist');

if (!await fs.exists(plistPath)) {
throw new Error(`Could not get Xcode version. ${plistPath} does not exist on disk.`);
}

const version = await plist.parsePlistFile(plistPath);
return semver.coerce(version.CFBundleShortVersionString);
const {CFBundleShortVersionString} = await readXcodePlist(developerPath);
return semver.coerce(CFBundleShortVersionString);
}

const getVersionMemoized = _.memoize(
Expand Down Expand Up @@ -187,7 +201,7 @@ async function getCommandLineToolsVersion () {
* Check https://trac.macports.org/wiki/XcodeVersionInfo
* to see the actual mapping between clang and other components.
*
* @returns {?string} The actual Clang version in x.x.x.x or x.x.x format,
* @returns {Promise[string?]} The actual Clang version in x.x.x.x or x.x.x format,
* which is supplied with Command Line Tools. `null` is returned
* if CLT are not installed.
*/
Expand Down

0 comments on commit 4d67e31

Please sign in to comment.