flutter/packages/flutter_tools/bin/xcode_debug.js
Victoria Ashworth 3cfe3720d6
Wait for CONFIGURATION_BUILD_DIR to update when debugging with Xcode (#135444)
So there appears to be a race situation between the flutter CLI and Xcode. In the CLI, we update the `CONFIGURATION_BUILD_DIR` in the Xcode build settings and then tell Xcode to install, launch, and debug the app. When Xcode installs the app, it should use the `CONFIGURATION_BUILD_DIR` to find the bundle. However, it appears that sometimes Xcode hasn't processed the change to the build settings before the install happens, which causes it to not be able to find the bundle.

Fixes https://github.com/flutter/flutter/issues/135442

--- 

Since it's a timing issue, there's not really a consistent way to test it.

I was able to confirm that it works, though, by using the following steps:
1. Create a flutter project
2. Open the project in Xcode
3. `flutter clean`
4. `flutter run --profile -v`

If I saw a print line `stderr: CONFIGURATION_BUILD_DIR: build/Debug-iphoneos`, that means it first found the old and incorrect `CONFIGURATION_BUILD_DIR` before updating to the the new, so I was able to confirm that it would wait until it updated.
2023-09-26 17:48:19 +00:00

664 lines
23 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.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}`);
}
}