mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Some users have their Xcode settings set to not debug (see example here https://github.com/flutter/flutter/issues/136197#issuecomment-1766834195). This will cause the [engine check for a debugger](22ce5c6a45/runtime/ptrace_check.cc (L56-L71)) to fail, which will cause an error and cause the app to crash.
This PR parses the scheme file to ensure the scheme is set to start a debugger and warn the user if it's not.
Fixes https://github.com/flutter/flutter/issues/136197.
544 lines
18 KiB
Dart
544 lines
18 KiB
Dart
// 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? debuggerIdentifer = launchAction.attributes
|
||
.where((XmlAttribute attribute) =>
|
||
attribute.localName == 'selectedDebuggerIdentifier')
|
||
.firstOrNull;
|
||
final XmlAttribute? launcherIdentifer = launchAction.attributes
|
||
.where((XmlAttribute attribute) =>
|
||
attribute.localName == 'selectedLauncherIdentifier')
|
||
.firstOrNull;
|
||
if (debuggerIdentifer == null ||
|
||
launcherIdentifer == null ||
|
||
!debuggerIdentifer.value.contains('LLDB') ||
|
||
!launcherIdentifer.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 (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,
|
||
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;
|
||
}
|