diff --git a/packages/flutter_tools/lib/src/android/adb.dart b/packages/flutter_tools/lib/src/android/adb.dart index 2392dc36ccf..377bca83806 100644 --- a/packages/flutter_tools/lib/src/android/adb.dart +++ b/packages/flutter_tools/lib/src/android/adb.dart @@ -74,7 +74,7 @@ class Adb { ).toList(); } - /// Listen to device activations and deactivations via the asb server's + /// Listen to device activations and deactivations via the adb server's /// 'track-devices' command. Call cancel on the returned stream to stop /// listening. Stream> trackDevices() { diff --git a/packages/flutter_tools/lib/src/android/device_android.dart b/packages/flutter_tools/lib/src/android/android_device.dart similarity index 98% rename from packages/flutter_tools/lib/src/android/device_android.dart rename to packages/flutter_tools/lib/src/android/android_device.dart index 404c0d8f1c4..f909666f27e 100644 --- a/packages/flutter_tools/lib/src/android/device_android.dart +++ b/packages/flutter_tools/lib/src/android/android_device.dart @@ -29,17 +29,11 @@ const String _deviceBundlePath = '/data/local/tmp/dev.flx'; // Path where the snapshot will be copied on the device. const String _deviceSnapshotPath = '/data/local/tmp/dev_snapshot.bin'; -class AndroidDeviceDiscovery extends DeviceDiscovery { - List _devices = []; +class AndroidDevices extends PollingDeviceDiscovery { + AndroidDevices() : super('AndroidDevices'); bool get supportsPlatform => true; - - Future init() { - _devices = getAdbDevices(); - return new Future.value(); - } - - List get devices => _devices; + List pollingGetDevices() => getAdbDevices(); } class AndroidDevice extends Device { diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart new file mode 100644 index 00000000000..b271fc18e5a --- /dev/null +++ b/packages/flutter_tools/lib/src/base/utils.dart @@ -0,0 +1,48 @@ +// 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'; + +/// A class to maintain a list of items, fire events when items are added or +/// removed, and calculate a diff of changes when a new list of items is +/// available. +class ItemListNotifier { + ItemListNotifier() { + _items = new Set(); + } + + ItemListNotifier.from(List items) { + _items = new Set.from(items); + } + + Set _items; + + StreamController _addedController = new StreamController.broadcast(); + StreamController _removedController = new StreamController.broadcast(); + + Stream get onAdded => _addedController.stream; + Stream get onRemoved => _removedController.stream; + + List get items => _items.toList(); + + void updateWithNewList(List updatedList) { + Set updatedSet = new Set.from(updatedList); + + Set addedItems = updatedSet.difference(_items); + Set removedItems = _items.difference(updatedSet); + + _items = updatedSet; + + for (T item in addedItems) + _addedController.add(item); + for (T item in removedItems) + _removedController.add(item); + } + + /// Close the streams. + void dispose() { + _addedController.close(); + _removedController.close(); + } +} diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart index 252e929f760..beea6cba470 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart @@ -6,15 +6,13 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import '../android/adb.dart'; -import '../android/android_sdk.dart'; -import '../android/device_android.dart'; +import '../android/android_device.dart'; import '../base/context.dart'; import '../base/logger.dart'; import '../device.dart'; import '../globals.dart'; -import '../ios/device_ios.dart'; -import '../ios/simulator.dart'; +import '../ios/devices.dart'; +import '../ios/simulators.dart'; import '../runner/flutter_command.dart'; import 'run.dart'; import 'stop.dart' as stop; @@ -126,10 +124,8 @@ class Daemon { throw 'no domain for method: $method'; _domainMap[prefix].handleCommand(name, id, request['params']); - } catch (error, trace) { + } catch (error) { _send({'id': id, 'error': _toJsonable(error)}); - stderr.writeln('error handling $request: $error'); - stderr.writeln(trace); } } @@ -170,8 +166,6 @@ abstract class Domain { } }).catchError((error, trace) { _send({'id': id, 'error': _toJsonable(error)}); - stderr.writeln("error handling '$name.$command': $error"); - stderr.writeln(trace); }); } @@ -286,166 +280,65 @@ class AppDomain extends Domain { /// This domain lets callers list and monitor connected devices. /// -/// It exports a `getDevices()` call, as well as firing `device.added`, -/// `device.removed`, and `device.changed` events. +/// It exports a `getDevices()` call, as well as firing `device.added` and +/// `device.removed` events. class DeviceDomain extends Domain { DeviceDomain(Daemon daemon) : super(daemon, 'device') { registerHandler('getDevices', getDevices); + registerHandler('enable', enable); + registerHandler('disable', disable); - _androidDeviceDiscovery = new AndroidDeviceDiscovery(); - _androidDeviceDiscovery.onAdded.listen((Device device) { - sendEvent('device.added', _deviceToMap(device)); - }); - _androidDeviceDiscovery.onRemoved.listen((Device device) { - sendEvent('device.removed', _deviceToMap(device)); - }); - _androidDeviceDiscovery.onChanged.listen((Device device) { - sendEvent('device.changed', _deviceToMap(device)); - }); + PollingDeviceDiscovery deviceDiscovery = new AndroidDevices(); + if (deviceDiscovery.supportsPlatform) + _discoverers.add(deviceDiscovery); - if (Platform.isMacOS) { - _iosSimulatorDeviceDiscovery = new IOSSimulatorDeviceDiscovery(); - _iosSimulatorDeviceDiscovery.onAdded.listen((Device device) { + deviceDiscovery = new IOSDevices(); + if (deviceDiscovery.supportsPlatform) + _discoverers.add(deviceDiscovery); + + deviceDiscovery = new IOSSimulators(); + if (deviceDiscovery.supportsPlatform) + _discoverers.add(deviceDiscovery); + + for (PollingDeviceDiscovery discoverer in _discoverers) { + discoverer.onAdded.listen((Device device) { sendEvent('device.added', _deviceToMap(device)); }); - _iosSimulatorDeviceDiscovery.onRemoved.listen((Device device) { + discoverer.onRemoved.listen((Device device) { sendEvent('device.removed', _deviceToMap(device)); }); } } - AndroidDeviceDiscovery _androidDeviceDiscovery; - IOSSimulatorDeviceDiscovery _iosSimulatorDeviceDiscovery; + List _discoverers = []; Future> getDevices(dynamic args) { - List devices = []; - devices.addAll(_androidDeviceDiscovery.getDevices()); - if (_iosSimulatorDeviceDiscovery != null) - devices.addAll(_iosSimulatorDeviceDiscovery.getDevices()); + List devices = _discoverers.expand((PollingDeviceDiscovery discoverer) { + return discoverer.devices; + }).toList(); return new Future.value(devices); } - void dispose() { - _androidDeviceDiscovery.dispose(); - _iosSimulatorDeviceDiscovery?.dispose(); - } -} - -class AndroidDeviceDiscovery { - AndroidDeviceDiscovery() { - _initAdb(); - - if (_adb != null) { - _subscription = _adb.trackDevices().listen(_handleUpdatedDevices); + /// Enable device events. + Future enable(dynamic args) { + for (PollingDeviceDiscovery discoverer in _discoverers) { + discoverer.startPolling(); } + return new Future.value(); } - Adb _adb; - StreamSubscription _subscription; - Map _devices = new Map(); - - StreamController addedController = new StreamController.broadcast(); - StreamController removedController = new StreamController.broadcast(); - StreamController changedController = new StreamController.broadcast(); - - List getDevices() => _devices.values.toList(); - - Stream get onAdded => addedController.stream; - Stream get onRemoved => removedController.stream; - Stream get onChanged => changedController.stream; - - void _initAdb() { - if (_adb == null) { - _adb = new Adb(getAdbPath(androidSdk)); - if (!_adb.exists()) - _adb = null; - } - } - - void _handleUpdatedDevices(List newDevices) { - List currentDevices = new List.from(getDevices()); - - for (AdbDevice device in newDevices) { - AndroidDevice androidDevice = _devices[device.id]; - - if (androidDevice == null) { - // device added - androidDevice = new AndroidDevice( - device.id, - productID: device.productID, - modelID: device.modelID, - deviceCodeName: device.deviceCodeName, - connected: device.isAvailable - ); - _devices[androidDevice.id] = androidDevice; - addedController.add(androidDevice); - } else { - currentDevices.remove(androidDevice); - - // check state - if (androidDevice.isConnected() != device.isAvailable) { - androidDevice.setConnected(device.isAvailable); - changedController.add(androidDevice); - } - } - } - - // device removed - for (AndroidDevice device in currentDevices) { - _devices.remove(device.id); - - removedController.add(device); + /// Disable device events. + Future disable(dynamic args) { + for (PollingDeviceDiscovery discoverer in _discoverers) { + discoverer.stopPolling(); } + return new Future.value(); } void dispose() { - _subscription?.cancel(); - } -} - -class IOSSimulatorDeviceDiscovery { - IOSSimulatorDeviceDiscovery() { - _subscription = SimControl.trackDevices().listen(_handleUpdatedDevices); - } - - StreamSubscription> _subscription; - - Map _devices = new Map(); - - StreamController addedController = new StreamController.broadcast(); - StreamController removedController = new StreamController.broadcast(); - - List getDevices() => _devices.values.toList(); - - Stream get onAdded => addedController.stream; - Stream get onRemoved => removedController.stream; - - void _handleUpdatedDevices(List newDevices) { - List currentDevices = new List.from(getDevices()); - - for (SimDevice device in newDevices) { - IOSSimulator androidDevice = _devices[device.udid]; - - if (androidDevice == null) { - // device added - androidDevice = new IOSSimulator(device.udid, name: device.name); - _devices[androidDevice.id] = androidDevice; - addedController.add(androidDevice); - } else { - currentDevices.remove(androidDevice); - } + for (PollingDeviceDiscovery discoverer in _discoverers) { + discoverer.dispose(); } - - // device removed - for (IOSSimulator device in currentDevices) { - _devices.remove(device.id); - - removedController.add(device); - } - } - - void dispose() { - _subscription?.cancel(); } } @@ -490,7 +383,7 @@ class NotifyingLogger extends Logger { } void printTrace(String message) { - _messageController.add(new LogMessage('trace', message)); + // This is a lot of traffic to send over the wire. } } diff --git a/packages/flutter_tools/lib/src/commands/install.dart b/packages/flutter_tools/lib/src/commands/install.dart index b9f86846f38..8e17360d745 100644 --- a/packages/flutter_tools/lib/src/commands/install.dart +++ b/packages/flutter_tools/lib/src/commands/install.dart @@ -7,7 +7,7 @@ import 'dart:io'; import '../application_package.dart'; import '../device.dart'; -import '../ios/simulator.dart'; +import '../ios/simulators.dart'; import '../runner/flutter_command.dart'; class InstallCommand extends FlutterCommand { diff --git a/packages/flutter_tools/lib/src/commands/trace.dart b/packages/flutter_tools/lib/src/commands/trace.dart index a5b0744aadc..f8f7a43ab3e 100644 --- a/packages/flutter_tools/lib/src/commands/trace.dart +++ b/packages/flutter_tools/lib/src/commands/trace.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import '../android/device_android.dart'; +import '../android/android_device.dart'; import '../application_package.dart'; import '../globals.dart'; import '../runner/flutter_command.dart'; diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index 04562ad9b30..df00d2b7c64 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -4,12 +4,14 @@ import 'dart:async'; -import 'android/device_android.dart'; +import 'android/android_device.dart'; import 'application_package.dart'; import 'base/common.dart'; +import 'base/utils.dart'; import 'build_configuration.dart'; import 'globals.dart'; -import 'ios/device_ios.dart'; +import 'ios/devices.dart'; +import 'ios/simulators.dart'; import 'toolchain.dart'; /// A class to get all available devices. @@ -18,27 +20,9 @@ class DeviceManager { /// of their methods are invoked. DeviceManager() { // Register the known discoverers. - _deviceDiscoverers.add(new AndroidDeviceDiscovery()); - _deviceDiscoverers.add(new IOSDeviceDiscovery()); - _deviceDiscoverers.add(new IOSSimulatorDiscovery()); - } - - Future _init() { - if (_initedCompleter == null) { - _initedCompleter = new Completer(); - - Future.forEach(_deviceDiscoverers, (DeviceDiscovery discoverer) { - if (!discoverer.supportsPlatform) - return null; - return discoverer.init(); - }).then((_) { - _initedCompleter.complete(); - }).catchError((error, stackTrace) { - _initedCompleter.completeError(error, stackTrace); - }); - } - - return _initedCompleter.future; + _deviceDiscoverers.add(new AndroidDevices()); + _deviceDiscoverers.add(new IOSDevices()); + _deviceDiscoverers.add(new IOSSimulators()); } List _deviceDiscoverers = []; @@ -46,8 +30,6 @@ class DeviceManager { /// A user-specified device ID. String specifiedDeviceId; - Completer _initedCompleter; - bool get hasSpecifiedDeviceId => specifiedDeviceId != null; /// Return the device with the matching ID; else, complete the Future with @@ -75,8 +57,6 @@ class DeviceManager { /// Return the list of all connected devices. Future> getAllConnectedDevices() async { - await _init(); - return _deviceDiscoverers .where((DeviceDiscovery discoverer) => discoverer.supportsPlatform) .expand((DeviceDiscovery discoverer) => discoverer.devices) @@ -87,10 +67,60 @@ class DeviceManager { /// An abstract class to discover and enumerate a specific type of devices. abstract class DeviceDiscovery { bool get supportsPlatform; - Future init(); List get devices; } +/// A [DeviceDiscovery] implementation that uses polling to discover device adds +/// and removals. +abstract class PollingDeviceDiscovery extends DeviceDiscovery { + PollingDeviceDiscovery(this.name); + + static const Duration _pollingDuration = const Duration(seconds: 4); + + final String name; + ItemListNotifier _items; + Timer _timer; + + List pollingGetDevices(); + + void startPolling() { + if (_timer == null) { + if (_items == null) + _items = new ItemListNotifier(); + _timer = new Timer.periodic(_pollingDuration, (Timer timer) { + _items.updateWithNewList(pollingGetDevices()); + }); + } + } + + void stopPolling() { + _timer?.cancel(); + _timer = null; + } + + List get devices { + if (_items == null) + _items = new ItemListNotifier.from(pollingGetDevices()); + return _items.items; + } + + Stream get onAdded { + if (_items == null) + _items = new ItemListNotifier(); + return _items.onAdded; + } + + Stream get onRemoved { + if (_items == null) + _items = new ItemListNotifier(); + return _items.onRemoved; + } + + void dispose() => stopPolling(); + + String toString() => '$name device discovery'; +} + abstract class Device { Device(this.id); @@ -139,6 +169,16 @@ abstract class Device { /// Stop an app package on the current device. Future stopApp(ApplicationPackage app); + int get hashCode => id.hashCode; + + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other is! Device) + return false; + return id == other.id; + } + String toString() => '$runtimeType $id'; } diff --git a/packages/flutter_tools/lib/src/ios/device_ios.dart b/packages/flutter_tools/lib/src/ios/device_ios.dart deleted file mode 100644 index 10bb96519c9..00000000000 --- a/packages/flutter_tools/lib/src/ios/device_ios.dart +++ /dev/null @@ -1,712 +0,0 @@ -// 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 'package:path/path.dart' as path; - -import '../application_package.dart'; -import '../artifacts.dart'; -import '../base/common.dart'; -import '../base/process.dart'; -import '../build_configuration.dart'; -import '../device.dart'; -import '../globals.dart'; -import '../ios/setup_xcodeproj.dart'; -import '../services.dart'; -import '../toolchain.dart'; -import 'simulator.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".'; - -class IOSDeviceDiscovery extends DeviceDiscovery { - List _devices = []; - - bool get supportsPlatform => Platform.isMacOS; - - Future init() { - _devices = IOSDevice.getAttachedDevices(); - return new Future.value(); - } - - List get devices => _devices; -} - -class IOSSimulatorDiscovery extends DeviceDiscovery { - List _devices = []; - - bool get supportsPlatform => Platform.isMacOS; - - Future init() { - _devices = IOSSimulator.getAttachedDevices(); - return new Future.value(); - } - - List get devices => _devices; -} - -class IOSDevice extends Device { - IOSDevice(String id, { this.name }) : super(id) { - _installerPath = _checkForCommand('ideviceinstaller'); - _listerPath = _checkForCommand('idevice_id'); - _informerPath = _checkForCommand('ideviceinfo'); - _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 _debuggerPath; - String get debuggerPath => _debuggerPath; - - String _loggerPath; - String get loggerPath => _loggerPath; - - String _pusherPath; - String get pusherPath => _pusherPath; - - final String name; - - bool get supportsStartPaused => false; - - static List getAttachedDevices([IOSDevice mockIOS]) { - if (!doctor.iosWorkflow.hasIdeviceId) - return []; - - List devices = []; - for (String id in _getAttachedDeviceIDs(mockIOS)) { - String name = _getDeviceName(id, mockIOS); - devices.add(new IOSDevice(id, name: name)); - } - return devices; - } - - static Iterable _getAttachedDeviceIDs([IOSDevice mockIOS]) { - String listerPath = (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id'); - try { - String output = runSync([listerPath, '-l']); - return output.trim().split('\n').where((String s) => s != null && s.isNotEmpty); - } catch (e) { - return []; - } - } - - static String _getDeviceName(String deviceID, [IOSDevice mockIOS]) { - String informerPath = (mockIOS != null) - ? mockIOS.informerPath - : _checkForCommand('ideviceinfo'); - return runSync([informerPath, '-k', 'DeviceName', '-u', deviceID]).trim(); - } - - static final Map _commandMap = {}; - static String _checkForCommand( - String command, [ - String macInstructions = _ideviceinstallerInstructions - ]) { - return _commandMap.putIfAbsent(command, () { - try { - command = runCheckedSync(['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 installApp(ApplicationPackage app) { - try { - runCheckedSync([installerPath, '-i', app.localPath]); - return true; - } catch (e) { - return false; - } - return false; - } - - @override - bool isConnected() => _getAttachedDeviceIDs().contains(id); - - @override - bool isSupported() => true; - - @override - bool isAppInstalled(ApplicationPackage app) { - try { - String apps = runCheckedSync([installerPath, '--list-apps']); - if (new RegExp(app.id, multiLine: true).hasMatch(apps)) { - return true; - } - } catch (e) { - return false; - } - return false; - } - - @override - Future startApp( - ApplicationPackage app, - Toolchain toolchain, { - String mainPath, - String route, - bool checked: true, - bool clearLogs: false, - bool startPaused: false, - int debugPort: observatoryDefaultPort, - Map platformArgs - }) async { - // TODO(chinmaygarde): Use checked, mainPath, route, clearLogs. - // TODO(devoncarew): Handle startPaused, debugPort. - printTrace('Building ${app.name} for $id'); - - // Step 1: Install the precompiled application if necessary. - bool buildResult = await _buildIOSXcodeProject(app, buildForDevice: true); - if (!buildResult) { - printError('Could not build the precompiled application for the device.'); - return false; - } - - // Step 2: Check that the application exists at the specified path. - Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphoneos', 'Runner.app')); - bool bundleExists = bundle.existsSync(); - if (!bundleExists) { - printError('Could not find the built application bundle at ${bundle.path}.'); - return false; - } - - // Step 3: Attempt to install the application on the device. - int installationResult = await runCommandAndStreamOutput([ - '/usr/bin/env', - 'ios-deploy', - '--id', - id, - '--bundle', - bundle.path, - ]); - - if (installationResult != 0) { - printError('Could not install ${bundle.path} on $id.'); - return false; - } - - printTrace('Installation successful.'); - return true; - } - - @override - Future stopApp(ApplicationPackage app) async { - // Currently we don't have a way to stop an app running on iOS. - return false; - } - - Future pushFile(ApplicationPackage app, String localFile, String targetFile) async { - if (Platform.isMacOS) { - runSync([ - pusherPath, - '-t', - '1', - '--bundle_id', - app.id, - '--upload', - localFile, - '--to', - targetFile - ]); - return true; - } else { - return false; - } - return false; - } - - @override - TargetPlatform get platform => TargetPlatform.iOS; - - DeviceLogReader createLogReader() => new _IOSDeviceLogReader(this); -} - -class IOSSimulator extends Device { - IOSSimulator(String id, { this.name }) : super(id); - - static List getAttachedDevices() { - if (!xcode.isInstalledAndMeetsVersionCheck) - return []; - - return SimControl.getConnectedDevices().map((SimDevice device) { - return new IOSSimulator(device.udid, name: device.name); - }).toList(); - } - - final String name; - - String get xcrunPath => path.join('/usr', 'bin', 'xcrun'); - - String _getSimulatorPath() { - return path.join(_homeDirectory, 'Library', 'Developer', 'CoreSimulator', 'Devices', id); - } - - String _getSimulatorAppHomeDirectory(ApplicationPackage app) { - String simulatorPath = _getSimulatorPath(); - if (simulatorPath == null) - return null; - return path.join(simulatorPath, 'data'); - } - - @override - bool installApp(ApplicationPackage app) { - if (!isConnected()) - return false; - - try { - SimControl.install(id, app.localPath); - return true; - } catch (e) { - return false; - } - } - - @override - bool isConnected() { - if (!Platform.isMacOS) - return false; - return SimControl.getConnectedDevices().any((SimDevice device) => device.udid == id); - } - - @override - bool isSupported() { - if (!Platform.isMacOS) { - _supportMessage = "Not supported on a non Mac host"; - return false; - } - - // Step 1: Check if the device is part of a blacklisted category. - // We do not support WatchOS or tvOS devices. - - RegExp blacklist = new RegExp(r'Apple (TV|Watch)', caseSensitive: false); - - if (blacklist.hasMatch(name)) { - _supportMessage = "Flutter does not support either the Apple TV or Watch. Choose an iPhone 5s or above."; - return false; - } - - // Step 2: Check if the device must be rejected because of its version. - // There is an artitifical check on older simulators where arm64 - // targetted applications cannot be run (even though the - // Flutter runner on the simulator is completely different). - - RegExp versionExp = new RegExp(r'iPhone ([0-9])+'); - Match match = versionExp.firstMatch(name); - - if (match == null) { - // Not an iPhone. All available non-iPhone simulators are compatible. - return true; - } - - if (int.parse(match.group(1)) > 5) { - // iPhones 6 and above are always fine. - return true; - } - - // The 's' subtype of 5 is compatible. - if (name.contains('iPhone 5s')) { - return true; - } - - _supportMessage = "The simulator version is too old. Choose an iPhone 5s or above."; - return false; - } - - String _supportMessage; - - @override - String supportMessage() { - if (isSupported()) { - return "Supported"; - } - - return _supportMessage != null ? _supportMessage : "Unknown"; - } - - @override - bool isAppInstalled(ApplicationPackage app) { - try { - String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); - return FileSystemEntity.isDirectorySync(simulatorHomeDirectory); - } catch (e) { - return false; - } - } - - @override - Future startApp( - ApplicationPackage app, - Toolchain toolchain, { - String mainPath, - String route, - bool checked: true, - bool clearLogs: false, - bool startPaused: false, - int debugPort: observatoryDefaultPort, - Map platformArgs - }) async { - // TODO(chinmaygarde): Use mainPath, route. - printTrace('Building ${app.name} for $id.'); - - if (clearLogs) - this.clearLogs(); - - // Step 1: Build the Xcode project. - bool buildResult = await _buildIOSXcodeProject(app, buildForDevice: false); - if (!buildResult) { - printError('Could not build the application for the simulator.'); - return false; - } - - // Step 2: Assert that the Xcode project was successfully built. - Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphonesimulator', 'Runner.app')); - bool bundleExists = await bundle.exists(); - if (!bundleExists) { - printError('Could not find the built application bundle at ${bundle.path}.'); - return false; - } - - // Step 3: Install the updated bundle to the simulator. - SimControl.install(id, path.absolute(bundle.path)); - - // Step 4: Prepare launch arguments. - List args = []; - - if (checked) - args.add("--enable-checked-mode"); - - if (startPaused) - args.add("--start-paused"); - - if (debugPort != observatoryDefaultPort) - args.add("--observatory-port=$debugPort"); - - // Step 5: Launch the updated application in the simulator. - try { - SimControl.launch(id, app.id, args); - } catch (error) { - printError('$error'); - return false; - } - - printTrace('Successfully started ${app.name} on $id.'); - - return true; - } - - @override - Future stopApp(ApplicationPackage app) async { - // Currently we don't have a way to stop an app running on iOS. - return false; - } - - Future pushFile( - ApplicationPackage app, String localFile, String targetFile) async { - if (Platform.isMacOS) { - String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); - runCheckedSync(['cp', localFile, path.join(simulatorHomeDirectory, targetFile)]); - return true; - } - return false; - } - - String get logFilePath { - return path.join(_homeDirectory, 'Library', 'Logs', 'CoreSimulator', id, 'system.log'); - } - - @override - TargetPlatform get platform => TargetPlatform.iOSSimulator; - - DeviceLogReader createLogReader() => new _IOSSimulatorLogReader(this); - - void clearLogs() { - File logFile = new File(logFilePath); - if (logFile.existsSync()) { - RandomAccessFile randomFile = logFile.openSync(mode: FileMode.WRITE); - randomFile.truncateSync(0); - randomFile.closeSync(); - } - } - - void ensureLogsExists() { - File logFile = new File(logFilePath); - if (!logFile.existsSync()) - logFile.writeAsBytesSync([]); - } -} - -class _IOSDeviceLogReader extends DeviceLogReader { - _IOSDeviceLogReader(this.device); - - final IOSDevice device; - - String get name => device.name; - - // TODO(devoncarew): Support [clear]. - Future logs({ bool clear: false }) async { - if (!device.isConnected()) - return 2; - - return await runCommandAndStreamOutput( - [device.loggerPath], - prefix: '[$name] ', - filter: new RegExp(r'Runner') - ); - } - - int get hashCode => name.hashCode; - - bool operator ==(dynamic other) { - if (identical(this, other)) - return true; - if (other is! _IOSDeviceLogReader) - return false; - return other.name == name; - } -} - -class _IOSSimulatorLogReader extends DeviceLogReader { - _IOSSimulatorLogReader(this.device); - - final IOSSimulator device; - - bool _lastWasFiltered = false; - - String get name => device.name; - - Future logs({ bool clear: false }) async { - if (!device.isConnected()) - return 2; - - if (clear) - device.clearLogs(); - - device.ensureLogsExists(); - - // Match the log prefix (in order to shorten it): - // 'Jan 29 01:31:44 devoncarew-macbookpro3 SpringBoard[96648]: ...' - RegExp mapRegex = new RegExp(r'\S+ +\S+ +\S+ \S+ (.+)\[\d+\]\)?: (.*)$'); - // Jan 31 19:23:28 --- last message repeated 1 time --- - RegExp lastMessageRegex = new RegExp(r'\S+ +\S+ +\S+ --- (.*) ---$'); - - // This filter matches many Flutter lines in the log: - // new RegExp(r'(FlutterRunner|flutter.runner.Runner|$id)'), but it misses - // a fair number, including ones that would be useful in diagnosing crashes. - // For now, we're not filtering the log file (but do clear it with each run). - - Future result = runCommandAndStreamOutput( - ['tail', '-n', '+0', '-F', device.logFilePath], - prefix: '[$name] ', - mapFunction: (String string) { - Match match = mapRegex.matchAsPrefix(string); - if (match != null) { - _lastWasFiltered = true; - - // Filter out some messages that clearly aren't related to Flutter. - if (string.contains(': could not find icon for representation -> com.apple.')) - return null; - String category = match.group(1); - String content = match.group(2); - if (category == 'Game Center' || category == 'itunesstored' || category == 'nanoregistrylaunchd' || - category == 'mstreamd' || category == 'syncdefaultsd' || category == 'companionappd' || - category == 'searchd') - return null; - - _lastWasFiltered = false; - - if (category == 'Runner') - return content; - return '$category: $content'; - } - match = lastMessageRegex.matchAsPrefix(string); - if (match != null && !_lastWasFiltered) - return '(${match.group(1)})'; - return string; - } - ); - - // Track system.log crashes. - // ReportCrash[37965]: Saved crash report for FlutterRunner[37941]... - runCommandAndStreamOutput( - ['tail', '-F', '/private/var/log/system.log'], - prefix: '[$name] ', - filter: new RegExp(r' FlutterRunner\[\d+\] '), - mapFunction: (String string) { - Match match = mapRegex.matchAsPrefix(string); - return match == null ? string : '${match.group(1)}: ${match.group(2)}'; - } - ); - - return await result; - } - - int get hashCode => device.logFilePath.hashCode; - - bool operator ==(dynamic other) { - if (identical(this, other)) - return true; - if (other is! _IOSSimulatorLogReader) - return false; - return other.device.logFilePath == device.logFilePath; - } -} - -final RegExp _xcodeVersionRegExp = new RegExp(r'Xcode (\d+)\..*'); -final String _xcodeRequirement = 'Xcode 7.0 or greater is required to develop for iOS.'; - -String get _homeDirectory => path.absolute(Platform.environment['HOME']); - -bool _checkXcodeVersion() { - if (!Platform.isMacOS) - return false; - try { - String version = runCheckedSync(['xcodebuild', '-version']); - Match match = _xcodeVersionRegExp.firstMatch(version); - if (int.parse(match[1]) < 7) { - printError('Found "${match[0]}". $_xcodeRequirement'); - return false; - } - } catch (e) { - printError('Cannot find "xcodebuid". $_xcodeRequirement'); - return false; - } - return true; -} - -bool _validateEngineRevision(ApplicationPackage app) { - String skyRevision = ArtifactStore.engineRevision; - String iosRevision = _getIOSEngineRevision(app); - - if (iosRevision != skyRevision) { - printError("Error: incompatible sky_engine revision."); - printStatus('sky_engine revision: $skyRevision, iOS engine revision: $iosRevision'); - return false; - } else { - printTrace('sky_engine revision: $skyRevision, iOS engine revision: $iosRevision'); - return true; - } -} - -String _getIOSEngineRevision(ApplicationPackage app) { - File revisionFile = new File(path.join(app.localPath, 'REVISION')); - if (revisionFile.existsSync()) { - return revisionFile.readAsStringSync().trim(); - } else { - return null; - } -} - -Future _buildIOSXcodeProject(ApplicationPackage app, { bool buildForDevice }) async { - String flutterProjectPath = Directory.current.path; - - if (xcodeProjectRequiresUpdate()) { - printTrace('Initializing the Xcode project.'); - if ((await setupXcodeProjectHarness(flutterProjectPath)) != 0) { - printError('Could not initialize the Xcode project.'); - return false; - } - } else { - updateXcodeLocalProperties(flutterProjectPath); - } - - if (!_validateEngineRevision(app)) - return false; - - if (!_checkXcodeVersion()) - return false; - - // Before the build, all service definitions must be updated and the dylibs - // copied over to a location that is suitable for Xcodebuild to find them. - - await _addServicesToBundle(new Directory(app.localPath)); - - List commands = [ - '/usr/bin/env', 'xcrun', 'xcodebuild', '-target', 'Runner', '-configuration', 'Release' - ]; - - if (buildForDevice) { - commands.addAll(['-sdk', 'iphoneos', '-arch', 'arm64']); - } else { - commands.addAll(['-sdk', 'iphonesimulator', '-arch', 'x86_64']); - } - - try { - runCheckedSync(commands, workingDirectory: app.localPath); - return true; - } catch (error) { - return false; - } -} - -Future _addServicesToBundle(Directory bundle) async { - List> services = []; - printTrace("Trying to resolve native pub services."); - - // Step 1: Parse the service configuration yaml files present in the service - // pub packages. - await parseServiceConfigs(services); - printTrace("Found ${services.length} service definition(s)."); - - // Step 2: Copy framework dylibs to the correct spot for xcodebuild to pick up. - Directory frameworksDirectory = new Directory(path.join(bundle.path, "Frameworks")); - await _copyServiceFrameworks(services, frameworksDirectory); - - // Step 3: Copy the service definitions manifest at the correct spot for - // xcodebuild to pick up. - File manifestFile = new File(path.join(bundle.path, "ServiceDefinitions.json")); - _copyServiceDefinitionsManifest(services, manifestFile); -} - -Future _copyServiceFrameworks(List> services, Directory frameworksDirectory) async { - printTrace("Copying service frameworks to '${path.absolute(frameworksDirectory.path)}'."); - frameworksDirectory.createSync(recursive: true); - for (Map service in services) { - String dylibPath = await getServiceFromUrl(service['ios-framework'], service['root'], service['name']); - File dylib = new File(dylibPath); - printTrace("Copying ${dylib.path} into bundle."); - if (!dylib.existsSync()) { - printError("The service dylib '${dylib.path}' does not exist."); - continue; - } - // Shell out so permissions on the dylib are preserved. - runCheckedSync(['/bin/cp', dylib.path, frameworksDirectory.path]); - } -} - -void _copyServiceDefinitionsManifest(List> services, File manifest) { - printTrace("Creating service definitions manifest at '${manifest.path}'"); - List> jsonServices = services.map((Map service) => { - 'name': service['name'], - // Since we have already moved it to the Frameworks directory. Strip away - // the directory and basenames. - 'framework': path.basenameWithoutExtension(service['ios-framework']) - }).toList(); - Map json = { 'services' : jsonServices }; - manifest.writeAsStringSync(JSON.encode(json), mode: FileMode.WRITE, flush: true); -} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart new file mode 100644 index 00000000000..b5900f07d93 --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -0,0 +1,255 @@ +// 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:io'; + +import 'package:path/path.dart' as path; + +import '../application_package.dart'; +import '../base/common.dart'; +import '../base/process.dart'; +import '../build_configuration.dart'; +import '../device.dart'; +import '../globals.dart'; +import '../toolchain.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".'; + +class IOSDevices extends PollingDeviceDiscovery { + IOSDevices() : super('IOSDevices'); + + bool get supportsPlatform => Platform.isMacOS; + List pollingGetDevices() => IOSDevice.getAttachedDevices(); +} + +class IOSDevice extends Device { + IOSDevice(String id, { this.name }) : super(id) { + _installerPath = _checkForCommand('ideviceinstaller'); + _listerPath = _checkForCommand('idevice_id'); + _informerPath = _checkForCommand('ideviceinfo'); + _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 _debuggerPath; + String get debuggerPath => _debuggerPath; + + String _loggerPath; + String get loggerPath => _loggerPath; + + String _pusherPath; + String get pusherPath => _pusherPath; + + final String name; + + bool get supportsStartPaused => false; + + static List getAttachedDevices([IOSDevice mockIOS]) { + if (!doctor.iosWorkflow.hasIdeviceId) + return []; + + List devices = []; + for (String id in _getAttachedDeviceIDs(mockIOS)) { + String name = _getDeviceName(id, mockIOS); + devices.add(new IOSDevice(id, name: name)); + } + return devices; + } + + static Iterable _getAttachedDeviceIDs([IOSDevice mockIOS]) { + String listerPath = (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id'); + try { + String output = runSync([listerPath, '-l']); + return output.trim().split('\n').where((String s) => s != null && s.isNotEmpty); + } catch (e) { + return []; + } + } + + static String _getDeviceName(String deviceID, [IOSDevice mockIOS]) { + String informerPath = (mockIOS != null) + ? mockIOS.informerPath + : _checkForCommand('ideviceinfo'); + return runSync([informerPath, '-k', 'DeviceName', '-u', deviceID]).trim(); + } + + static final Map _commandMap = {}; + static String _checkForCommand( + String command, [ + String macInstructions = _ideviceinstallerInstructions + ]) { + return _commandMap.putIfAbsent(command, () { + try { + command = runCheckedSync(['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 installApp(ApplicationPackage app) { + try { + runCheckedSync([installerPath, '-i', app.localPath]); + return true; + } catch (e) { + return false; + } + return false; + } + + @override + bool isConnected() => _getAttachedDeviceIDs().contains(id); + + @override + bool isSupported() => true; + + @override + bool isAppInstalled(ApplicationPackage app) { + try { + String apps = runCheckedSync([installerPath, '--list-apps']); + if (new RegExp(app.id, multiLine: true).hasMatch(apps)) { + return true; + } + } catch (e) { + return false; + } + return false; + } + + @override + Future startApp( + ApplicationPackage app, + Toolchain toolchain, { + String mainPath, + String route, + bool checked: true, + bool clearLogs: false, + bool startPaused: false, + int debugPort: observatoryDefaultPort, + Map platformArgs + }) async { + // TODO(chinmaygarde): Use checked, mainPath, route, clearLogs. + // TODO(devoncarew): Handle startPaused, debugPort. + printTrace('Building ${app.name} for $id'); + + // Step 1: Install the precompiled application if necessary. + bool buildResult = await buildIOSXcodeProject(app, buildForDevice: true); + if (!buildResult) { + printError('Could not build the precompiled application for the device.'); + return false; + } + + // Step 2: Check that the application exists at the specified path. + Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphoneos', 'Runner.app')); + bool bundleExists = bundle.existsSync(); + if (!bundleExists) { + printError('Could not find the built application bundle at ${bundle.path}.'); + return false; + } + + // Step 3: Attempt to install the application on the device. + int installationResult = await runCommandAndStreamOutput([ + '/usr/bin/env', + 'ios-deploy', + '--id', + id, + '--bundle', + bundle.path, + ]); + + if (installationResult != 0) { + printError('Could not install ${bundle.path} on $id.'); + return false; + } + + printTrace('Installation successful.'); + return true; + } + + @override + Future stopApp(ApplicationPackage app) async { + // Currently we don't have a way to stop an app running on iOS. + return false; + } + + Future pushFile(ApplicationPackage app, String localFile, String targetFile) async { + if (Platform.isMacOS) { + runSync([ + pusherPath, + '-t', + '1', + '--bundle_id', + app.id, + '--upload', + localFile, + '--to', + targetFile + ]); + return true; + } else { + return false; + } + return false; + } + + @override + TargetPlatform get platform => TargetPlatform.iOS; + + DeviceLogReader createLogReader() => new _IOSDeviceLogReader(this); +} + +class _IOSDeviceLogReader extends DeviceLogReader { + _IOSDeviceLogReader(this.device); + + final IOSDevice device; + + String get name => device.name; + + // TODO(devoncarew): Support [clear]. + Future logs({ bool clear: false }) async { + if (!device.isConnected()) + return 2; + + return await runCommandAndStreamOutput( + [device.loggerPath], + prefix: '[$name] ', + filter: new RegExp(r'Runner') + ); + } + + int get hashCode => name.hashCode; + + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other is! _IOSDeviceLogReader) + return false; + return other.name == name; + } +} diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index d2aacc3339f..ef95ce0ed37 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -2,8 +2,23 @@ // 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' show JSON; +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import '../application_package.dart'; +import '../artifacts.dart'; import '../base/context.dart'; import '../base/process.dart'; +import '../globals.dart'; +import '../services.dart'; +import 'setup_xcodeproj.dart'; + +String get homeDirectory => path.absolute(Platform.environment['HOME']); + +// TODO(devoncarew): Refactor functionality into XCode. const int kXcodeRequiredVersionMajor = 7; const int kXcodeRequiredVersionMinor = 2; @@ -51,3 +66,135 @@ class XCode { return false; } } + +Future buildIOSXcodeProject(ApplicationPackage app, { bool buildForDevice }) async { + String flutterProjectPath = Directory.current.path; + + if (xcodeProjectRequiresUpdate()) { + printTrace('Initializing the Xcode project.'); + if ((await setupXcodeProjectHarness(flutterProjectPath)) != 0) { + printError('Could not initialize the Xcode project.'); + return false; + } + } else { + updateXcodeLocalProperties(flutterProjectPath); + } + + if (!_validateEngineRevision(app)) + return false; + + if (!_checkXcodeVersion()) + return false; + + // Before the build, all service definitions must be updated and the dylibs + // copied over to a location that is suitable for Xcodebuild to find them. + + await _addServicesToBundle(new Directory(app.localPath)); + + List commands = [ + '/usr/bin/env', 'xcrun', 'xcodebuild', '-target', 'Runner', '-configuration', 'Release' + ]; + + if (buildForDevice) { + commands.addAll(['-sdk', 'iphoneos', '-arch', 'arm64']); + } else { + commands.addAll(['-sdk', 'iphonesimulator', '-arch', 'x86_64']); + } + + try { + runCheckedSync(commands, workingDirectory: app.localPath); + return true; + } catch (error) { + return false; + } +} + +final RegExp _xcodeVersionRegExp = new RegExp(r'Xcode (\d+)\..*'); +final String _xcodeRequirement = 'Xcode 7.0 or greater is required to develop for iOS.'; + +bool _checkXcodeVersion() { + if (!Platform.isMacOS) + return false; + try { + String version = runCheckedSync(['xcodebuild', '-version']); + Match match = _xcodeVersionRegExp.firstMatch(version); + if (int.parse(match[1]) < 7) { + printError('Found "${match[0]}". $_xcodeRequirement'); + return false; + } + } catch (e) { + printError('Cannot find "xcodebuid". $_xcodeRequirement'); + return false; + } + return true; +} + +bool _validateEngineRevision(ApplicationPackage app) { + String skyRevision = ArtifactStore.engineRevision; + String iosRevision = _getIOSEngineRevision(app); + + if (iosRevision != skyRevision) { + printError("Error: incompatible sky_engine revision."); + printStatus('sky_engine revision: $skyRevision, iOS engine revision: $iosRevision'); + return false; + } else { + printTrace('sky_engine revision: $skyRevision, iOS engine revision: $iosRevision'); + return true; + } +} + +String _getIOSEngineRevision(ApplicationPackage app) { + File revisionFile = new File(path.join(app.localPath, 'REVISION')); + if (revisionFile.existsSync()) { + return revisionFile.readAsStringSync().trim(); + } else { + return null; + } +} + +Future _addServicesToBundle(Directory bundle) async { + List> services = []; + printTrace("Trying to resolve native pub services."); + + // Step 1: Parse the service configuration yaml files present in the service + // pub packages. + await parseServiceConfigs(services); + printTrace("Found ${services.length} service definition(s)."); + + // Step 2: Copy framework dylibs to the correct spot for xcodebuild to pick up. + Directory frameworksDirectory = new Directory(path.join(bundle.path, "Frameworks")); + await _copyServiceFrameworks(services, frameworksDirectory); + + // Step 3: Copy the service definitions manifest at the correct spot for + // xcodebuild to pick up. + File manifestFile = new File(path.join(bundle.path, "ServiceDefinitions.json")); + _copyServiceDefinitionsManifest(services, manifestFile); +} + +Future _copyServiceFrameworks(List> services, Directory frameworksDirectory) async { + printTrace("Copying service frameworks to '${path.absolute(frameworksDirectory.path)}'."); + frameworksDirectory.createSync(recursive: true); + for (Map service in services) { + String dylibPath = await getServiceFromUrl(service['ios-framework'], service['root'], service['name']); + File dylib = new File(dylibPath); + printTrace("Copying ${dylib.path} into bundle."); + if (!dylib.existsSync()) { + printError("The service dylib '${dylib.path}' does not exist."); + continue; + } + // Shell out so permissions on the dylib are preserved. + runCheckedSync(['/bin/cp', dylib.path, frameworksDirectory.path]); + } +} + +void _copyServiceDefinitionsManifest(List> services, File manifest) { + printTrace("Creating service definitions manifest at '${manifest.path}'"); + List> jsonServices = services.map((Map service) => { + 'name': service['name'], + // Since we have already moved it to the Frameworks directory. Strip away + // the directory and basenames. + 'framework': path.basenameWithoutExtension(service['ios-framework']) + }).toList(); + Map json = { 'services' : jsonServices }; + manifest.writeAsStringSync(JSON.encode(json), mode: FileMode.WRITE, flush: true); +} diff --git a/packages/flutter_tools/lib/src/ios/simulator.dart b/packages/flutter_tools/lib/src/ios/simulator.dart deleted file mode 100644 index 33281914213..00000000000 --- a/packages/flutter_tools/lib/src/ios/simulator.dart +++ /dev/null @@ -1,173 +0,0 @@ -// 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' show JSON; -import 'dart:io'; - -import '../base/process.dart'; -import '../globals.dart'; - -const String _xcrunPath = '/usr/bin/xcrun'; - -const String _simulatorPath = - '/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/MacOS/Simulator'; - -/// A wrapper around the `simctl` command line tool. -class SimControl { - static Future boot({String deviceId}) async { - if (_isAnyConnected()) - return true; - - if (deviceId == null) { - runDetached([_simulatorPath]); - Future checkConnection([int attempts = 20]) async { - if (attempts == 0) { - printStatus('Timed out waiting for iOS Simulator to boot.'); - return false; - } - if (!_isAnyConnected()) { - printStatus('Waiting for iOS Simulator to boot...'); - return await new Future.delayed(new Duration(milliseconds: 500), - () => checkConnection(attempts - 1) - ); - } - return true; - } - return await checkConnection(); - } else { - try { - runCheckedSync([_xcrunPath, 'simctl', 'boot', deviceId]); - return true; - } catch (e) { - printError('Unable to boot iOS Simulator $deviceId: ', e); - return false; - } - } - - return false; - } - - /// Returns a list of all available devices, both potential and connected. - static List getDevices() { - // { - // "devices" : { - // "com.apple.CoreSimulator.SimRuntime.iOS-8-2" : [ - // { - // "state" : "Shutdown", - // "availability" : " (unavailable, runtime profile not found)", - // "name" : "iPhone 4s", - // "udid" : "1913014C-6DCB-485D-AC6B-7CD76D322F5B" - // }, - // ... - - List args = ['simctl', 'list', '--json', 'devices']; - printTrace('$_xcrunPath ${args.join(' ')}'); - ProcessResult results = Process.runSync(_xcrunPath, args); - if (results.exitCode != 0) { - printError('Error executing simctl: ${results.exitCode}\n${results.stderr}'); - return []; - } - - List devices = []; - - Map> data = JSON.decode(results.stdout); - Map devicesSection = data['devices']; - - for (String deviceCategory in devicesSection.keys) { - List devicesData = devicesSection[deviceCategory]; - - for (Map data in devicesData) { - devices.add(new SimDevice(deviceCategory, data)); - } - } - - return devices; - } - - /// Returns all the connected simulator devices. - static List getConnectedDevices() { - return getDevices().where((SimDevice device) => device.isBooted).toList(); - } - - static StreamController> _trackDevicesControler; - - /// Listens to changes in the set of connected devices. The implementation - /// currently uses polling. Callers should be careful to call cancel() on any - /// stream subscription when finished. - /// - /// TODO(devoncarew): We could investigate using the usbmuxd protocol directly. - static Stream> trackDevices() { - if (_trackDevicesControler == null) { - Timer timer; - Set deviceIds = new Set(); - - _trackDevicesControler = new StreamController.broadcast( - onListen: () { - timer = new Timer.periodic(new Duration(seconds: 4), (Timer timer) { - List devices = getConnectedDevices(); - - if (_updateDeviceIds(devices, deviceIds)) { - _trackDevicesControler.add(devices); - } - }); - }, onCancel: () { - timer?.cancel(); - deviceIds.clear(); - } - ); - } - - return _trackDevicesControler.stream; - } - - /// Update the cached set of device IDs and return whether there were any changes. - static bool _updateDeviceIds(List devices, Set deviceIds) { - Set newIds = new Set.from(devices.map((SimDevice device) => device.udid)); - - bool changed = false; - - for (String id in newIds) { - if (!deviceIds.contains(id)) - changed = true; - } - - for (String id in deviceIds) { - if (!newIds.contains(id)) - changed = true; - } - - deviceIds.clear(); - deviceIds.addAll(newIds); - - return changed; - } - - static bool _isAnyConnected() => getConnectedDevices().isNotEmpty; - - static void install(String deviceId, String appPath) { - runCheckedSync([_xcrunPath, 'simctl', 'install', deviceId, appPath]); - } - - static void launch(String deviceId, String appIdentifier, [List launchArgs]) { - List args = [_xcrunPath, 'simctl', 'launch', deviceId, appIdentifier]; - if (launchArgs != null) - args.addAll(launchArgs); - runCheckedSync(args); - } -} - -class SimDevice { - SimDevice(this.category, this.data); - - final String category; - final Map data; - - String get state => data['state']; - String get availability => data['availability']; - String get name => data['name']; - String get udid => data['udid']; - - bool get isBooted => state == 'Booted'; -} diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart new file mode 100644 index 00000000000..94809818637 --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/simulators.dart @@ -0,0 +1,488 @@ +// 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' show JSON; +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import '../application_package.dart'; +import '../base/common.dart'; +import '../base/process.dart'; +import '../build_configuration.dart'; +import '../device.dart'; +import '../globals.dart'; +import '../toolchain.dart'; +import 'mac.dart'; + +const String _xcrunPath = '/usr/bin/xcrun'; + +const String _simulatorPath = + '/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/MacOS/Simulator'; + +class IOSSimulators extends PollingDeviceDiscovery { + IOSSimulators() : super('IOSSimulators'); + + bool get supportsPlatform => Platform.isMacOS; + List pollingGetDevices() => IOSSimulator.getAttachedDevices(); +} + +/// A wrapper around the `simctl` command line tool. +class SimControl { + static Future boot({String deviceId}) async { + if (_isAnyConnected()) + return true; + + if (deviceId == null) { + runDetached([_simulatorPath]); + Future checkConnection([int attempts = 20]) async { + if (attempts == 0) { + printStatus('Timed out waiting for iOS Simulator to boot.'); + return false; + } + if (!_isAnyConnected()) { + printStatus('Waiting for iOS Simulator to boot...'); + return await new Future.delayed(new Duration(milliseconds: 500), + () => checkConnection(attempts - 1) + ); + } + return true; + } + return await checkConnection(); + } else { + try { + runCheckedSync([_xcrunPath, 'simctl', 'boot', deviceId]); + return true; + } catch (e) { + printError('Unable to boot iOS Simulator $deviceId: ', e); + return false; + } + } + + return false; + } + + /// Returns a list of all available devices, both potential and connected. + static List getDevices() { + // { + // "devices" : { + // "com.apple.CoreSimulator.SimRuntime.iOS-8-2" : [ + // { + // "state" : "Shutdown", + // "availability" : " (unavailable, runtime profile not found)", + // "name" : "iPhone 4s", + // "udid" : "1913014C-6DCB-485D-AC6B-7CD76D322F5B" + // }, + // ... + + List args = ['simctl', 'list', '--json', 'devices']; + printTrace('$_xcrunPath ${args.join(' ')}'); + ProcessResult results = Process.runSync(_xcrunPath, args); + if (results.exitCode != 0) { + printError('Error executing simctl: ${results.exitCode}\n${results.stderr}'); + return []; + } + + List devices = []; + + Map> data = JSON.decode(results.stdout); + Map devicesSection = data['devices']; + + for (String deviceCategory in devicesSection.keys) { + List devicesData = devicesSection[deviceCategory]; + + for (Map data in devicesData) { + devices.add(new SimDevice(deviceCategory, data)); + } + } + + return devices; + } + + /// Returns all the connected simulator devices. + static List getConnectedDevices() { + return getDevices().where((SimDevice device) => device.isBooted).toList(); + } + + static StreamController> _trackDevicesControler; + + /// Listens to changes in the set of connected devices. The implementation + /// currently uses polling. Callers should be careful to call cancel() on any + /// stream subscription when finished. + /// + /// TODO(devoncarew): We could investigate using the usbmuxd protocol directly. + static Stream> trackDevices() { + if (_trackDevicesControler == null) { + Timer timer; + Set deviceIds = new Set(); + + _trackDevicesControler = new StreamController.broadcast( + onListen: () { + timer = new Timer.periodic(new Duration(seconds: 4), (Timer timer) { + List devices = getConnectedDevices(); + + if (_updateDeviceIds(devices, deviceIds)) { + _trackDevicesControler.add(devices); + } + }); + }, onCancel: () { + timer?.cancel(); + deviceIds.clear(); + } + ); + } + + return _trackDevicesControler.stream; + } + + /// Update the cached set of device IDs and return whether there were any changes. + static bool _updateDeviceIds(List devices, Set deviceIds) { + Set newIds = new Set.from(devices.map((SimDevice device) => device.udid)); + + bool changed = false; + + for (String id in newIds) { + if (!deviceIds.contains(id)) + changed = true; + } + + for (String id in deviceIds) { + if (!newIds.contains(id)) + changed = true; + } + + deviceIds.clear(); + deviceIds.addAll(newIds); + + return changed; + } + + static bool _isAnyConnected() => getConnectedDevices().isNotEmpty; + + static void install(String deviceId, String appPath) { + runCheckedSync([_xcrunPath, 'simctl', 'install', deviceId, appPath]); + } + + static void launch(String deviceId, String appIdentifier, [List launchArgs]) { + List args = [_xcrunPath, 'simctl', 'launch', deviceId, appIdentifier]; + if (launchArgs != null) + args.addAll(launchArgs); + runCheckedSync(args); + } +} + +class SimDevice { + SimDevice(this.category, this.data); + + final String category; + final Map data; + + String get state => data['state']; + String get availability => data['availability']; + String get name => data['name']; + String get udid => data['udid']; + + bool get isBooted => state == 'Booted'; +} + +class IOSSimulator extends Device { + IOSSimulator(String id, { this.name }) : super(id); + + static List getAttachedDevices() { + if (!xcode.isInstalledAndMeetsVersionCheck) + return []; + + return SimControl.getConnectedDevices().map((SimDevice device) { + return new IOSSimulator(device.udid, name: device.name); + }).toList(); + } + + final String name; + + String get xcrunPath => path.join('/usr', 'bin', 'xcrun'); + + String _getSimulatorPath() { + return path.join(homeDirectory, 'Library', 'Developer', 'CoreSimulator', 'Devices', id); + } + + String _getSimulatorAppHomeDirectory(ApplicationPackage app) { + String simulatorPath = _getSimulatorPath(); + if (simulatorPath == null) + return null; + return path.join(simulatorPath, 'data'); + } + + @override + bool installApp(ApplicationPackage app) { + if (!isConnected()) + return false; + + try { + SimControl.install(id, app.localPath); + return true; + } catch (e) { + return false; + } + } + + @override + bool isConnected() { + if (!Platform.isMacOS) + return false; + return SimControl.getConnectedDevices().any((SimDevice device) => device.udid == id); + } + + @override + bool isSupported() { + if (!Platform.isMacOS) { + _supportMessage = "Not supported on a non Mac host"; + return false; + } + + // Step 1: Check if the device is part of a blacklisted category. + // We do not support WatchOS or tvOS devices. + + RegExp blacklist = new RegExp(r'Apple (TV|Watch)', caseSensitive: false); + + if (blacklist.hasMatch(name)) { + _supportMessage = "Flutter does not support either the Apple TV or Watch. Choose an iPhone 5s or above."; + return false; + } + + // Step 2: Check if the device must be rejected because of its version. + // There is an artitifical check on older simulators where arm64 + // targetted applications cannot be run (even though the + // Flutter runner on the simulator is completely different). + + RegExp versionExp = new RegExp(r'iPhone ([0-9])+'); + Match match = versionExp.firstMatch(name); + + if (match == null) { + // Not an iPhone. All available non-iPhone simulators are compatible. + return true; + } + + if (int.parse(match.group(1)) > 5) { + // iPhones 6 and above are always fine. + return true; + } + + // The 's' subtype of 5 is compatible. + if (name.contains('iPhone 5s')) { + return true; + } + + _supportMessage = "The simulator version is too old. Choose an iPhone 5s or above."; + return false; + } + + String _supportMessage; + + @override + String supportMessage() { + if (isSupported()) { + return "Supported"; + } + + return _supportMessage != null ? _supportMessage : "Unknown"; + } + + @override + bool isAppInstalled(ApplicationPackage app) { + try { + String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); + return FileSystemEntity.isDirectorySync(simulatorHomeDirectory); + } catch (e) { + return false; + } + } + + @override + Future startApp( + ApplicationPackage app, + Toolchain toolchain, { + String mainPath, + String route, + bool checked: true, + bool clearLogs: false, + bool startPaused: false, + int debugPort: observatoryDefaultPort, + Map platformArgs + }) async { + // TODO(chinmaygarde): Use mainPath, route. + printTrace('Building ${app.name} for $id.'); + + if (clearLogs) + this.clearLogs(); + + // Step 1: Build the Xcode project. + bool buildResult = await buildIOSXcodeProject(app, buildForDevice: false); + if (!buildResult) { + printError('Could not build the application for the simulator.'); + return false; + } + + // Step 2: Assert that the Xcode project was successfully built. + Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphonesimulator', 'Runner.app')); + bool bundleExists = await bundle.exists(); + if (!bundleExists) { + printError('Could not find the built application bundle at ${bundle.path}.'); + return false; + } + + // Step 3: Install the updated bundle to the simulator. + SimControl.install(id, path.absolute(bundle.path)); + + // Step 4: Prepare launch arguments. + List args = []; + + if (checked) + args.add("--enable-checked-mode"); + + if (startPaused) + args.add("--start-paused"); + + if (debugPort != observatoryDefaultPort) + args.add("--observatory-port=$debugPort"); + + // Step 5: Launch the updated application in the simulator. + try { + SimControl.launch(id, app.id, args); + } catch (error) { + printError('$error'); + return false; + } + + printTrace('Successfully started ${app.name} on $id.'); + + return true; + } + + @override + Future stopApp(ApplicationPackage app) async { + // Currently we don't have a way to stop an app running on iOS. + return false; + } + + Future pushFile( + ApplicationPackage app, String localFile, String targetFile) async { + if (Platform.isMacOS) { + String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); + runCheckedSync(['cp', localFile, path.join(simulatorHomeDirectory, targetFile)]); + return true; + } + return false; + } + + String get logFilePath { + return path.join(homeDirectory, 'Library', 'Logs', 'CoreSimulator', id, 'system.log'); + } + + @override + TargetPlatform get platform => TargetPlatform.iOSSimulator; + + DeviceLogReader createLogReader() => new _IOSSimulatorLogReader(this); + + void clearLogs() { + File logFile = new File(logFilePath); + if (logFile.existsSync()) { + RandomAccessFile randomFile = logFile.openSync(mode: FileMode.WRITE); + randomFile.truncateSync(0); + randomFile.closeSync(); + } + } + + void ensureLogsExists() { + File logFile = new File(logFilePath); + if (!logFile.existsSync()) + logFile.writeAsBytesSync([]); + } +} + +class _IOSSimulatorLogReader extends DeviceLogReader { + _IOSSimulatorLogReader(this.device); + + final IOSSimulator device; + + bool _lastWasFiltered = false; + + String get name => device.name; + + Future logs({ bool clear: false }) async { + if (!device.isConnected()) + return 2; + + if (clear) + device.clearLogs(); + + device.ensureLogsExists(); + + // Match the log prefix (in order to shorten it): + // 'Jan 29 01:31:44 devoncarew-macbookpro3 SpringBoard[96648]: ...' + RegExp mapRegex = new RegExp(r'\S+ +\S+ +\S+ \S+ (.+)\[\d+\]\)?: (.*)$'); + // Jan 31 19:23:28 --- last message repeated 1 time --- + RegExp lastMessageRegex = new RegExp(r'\S+ +\S+ +\S+ --- (.*) ---$'); + + // This filter matches many Flutter lines in the log: + // new RegExp(r'(FlutterRunner|flutter.runner.Runner|$id)'), but it misses + // a fair number, including ones that would be useful in diagnosing crashes. + // For now, we're not filtering the log file (but do clear it with each run). + + Future result = runCommandAndStreamOutput( + ['tail', '-n', '+0', '-F', device.logFilePath], + prefix: '[$name] ', + mapFunction: (String string) { + Match match = mapRegex.matchAsPrefix(string); + if (match != null) { + _lastWasFiltered = true; + + // Filter out some messages that clearly aren't related to Flutter. + if (string.contains(': could not find icon for representation -> com.apple.')) + return null; + String category = match.group(1); + String content = match.group(2); + if (category == 'Game Center' || category == 'itunesstored' || category == 'nanoregistrylaunchd' || + category == 'mstreamd' || category == 'syncdefaultsd' || category == 'companionappd' || + category == 'searchd') + return null; + + _lastWasFiltered = false; + + if (category == 'Runner') + return content; + return '$category: $content'; + } + match = lastMessageRegex.matchAsPrefix(string); + if (match != null && !_lastWasFiltered) + return '(${match.group(1)})'; + return string; + } + ); + + // Track system.log crashes. + // ReportCrash[37965]: Saved crash report for FlutterRunner[37941]... + runCommandAndStreamOutput( + ['tail', '-F', '/private/var/log/system.log'], + prefix: '[$name] ', + filter: new RegExp(r' FlutterRunner\[\d+\] '), + mapFunction: (String string) { + Match match = mapRegex.matchAsPrefix(string); + return match == null ? string : '${match.group(1)}: ${match.group(2)}'; + } + ); + + return await result; + } + + int get hashCode => device.logFilePath.hashCode; + + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other is! _IOSSimulatorLogReader) + return false; + return other.device.logFilePath == device.logFilePath; + } +} diff --git a/packages/flutter_tools/test/android_device_test.dart b/packages/flutter_tools/test/android_device_test.dart index d71878d656f..2ad4701e2d4 100644 --- a/packages/flutter_tools/test/android_device_test.dart +++ b/packages/flutter_tools/test/android_device_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_tools/src/android/device_android.dart'; +import 'package:flutter_tools/src/android/android_device.dart'; import 'package:test/test.dart'; import 'src/context.dart'; diff --git a/packages/flutter_tools/test/base_utils_test.dart b/packages/flutter_tools/test/base_utils_test.dart new file mode 100644 index 00000000000..053fdd4307a --- /dev/null +++ b/packages/flutter_tools/test/base_utils_test.dart @@ -0,0 +1,37 @@ +// 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 'package:flutter_tools/src/base/utils.dart'; +import 'package:test/test.dart'; + +main() => defineTests(); + +defineTests() { + group('ItemListNotifier', () { + test('sends notifications', () async { + ItemListNotifier list = new ItemListNotifier(); + expect(list.items, isEmpty); + + Future> addedStreamItems = list.onAdded.toList(); + Future> removedStreamItems = list.onRemoved.toList(); + + list.updateWithNewList(['aaa']); + list.updateWithNewList(['aaa', 'bbb']); + list.updateWithNewList(['bbb']); + list.dispose(); + + List addedItems = await addedStreamItems; + List removedItems = await removedStreamItems; + + expect(addedItems.length, 2); + expect(addedItems.first, 'aaa'); + expect(addedItems[1], 'bbb'); + + expect(removedItems.length, 1); + expect(removedItems.first, 'aaa'); + }); + }); +} diff --git a/packages/flutter_tools/test/daemon_test.dart b/packages/flutter_tools/test/daemon_test.dart index 70035a973b7..29ff62f1d18 100644 --- a/packages/flutter_tools/test/daemon_test.dart +++ b/packages/flutter_tools/test/daemon_test.dart @@ -3,11 +3,14 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:io'; import 'package:flutter_tools/src/base/context.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/commands/daemon.dart'; +import 'package:flutter_tools/src/doctor.dart'; import 'package:flutter_tools/src/globals.dart'; +import 'package:flutter_tools/src/ios/mac.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -31,6 +34,9 @@ defineTests() { appContext = new AppContext(); notifyingLogger = new NotifyingLogger(); appContext[Logger] = notifyingLogger; + appContext[Doctor] = new Doctor(); + if (Platform.isMacOS) + appContext[XCode] = new XCode(); }); tearDown(() { diff --git a/packages/flutter_tools/test/src/mocks.dart b/packages/flutter_tools/test/src/mocks.dart index a5f3ee3f7da..8936cf9c78f 100644 --- a/packages/flutter_tools/test/src/mocks.dart +++ b/packages/flutter_tools/test/src/mocks.dart @@ -2,11 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_tools/src/android/device_android.dart'; +import 'package:flutter_tools/src/android/android_device.dart'; import 'package:flutter_tools/src/application_package.dart'; import 'package:flutter_tools/src/build_configuration.dart'; import 'package:flutter_tools/src/device.dart'; -import 'package:flutter_tools/src/ios/device_ios.dart'; +import 'package:flutter_tools/src/ios/devices.dart'; +import 'package:flutter_tools/src/ios/simulators.dart'; import 'package:flutter_tools/src/runner/flutter_command.dart'; import 'package:flutter_tools/src/toolchain.dart'; import 'package:mockito/mockito.dart';