| // 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 '../artifacts.dart'; |
| import '../base/file_system.dart'; |
| import '../base/io.dart'; |
| import '../base/logger.dart'; |
| import '../base/platform.dart'; |
| import '../base/process.dart'; |
| import '../cache.dart'; |
| import '../convert.dart'; |
| import 'code_signing.dart'; |
| import 'iproxy.dart'; |
| |
| // Error message patterns from ios-deploy output |
| const String noProvisioningProfileErrorOne = 'Error 0xe8008015'; |
| const String noProvisioningProfileErrorTwo = 'Error 0xe8000067'; |
| const String deviceLockedError = 'e80000e2'; |
| const String deviceLockedErrorMessage = 'the device was not, or could not be, unlocked'; |
| const String unknownAppLaunchError = 'Error 0xe8000022'; |
| |
| class IOSDeploy { |
| IOSDeploy({ |
| required Artifacts artifacts, |
| required Cache cache, |
| required Logger logger, |
| required Platform platform, |
| required ProcessManager processManager, |
| }) : _platform = platform, |
| _cache = cache, |
| _processUtils = ProcessUtils(processManager: processManager, logger: logger), |
| _logger = logger, |
| _binaryPath = artifacts.getHostArtifact(HostArtifact.iosDeploy).path; |
| |
| final Cache _cache; |
| final String _binaryPath; |
| final Logger _logger; |
| final Platform _platform; |
| final ProcessUtils _processUtils; |
| |
| Map<String, String> get iosDeployEnv { |
| // Push /usr/bin to the front of PATH to pick up default system python, package 'six'. |
| // |
| // ios-deploy transitively depends on LLDB.framework, which invokes a |
| // Python script that uses package 'six'. LLDB.framework relies on the |
| // python at the front of the path, which may not include package 'six'. |
| // Ensure that we pick up the system install of python, which includes it. |
| final Map<String, String> environment = Map<String, String>.of(_platform.environment); |
| environment['PATH'] = '/usr/bin:${environment['PATH']}'; |
| environment.addEntries(<MapEntry<String, String>>[_cache.dyLdLibEntry]); |
| return environment; |
| } |
| |
| /// Uninstalls the specified app bundle. |
| /// |
| /// Uses ios-deploy and returns the exit code. |
| Future<int> uninstallApp({ |
| required String deviceId, |
| required String bundleId, |
| }) async { |
| final List<String> launchCommand = <String>[ |
| _binaryPath, |
| '--id', |
| deviceId, |
| '--uninstall_only', |
| '--bundle_id', |
| bundleId, |
| ]; |
| |
| return _processUtils.stream( |
| launchCommand, |
| mapFunction: _monitorFailure, |
| trace: true, |
| environment: iosDeployEnv, |
| ); |
| } |
| |
| /// Installs the specified app bundle. |
| /// |
| /// Uses ios-deploy and returns the exit code. |
| Future<int> installApp({ |
| required String deviceId, |
| required String bundlePath, |
| required List<String>launchArguments, |
| required IOSDeviceConnectionInterface interfaceType, |
| Directory? appDeltaDirectory, |
| }) async { |
| appDeltaDirectory?.createSync(recursive: true); |
| final List<String> launchCommand = <String>[ |
| _binaryPath, |
| '--id', |
| deviceId, |
| '--bundle', |
| bundlePath, |
| if (appDeltaDirectory != null) ...<String>[ |
| '--app_deltas', |
| appDeltaDirectory.path, |
| ], |
| if (interfaceType != IOSDeviceConnectionInterface.network) |
| '--no-wifi', |
| if (launchArguments.isNotEmpty) ...<String>[ |
| '--args', |
| launchArguments.join(' '), |
| ], |
| ]; |
| |
| return _processUtils.stream( |
| launchCommand, |
| mapFunction: _monitorFailure, |
| trace: true, |
| environment: iosDeployEnv, |
| ); |
| } |
| |
| /// Returns [IOSDeployDebugger] wrapping attached debugger logic. |
| /// |
| /// This method does not install the app. Call [IOSDeployDebugger.launchAndAttach()] |
| /// to install and attach the debugger to the specified app bundle. |
| IOSDeployDebugger prepareDebuggerForLaunch({ |
| required String deviceId, |
| required String bundlePath, |
| required List<String> launchArguments, |
| required IOSDeviceConnectionInterface interfaceType, |
| Directory? appDeltaDirectory, |
| required bool uninstallFirst, |
| }) { |
| appDeltaDirectory?.createSync(recursive: true); |
| // Interactive debug session to support sending the lldb detach command. |
| final List<String> launchCommand = <String>[ |
| 'script', |
| '-t', |
| '0', |
| '/dev/null', |
| _binaryPath, |
| '--id', |
| deviceId, |
| '--bundle', |
| bundlePath, |
| if (appDeltaDirectory != null) ...<String>[ |
| '--app_deltas', |
| appDeltaDirectory.path, |
| ], |
| if (uninstallFirst) |
| '--uninstall', |
| '--debug', |
| if (interfaceType != IOSDeviceConnectionInterface.network) |
| '--no-wifi', |
| if (launchArguments.isNotEmpty) ...<String>[ |
| '--args', |
| launchArguments.join(' '), |
| ], |
| ]; |
| return IOSDeployDebugger( |
| launchCommand: launchCommand, |
| logger: _logger, |
| processUtils: _processUtils, |
| iosDeployEnv: iosDeployEnv, |
| ); |
| } |
| |
| /// Installs and then runs the specified app bundle. |
| /// |
| /// Uses ios-deploy and returns the exit code. |
| Future<int> launchApp({ |
| required String deviceId, |
| required String bundlePath, |
| required List<String> launchArguments, |
| required IOSDeviceConnectionInterface interfaceType, |
| required bool uninstallFirst, |
| Directory? appDeltaDirectory, |
| }) async { |
| appDeltaDirectory?.createSync(recursive: true); |
| final List<String> launchCommand = <String>[ |
| _binaryPath, |
| '--id', |
| deviceId, |
| '--bundle', |
| bundlePath, |
| if (appDeltaDirectory != null) ...<String>[ |
| '--app_deltas', |
| appDeltaDirectory.path, |
| ], |
| if (interfaceType != IOSDeviceConnectionInterface.network) |
| '--no-wifi', |
| if (uninstallFirst) |
| '--uninstall', |
| '--justlaunch', |
| if (launchArguments.isNotEmpty) ...<String>[ |
| '--args', |
| launchArguments.join(' '), |
| ], |
| ]; |
| |
| return _processUtils.stream( |
| launchCommand, |
| mapFunction: _monitorFailure, |
| trace: true, |
| environment: iosDeployEnv, |
| ); |
| } |
| |
| Future<bool> isAppInstalled({ |
| required String bundleId, |
| required String deviceId, |
| }) async { |
| final List<String> launchCommand = <String>[ |
| _binaryPath, |
| '--id', |
| deviceId, |
| '--exists', |
| '--timeout', // If the device is not connected, ios-deploy will wait forever. |
| '10', |
| '--bundle_id', |
| bundleId, |
| ]; |
| final RunResult result = await _processUtils.run( |
| launchCommand, |
| environment: iosDeployEnv, |
| ); |
| // Device successfully connected, but app not installed. |
| if (result.exitCode == 255) { |
| _logger.printTrace('$bundleId not installed on $deviceId'); |
| return false; |
| } |
| if (result.exitCode != 0) { |
| _logger.printTrace('App install check failed: ${result.stderr}'); |
| return false; |
| } |
| return true; |
| } |
| |
| String _monitorFailure(String stdout) => _monitorIOSDeployFailure(stdout, _logger); |
| } |
| |
| /// lldb attach state flow. |
| enum _IOSDeployDebuggerState { |
| detached, |
| launching, |
| attached, |
| } |
| |
| /// Wrapper to launch app and attach the debugger with ios-deploy. |
| class IOSDeployDebugger { |
| IOSDeployDebugger({ |
| required Logger logger, |
| required ProcessUtils processUtils, |
| required List<String> launchCommand, |
| required Map<String, String> iosDeployEnv, |
| }) : _processUtils = processUtils, |
| _logger = logger, |
| _launchCommand = launchCommand, |
| _iosDeployEnv = iosDeployEnv, |
| _debuggerState = _IOSDeployDebuggerState.detached; |
| |
| /// Create a [IOSDeployDebugger] for testing. |
| /// |
| /// Sets the command to "ios-deploy" and environment to an empty map. |
| @visibleForTesting |
| factory IOSDeployDebugger.test({ |
| required ProcessManager processManager, |
| Logger? logger, |
| }) { |
| final Logger debugLogger = logger ?? BufferLogger.test(); |
| return IOSDeployDebugger( |
| logger: debugLogger, |
| processUtils: ProcessUtils(logger: debugLogger, processManager: processManager), |
| launchCommand: <String>['ios-deploy'], |
| iosDeployEnv: <String, String>{}, |
| ); |
| } |
| |
| final Logger _logger; |
| final ProcessUtils _processUtils; |
| final List<String> _launchCommand; |
| final Map<String, String> _iosDeployEnv; |
| |
| Process? _iosDeployProcess; |
| |
| Stream<String> get logLines => _debuggerOutput.stream; |
| final StreamController<String> _debuggerOutput = StreamController<String>.broadcast(); |
| |
| bool get debuggerAttached => _debuggerState == _IOSDeployDebuggerState.attached; |
| _IOSDeployDebuggerState _debuggerState; |
| |
| // (lldb) platform select remote-'ios' --sysroot |
| // https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L33 |
| // This regex is to get the configurable lldb prompt. By default this prompt will be "lldb". |
| static final RegExp _lldbPlatformSelect = RegExp(r"\s*platform select remote-'ios' --sysroot"); |
| |
| // (lldb) run |
| // https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L51 |
| static final RegExp _lldbProcessExit = RegExp(r'Process \d* exited with status ='); |
| |
| // (lldb) Process 6152 stopped |
| static final RegExp _lldbProcessStopped = RegExp(r'Process \d* stopped'); |
| |
| // (lldb) Process 6152 detached |
| static final RegExp _lldbProcessDetached = RegExp(r'Process \d* detached'); |
| |
| // (lldb) Process 6152 resuming |
| static final RegExp _lldbProcessResuming = RegExp(r'Process \d+ resuming'); |
| |
| // Send signal to stop (pause) the app. Used before a backtrace dump. |
| static const String _signalStop = 'process signal SIGSTOP'; |
| |
| static const String _processResume = 'process continue'; |
| static const String _processInterrupt = 'process interrupt'; |
| |
| // Print backtrace for all threads while app is stopped. |
| static const String _backTraceAll = 'thread backtrace all'; |
| |
| /// If this is non-null, then the app process is paused and awaiting backtrace logging. |
| /// |
| /// The future should be completed once the backtraces are logged. |
| Completer<void>? _processResumeCompleter; |
| |
| /// Launch the app on the device, and attach the debugger. |
| /// |
| /// Returns whether or not the debugger successfully attached. |
| Future<bool> launchAndAttach() async { |
| // Return when the debugger attaches, or the ios-deploy process exits. |
| |
| // (lldb) run |
| // https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L51 |
| RegExp lldbRun = RegExp(r'\(lldb\)\s*run'); |
| |
| final Completer<bool> debuggerCompleter = Completer<bool>(); |
| try { |
| _iosDeployProcess = await _processUtils.start( |
| _launchCommand, |
| environment: _iosDeployEnv, |
| ); |
| String? lastLineFromDebugger; |
| final StreamSubscription<String> stdoutSubscription = _iosDeployProcess!.stdout |
| .transform<String>(utf8.decoder) |
| .transform<String>(const LineSplitter()) |
| .listen((String line) { |
| _monitorIOSDeployFailure(line, _logger); |
| |
| // (lldb) platform select remote-'ios' --sysroot |
| // Use the configurable custom lldb prompt in the regex. The developer can set this prompt to anything. |
| // For example `settings set prompt "(mylldb)"` in ~/.lldbinit results in: |
| // "(mylldb) platform select remote-'ios' --sysroot" |
| if (_lldbPlatformSelect.hasMatch(line)) { |
| final String platformSelect = _lldbPlatformSelect.stringMatch(line) ?? ''; |
| if (platformSelect.isEmpty) { |
| return; |
| } |
| final int promptEndIndex = line.indexOf(platformSelect); |
| if (promptEndIndex == -1) { |
| return; |
| } |
| final String prompt = line.substring(0, promptEndIndex); |
| lldbRun = RegExp(RegExp.escape(prompt) + r'\s*run'); |
| _logger.printTrace(line); |
| return; |
| } |
| |
| // (lldb) run |
| // success |
| // 2020-09-15 13:42:25.185474-0700 Runner[477:181141] flutter: The Dart VM service is listening on http://127.0.0.1:57782/ |
| if (lldbRun.hasMatch(line)) { |
| _logger.printTrace(line); |
| _debuggerState = _IOSDeployDebuggerState.launching; |
| return; |
| } |
| // Next line after "run" must be "success", or the attach failed. |
| // Example: "error: process launch failed" |
| if (_debuggerState == _IOSDeployDebuggerState.launching) { |
| _logger.printTrace(line); |
| final bool attachSuccess = line == 'success'; |
| _debuggerState = attachSuccess ? _IOSDeployDebuggerState.attached : _IOSDeployDebuggerState.detached; |
| if (!debuggerCompleter.isCompleted) { |
| debuggerCompleter.complete(attachSuccess); |
| } |
| return; |
| } |
| if (line == _signalStop) { |
| // The app is about to be stopped. Only show in verbose mode. |
| _logger.printTrace(line); |
| return; |
| } |
| if (line == _backTraceAll) { |
| // The app is stopped and the backtrace for all threads will be printed. |
| _logger.printTrace(line); |
| // Even though we're not "detached", just stopped, mark as detached so the backtrace |
| // is only show in verbose. |
| _debuggerState = _IOSDeployDebuggerState.detached; |
| |
| // If we paused the app and are waiting to resume it, complete the completer |
| final Completer<void>? processResumeCompleter = _processResumeCompleter; |
| if (processResumeCompleter != null) { |
| _processResumeCompleter = null; |
| processResumeCompleter.complete(); |
| } |
| return; |
| } |
| |
| if (line.contains('PROCESS_STOPPED') || _lldbProcessStopped.hasMatch(line)) { |
| // The app has been stopped. Dump the backtrace, and detach. |
| _logger.printTrace(line); |
| _iosDeployProcess?.stdin.writeln(_backTraceAll); |
| if (_processResumeCompleter == null) { |
| detach(); |
| } |
| return; |
| } |
| |
| if (line.contains('PROCESS_EXITED') || _lldbProcessExit.hasMatch(line)) { |
| // The app exited or crashed, so exit. Continue passing debugging |
| // messages to the log reader until it exits to capture crash dumps. |
| _logger.printTrace(line); |
| exit(); |
| return; |
| } |
| if (_lldbProcessDetached.hasMatch(line)) { |
| // The debugger has detached from the app, and there will be no more debugging messages. |
| // Kill the ios-deploy process. |
| exit(); |
| return; |
| } |
| |
| if (_lldbProcessResuming.hasMatch(line)) { |
| _logger.printTrace(line); |
| // we marked this detached when we received [_backTraceAll] |
| _debuggerState = _IOSDeployDebuggerState.attached; |
| return; |
| } |
| |
| if (_debuggerState != _IOSDeployDebuggerState.attached) { |
| _logger.printTrace(line); |
| return; |
| } |
| if (lastLineFromDebugger != null && lastLineFromDebugger!.isNotEmpty && line.isEmpty) { |
| // The lldb console stream from ios-deploy is separated lines by an extra \r\n. |
| // To avoid all lines being double spaced, if the last line from the |
| // debugger was not an empty line, skip this empty line. |
| // This will still cause "legit" logged newlines to be doubled... |
| } else if (!_debuggerOutput.isClosed) { |
| _debuggerOutput.add(line); |
| } |
| lastLineFromDebugger = line; |
| }); |
| final StreamSubscription<String> stderrSubscription = _iosDeployProcess!.stderr |
| .transform<String>(utf8.decoder) |
| .transform<String>(const LineSplitter()) |
| .listen((String line) { |
| _monitorIOSDeployFailure(line, _logger); |
| _logger.printTrace(line); |
| }); |
| unawaited(_iosDeployProcess!.exitCode.then((int status) async { |
| _logger.printTrace('ios-deploy exited with code $exitCode'); |
| _debuggerState = _IOSDeployDebuggerState.detached; |
| await stdoutSubscription.cancel(); |
| await stderrSubscription.cancel(); |
| }).whenComplete(() async { |
| if (_debuggerOutput.hasListener) { |
| // Tell listeners the process died. |
| await _debuggerOutput.close(); |
| } |
| if (!debuggerCompleter.isCompleted) { |
| debuggerCompleter.complete(false); |
| } |
| _iosDeployProcess = null; |
| })); |
| } on ProcessException catch (exception, stackTrace) { |
| _logger.printTrace('ios-deploy failed: $exception'); |
| _debuggerState = _IOSDeployDebuggerState.detached; |
| if (!_debuggerOutput.isClosed) { |
| _debuggerOutput.addError(exception, stackTrace); |
| } |
| } on ArgumentError catch (exception, stackTrace) { |
| _logger.printTrace('ios-deploy failed: $exception'); |
| _debuggerState = _IOSDeployDebuggerState.detached; |
| if (!_debuggerOutput.isClosed) { |
| _debuggerOutput.addError(exception, stackTrace); |
| } |
| } |
| // Wait until the debugger attaches, or the attempt fails. |
| return debuggerCompleter.future; |
| } |
| |
| bool exit() { |
| final bool success = (_iosDeployProcess == null) || _iosDeployProcess!.kill(); |
| _iosDeployProcess = null; |
| return success; |
| } |
| |
| /// Pause app, dump backtrace for debugging, and resume. |
| Future<void> pauseDumpBacktraceResume() async { |
| if (!debuggerAttached) { |
| return; |
| } |
| final Completer<void> completer = Completer<void>(); |
| _processResumeCompleter = completer; |
| try { |
| // Stop the app, which will prompt the backtrace to be printed for all threads in the stdoutSubscription handler. |
| _iosDeployProcess?.stdin.writeln(_processInterrupt); |
| } on SocketException catch (error) { |
| _logger.printTrace('Could not stop app from debugger: $error'); |
| } |
| // wait for backtrace to be dumped |
| await completer.future; |
| _iosDeployProcess?.stdin.writeln(_processResume); |
| } |
| |
| Future<void> stopAndDumpBacktrace() async { |
| if (!debuggerAttached) { |
| return; |
| } |
| try { |
| // Stop the app, which will prompt the backtrace to be printed for all threads in the stdoutSubscription handler. |
| _iosDeployProcess?.stdin.writeln(_signalStop); |
| } on SocketException catch (error) { |
| // Best effort, try to detach, but maybe the app already exited or already detached. |
| _logger.printTrace('Could not stop app from debugger: $error'); |
| } |
| // Wait for logging to finish on process exit. |
| return logLines.drain(); |
| } |
| |
| void detach() { |
| if (!debuggerAttached) { |
| return; |
| } |
| |
| try { |
| // Detach lldb from the app process. |
| _iosDeployProcess?.stdin.writeln('process detach'); |
| } on SocketException catch (error) { |
| // Best effort, try to detach, but maybe the app already exited or already detached. |
| _logger.printTrace('Could not detach from debugger: $error'); |
| } |
| } |
| } |
| |
| // Maps stdout line stream. Must return original line. |
| String _monitorIOSDeployFailure(String stdout, Logger logger) { |
| // Installation issues. |
| if (stdout.contains(noProvisioningProfileErrorOne) || stdout.contains(noProvisioningProfileErrorTwo)) { |
| logger.printError(noProvisioningProfileInstruction, emphasis: true); |
| |
| // Launch issues. |
| } else if (stdout.contains(deviceLockedError) || stdout.contains(deviceLockedErrorMessage)) { |
| logger.printError(''' |
| ═══════════════════════════════════════════════════════════════════════════════════ |
| Your device is locked. Unlock your device first before running. |
| ═══════════════════════════════════════════════════════════════════════════════════''', |
| emphasis: true); |
| } else if (stdout.contains(unknownAppLaunchError)) { |
| logger.printError(''' |
| ═══════════════════════════════════════════════════════════════════════════════════ |
| Error launching app. Try launching from within Xcode via: |
| open ios/Runner.xcworkspace |
| |
| Your Xcode version may be too old for your iOS version. |
| ═══════════════════════════════════════════════════════════════════════════════════''', |
| emphasis: true); |
| } |
| |
| return stdout; |
| } |