mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00

*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].*
532 lines
18 KiB
JavaScript
532 lines
18 KiB
JavaScript
// 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);
|
||
}
|