diff --git a/packages/flutter_tools/bin/xcode_debug.js b/packages/flutter_tools/bin/xcode_debug.js index 25f16a27298..611ba1ba8fe 100644 --- a/packages/flutter_tools/bin/xcode_debug.js +++ b/packages/flutter_tools/bin/xcode_debug.js @@ -61,6 +61,11 @@ class CommandArguments { 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']); @@ -91,6 +96,45 @@ class CommandArguments { 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. * @@ -101,33 +145,28 @@ class CommandArguments { * 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; + 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. * @@ -182,9 +221,7 @@ class CommandArguments { if (this.isArgumentAllowed(flag, value) === false) { return null; } - if (value == null || value === '') { - throw `Missing value for ${flag}`; - } + this.validateRequiredArgument(flag, value); return value; } @@ -226,9 +263,7 @@ class CommandArguments { if (this.isArgumentAllowed(flag, value) === false) { return null; } - if (value == null || value === '') { - throw `Missing value for ${flag}`; - } + this.validateRequiredArgument(flag, value); try { return JSON.parse(value); } catch (e) { @@ -347,6 +382,15 @@ function debugApp(xcode, args) { 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 @@ -528,3 +572,92 @@ function stopApp(xcode, args) { } 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}`); + } +} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 0316576fb6e..ca6e594885f 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -721,6 +721,18 @@ class IOSDevice extends Device { return LaunchResult.failed(); } finally { startAppStatus.stop(); + + if ((isCoreDevice || forceXcodeDebugWorkflow) && debuggingOptions.debuggingEnabled && package is BuildableIOSApp) { + // When debugging via Xcode, after the app launches, reset the Generated + // settings to not include the custom configuration build directory. + // This is to prevent confusion if the project is later ran via Xcode + // rather than the Flutter CLI. + await updateGeneratedXcodeProperties( + project: FlutterProject.current(), + buildInfo: debuggingOptions.buildInfo, + targetOverride: mainPath, + ); + } } } @@ -818,6 +830,8 @@ class IOSDevice extends Device { scheme: scheme, xcodeProject: project.xcodeProject, xcodeWorkspace: project.xcodeWorkspace!, + hostAppProjectName: project.hostAppProjectName, + expectedConfigurationBuildDir: bundle.parent.absolute.path, verboseLogging: _logger.isVerbose, ); } else { @@ -839,18 +853,6 @@ class IOSDevice extends Device { shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); } - if (package is BuildableIOSApp) { - // After automating Xcode, reset the Generated settings to not include - // the custom configuration build directory. This is to prevent - // confusion if the project is later ran via Xcode rather than the - // Flutter CLI. - await updateGeneratedXcodeProperties( - project: flutterProject, - buildInfo: debuggingOptions.buildInfo, - targetOverride: mainPath, - ); - } - return debugSuccess; } } diff --git a/packages/flutter_tools/lib/src/ios/xcode_debug.dart b/packages/flutter_tools/lib/src/ios/xcode_debug.dart index e1b50364357..563ec9d8e34 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_debug.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_debug.dart @@ -85,6 +85,13 @@ class XcodeDebug { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, + if (project.expectedConfigurationBuildDir != null) + ...[ + '--expected-configuration-build-dir', + project.expectedConfigurationBuildDir!, + ], '--device-id', deviceId, '--scheme', @@ -310,6 +317,7 @@ class XcodeDebug { _xcode.xcodeAppPath, '-g', // Do not bring the application to the foreground. '-j', // Launches the app hidden. + '-F', // Open "fresh", without restoring windows. xcodeWorkspace.path ], throwOnError: true, @@ -396,6 +404,7 @@ class XcodeDebug { return XcodeDebugProject( scheme: 'Runner', + hostAppProjectName: 'Runner', xcodeProject: tempXcodeProject.childDirectory('Runner.xcodeproj'), xcodeWorkspace: tempXcodeProject.childDirectory('Runner.xcworkspace'), isTemporaryProject: true, @@ -470,6 +479,8 @@ class XcodeDebugProject { required this.scheme, required this.xcodeWorkspace, required this.xcodeProject, + required this.hostAppProjectName, + this.expectedConfigurationBuildDir, this.isTemporaryProject = false, this.verboseLogging = false, }); @@ -477,6 +488,8 @@ class XcodeDebugProject { final String scheme; final Directory xcodeWorkspace; final Directory xcodeProject; + final String hostAppProjectName; + final String? expectedConfigurationBuildDir; final bool isTemporaryProject; /// When [verboseLogging] is true, the xcode_debug.js script will log diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index 00d19d1edea..68d78e5f9b3 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -472,6 +472,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -534,6 +535,8 @@ void main() { scheme: 'Runner', xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + hostAppProjectName: 'Runner', + expectedConfigurationBuildDir: '/build/ios/iphoneos', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 8e8e2235a6b..9ca0aceca47 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -625,6 +625,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -669,6 +670,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -729,6 +731,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -781,6 +784,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], diff --git a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart index cbd2416c2d9..0fe3a8aa9bd 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart @@ -56,10 +56,11 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); }); - testWithoutContext('succeeds in opening and debugging with launch options and verbose logging', () async { + testWithoutContext('succeeds in opening and debugging with launch options, expectedConfigurationBuildDir, and verbose logging', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ @@ -88,6 +89,7 @@ void main() { pathToXcodeApp, '-g', '-j', + '-F', xcworkspace.path ], ), @@ -105,6 +107,10 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, + '--expected-configuration-build-dir', + '/build/ios/iphoneos', '--device-id', deviceId, '--scheme', @@ -131,6 +137,8 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', + expectedConfigurationBuildDir: '/build/ios/iphoneos', verboseLogging: true, ); @@ -150,7 +158,7 @@ void main() { expect(status, true); }); - testWithoutContext('succeeds in opening and debugging without launch options and verbose logging', () async { + testWithoutContext('succeeds in opening and debugging without launch options, expectedConfigurationBuildDir, and verbose logging', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ @@ -178,6 +186,7 @@ void main() { pathToXcodeApp, '-g', '-j', + '-F', xcworkspace.path ], ), @@ -195,6 +204,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -257,6 +268,7 @@ void main() { pathToXcodeApp, '-g', '-j', + '-F', xcworkspace.path ], exception: ProcessException( @@ -266,6 +278,7 @@ void main() { '/non_existant_path', '-g', '-j', + '-F', xcworkspace.path, ], 'The application /non_existant_path cannot be opened for an unexpected reason', @@ -332,6 +345,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -401,6 +416,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -474,6 +491,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -547,6 +566,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -674,6 +695,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, @@ -731,6 +753,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', isTemporaryProject: true, ); @@ -794,6 +817,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', isTemporaryProject: true, ); final XcodeDebug xcodeDebug = XcodeDebug( @@ -857,6 +881,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, @@ -899,6 +924,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', isTemporaryProject: true, ); final XcodeDebug xcodeDebug = XcodeDebug( @@ -950,6 +976,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); });