flutter/packages/flutter_tools/bin/xcode_debug.js
Mustafa Ateş Uzun 6875827f46
doc: add flag params (#132485)
*Replace this paragraph with a description of what this PR is changing or adding, and why. Consider including before/after screenshots.*

*List which issues are fixed by this PR. You must list at least one issue.*

*If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
2023-08-18 17:48:05 +00:00

532 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.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;
}
/**
* 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 allowedArguments = {
'common': {
'--xcode-path': true,
'--project-path': true,
'--workspace-path': true,
'--verbose': true,
},
'check-workspace-opened': {},
'debug': {
'--device-id': true,
'--scheme': true,
'--skip-building': true,
'--launch-args': true,
},
'stop': {
'--close-window': true,
'--prompt-to-save': true,
},
}
const isAllowed = allowedArguments['common'][flag] === true || allowedArguments[this.command][flag] === true;
if (isAllowed === false && (value != null && value !== '')) {
throw `The flag ${flag} is not allowed for the command ${this.command}.`;
}
return isAllowed;
}
/**
* 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;
}
if (value == null || value === '') {
throw `Missing value for ${flag}`;
}
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;
}
if (value == null || value === '') {
throw `Missing value for ${flag}`;
}
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)
}
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);
}