| // 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 'dart:io'; |
| |
| import '../application_package.dart'; |
| import '../base/os.dart'; |
| import '../base/process.dart'; |
| import '../build_info.dart'; |
| import '../device.dart'; |
| import '../globals.dart'; |
| import '../protocol_discovery.dart'; |
| import 'mac.dart'; |
| |
| const String _ideviceinstallerInstructions = |
| 'To work with iOS devices, please install ideviceinstaller.\n' |
| 'If you use homebrew, you can install it with "\$ brew install ideviceinstaller".'; |
| |
| const Duration kPortForwardTimeout = const Duration(seconds: 10); |
| |
| class IOSDevices extends PollingDeviceDiscovery { |
| IOSDevices() : super('IOSDevices'); |
| |
| @override |
| bool get supportsPlatform => Platform.isMacOS; |
| |
| @override |
| List<Device> pollingGetDevices() => IOSDevice.getAttachedDevices(); |
| } |
| |
| class IOSDevice extends Device { |
| IOSDevice(String id, { this.name }) : super(id) { |
| _installerPath = _checkForCommand('ideviceinstaller'); |
| _listerPath = _checkForCommand('idevice_id'); |
| _informerPath = _checkForCommand('ideviceinfo'); |
| _iproxyPath = _checkForCommand('iproxy'); |
| _debuggerPath = _checkForCommand('idevicedebug'); |
| _loggerPath = _checkForCommand('idevicesyslog'); |
| _pusherPath = _checkForCommand( |
| 'ios-deploy', |
| 'To copy files to iOS devices, please install ios-deploy. ' |
| 'You can do this using homebrew as follows:\n' |
| '\$ brew tap flutter/flutter\n' |
| '\$ brew install ios-deploy'); |
| } |
| |
| String _installerPath; |
| String get installerPath => _installerPath; |
| |
| String _listerPath; |
| String get listerPath => _listerPath; |
| |
| String _informerPath; |
| String get informerPath => _informerPath; |
| |
| String _iproxyPath; |
| String get iproxyPath => _iproxyPath; |
| |
| String _debuggerPath; |
| String get debuggerPath => _debuggerPath; |
| |
| String _loggerPath; |
| String get loggerPath => _loggerPath; |
| |
| String _pusherPath; |
| String get pusherPath => _pusherPath; |
| |
| @override |
| bool get supportsHotMode => true; |
| |
| @override |
| final String name; |
| |
| Map<ApplicationPackage, _IOSDeviceLogReader> _logReaders; |
| |
| _IOSDevicePortForwarder _portForwarder; |
| |
| @override |
| bool get isLocalEmulator => false; |
| |
| @override |
| bool get supportsStartPaused => false; |
| |
| static List<IOSDevice> getAttachedDevices([IOSDevice mockIOS]) { |
| if (!doctor.iosWorkflow.hasIDeviceId) |
| return <IOSDevice>[]; |
| |
| List<IOSDevice> devices = <IOSDevice>[]; |
| for (String id in _getAttachedDeviceIDs(mockIOS)) { |
| String name = IOSDevice._getDeviceInfo(id, 'DeviceName', mockIOS); |
| devices.add(new IOSDevice(id, name: name)); |
| } |
| return devices; |
| } |
| |
| static Iterable<String> _getAttachedDeviceIDs([IOSDevice mockIOS]) { |
| String listerPath = (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id'); |
| try { |
| String output = runSync(<String>[listerPath, '-l']); |
| return output.trim().split('\n').where((String s) => s != null && s.isNotEmpty); |
| } catch (e) { |
| return <String>[]; |
| } |
| } |
| |
| static String _getDeviceInfo(String deviceID, String infoKey, [IOSDevice mockIOS]) { |
| String informerPath = (mockIOS != null) |
| ? mockIOS.informerPath |
| : _checkForCommand('ideviceinfo'); |
| return runSync(<String>[informerPath, '-k', infoKey, '-u', deviceID]).trim(); |
| } |
| |
| static final Map<String, String> _commandMap = <String, String>{}; |
| static String _checkForCommand( |
| String command, [ |
| String macInstructions = _ideviceinstallerInstructions |
| ]) { |
| return _commandMap.putIfAbsent(command, () { |
| 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 command; |
| }); |
| } |
| |
| @override |
| bool isAppInstalled(ApplicationPackage app) { |
| try { |
| String apps = runCheckedSync(<String>[installerPath, '--list-apps']); |
| if (new RegExp(app.id, multiLine: true).hasMatch(apps)) { |
| return true; |
| } |
| } catch (e) { |
| return false; |
| } |
| return false; |
| } |
| |
| @override |
| bool installApp(ApplicationPackage app) { |
| IOSApp iosApp = app; |
| Directory bundle = new Directory(iosApp.deviceBundlePath); |
| if (!bundle.existsSync()) { |
| printError("Could not find application bundle at ${bundle.path}; have you run 'flutter build ios'?"); |
| return false; |
| } |
| |
| try { |
| runCheckedSync(<String>[installerPath, '-i', iosApp.deviceBundlePath]); |
| return true; |
| } catch (e) { |
| return false; |
| } |
| } |
| |
| @override |
| bool uninstallApp(ApplicationPackage app) { |
| try { |
| runCheckedSync(<String>[installerPath, '-U', app.id]); |
| return true; |
| } catch (e) { |
| return false; |
| } |
| } |
| |
| @override |
| bool isSupported() => true; |
| |
| @override |
| Future<LaunchResult> startApp( |
| ApplicationPackage app, |
| BuildMode mode, { |
| String mainPath, |
| String route, |
| DebuggingOptions debuggingOptions, |
| Map<String, dynamic> platformArgs, |
| bool prebuiltApplication: false |
| }) async { |
| if (!prebuiltApplication) { |
| // TODO(chinmaygarde): Use checked, mainPath, route. |
| // TODO(devoncarew): Handle startPaused, debugPort. |
| printTrace('Building ${app.name} for $id'); |
| |
| // Step 1: Build the precompiled/DBC application if necessary. |
| XcodeBuildResult buildResult = await buildXcodeProject(app: app, mode: mode, target: mainPath, buildForDevice: true); |
| if (!buildResult.success) { |
| printError('Could not build the precompiled application for the device.'); |
| diagnoseXcodeBuildFailure(buildResult); |
| printError(''); |
| return new LaunchResult.failed(); |
| } |
| } |
| |
| // Step 2: Check that the application exists at the specified path. |
| IOSApp iosApp = app; |
| Directory bundle = new 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. |
| List<String> launchArguments = <String>[]; |
| |
| if (debuggingOptions.startPaused) |
| launchArguments.add("--start-paused"); |
| |
| if (debuggingOptions.debuggingEnabled) { |
| launchArguments.add("--enable-checked-mode"); |
| |
| // Note: We do NOT need to set the observatory port since this is going to |
| // be setup on the device. Let it pick a port automatically. We will check |
| // the port picked and scrape that later. |
| } |
| |
| if (platformArgs['trace-startup'] ?? false) |
| launchArguments.add('--trace-startup'); |
| |
| List<String> launchCommand = <String>[ |
| '/usr/bin/env', |
| 'ios-deploy', |
| '--id', |
| id, |
| '--bundle', |
| bundle.path, |
| '--justlaunch', |
| ]; |
| |
| if (launchArguments.length > 0) { |
| launchCommand.add('--args'); |
| launchCommand.add('${launchArguments.join(" ")}'); |
| } |
| |
| int installationResult = -1; |
| int localObsPort; |
| int localDiagPort; |
| |
| if (!debuggingOptions.debuggingEnabled) { |
| // If debugging is not enabled, just launch the application and continue. |
| printTrace("Debugging is not enabled"); |
| installationResult = await runCommandAndStreamOutput(launchCommand, trace: true); |
| } else { |
| // Debugging is enabled, look for the observatory and diagnostic server |
| // ports post launch. |
| printTrace("Debugging is enabled, connecting to observatory and the diagnostic server"); |
| |
| Future<int> forwardObsPort = _acquireAndForwardPort(app, |
| ProtocolDiscovery.kObservatoryService, |
| debuggingOptions.observatoryPort); |
| Future<int> forwardDiagPort; |
| if (debuggingOptions.buildMode == BuildMode.debug) { |
| forwardDiagPort = _acquireAndForwardPort(app, |
| ProtocolDiscovery.kDiagnosticService, |
| debuggingOptions.diagnosticPort); |
| } else { |
| forwardDiagPort = new Future<int>.value(null); |
| } |
| |
| Future<int> launch = runCommandAndStreamOutput(launchCommand, trace: true); |
| |
| List<int> ports = await launch.then((int result) async { |
| installationResult = result; |
| |
| if (result != 0) { |
| printTrace("Failed to launch the application on device."); |
| return <int>[null, null]; |
| } |
| |
| printTrace("Application launched on the device. Attempting to forward ports."); |
| return await Future.wait(<Future<int>>[forwardObsPort, forwardDiagPort]) |
| .timeout( |
| kPortForwardTimeout, |
| onTimeout: () { |
| throw new TimeoutException('Timeout while waiting to acquire and forward ports.'); |
| }, |
| ); |
| }); |
| |
| printTrace("Local Observatory Port: ${ports[0]}"); |
| printTrace("Local Diagnostic Server Port: ${ports[1]}"); |
| |
| localObsPort = ports[0]; |
| localDiagPort = ports[1]; |
| } |
| |
| 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.xcodeproj"); |
| printError(''); |
| return new LaunchResult.failed(); |
| } |
| |
| return new LaunchResult.succeeded(observatoryPort: localObsPort, diagnosticPort: localDiagPort); |
| } |
| |
| Future<int> _acquireAndForwardPort( |
| ApplicationPackage app, |
| String serviceName, |
| int localPort) async { |
| Duration stepTimeout = const Duration(seconds: 60); |
| |
| Future<int> remote = new ProtocolDiscovery(getLogReader(app: app), serviceName).nextPort(); |
| |
| int remotePort = await remote.timeout(stepTimeout, |
| onTimeout: () { |
| printTrace("Timeout while attempting to retrieve remote port for $serviceName"); |
| return null; |
| }); |
| |
| if (remotePort == null) { |
| printTrace("Could not read port on device for $serviceName"); |
| return null; |
| } |
| |
| if ((localPort == null) || (localPort == 0)) { |
| localPort = await findAvailablePort(); |
| printTrace("Auto selected local port to $localPort"); |
| } |
| |
| int forwardResult = await portForwarder.forward(remotePort, |
| hostPort: localPort).timeout(stepTimeout, onTimeout: () { |
| printTrace("Timeout while atempting to foward port for $serviceName"); |
| return null; |
| }); |
| |
| if (forwardResult == null) { |
| printTrace("Could not foward remote $serviceName port $remotePort to local port $localPort"); |
| return null; |
| } |
| |
| printStatus('$serviceName listening on http://127.0.0.1:$localPort'); |
| return localPort; |
| } |
| |
| @override |
| Future<bool> stopApp(ApplicationPackage app) async { |
| // Currently we don't have a way to stop an app running on iOS. |
| return false; |
| } |
| |
| Future<bool> pushFile(ApplicationPackage app, String localFile, String targetFile) async { |
| if (Platform.isMacOS) { |
| runSync(<String>[ |
| pusherPath, |
| '-t', |
| '1', |
| '--bundle_id', |
| app.id, |
| '--upload', |
| localFile, |
| '--to', |
| targetFile |
| ]); |
| return true; |
| } else { |
| return false; |
| } |
| } |
| |
| @override |
| TargetPlatform get platform => TargetPlatform.ios; |
| |
| @override |
| String get sdkNameAndVersion => 'iOS $_sdkVersion ($_buildVersion)'; |
| |
| String get _sdkVersion => _getDeviceInfo(id, 'ProductVersion'); |
| |
| String get _buildVersion => _getDeviceInfo(id, 'BuildVersion'); |
| |
| @override |
| DeviceLogReader getLogReader({ApplicationPackage app}) { |
| _logReaders ??= <ApplicationPackage, _IOSDeviceLogReader>{}; |
| return _logReaders.putIfAbsent(app, () => new _IOSDeviceLogReader(this, app)); |
| } |
| |
| @override |
| DevicePortForwarder get portForwarder { |
| if (_portForwarder == null) |
| _portForwarder = new _IOSDevicePortForwarder(this); |
| |
| return _portForwarder; |
| } |
| |
| @override |
| void clearLogs() { |
| } |
| |
| @override |
| bool get supportsScreenshot => false; |
| |
| @override |
| Future<bool> takeScreenshot(File outputFile) { |
| // We could use idevicescreenshot here (installed along with the brew |
| // ideviceinstaller tools). It however requires a developer disk image on |
| // the device. |
| |
| return new Future<bool>.value(false); |
| } |
| } |
| |
| class _IOSDeviceLogReader extends DeviceLogReader { |
| RegExp _lineRegex; |
| |
| _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(libsystem_asl.dylib)[297] <Notice>: |
| String appName = app == null ? '' : app.name.replaceAll('.app', ''); |
| _lineRegex = new RegExp(appName + r'(\(.*\))?\[[\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() { |
| runCommand(<String>[device.loggerPath]).then((Process process) { |
| _process = process; |
| _process.stdout.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine); |
| _process.stderr.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine); |
| |
| _process.exitCode.then((int code) { |
| if (_linesController.hasListener) |
| _linesController.close(); |
| }); |
| }); |
| } |
| |
| void _onLine(String line) { |
| Match match = _lineRegex.firstMatch(line); |
| |
| if (match != null) { |
| // Only display the log line after the initial device and executable information. |
| _linesController.add(line.substring(match.end)); |
| } |
| } |
| |
| void _stop() { |
| _process?.kill(); |
| } |
| } |
| |
| class _IOSDevicePortForwarder extends DevicePortForwarder { |
| _IOSDevicePortForwarder(this.device) : _forwardedPorts = new List<ForwardedPort>(); |
| |
| final IOSDevice device; |
| |
| final List<ForwardedPort> _forwardedPorts; |
| |
| @override |
| List<ForwardedPort> get forwardedPorts => _forwardedPorts; |
| |
| @override |
| Future<int> forward(int devicePort, {int hostPort: null}) async { |
| if ((hostPort == null) || (hostPort == 0)) { |
| // Auto select host port. |
| hostPort = await findAvailablePort(); |
| } |
| |
| // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID |
| Process process = await runCommand(<String>[ |
| device.iproxyPath, |
| hostPort.toString(), |
| devicePort.toString(), |
| device.id, |
| ]); |
| |
| ForwardedPort forwardedPort = new ForwardedPort.withContext(hostPort, |
| devicePort, process); |
| |
| printTrace("Forwarded port $forwardedPort"); |
| |
| _forwardedPorts.add(forwardedPort); |
| |
| return 1; |
| } |
| |
| @override |
| Future<Null> unforward(ForwardedPort forwardedPort) async { |
| if (!_forwardedPorts.remove(forwardedPort)) { |
| // Not in list. Nothing to remove. |
| return null; |
| } |
| |
| printTrace("Unforwarding port $forwardedPort"); |
| |
| Process process = forwardedPort.context; |
| |
| if (process != null) { |
| Process.killPid(process.pid); |
| } else { |
| printError("Forwarded port did not have a valid process"); |
| } |
| |
| return null; |
| } |
| } |