devices.dart 14.2 KB
Newer Older
1 2 3 4 5
// 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';
6
import 'dart:convert';
7 8

import '../application_package.dart';
9
import '../base/file_system.dart';
10
import '../base/io.dart';
11
import '../base/platform.dart';
12
import '../base/port_scanner.dart';
13
import '../base/process.dart';
14
import '../base/process_manager.dart';
15
import '../build_info.dart';
16 17
import '../device.dart';
import '../globals.dart';
18
import '../protocol_discovery.dart';
19
import 'code_signing.dart';
20
import 'ios_workflow.dart';
21 22
import 'mac.dart';

23
const String _kIdeviceinstallerInstructions =
24 25
    'To work with iOS devices, please install ideviceinstaller. To install, run:\n'
    'brew install ideviceinstaller.';
26

27 28
const Duration kPortForwardTimeout = const Duration(seconds: 10);

29
class IOSDevices extends PollingDeviceDiscovery {
30
  IOSDevices() : super('iOS devices');
31

32
  @override
33
  bool get supportsPlatform => platform.isMacOS;
34

35
  @override
36
  bool get canListAnything => iosWorkflow.canListDevices;
37

38
  @override
39
  Future<List<Device>> pollingGetDevices() => IOSDevice.getAttachedDevices();
40 41 42
}

class IOSDevice extends Device {
43
  IOSDevice(String id, { this.name, String sdkVersion }) : _sdkVersion = sdkVersion, super(id) {
44
    _installerPath = _checkForCommand('ideviceinstaller');
45
    _iproxyPath = _checkForCommand('iproxy');
46 47 48
  }

  String _installerPath;
49
  String _iproxyPath;
50

51 52
  final String _sdkVersion;

53 54 55
  @override
  bool get supportsHotMode => true;

56
  @override
57 58
  final String name;

59
  Map<ApplicationPackage, _IOSDeviceLogReader> _logReaders;
60

61 62
  _IOSDevicePortForwarder _portForwarder;

63
  @override
64
  Future<bool> get isLocalEmulator async => false;
65

66
  @override
67 68
  bool get supportsStartPaused => false;

69
  static Future<List<IOSDevice>> getAttachedDevices() async {
70
    if (!iMobileDevice.isInstalled)
71 72
      return <IOSDevice>[];

73
    final List<IOSDevice> devices = <IOSDevice>[];
74 75 76 77 78 79 80 81
    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));
82 83 84 85 86 87
    }
    return devices;
  }

  static String _checkForCommand(
    String command, [
88
    String macInstructions = _kIdeviceinstallerInstructions
89
  ]) {
90 91 92
    try {
      command = runCheckedSync(<String>['which', command]).trim();
    } catch (e) {
93
      if (platform.isMacOS) {
94 95 96
        printError('$command not found. $macInstructions');
      } else {
        printError('Cannot control iOS devices or simulators. $command is not available on your platform.');
97
      }
98 99 100
      return null;
    }
    return command;
101 102 103
  }

  @override
104
  Future<bool> isAppInstalled(ApplicationPackage app) async {
105
    try {
106
      final RunResult apps = await runCheckedAsync(<String>[_installerPath, '--list-apps']);
107
      if (new RegExp(app.id, multiLine: true).hasMatch(apps.stdout)) {
108 109
        return true;
      }
110 111 112 113 114 115
    } catch (e) {
      return false;
    }
    return false;
  }

116
  @override
117
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
118

119
  @override
120
  Future<bool> installApp(ApplicationPackage app) async {
121 122
    final IOSApp iosApp = app;
    final Directory bundle = fs.directory(iosApp.deviceBundlePath);
123
    if (!bundle.existsSync()) {
124
      printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?');
125 126 127 128
      return false;
    }

    try {
129
      await runCheckedAsync(<String>[_installerPath, '-i', iosApp.deviceBundlePath]);
130 131 132 133 134
      return true;
    } catch (e) {
      return false;
    }
  }
135 136

  @override
137
  Future<bool> uninstallApp(ApplicationPackage app) async {
138
    try {
139
      await runCheckedAsync(<String>[_installerPath, '-U', app.id]);
140
      return true;
141 142 143 144 145
    } catch (e) {
      return false;
    }
  }

146 147 148
  @override
  bool isSupported() => true;

149
  @override
