blob: ac86858693b9d08e40e6988c0fbd82080ee28d9a [file] [log] [blame]
// 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 '../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,
'--project-name',
project.hostAppProjectName,
if (project.expectedConfigurationBuildDir != null)
...<String>[
'--expected-configuration-build-dir',
project.expectedConfigurationBuildDir!,
],
'--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.
'-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<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',
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 XmlDocument document = XmlDocument.parse(schemeXml);
final Iterable<XmlNode> 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');
}
}
}
@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 (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;
}