| // Copyright 2016 The Chromium 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 'dart:convert'; |
| |
| import 'package:meta/meta.dart'; |
| |
| import '../application_package.dart'; |
| import '../base/file_system.dart'; |
| import '../base/io.dart'; |
| import '../base/logger.dart'; |
| import '../base/platform.dart'; |
| import '../base/process.dart'; |
| import '../base/process_manager.dart'; |
| import '../build_info.dart'; |
| import '../device.dart'; |
| import '../globals.dart'; |
| import '../protocol_discovery.dart'; |
| import 'code_signing.dart'; |
| import 'ios_workflow.dart'; |
| import 'mac.dart'; |
| |
| const String _kIdeviceinstallerInstructions = |
| 'To work with iOS devices, please install ideviceinstaller. To install, run:\n' |
| 'brew install ideviceinstaller.'; |
| |
| const Duration kPortForwardTimeout = Duration(seconds: 10); |
| |
| class IOSDeploy { |
| const IOSDeploy(); |
| |
| /// Installs and runs the specified app bundle using ios-deploy, then returns |
| /// the exit code. |
| Future<int> runApp({ |
| @required String deviceId, |
| @required String bundlePath, |
| @required List<String> launchArguments, |
| }) async { |
| final List<String> launchCommand = <String>[ |
| '/usr/bin/env', |
| 'ios-deploy', |
| '--id', |
| deviceId, |
| '--bundle', |
| bundlePath, |
| '--no-wifi', |
| '--justlaunch', |
| ]; |
| if (launchArguments.isNotEmpty) { |
| launchCommand.add('--args'); |
| launchCommand.add('${launchArguments.join(" ")}'); |
| } |
| |
| // 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 does include |
| // it. |
| final Map<String, String> iosDeployEnv = new Map<String, String>.from(platform.environment); |
| iosDeployEnv['PATH'] = '/usr/bin:${iosDeployEnv['PATH']}'; |
| |
| return await runCommandAndStreamOutput( |
| launchCommand, |
| mapFunction: _monitorInstallationFailure, |
| trace: true, |
| environment: iosDeployEnv, |
| ); |
| } |
| |
| // Maps stdout line stream. Must return original line. |
| String _monitorInstallationFailure(String stdout) { |
| // Installation issues. |
| if (stdout.contains('Error 0xe8008015') || stdout.contains('Error 0xe8000067')) { |
| printError(noProvisioningProfileInstruction, emphasis: true); |
| |
| // Launch issues. |
| } else if (stdout.contains('e80000e2')) { |
| printError(''' |
| ═══════════════════════════════════════════════════════════════════════════════════ |
| Your device is locked. Unlock your device first before running. |
| ═══════════════════════════════════════════════════════════════════════════════════''', |
| emphasis: true); |
| } else if (stdout.contains('Error 0xe8000022')) { |
| 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; |
| } |
| } |
| |
| class IOSDevices extends PollingDeviceDiscovery { |
| IOSDevices() : super('iOS devices'); |
| |
| @override |
| bool get supportsPlatform => platform.isMacOS; |
| |
| @override |
| bool get canListAnything => iosWorkflow.canListDevices; |
| |
| @override |
| Future<List<Device>> pollingGetDevices() => IOSDevice.getAttachedDevices(); |
| } |
| |
| class IOSDevice extends Device { |
| IOSDevice(String id, { this.name, String sdkVersion }) : _sdkVersion = sdkVersion, super(id) { |
| _installerPath = _checkForCommand('ideviceinstaller'); |
| _iproxyPath = _checkForCommand('iproxy'); |
| } |
| |
| String _installerPath; |
| String _iproxyPath; |
| |
| final String _sdkVersion; |
| |
| @override |
| bool get supportsHotMode => true; |
| |
| @override |
| final String name; |
| |
| Map<ApplicationPackage, _IOSDeviceLogReader> _logReaders; |
| |
| _IOSDevicePortForwarder _portForwarder; |
| |
| @override |
| Future<bool> get isLocalEmulator async => false; |
| |
| @override |
| bool get supportsStartPaused => false; |
| |
| static Future<List<IOSDevice>> getAttachedDevices() async { |
| if (!iMobileDevice.isInstalled) |
| return <IOSDevice>[]; |
| |
| final List<IOSDevice> devices = <IOSDevice>[]; |
| for (String id in (await iMobileDevice.getAvailableDeviceIDs()).split('\n')) { |
| id = id.trim(); |
| if (id.isEmpty) |
| continue; |
| |
| final String deviceName = await iMobileDevice.getInfoForDevice(id, 'DeviceName'); |
| final String sdkVersion = await iMobileDevice.getInfoForDevice(id, 'ProductVersion'); |
| devices.add(new IOSDevice(id, name: deviceName, sdkVersion: sdkVersion)); |
| } |
| return devices; |
| } |
| |
| static String _checkForCommand( |
| String command, [ |
| String macInstructions = _kIdeviceinstallerInstructions |
| ]) { |
| try { |
| command = runCheckedSync(<String>['which', command]).trim(); |
| } catch (e) { |
| if (platform.isMacOS) { |
| printError('$command not found. $macInstructions'); |
| } else { |
| printError('Cannot control iOS devices or simulators. $command is not available on your platform.'); |
| } |
| return null; |
| } |
| return command; |
| } |
| |
| @override |
| Future<bool> isAppInstalled(ApplicationPackage app) async { |
| try { |
| final RunResult apps = await runCheckedAsync(<String>[_installerPath, '--list-apps']); |
| if (new RegExp(app.id, multiLine: true).hasMatch(apps.stdout)) { |
| return true; |
| } |
| } catch (e) { |
| return false; |
| } |
| return false; |
| } |
| |
| @override |
| Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false; |
| |
| @override |
| Future<bool> installApp(ApplicationPackage app) async { |
| final IOSApp iosApp = app; |
| final Directory bundle = fs.directory(iosApp.deviceBundlePath); |
| if (!bundle.existsSync()) { |
| printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?'); |
| return false; |
| } |
| |
| try { |
| await runCheckedAsync(<String>[_installerPath, '-i', iosApp.deviceBundlePath]); |
| return true; |
| } catch (e) { |
| return false; |
| } |
| } |
| |
| @override |
| Future<bool> uninstallApp(ApplicationPackage app) async { |
| try { |
| await runCheckedAsync(<String>[_installerPath, '-U', app.id]); |
| return true; |
| } catch (e) { |
| return false; |
| } |
| } |
| |
| @override |
| bool isSupported() => true; |
| |
| @override |
| Future<LaunchResult> startApp( |
| ApplicationPackage package, { |
| String mainPath, |
| String route, |
| DebuggingOptions debuggingOptions, |
| Map<String, dynamic> platformArgs, |
| bool prebuiltApplication = false, |
| bool applicationNeedsRebuild = false, |
| bool usesTerminalUi = true, |
| bool ipv6 = false, |
| }) async { |
| if (!prebuiltApplication) { |
| // TODO(chinmaygarde): Use mainPath, route. |
| printTrace('Building ${package.name} for $id'); |
| |
| // Step 1: Build the precompiled/DBC application if necessary. |
| final XcodeBuildResult buildResult = await buildXcodeProject( |
| app: package, |
| buildInfo: debuggingOptions.buildInfo, |
| targetOverride: mainPath, |
| buildForDevice: true, |
| usesTerminalUi: usesTerminalUi, |
| ); |
| if (!buildResult.success) { |
| printError('Could not build the precompiled application for the device.'); |
| await diagnoseXcodeBuildFailure(buildResult); |
| printError(''); |
| return new LaunchResult.failed(); |
| } |
| } else { |
| if (!await installApp(package)) |
| return new LaunchResult.failed(); |
| } |
| |
| // Step 2: Check that the application exists at the specified path. |
| final IOSApp iosApp = package; |
| final Directory bundle = fs.directory(iosApp.deviceBundlePath); |
| if (!bundle.existsSync()) { |
| printError('Could not find the built application bundle at ${bundle.path}.'); |
| return new LaunchResult.failed(); |
| } |
| |
| // Step 3: Attempt to install the application on the device. |
| final List<String> launchArguments = <String>['--enable-dart-profiling']; |
| |
| if (debuggingOptions.startPaused) |
| launchArguments.add('--start-paused'); |
| |
| if (debuggingOptions.useTestFonts) |
| launchArguments.add('--use-test-fonts'); |
| |
| if (debuggingOptions.debuggingEnabled) |
| launchArguments.add('--enable-checked-mode'); |
| |
| if (debuggingOptions.enableSoftwareRendering) |
| launchArguments.add('--enable-software-rendering'); |
| |
| if (debuggingOptions.skiaDeterministicRendering) |
| launchArguments.add('--skia-deterministic-rendering'); |
| |
| if (debuggingOptions.traceSkia) |
| launchArguments.add('--trace-skia'); |
| |
| if (platformArgs['trace-startup'] ?? false) |
| launchArguments.add('--trace-startup'); |
| |
| int installationResult = -1; |
| Uri localObservatoryUri; |
| |
| final Status installStatus = logger.startProgress('Installing and launching...', expectSlowOperation: true); |
| |
| if (!debuggingOptions.debuggingEnabled) { |
| // If debugging is not enabled, just launch the application and continue. |
| printTrace('Debugging is not enabled'); |
| installationResult = await const IOSDeploy().runApp( |
| deviceId: id, |
| bundlePath: bundle.path, |
| launchArguments: launchArguments, |
| ); |
| } else { |
| // Debugging is enabled, look for the observatory server port post launch. |
| printTrace('Debugging is enabled, connecting to observatory'); |
| |
| // TODO(danrubel): The Android device class does something similar to this code below. |
| // The various Device subclasses should be refactored and common code moved into the superclass. |
| final ProtocolDiscovery observatoryDiscovery = new ProtocolDiscovery.observatory( |
| getLogReader(app: package), |
| portForwarder: portForwarder, |
| hostPort: debuggingOptions.observatoryPort, |
| ipv6: ipv6, |
| ); |
| |
| final Future<Uri> forwardObservatoryUri = observatoryDiscovery.uri; |
| |
| final Future<int> launch = const IOSDeploy().runApp( |
| deviceId: id, |
| bundlePath: bundle.path, |
| launchArguments: launchArguments, |
| ); |
| |
| localObservatoryUri = await launch.then<Uri>((int result) async { |
| installationResult = result; |
| |
| if (result != 0) { |
| printTrace('Failed to launch the application on device.'); |
| return null; |
| } |
| |
| printTrace('Application launched on the device. Waiting for observatory port.'); |
| return await forwardObservatoryUri; |
| }).whenComplete(() { |
| observatoryDiscovery.cancel(); |
| }); |
| } |
| installStatus.stop(); |
| |
| if (installationResult != 0) { |
| printError('Could not install ${bundle.path} on $id.'); |
| printError('Try launching Xcode and selecting "Product > Run" to fix the problem:'); |
| printError(' open ios/Runner.xcworkspace'); |
| printError(''); |
| return new LaunchResult.failed(); |
| } |
| |
| return new LaunchResult.succeeded(observatoryUri: localObservatoryUri); |
| } |
| |
| @override |
| Future<bool> stopApp(ApplicationPackage app) async { |
| // Currently we don't have a way to stop an app running on iOS. |
| return false; |
| } |
| |
| @override |
| Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios; |
| |
| @override |
| Future<String> get sdkNameAndVersion async => 'iOS $_sdkVersion'; |
| |
| @override |
| DeviceLogReader getLogReader({ApplicationPackage app}) { |
| _logReaders ??= <ApplicationPackage, _IOSDeviceLogReader>{}; |
| return _logReaders.putIfAbsent(app, () => new _IOSDeviceLogReader(this, app)); |
| } |
| |
| @override |
| DevicePortForwarder get portForwarder => _portForwarder ??= new _IOSDevicePortForwarder(this); |
| |
| @override |
| void clearLogs() { |
| } |
| |
| @override |
| bool get supportsScreenshot => iMobileDevice.isInstalled; |
| |
| @override |
| Future<Null> takeScreenshot(File outputFile) => iMobileDevice.takeScreenshot(outputFile); |
| } |
| |
| /// Decodes an encoded syslog string to a UTF-8 representation. |
| /// |
| /// Apple's syslog logs are encoded in 7-bit form. Input bytes are encoded as follows: |
| /// 1. 0x00 to 0x19: non-printing range. Some ignored, some encoded as <...>. |
| /// 2. 0x20 to 0x7f: as-is, with the exception of 0x5c (backslash). |
| /// 3. 0x5c (backslash): octal representation \134. |
| /// 4. 0x80 to 0x9f: \M^x (using control-character notation for range 0x00 to 0x40). |
| /// 5. 0xa0: octal representation \240. |
| /// 6. 0xa1 to 0xf7: \M-x (where x is the input byte stripped of its high-order bit). |
| /// 7. 0xf8 to 0xff: unused in 4-byte UTF-8. |
| String decodeSyslog(String line) { |
| // UTF-8 values for \, M, -, ^. |
| const int kBackslash = 0x5c; |
| const int kM = 0x4d; |
| const int kDash = 0x2d; |
| const int kCaret = 0x5e; |
| |
| // Mask for the UTF-8 digit range. |
| const int kNum = 0x30; |
| |
| // Returns true when `byte` is within the UTF-8 7-bit digit range (0x30 to 0x39). |
| bool isDigit(int byte) => (byte & 0xf0) == kNum; |
| |
| // Converts a three-digit ASCII (UTF-8) representation of an octal number `xyz` to an integer. |
| int decodeOctal(int x, int y, int z) => (x & 0x3) << 6 | (y & 0x7) << 3 | z & 0x7; |
| |
| try { |
| final List<int> bytes = utf8.encode(line); |
| final List<int> out = <int>[]; |
| for (int i = 0; i < bytes.length; ) { |
| if (bytes[i] != kBackslash || i > bytes.length - 4) { |
| // Unmapped byte: copy as-is. |
| out.add(bytes[i++]); |
| } else { |
| // Mapped byte: decode next 4 bytes. |
| if (bytes[i + 1] == kM && bytes[i + 2] == kCaret) { |
| // \M^x form: bytes in range 0x80 to 0x9f. |
| out.add((bytes[i + 3] & 0x7f) + 0x40); |
| } else if (bytes[i + 1] == kM && bytes[i + 2] == kDash) { |
| // \M-x form: bytes in range 0xa0 to 0xf7. |
| out.add(bytes[i + 3] | 0x80); |
| } else if (bytes.getRange(i + 1, i + 3).every(isDigit)) { |
| // \ddd form: octal representation (only used for \134 and \240). |
| out.add(decodeOctal(bytes[i + 1], bytes[i + 2], bytes[i + 3])); |
| } else { |
| // Unknown form: copy as-is. |
| out.addAll(bytes.getRange(0, 4)); |
| } |
| i += 4; |
| } |
| } |
| return utf8.decode(out); |
| } catch (_) { |
| // Unable to decode line: return as-is. |
| return line; |
| } |
| } |
| |
| class _IOSDeviceLogReader extends DeviceLogReader { |
| // Matches a syslog line from the runner. |
| RegExp _runnerLineRegex; |
| // Matches a syslog line from any app. |
| RegExp _anyLineRegex; |
| |
| _IOSDeviceLogReader(this.device, ApplicationPackage app) { |
| _linesController = new StreamController<String>.broadcast( |
| onListen: _start, |
| onCancel: _stop |
| ); |
| |
| // Match for lines for the runner in syslog. |
| // |
| // iOS 9 format: Runner[297] <Notice>: |
| // iOS 10 format: Runner(Flutter)[297] <Notice>: |
| final String appName = app == null ? '' : app.name.replaceAll('.app', ''); |
| _runnerLineRegex = new RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '); |
| // Similar to above, but allows ~arbitrary components instead of "Runner" |
| // and "Flutter". The regex tries to strike a balance between not producing |
| // false positives and not producing false negatives. |
| _anyLineRegex = new RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: '); |
| } |
| |
| final IOSDevice device; |
| |
| StreamController<String> _linesController; |
| Process _process; |
| |
| @override |
| Stream<String> get logLines => _linesController.stream; |
| |
| @override |
| String get name => device.name; |
| |
| void _start() { |
| iMobileDevice.startLogger().then<Null>((Process process) { |
| _process = process; |
| _process.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(_newLineHandler()); |
| _process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_newLineHandler()); |
| _process.exitCode.whenComplete(() { |
| if (_linesController.hasListener) |
| _linesController.close(); |
| }); |
| }); |
| } |
| |
| // Returns a stateful line handler to properly capture multi-line output. |
| // |
| // For multi-line log messages, any line after the first is logged without |
| // any specific prefix. To properly capture those, we enter "printing" mode |
| // after matching a log line from the runner. When in printing mode, we print |
| // all lines until we find the start of another log message (from any app). |
| Function _newLineHandler() { |
| bool printing = false; |
| |
| return (String line) { |
| if (printing) { |
| if (!_anyLineRegex.hasMatch(line)) { |
| _linesController.add(decodeSyslog(line)); |
| return; |
| } |
| |
| printing = false; |
| } |
| |
| final Match match = _runnerLineRegex.firstMatch(line); |
| |
| if (match != null) { |
| final String logLine = line.substring(match.end); |
| // Only display the log line after the initial device and executable information. |
| _linesController.add(decodeSyslog(logLine)); |
| |
| printing = true; |
| } |
| }; |
| } |
| |
| void _stop() { |
| _process?.kill(); |
| } |
| } |
| |
| class _IOSDevicePortForwarder extends DevicePortForwarder { |
| _IOSDevicePortForwarder(this.device) : _forwardedPorts = <ForwardedPort>[]; |
| |
| final IOSDevice device; |
| |
| final List<ForwardedPort> _forwardedPorts; |
| |
| @override |
| List<ForwardedPort> get forwardedPorts => _forwardedPorts; |
| |
| static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1); |
| |
| @override |
| Future<int> forward(int devicePort, {int hostPort}) async { |
| final bool autoselect = hostPort == null || hostPort == 0; |
| if (autoselect) |
| hostPort = 1024; |
| |
| Process process; |
| |
| bool connected = false; |
| while (!connected) { |
| printTrace('attempting to forward device port $devicePort to host port $hostPort'); |
| // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID |
| process = await runCommand(<String>[ |
| device._iproxyPath, |
| hostPort.toString(), |
| devicePort.toString(), |
| device.id, |
| ]); |
| // TODO(ianh): This is a flakey race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674 |
| connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false); |
| if (!connected) { |
| if (autoselect) { |
| hostPort += 1; |
| if (hostPort > 65535) |
| throw new Exception('Could not find open port on host.'); |
| } else { |
| throw new Exception('Port $hostPort is not available.'); |
| } |
| } |
| } |
| assert(connected); |
| assert(process != null); |
| |
| final ForwardedPort forwardedPort = new ForwardedPort.withContext( |
| hostPort, devicePort, process, |
| ); |
| printTrace('Forwarded port $forwardedPort'); |
| _forwardedPorts.add(forwardedPort); |
| return hostPort; |
| } |
| |
| @override |
| Future<Null> unforward(ForwardedPort forwardedPort) async { |
| if (!_forwardedPorts.remove(forwardedPort)) { |
| // Not in list. Nothing to remove. |
| return null; |
| } |
| |
| printTrace('Unforwarding port $forwardedPort'); |
| |
| final Process process = forwardedPort.context; |
| |
| if (process != null) { |
| processManager.killPid(process.pid); |
| } else { |
| printError('Forwarded port did not have a valid process'); |
| } |
| |
| return null; |
| } |
| } |