// 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 '../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 '../convert.dart'; import '../macos/xcode.dart'; import '../template.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<bool> debugApp({ required XcodeDebugProject project, required String deviceId, required List<String> 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<String>? stdoutSubscription; StreamSubscription<String>? stderrSubscription; try { startDebugActionProcess = await _processUtils.start( <String>[ ..._xcode.xcrunCommand(), 'osascript', '-l', 'JavaScript', _xcode.xcodeAutomationScriptPath, 'debug', '--xcode-path', _xcode.xcodeAppPath, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--device-id', deviceId, '--scheme', project.scheme, '--skip-building', '--launch-args', json.encode(launchArguments), if (project.verboseLogging) '--verbose', ], ); final StringBuffer stdoutBuffer = StringBuffer(); stdoutSubscription = startDebugActionProcess!.stdout .transform<String>(utf8.decoder) .transform<String>(const LineSplitter()) .listen((String line) { _logger.printTrace(line); stdoutBuffer.write(line); }); final StringBuffer stderrBuffer = StringBuffer(); bool permissionWarningPrinted = false; // console.log from the script are found in the stderr stderrSubscription = startDebugActionProcess!.stderr .transform<String>(utf8.decoder) .transform<String>(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<bool> 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<void>.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<bool> _forceExitXcode() async { final RunResult result = await _processUtils.run( <String>[ 'killall', '-9', 'Xcode', ], ); if (result.exitCode != 0) { _logger.printError('Error killing Xcode: ${result.exitCode}\n${result.stderr}'); return false; } return true; } Future<bool> _isProjectOpenInXcode({ required XcodeDebugProject project, }) async { final RunResult result = await _processUtils.run( <String>[ ..._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) { try { final Object decodeResult = json.decode(results) as Object; if (decodeResult is Map<String, Object?>) { final XcodeAutomationScriptResponse response = XcodeAutomationScriptResponse.fromJson(decodeResult); // Status should always be found if (response.status != null) { return response; } } _logger.printError('osascript returned unexpected JSON response: $results'); return null; } on FormatException { _logger.printError('osascript returned non-JSON response: $results'); return null; } } Future<bool> _openProjectInXcode({ required Directory xcodeWorkspace, }) async { try { await _processUtils.run( <String>[ 'open', '-a', _xcode.xcodeAppPath, '-g', // Do not bring the application to the foreground. '-j', // Launches the app hidden. 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<bool> stopDebuggingApp({ required XcodeDebugProject project, bool closeXcode = false, bool promptToSaveOnClose = false, }) async { final RunResult result = await _processUtils.run( <String>[ ..._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<XcodeDebugProject> 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, <String, Object>{ 'applicationBundlePath': deviceBundlePath }, printStatusWhenWriting: false, ); return XcodeDebugProject( scheme: 'Runner', xcodeProject: tempXcodeProject.childDirectory('Runner.xcodeproj'), xcodeWorkspace: tempXcodeProject.childDirectory('Runner.xcworkspace'), isTemporaryProject: true, verboseLogging: verboseLogging, ); } } @visibleForTesting class XcodeAutomationScriptResponse { XcodeAutomationScriptResponse._({ this.status, this.errorMessage, this.debugResult, }); factory XcodeAutomationScriptResponse.fromJson(Map<String, Object?> data) { XcodeAutomationScriptDebugResult? debugResult; if (data['debugResult'] != null && data['debugResult'] is Map<String, Object?>) { debugResult = XcodeAutomationScriptDebugResult.fromJson( data['debugResult']! as Map<String, Object?>, ); } 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<String, Object?> 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 (sucessfully 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, this.isTemporaryProject = false, this.verboseLogging = false, }); final String scheme; final Directory xcodeWorkspace; final Directory xcodeProject; 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; }