// 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. import 'dart:async'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; import 'package:xml/xml.dart'; import 'package:xml/xpath.dart'; import '../base/common.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/template.dart'; import '../build_info.dart'; import '../convert.dart'; import '../macos/xcode.dart'; import '../project.dart'; import '../template.dart'; import 'xcode_build_settings.dart'; /// A class to handle interacting with Xcode via OSA (Open Scripting Architecture) /// Scripting to debug Flutter applications. class XcodeDebug { XcodeDebug({ required Logger logger, required ProcessManager processManager, required Xcode xcode, required FileSystem fileSystem, }) : _logger = logger, _processUtils = ProcessUtils(logger: logger, processManager: processManager), _xcode = xcode, _fileSystem = fileSystem; final ProcessUtils _processUtils; final Logger _logger; final Xcode _xcode; final FileSystem _fileSystem; /// Process to start Xcode's debug action. @visibleForTesting Process? startDebugActionProcess; /// Information about the project that is currently being debugged. @visibleForTesting XcodeDebugProject? currentDebuggingProject; /// Whether the debug action has been started. bool get debugStarted => currentDebuggingProject != null; /// Install, launch, and start a debug session for app through Xcode interface, /// automated by OSA scripting. First checks if the project is opened in /// Xcode. If it isn't, open it with the `open` command. /// /// The OSA script waits until the project is opened and the debug action /// has started. It does not wait for the app to install, launch, or start /// the debug session. Future debugApp({ required XcodeDebugProject project, required String deviceId, required List launchArguments, }) async { // If project is not already opened in Xcode, open it. if (!await _isProjectOpenInXcode(project: project)) { final bool openResult = await _openProjectInXcode(xcodeWorkspace: project.xcodeWorkspace); if (!openResult) { return openResult; } } currentDebuggingProject = project; StreamSubscription? stdoutSubscription; StreamSubscription? stderrSubscription; try { startDebugActionProcess = await _processUtils.start([ ..._xcode.xcrunCommand(), 'osascript', '-l', 'JavaScript', _xcode.xcodeAutomationScriptPath, 'debug', '--xcode-path', _xcode.xcodeAppPath, '--project-path', 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', project.scheme, '--skip-building', '--launch-args', json.encode(launchArguments), if (project.verboseLogging) '--verbose', ]); final stdoutBuffer = StringBuffer(); stdoutSubscription = startDebugActionProcess!.stdout .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String line) { _logger.printTrace(line); stdoutBuffer.write(line); }); final stderrBuffer = StringBuffer(); var permissionWarningPrinted = false; // console.log from the script are found in the stderr stderrSubscription = startDebugActionProcess!.stderr .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String line) { _logger.printTrace('stderr: $line'); stderrBuffer.write(line); // This error may occur if Xcode automation has not been allowed. // Example: Failed to get workspace: Error: An error occurred. if (!permissionWarningPrinted && line.contains('Failed to get workspace') && line.contains('An error occurred')) { _logger.printError( 'There was an error finding the project in Xcode. Ensure permission ' 'has been given to control Xcode in Settings > Privacy & Security > Automation.', ); permissionWarningPrinted = true; } }); final int exitCode = await startDebugActionProcess!.exitCode.whenComplete(() async { await stdoutSubscription?.cancel(); await stderrSubscription?.cancel(); startDebugActionProcess = null; }); if (exitCode != 0) { _logger.printError('Error executing osascript: $exitCode\n$stderrBuffer'); return false; } final XcodeAutomationScriptResponse? response = parseScriptResponse(stdoutBuffer.toString()); if (response == null) { return false; } if (response.status == false) { _logger.printError('Error starting debug session in Xcode: ${response.errorMessage}'); return false; } if (response.debugResult == null) { _logger.printError('Unable to get debug results from response: $stdoutBuffer'); return false; } if (response.debugResult?.status != 'running') { _logger.printError( 'Unexpected debug results: \n' ' Status: ${response.debugResult?.status}\n' ' Completed: ${response.debugResult?.completed}\n' ' Error Message: ${response.debugResult?.errorMessage}\n', ); return false; } return true; } on ProcessException catch (exception) { _logger.printError('Error executing osascript: $exitCode\n$exception'); await stdoutSubscription?.cancel(); await stderrSubscription?.cancel(); startDebugActionProcess = null; return false; } } /// Kills [startDebugActionProcess] if it's still running. If [force] is true, it /// will kill all Xcode app processes. Otherwise, it will stop the debug /// session in Xcode. If the project is temporary, it will close the Xcode /// window of the project and then delete the project. Future exit({bool force = false, @visibleForTesting bool skipDelay = false}) async { final bool success = (startDebugActionProcess == null) || startDebugActionProcess!.kill(); if (force) { await _forceExitXcode(); if (currentDebuggingProject != null) { final XcodeDebugProject project = currentDebuggingProject!; if (project.isTemporaryProject) { // Only delete if it exists. This is to prevent crashes when racing // with shutdown hooks to delete temporary files. ErrorHandlingFileSystem.deleteIfExists(project.xcodeProject.parent, recursive: true); } currentDebuggingProject = null; } } if (currentDebuggingProject != null) { final XcodeDebugProject project = currentDebuggingProject!; await stopDebuggingApp(project: project, closeXcode: project.isTemporaryProject); if (project.isTemporaryProject) { // Wait a couple seconds before deleting the project. If project is // still opened in Xcode and it's deleted, it will prompt the user to // restore it. if (!skipDelay) { await Future.delayed(const Duration(seconds: 2)); } try { project.xcodeProject.parent.deleteSync(recursive: true); } on FileSystemException { _logger.printError( 'Failed to delete temporary Xcode project: ${project.xcodeProject.parent.path}', ); } } currentDebuggingProject = null; } return success; } /// Kill all opened Xcode applications. Future _forceExitXcode() async { final RunResult result = await _processUtils.run(['killall', '-9', 'Xcode']); if (result.exitCode != 0) { _logger.printError('Error killing Xcode: ${result.exitCode}\n${result.stderr}'); return false; } return true; } Future _isProjectOpenInXcode({required XcodeDebugProject project}) async { final RunResult result = await _processUtils.run([ ..._xcode.xcrunCommand(), 'osascript', '-l', 'JavaScript', _xcode.xcodeAutomationScriptPath, 'check-workspace-opened', '--xcode-path', _xcode.xcodeAppPath, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, if (project.verboseLogging) '--verbose', ]); if (result.exitCode != 0) { _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}'); return false; } final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout); if (response == null) { return false; } if (response.status == false) { _logger.printTrace('Error checking if project opened in Xcode: ${response.errorMessage}'); return false; } return true; } @visibleForTesting XcodeAutomationScriptResponse? parseScriptResponse(String results) { // Some users reported text before the json. Trim any text before the opening // curly brace. // Example: `start process_extensions{"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"running","errorMessage":null}}` final String trimmedResults; final int jsonBeginIndex = results.indexOf('{'); if (jsonBeginIndex > -1) { trimmedResults = results.substring(jsonBeginIndex); } else { trimmedResults = results; } try { final decodeResult = json.decode(trimmedResults) as Object; if (decodeResult is Map) { final response = XcodeAutomationScriptResponse.fromJson(decodeResult); // Status should always be found if (response.status != null) { return response; } } _logger.printError('osascript returned unexpected JSON response: $trimmedResults'); return null; } on FormatException { _logger.printError('osascript returned non-JSON response: $trimmedResults'); return null; } } Future _openProjectInXcode({required Directory xcodeWorkspace}) async { try { await _processUtils.run([ 'open', '-a', _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); return true; } on ProcessException catch (error, stackTrace) { _logger.printError('$error', stackTrace: stackTrace); } return false; } /// Using OSA Scripting, stop the debug session in Xcode. /// /// If [closeXcode] is true, it will close the Xcode window that has the /// project opened. If [promptToSaveOnClose] is true, it will ask the user if /// they want to save any changes before it closes. Future stopDebuggingApp({ required XcodeDebugProject project, bool closeXcode = false, bool promptToSaveOnClose = false, }) async { final RunResult result = await _processUtils.run([ ..._xcode.xcrunCommand(), 'osascript', '-l', 'JavaScript', _xcode.xcodeAutomationScriptPath, 'stop', '--xcode-path', _xcode.xcodeAppPath, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, if (closeXcode) '--close-window', if (promptToSaveOnClose) '--prompt-to-save', if (project.verboseLogging) '--verbose', ]); if (result.exitCode != 0) { _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}'); return false; } final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout); if (response == null) { return false; } if (response.status == false) { _logger.printError('Error stopping app in Xcode: ${response.errorMessage}'); return false; } return true; } /// Create a temporary empty Xcode project with the application bundle /// location explicitly set. Future createXcodeProjectWithCustomBundle( String deviceBundlePath, { required TemplateRenderer templateRenderer, @visibleForTesting Directory? projectDestination, bool verboseLogging = false, }) async { final Directory tempXcodeProject = projectDestination ?? _fileSystem.systemTempDirectory.createTempSync('flutter_empty_xcode.'); final Template template = await Template.fromName( _fileSystem.path.join('xcode', 'ios', 'custom_application_bundle'), fileSystem: _fileSystem, templateManifest: null, logger: _logger, templateRenderer: templateRenderer, ); template.render(tempXcodeProject, { 'applicationBundlePath': deviceBundlePath, }, printStatusWhenWriting: false); return XcodeDebugProject( scheme: 'Runner', hostAppProjectName: 'Runner', xcodeProject: tempXcodeProject.childDirectory('Runner.xcodeproj'), xcodeWorkspace: tempXcodeProject.childDirectory('Runner.xcworkspace'), isTemporaryProject: true, verboseLogging: verboseLogging, ); } /// Ensure the Xcode project is set up to launch an LLDB debugger. If these /// settings are not set, the launch will fail with a "Cannot create a /// FlutterEngine instance in debug mode without Flutter tooling or Xcode." /// error message. These settings should be correct by default, but some users /// reported them not being so after upgrading to Xcode 15. void ensureXcodeDebuggerLaunchAction(File schemeFile) { if (!schemeFile.existsSync()) { _logger.printError('Failed to find ${schemeFile.path}'); return; } final String schemeXml = schemeFile.readAsStringSync(); try { final document = XmlDocument.parse(schemeXml); final Iterable nodes = document.xpath('/Scheme/LaunchAction'); if (nodes.isEmpty) { _logger.printError('Failed to find LaunchAction for the Scheme in ${schemeFile.path}.'); return; } final XmlNode launchAction = nodes.first; final XmlAttribute? debuggerIdentifier = launchAction.attributes .where((XmlAttribute attribute) => attribute.localName == 'selectedDebuggerIdentifier') .firstOrNull; final XmlAttribute? launcherIdentifier = launchAction.attributes .where((XmlAttribute attribute) => attribute.localName == 'selectedLauncherIdentifier') .firstOrNull; if (debuggerIdentifier == null || launcherIdentifier == null || !debuggerIdentifier.value.contains('LLDB') || !launcherIdentifier.value.contains('LLDB')) { throwToolExit(''' Your Xcode project is not setup to start a debugger. To fix this, launch Xcode and select "Product > Scheme > Edit Scheme", select "Run" in the sidebar, and ensure "Debug executable" is checked in the "Info" tab. '''); } } on XmlException catch (exception) { _logger.printError('Failed to parse ${schemeFile.path}: $exception'); } } /// Update CONFIGURATION_BUILD_DIR in the [project]'s Xcode build settings. Future updateConfigurationBuildDir({ required FlutterProject project, required BuildInfo buildInfo, String? mainPath, required String configurationBuildDir, }) async { await updateGeneratedXcodeProperties( project: project, buildInfo: buildInfo, targetOverride: mainPath, configurationBuildDir: configurationBuildDir, ); } } @visibleForTesting class XcodeAutomationScriptResponse { XcodeAutomationScriptResponse._({this.status, this.errorMessage, this.debugResult}); factory XcodeAutomationScriptResponse.fromJson(Map data) { XcodeAutomationScriptDebugResult? debugResult; if (data case {'debugResult': final Map resultData}) { debugResult = XcodeAutomationScriptDebugResult.fromJson(resultData); } return XcodeAutomationScriptResponse._( status: data['status'] is bool? ? data['status'] as bool? : null, errorMessage: data['errorMessage']?.toString(), debugResult: debugResult, ); } final bool? status; final String? errorMessage; final XcodeAutomationScriptDebugResult? debugResult; } @visibleForTesting class XcodeAutomationScriptDebugResult { XcodeAutomationScriptDebugResult._({ required this.completed, required this.status, required this.errorMessage, }); factory XcodeAutomationScriptDebugResult.fromJson(Map data) { return XcodeAutomationScriptDebugResult._( completed: data['completed'] is bool? ? data['completed'] as bool? : null, status: data['status']?.toString(), errorMessage: data['errorMessage']?.toString(), ); } /// Whether this scheme action has completed (successfully or otherwise). Will /// be false if still running. final bool? completed; /// The status of the debug action. Potential statuses include: /// `not yet started`, `‌running`, `‌cancelled`, `‌failed`, `‌error occurred`, /// and `‌succeeded`. /// /// Only the status of `‌running` indicates the debug action has started successfully. /// For example, `‌succeeded` often does not indicate success as if the action fails, /// it will sometimes return `‌succeeded`. final String? status; /// When [status] is `‌error occurred`, an error message is provided. /// Otherwise, this will be null. final String? errorMessage; } class XcodeDebugProject { XcodeDebugProject({ required this.scheme, required this.xcodeWorkspace, required this.xcodeProject, required this.hostAppProjectName, this.expectedConfigurationBuildDir, this.isTemporaryProject = false, this.verboseLogging = false, }); 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 /// additional information via console.log, which is sent to stderr. final bool verboseLogging; }