Devon Carew's avatar
Devon Carew committed
150
  Future<LaunchResult> startApp(
151
    ApplicationPackage app, {
152 153
    String mainPath,
    String route,
Devon Carew's avatar
Devon Carew committed
154
    DebuggingOptions debuggingOptions,
155
    Map<String, dynamic> platformArgs,
156
    bool prebuiltApplication: false,
157
    bool previewDart2: false,
158
    bool applicationNeedsRebuild: false,
159
    bool usesTerminalUi: true,
160
    bool ipv6: false,
161
  }) async {
162
    if (!prebuiltApplication) {
163
      // TODO(chinmaygarde): Use mainPath, route.
164 165 166
      printTrace('Building ${app.name} for $id');

      // Step 1: Build the precompiled/DBC application if necessary.
167 168
      final XcodeBuildResult buildResult = await buildXcodeProject(
          app: app,
169
          buildInfo: debuggingOptions.buildInfo,
170 171 172 173
          target: mainPath,
          buildForDevice: true,
          usesTerminalUi: usesTerminalUi,
      );
174 175
      if (!buildResult.success) {
        printError('Could not build the precompiled application for the device.');
176
        await diagnoseXcodeBuildFailure(buildResult, app);
177 178 179
        printError('');
        return new LaunchResult.failed();
      }
180
    } else {
181
      if (!await installApp(app))
182
        return new LaunchResult.failed();
183 184 185
    }

    // Step 2: Check that the application exists at the specified path.
186 187
    final IOSApp iosApp = app;
    final Directory bundle = fs.directory(iosApp.deviceBundlePath);
188
    if (!bundle.existsSync()) {
189
      printError('Could not find the built application bundle at ${bundle.path}.');
Devon Carew's avatar
Devon Carew committed
190
      return new LaunchResult.failed();
191 192 193
    }

    // Step 3: Attempt to install the application on the device.
194
    final List<String> launchArguments = <String>['--enable-dart-profiling'];
195 196

    if (debuggingOptions.startPaused)
197
      launchArguments.add('--start-paused');
198

199
    if (debuggingOptions.useTestFonts)
200
      launchArguments.add('--use-test-fonts');
201

202
    if (debuggingOptions.debuggingEnabled) {
203
      launchArguments.add('--enable-checked-mode');
204 205 206 207 208 209

      // 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.
    }

210
    if (debuggingOptions.enableSoftwareRendering)
211 212
      launchArguments.add('--enable-software-rendering');

213 214 215
    if (debuggingOptions.traceSkia)
      launchArguments.add('--trace-skia');

216 217 218
    if (platformArgs['trace-startup'] ?? false)
      launchArguments.add('--trace-startup');

219
    final List<String> launchCommand = <String>[
220 221 222 223 224 225
      '/usr/bin/env',
      'ios-deploy',
      '--id',
      id,
      '--bundle',
      bundle.path,
226
      '--no-wifi',
227
      '--justlaunch',
228 229
    ];

230
    if (launchArguments.isNotEmpty) {
231
      launchCommand.add('--args');
232
      launchCommand.add('${launchArguments.join(" ")}');
233 234 235
    }

    int installationResult = -1;
236
    Uri localObservatoryUri;
237

238
    if (!debuggingOptions.debuggingEnabled) {
239
      // If debugging is not enabled, just launch the application and continue.
240
      printTrace('Debugging is not enabled');
241 242 243 244 245
      installationResult = await runCommandAndStreamOutput(
        launchCommand,
        mapFunction: monitorInstallationFailure,
        trace: true,
      );
246
    } else {
247 248
      // Debugging is enabled, look for the observatory server port post launch.
      printTrace('Debugging is enabled, connecting to observatory');
249

250 251
      // 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.
252
      final ProtocolDiscovery observatoryDiscovery = new ProtocolDiscovery.observatory(
253 254 255 256 257
        getLogReader(app: app),
        portForwarder: portForwarder,
        hostPort: debuggingOptions.observatoryPort,
        ipv6: ipv6,
      );
258

259
      final Future<Uri> forwardObservatoryUri = observatoryDiscovery.uri;
260

261 262 263 264 265
      final Future<int> launch = runCommandAndStreamOutput(
        launchCommand,
        mapFunction: monitorInstallationFailure,
        trace: true,
      );
266

267
      localObservatoryUri = await launch.then<Uri>((int result) async {
268 269 270
        installationResult = result;

        if (result != 0) {
271
          printTrace('Failed to launch the application on device.');
272
          return null;
273 274
        }

275
        printTrace('Application launched on the device. Waiting for observatory port.');
276
        return await forwardObservatoryUri;
277 278
      }).whenComplete(() {
        observatoryDiscovery.cancel();
279 280
      });
    }
281 282 283

    if (installationResult != 0) {
      printError('Could not install ${bundle.path} on $id.');
284
      printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
285
      printError('  open ios/Runner.xcworkspace');
286
      printError('');
Devon Carew's avatar
Devon Carew committed
287
      return new LaunchResult.failed();
288 289
    }

290
    return new LaunchResult.succeeded(observatoryUri: localObservatoryUri);
291 292
  }

293 294 295 296 297 298 299
  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  @override
300
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
301

302
  @override
