blob: 8db8b6563d8923c2d82120ebafc6bfcbcef68e69 [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 '../artifacts.dart';
import '../base/common.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../build_info.dart';
import '../cache.dart';
import '../convert.dart';
import 'code_signing.dart';
import 'devices.dart';
// Error message patterns from ios-deploy output
const String noProvisioningProfileErrorOne = 'Error 0xe8008015';
const String noProvisioningProfileErrorTwo = 'Error 0xe8000067';
const String deviceLockedError = 'e80000e2';
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.getArtifactPath(Artifact.iosDeploy, platform: TargetPlatform.ios);
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 IOSDeviceInterface interfaceType,
}) async {
final List<String> launchCommand = <String>[
_binaryPath,
'--id',
deviceId,
'--bundle',
bundlePath,
if (interfaceType != IOSDeviceInterface.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 IOSDeviceInterface interfaceType,
}) {
// 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,
'--debug',
if (interfaceType != IOSDeviceInterface.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 IOSDeviceInterface interfaceType,
}) async {
final List<String> launchCommand = <String>[
_binaryPath,
'--id',
deviceId,
'--bundle',
bundlePath,
if (interfaceType != IOSDeviceInterface.network)
'--no-wifi',
'--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) run
// https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L51
static final RegExp _lldbRun = RegExp(r'\(lldb\)\s*run');
// (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 =');
/// 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.
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) run
// success
// 2020-09-15 13:42:25.185474-0700 Runner[477:181141] flutter: Observatory 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.contains('PROCESS_STOPPED') ||
line.contains('PROCESS_EXITED') ||
_lldbProcessExit.hasMatch(line)) {
// The app exited or crashed, so stop echoing the output.
// Don't pass any further ios-deploy debugging messages to the log reader after it exits.
_debuggerState = _IOSDeployDebuggerState.detached;
_logger.printTrace(line);
if (!debuggerCompleter.isCompleted) {
debuggerCompleter.complete(false);
}
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 {
_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) {
_logger.printTrace('ios-deploy exited with code $exitCode');
_debuggerState = _IOSDeployDebuggerState.detached;
unawaited(stdoutSubscription.cancel());
unawaited(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;
_debuggerOutput.addError(exception, stackTrace);
} on ArgumentError catch (exception, stackTrace) {
_logger.printTrace('ios-deploy failed: $exception');
_debuggerState = _IOSDeployDebuggerState.detached;
_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;
}
void detach() {
if (!debuggerAttached) {
return;
}
try {
// Detach lldb from the app process.
_iosDeployProcess?.stdin?.writeln('process detach');
_debuggerState = _IOSDeployDebuggerState.detached;
} 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)) {
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;
}