// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview OSA Script to interact with Xcode. Functionality includes * checking if a given project is open in Xcode, starting a debug session for * a given project, and stopping a debug session for a given project. */ 'use strict'; /** * OSA Script `run` handler that is called when the script is run. When ran * with `osascript`, arguments are passed from the command line to the direct * parameter of the `run` handler as a list of strings. * * @param {?Array<string>=} args_array * @returns {!RunJsonResponse} The validated command. */ function run(args_array = []) { let args; try { args = new CommandArguments(args_array); } catch (e) { return new RunJsonResponse(false, `Failed to parse arguments: ${e}`).stringify(); } const xcodeResult = getXcode(args); if (xcodeResult.error != null) { return new RunJsonResponse(false, xcodeResult.error).stringify(); } const xcode = xcodeResult.result; if (args.command === 'check-workspace-opened') { const result = getWorkspaceDocument(xcode, args); return new RunJsonResponse(result.error == null, result.error).stringify(); } else if (args.command === 'debug') { const result = debugApp(xcode, args); return new RunJsonResponse(result.error == null, result.error, result.result).stringify(); } else if (args.command === 'stop') { const result = stopApp(xcode, args); return new RunJsonResponse(result.error == null, result.error).stringify(); } else { return new RunJsonResponse(false, 'Unknown command').stringify(); } } /** * Parsed and validated arguments passed from the command line. */ class CommandArguments { /** * * @param {!Array<string>} args List of arguments passed from the command line. */ constructor(args) { this.command = this.validatedCommand(args[0]); const parsedArguments = this.parseArguments(args); this.xcodePath = this.validatedStringArgument('--xcode-path', parsedArguments['--xcode-path']); this.projectPath = this.validatedStringArgument('--project-path', parsedArguments['--project-path']); this.projectName = this.validatedStringArgument('--project-name', parsedArguments['--project-name']); this.expectedConfigurationBuildDir = this.validatedStringArgument( '--expected-configuration-build-dir', parsedArguments['--expected-configuration-build-dir'], ); this.workspacePath = this.validatedStringArgument('--workspace-path', parsedArguments['--workspace-path']); this.targetDestinationId = this.validatedStringArgument('--device-id', parsedArguments['--device-id']); this.targetSchemeName = this.validatedStringArgument('--scheme', parsedArguments['--scheme']); this.skipBuilding = this.validatedBoolArgument('--skip-building', parsedArguments['--skip-building']); this.launchArguments = this.validatedJsonArgument('--launch-args', parsedArguments['--launch-args']); this.closeWindowOnStop = this.validatedBoolArgument('--close-window', parsedArguments['--close-window']); this.promptToSaveBeforeClose = this.validatedBoolArgument('--prompt-to-save', parsedArguments['--prompt-to-save']); this.verbose = this.validatedBoolArgument('--verbose', parsedArguments['--verbose']); if (this.verbose === true) { console.log(JSON.stringify(this)); } } /** * Validates the command is available. * * @param {?string} command * @returns {!string} The validated command. * @throws Will throw an error if command is not recognized. */ validatedCommand(command) { const allowedCommands = ['check-workspace-opened', 'debug', 'stop']; if (allowedCommands.includes(command) === false) { throw `Unrecognized Command: ${command}`; } return command; } /** * Returns map of commands to map of allowed arguments. For each command, if * an argument flag is a key, than that flag is allowed for that command. If * the value for the key is true, then it is required for the command. * * @returns {!string} Map of commands to allowed and optionally required * arguments. */ argumentSettings() { return { 'check-workspace-opened': { '--xcode-path': true, '--project-path': true, '--workspace-path': true, '--verbose': false, }, 'debug': { '--xcode-path': true, '--project-path': true, '--workspace-path': true, '--project-name': true, '--expected-configuration-build-dir': false, '--device-id': true, '--scheme': true, '--skip-building': true, '--launch-args': true, '--verbose': false, }, 'stop': { '--xcode-path': true, '--project-path': true, '--workspace-path': true, '--close-window': true, '--prompt-to-save': true, '--verbose': false, }, }; } /** * Validates the flag is allowed for the current command. * * @param {!string} flag * @param {?string} value * @returns {!bool} * @throws Will throw an error if the flag is not allowed for the current * command and the value is not null, undefined, or empty. */ isArgumentAllowed(flag, value) { const isAllowed = this.argumentSettings()[this.command].hasOwnProperty(flag); if (isAllowed === false && (value != null && value !== '')) { throw `The flag ${flag} is not allowed for the command ${this.command}.`; } return isAllowed; } /** * Validates required flag has a value. * * @param {!string} flag * @param {?string} value * @throws Will throw an error if the flag is required for the current * command and the value is not null, undefined, or empty. */ validateRequiredArgument(flag, value) { const isRequired = this.argumentSettings()[this.command][flag] === true; if (isRequired === true && (value == null || value === '')) { throw `Missing value for ${flag}`; } } /** * Parses the command line arguments into an object. * * @param {!Array<string>} args List of arguments passed from the command line. * @returns {!Object.<string, string>} Object mapping flag to value. * @throws Will throw an error if flag does not begin with '--'. */ parseArguments(args) { const valuesPerFlag = {}; for (let index = 1; index < args.length; index++) { const entry = args[index]; let flag; let value; const splitIndex = entry.indexOf('='); if (splitIndex === -1) { flag = entry; value = args[index + 1]; // If the flag is allowed for the command, and the next value in the // array is null/undefined or also a flag, treat the flag like a boolean // flag and set the value to 'true'. if (this.isArgumentAllowed(flag) && (value == null || value.startsWith('--'))) { value = 'true'; } else { index++; } } else { flag = entry.substring(0, splitIndex); value = entry.substring(splitIndex + 1, entry.length + 1); } if (flag.startsWith('--') === false) { throw `Unrecognized Flag: ${flag}`; } valuesPerFlag[flag] = value; } return valuesPerFlag; } /** * Validates the flag is allowed and `value` is valid. If the flag is not * allowed for the current command, return `null`. * * @param {!string} flag * @param {?string} value * @returns {!string} * @throws Will throw an error if the flag is allowed and `value` is null, * undefined, or empty. */ validatedStringArgument(flag, value) { if (this.isArgumentAllowed(flag, value) === false) { return null; } this.validateRequiredArgument(flag, value); return value; } /** * Validates the flag is allowed, validates `value` is valid, and converts * `value` to a boolean. A `value` of null, undefined, or empty, it will * return true. If the flag is not allowed for the current command, will * return `null`. * * @param {!string} flag * @param {?string} value * @returns {?boolean} * @throws Will throw an error if the flag is allowed and `value` is not * null, undefined, empty, 'true', or 'false'. */ validatedBoolArgument(flag, value) { if (this.isArgumentAllowed(flag, value) === false) { return null; } if (value == null || value === '') { return false; } if (value !== 'true' && value !== 'false') { throw `Invalid value for ${flag}`; } return value === 'true'; } /** * Validates the flag is allowed, `value` is valid, and parses `value` as JSON. * If the flag is not allowed for the current command, will return `null`. * * @param {!string} flag * @param {?string} value * @returns {!Object} * @throws Will throw an error if the flag is allowed and the value is * null, undefined, or empty. Will also throw an error if parsing fails. */ validatedJsonArgument(flag, value) { if (this.isArgumentAllowed(flag, value) === false) { return null; } this.validateRequiredArgument(flag, value); try { return JSON.parse(value); } catch (e) { throw `Error parsing ${flag}: ${e}`; } } } /** * Response to return in `run` function. */ class RunJsonResponse { /** * * @param {!bool} success Whether the command was successful. * @param {?string=} errorMessage Defaults to null. * @param {?DebugResult=} debugResult Curated results from Xcode's debug * function. Defaults to null. */ constructor(success, errorMessage = null, debugResult = null) { this.status = success; this.errorMessage = errorMessage; this.debugResult = debugResult; } /** * Converts this object to a JSON string. * * @returns {!string} * @throws Throws an error if conversion fails. */ stringify() { return JSON.stringify(this); } } /** * Utility class to return a result along with a potential error. */ class FunctionResult { /** * * @param {?Object} result * @param {?string=} error Defaults to null. */ constructor(result, error = null) { this.result = result; this.error = error; } } /** * Curated results from Xcode's debug function. Mirrors parts of * `scheme action result` from Xcode's Script Editor dictionary. */ class DebugResult { /** * * @param {!Object} result */ constructor(result) { this.completed = result.completed(); this.status = result.status(); this.errorMessage = result.errorMessage(); } } /** * Get the Xcode application from the given path. Since macs can have multiple * Xcode version, we use the path to target the specific Xcode application. * If the Xcode app is not running, return null with an error. * * @param {!CommandArguments} args * @returns {!FunctionResult} Return either an `Application` (Mac Scripting class) * or null as the `result`. */ function getXcode(args) { try { const xcode = Application(args.xcodePath); const isXcodeRunning = xcode.running(); if (isXcodeRunning === false) { return new FunctionResult(null, 'Xcode is not running'); } return new FunctionResult(xcode); } catch (e) { return new FunctionResult(null, `Failed to get Xcode application: ${e}`); } } /** * After setting the active run destination to the targeted device, uses Xcode * debug function from Mac Scripting for Xcode to install the app on the device * and start a debugging session using the 'run' or 'run without building' scheme * action (depending on `args.skipBuilding`). Waits for the debugging session * to start running. * * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. * @param {!CommandArguments} args * @returns {!FunctionResult} Return either a `DebugResult` or null as the `result`. */ function debugApp(xcode, args) { const workspaceResult = waitForWorkspaceToLoad(xcode, args); if (workspaceResult.error != null) { return new FunctionResult(null, workspaceResult.error); } const targetWorkspace = workspaceResult.result; const destinationResult = getTargetDestination( targetWorkspace, args.targetDestinationId, args.verbose, ); if (destinationResult.error != null) { return new FunctionResult(null, destinationResult.error) } // If expectedConfigurationBuildDir is available, ensure that it matches the // build settings. if (args.expectedConfigurationBuildDir != null && args.expectedConfigurationBuildDir !== '') { const updateResult = waitForConfigurationBuildDirToUpdate(targetWorkspace, args); if (updateResult.error != null) { return new FunctionResult(null, updateResult.error); } } try { // Documentation from the Xcode Script Editor dictionary indicates that the // `debug` function has a parameter called `runDestinationSpecifier` which // is used to specify which device to debug the app on. It also states that // it should be the same as the xcodebuild -destination specifier. It also // states that if not specified, the `activeRunDestination` is used instead. // // Experimentation has shown that the `runDestinationSpecifier` does not work. // It will always use the `activeRunDestination`. To mitigate this, we set // the `activeRunDestination` to the targeted device prior to starting the debug. targetWorkspace.activeRunDestination = destinationResult.result; const actionResult = targetWorkspace.debug({ scheme: args.targetSchemeName, skipBuilding: args.skipBuilding, commandLineArguments: args.launchArguments, }); // Wait until scheme action has started up to a max of 10 minutes. // This does not wait for app to install, launch, or start debug session. // Potential statuses include: not yet started/running/cancelled/failed/error occurred/succeeded. const checkFrequencyInSeconds = 0.5; const maxWaitInSeconds = 10 * 60; // 10 minutes const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); for (let i = 0; i < iterations; i++) { if (actionResult.status() !== 'not yet started') { break; } if (args.verbose === true && i % verboseLogInterval === 0) { console.log(`Action result status: ${actionResult.status()}`); } delay(checkFrequencyInSeconds); } return new FunctionResult(new DebugResult(actionResult)); } catch (e) { return new FunctionResult(null, `Failed to start debugging session: ${e}`); } } /** * Iterates through available run destinations looking for one with a matching * `deviceId`. If device is not found, return null with an error. * * @param {!WorkspaceDocument} targetWorkspace A `WorkspaceDocument` (Xcode Mac * Scripting class). * @param {!string} deviceId * @param {?bool=} verbose Defaults to false. * @returns {!FunctionResult} Return either a `RunDestination` (Xcode Mac * Scripting class) or null as the `result`. */ function getTargetDestination(targetWorkspace, deviceId, verbose = false) { try { for (let destination of targetWorkspace.runDestinations()) { const device = destination.device(); if (verbose === true && device != null) { console.log(`Device: ${device.name()} (${device.deviceIdentifier()})`); } if (device != null && device.deviceIdentifier() === deviceId) { return new FunctionResult(destination); } } return new FunctionResult( null, 'Unable to find target device. Ensure that the device is paired, ' + 'unlocked, connected, and has an iOS version at least as high as the ' + 'Minimum Deployment.', ); } catch (e) { return new FunctionResult(null, `Failed to get target destination: ${e}`); } } /** * Waits for the workspace to load. If the workspace is not loaded or in the * process of opening, it will wait up to 10 minutes. * * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. * @param {!CommandArguments} args * @returns {!FunctionResult} Return either a `WorkspaceDocument` (Xcode Mac * Scripting class) or null as the `result`. */ function waitForWorkspaceToLoad(xcode, args) { try { const checkFrequencyInSeconds = 0.5; const maxWaitInSeconds = 10 * 60; // 10 minutes const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); for (let i = 0; i < iterations; i++) { // Every 10 seconds, print the list of workspaces if verbose const verbose = args.verbose && i % verboseLogInterval === 0; const workspaceResult = getWorkspaceDocument(xcode, args, verbose); if (workspaceResult.error == null) { const document = workspaceResult.result; if (document.loaded() === true) { return new FunctionResult(document, null); } } else if (verbose === true) { console.log(workspaceResult.error); } delay(checkFrequencyInSeconds); } return new FunctionResult(null, 'Timed out waiting for workspace to load'); } catch (e) { return new FunctionResult(null, `Failed to wait for workspace to load: ${e}`); } } /** * Gets workspace opened in Xcode matching the projectPath or workspacePath * from the command line arguments. If workspace is not found, return null with * an error. * * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. * @param {!CommandArguments} args * @param {?bool=} verbose Defaults to false. * @returns {!FunctionResult} Return either a `WorkspaceDocument` (Xcode Mac * Scripting class) or null as the `result`. */ function getWorkspaceDocument(xcode, args, verbose = false) { const privatePrefix = '/private'; try { const documents = xcode.workspaceDocuments(); for (let document of documents) { const filePath = document.file().toString(); if (verbose === true) { console.log(`Workspace: ${filePath}`); } if (filePath === args.projectPath || filePath === args.workspacePath) { return new FunctionResult(document); } // Sometimes when the project is in a temporary directory, it'll be // prefixed with `/private` but the args will not. Remove the // prefix before matching. if (filePath.startsWith(privatePrefix) === true) { const filePathWithoutPrefix = filePath.slice(privatePrefix.length); if (filePathWithoutPrefix === args.projectPath || filePathWithoutPrefix === args.workspacePath) { return new FunctionResult(document); } } } } catch (e) { return new FunctionResult(null, `Failed to get workspace: ${e}`); } return new FunctionResult(null, `Failed to get workspace.`); } /** * Stops all debug sessions in the target workspace. * * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. * @param {!CommandArguments} args * @returns {!FunctionResult} Always returns null as the `result`. */ function stopApp(xcode, args) { const workspaceResult = getWorkspaceDocument(xcode, args); if (workspaceResult.error != null) { return new FunctionResult(null, workspaceResult.error); } const targetDocument = workspaceResult.result; try { targetDocument.stop(); if (args.closeWindowOnStop === true) { // Wait a couple seconds before closing Xcode, otherwise it'll prompt the // user to stop the app. delay(2); targetDocument.close({ saving: args.promptToSaveBeforeClose === true ? 'ask' : 'no', }); } } catch (e) { return new FunctionResult(null, `Failed to stop app: ${e}`); } return new FunctionResult(null, null); } /** * Gets resolved build setting for CONFIGURATION_BUILD_DIR and waits until its * value matches the `--expected-configuration-build-dir` argument. Waits up to * 2 minutes. * * @param {!WorkspaceDocument} targetWorkspace A `WorkspaceDocument` (Xcode Mac * Scripting class). * @param {!CommandArguments} args * @returns {!FunctionResult} Always returns null as the `result`. */ function waitForConfigurationBuildDirToUpdate(targetWorkspace, args) { // Get the project let project; try { project = targetWorkspace.projects().find(x => x.name() == args.projectName); } catch (e) { return new FunctionResult(null, `Failed to find project ${args.projectName}: ${e}`); } if (project == null) { return new FunctionResult(null, `Failed to find project ${args.projectName}.`); } // Get the target let target; try { // The target is probably named the same as the project, but if not, just use the first. const targets = project.targets(); target = targets.find(x => x.name() == args.projectName); if (target == null && targets.length > 0) { target = targets[0]; if (args.verbose) { console.log(`Failed to find target named ${args.projectName}, picking first target: ${target.name()}.`); } } } catch (e) { return new FunctionResult(null, `Failed to find target: ${e}`); } if (target == null) { return new FunctionResult(null, `Failed to find target.`); } try { // Use the first build configuration (Debug). Any should do since they all // include Generated.xcconfig. const buildConfig = target.buildConfigurations()[0]; const buildSettings = buildConfig.resolvedBuildSettings().reverse(); // CONFIGURATION_BUILD_DIR is often at (reverse) index 225 for Xcode // projects, so check there first. If it's not there, search the build // settings (which can be a little slow). const defaultIndex = 225; let configurationBuildDirSettings; if (buildSettings[defaultIndex] != null && buildSettings[defaultIndex].name() === 'CONFIGURATION_BUILD_DIR') { configurationBuildDirSettings = buildSettings[defaultIndex]; } else { configurationBuildDirSettings = buildSettings.find(x => x.name() === 'CONFIGURATION_BUILD_DIR'); } if (configurationBuildDirSettings == null) { // This should not happen, even if it's not set by Flutter, there should // always be a resolved build setting for CONFIGURATION_BUILD_DIR. return new FunctionResult(null, `Unable to find CONFIGURATION_BUILD_DIR.`); } // Wait up to 2 minutes for the CONFIGURATION_BUILD_DIR to update to the // expected value. const checkFrequencyInSeconds = 0.5; const maxWaitInSeconds = 2 * 60; // 2 minutes const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); for (let i = 0; i < iterations; i++) { const verbose = args.verbose && i % verboseLogInterval === 0; const configurationBuildDir = configurationBuildDirSettings.value(); if (configurationBuildDir === args.expectedConfigurationBuildDir) { console.log(`CONFIGURATION_BUILD_DIR: ${configurationBuildDir}`); return new FunctionResult(null, null); } if (verbose) { console.log(`Current CONFIGURATION_BUILD_DIR: ${configurationBuildDir} while expecting ${args.expectedConfigurationBuildDir}`); } delay(checkFrequencyInSeconds); } return new FunctionResult(null, 'Timed out waiting for CONFIGURATION_BUILD_DIR to update.'); } catch (e) { return new FunctionResult(null, `Failed to get CONFIGURATION_BUILD_DIR: ${e}`); } }