| // 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}`); |
| } |
| } |