303
  Future<String> get sdkNameAndVersion async => 'iOS $_sdkVersion';
304

305
  @override
306 307 308
  DeviceLogReader getLogReader({ApplicationPackage app}) {
    _logReaders ??= <ApplicationPackage, _IOSDeviceLogReader>{};
    return _logReaders.putIfAbsent(app, () => new _IOSDeviceLogReader(this, app));
309 310
  }

311
  @override
312
  DevicePortForwarder get portForwarder => _portForwarder ??= new _IOSDevicePortForwarder(this);
313

314
  @override
315 316
  void clearLogs() {
  }
Devon Carew's avatar
Devon Carew committed
317 318

  @override
319
  bool get supportsScreenshot => iMobileDevice.isInstalled;
Devon Carew's avatar
Devon Carew committed
320 321

  @override
322
  Future<Null> takeScreenshot(File outputFile) => iMobileDevice.takeScreenshot(outputFile);
323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349

  // 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;
  }
350 351 352
}

class _IOSDeviceLogReader extends DeviceLogReader {
353 354 355
  RegExp _lineRegex;

  _IOSDeviceLogReader(this.device, ApplicationPackage app) {
Devon Carew's avatar
Devon Carew committed
356
    _linesController = new StreamController<String>.broadcast(
357 358 359 360 361 362 363
      onListen: _start,
      onCancel: _stop
    );

    // Match for lines for the runner in syslog.
    //
    // iOS 9 format:  Runner[297] <Notice>:
364
    // iOS 10 format: Runner(Flutter)[297] <Notice>:
365
    final String appName = app == null ? '' : app.name.replaceAll('.app', '');
366
    _lineRegex = new RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: ');
Devon Carew's avatar
Devon Carew committed
367
  }
368 369 370

  final IOSDevice device;

Devon Carew's avatar
Devon Carew committed
371
  StreamController<String> _linesController;
372
  Process _process;
373

374
  @override
Devon Carew's avatar
Devon Carew committed
375
  Stream<String> get logLines => _linesController.stream;
376

377
  @override
378 379
  String get name => device.name;

Devon Carew's avatar
Devon Carew committed
380
  void _start() {
381
    iMobileDevice.startLogger().then<Null>((Process process) {
Devon Carew's avatar
Devon Carew committed
382 383 384
      _process = process;
      _process.stdout.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine);
      _process.stderr.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine);
385
      _process.exitCode.whenComplete(() {
Devon Carew's avatar
Devon Carew committed
386 387 388 389
        if (_linesController.hasListener)
          _linesController.close();
      });
    });
390 391 392
  }

  void _onLine(String line) {
393
    final Match match = _lineRegex.firstMatch(line);
394 395

    if (match != null) {
396
      final String logLine = line.substring(match.end);
397 398
      // Only display the log line after the initial device and executable information.
      _linesController.add(logLine);
399
    }
400 401
  }

Devon Carew's avatar
Devon Carew committed
402 403
  void _stop() {
    _process?.kill();
404 405
  }
}
406 407

class _IOSDevicePortForwarder extends DevicePortForwarder {
408
  _IOSDevicePortForwarder(this.device) : _forwardedPorts = <ForwardedPort>[];
409 410 411

  final IOSDevice device;

412 413
  final List<ForwardedPort> _forwardedPorts;

414
  @override
415
  List<ForwardedPort> get forwardedPorts => _forwardedPorts;
416

417
  @override
418
  Future<int> forward(int devicePort, {int hostPort}) async {
419 420
    if ((hostPort == null) || (hostPort == 0)) {
      // Auto select host port.
421
      hostPort = await portScanner.findAvailablePort();
422
    }
423 424

    // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID
425
    final Process process = await runCommand(<String>[
426
      device._iproxyPath,
427 428 429 430 431
      hostPort.toString(),
      devicePort.toString(),
      device.id,
    ]);

432
    final ForwardedPort forwardedPort = new ForwardedPort.withContext(hostPort,
433 434
        devicePort, process);

435
    printTrace('Forwarded port $forwardedPort');
436 437 438

    _forwardedPorts.add(forwardedPort);

439
    return hostPort;
440 441
  }

442
  @override
Ian Hickson's avatar
Ian Hickson committed
443
  Future<Null> unforward(ForwardedPort forwardedPort) async {
444 445 446 447 448
    if (!_forwardedPorts.remove(forwardedPort)) {
      // Not in list. Nothing to remove.
      return null;
    }

449
    printTrace('Unforwarding port $forwardedPort');
450

451
    final Process process = forwardedPort.context;
452 453

    if (process != null) {
454
      processManager.killPid(process.pid);
455
    } else {
456
      printError('Forwarded port did not have a valid process');
457 458 459
    }

    return null;
460 461
  }
}