devices.dart 20.4 KB
Newer Older
1 2 3 4 5 6
// 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';

7 8
import 'package:meta/meta.dart';

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

26
const String _kIdeviceinstallerInstructions =
27 28
    'To work with iOS devices, please install ideviceinstaller. To install, run:\n'
    'brew install ideviceinstaller.';
29

30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
class IOSDeploy {
  const IOSDeploy();

  /// Installs and runs the specified app bundle using ios-deploy, then returns
  /// the exit code.
  Future<int> runApp({
    @required String deviceId,
    @required String bundlePath,
    @required List<String> launchArguments,
  }) async {
    final List<String> launchCommand = <String>[
      '/usr/bin/env',
      'ios-deploy',
      '--id',
      deviceId,
      '--bundle',
      bundlePath,
      '--no-wifi',
      '--justlaunch',
    ];
    if (launchArguments.isNotEmpty) {
      launchCommand.add('--args');
      launchCommand.add('${launchArguments.join(" ")}');
    }

    // Push /usr/bin to the front of PATH to pick up default system python, package 'six'.
    //
    // ios-deploy transitively depends on LLDB.framework, which invokes a
    // Python script that uses package 'six'. LLDB.framework relies on the
    // python at the front of the path, which may not include package 'six'.
    // Ensure that we pick up the system install of python, which does include
    // it.
62
    final Map<String, String> iosDeployEnv = Map<String, String>.from(platform.environment);
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
    iosDeployEnv['PATH'] = '/usr/bin:${iosDeployEnv['PATH']}';

    return await runCommandAndStreamOutput(
      launchCommand,
      mapFunction: _monitorInstallationFailure,
      trace: true,
      environment: iosDeployEnv,
    );
  }

  // Maps stdout line stream. Must return original line.
  String _monitorInstallationFailure(String stdout) {
    // Installation issues.
    if (stdout.contains('Error 0xe8008015') || stdout.contains('Error 0xe8000067')) {
      printError(noProvisioningProfileInstruction, emphasis: true);

    // Launch issues.
    } else if (stdout.contains('e80000e2')) {
      printError('''
═══════════════════════════════════════════════════════════════════════════════════
Your device is locked. Unlock your device first before running.
═══════════════════════════════════════════════════════════════════════════════════''',
      emphasis: true);
    } else if (stdout.contains('Error 0xe8000022')) {
      printError('''
═══════════════════════════════════════════════════════════════════════════════════
Error launching app. Try launching from within Xcode via:
    open ios/Runner.xcworkspace

Your Xcode version may be too old for your iOS version.
═══════════════════════════════════════════════════════════════════════════════════''',
      emphasis: true);
    }

    return stdout;
  }
}

101
class IOSDevices extends PollingDeviceDiscovery {
102
  IOSDevices() : super('iOS devices');
103

104
  @override
105
  bool get supportsPlatform => platform.isMacOS;
106

107
  @override
108
  bool get canListAnything => iosWorkflow.canListDevices;
109

110
  @override
111
  Future<List<Device>> pollingGetDevices() => IOSDevice.getAttachedDevices();
112 113 114
}

class IOSDevice extends Device {
115 116 117
  IOSDevice(String id, { this.name, String sdkVersion })
      : _sdkVersion = sdkVersion,
        super(id) {
118
    _installerPath = _checkForCommand('ideviceinstaller');
119
    _iproxyPath = _checkForCommand('iproxy');
120 121 122
  }

  String _installerPath;
123
  String _iproxyPath;
124

125 126
  final String _sdkVersion;

127
  @override
128 129 130 131
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => true;
132

133
  @override
134 135
  final String name;

136
  Map<ApplicationPackage, _IOSDeviceLogReader> _logReaders;
137

138 139
  _IOSDevicePortForwarder _portForwarder;

140
  @override
141
  Future<bool> get isLocalEmulator async => false;
142

143
  @override
144 145
  bool get supportsStartPaused => false;

146
  static Future<List<IOSDevice>> getAttachedDevices() async {
147
    if (!iMobileDevice.isInstalled)
148 149
      return <IOSDevice>[];

150
    final List<IOSDevice> devices = <IOSDevice>[];
151 152 153 154 155
    for (String id in (await iMobileDevice.getAvailableDeviceIDs()).split('\n')) {
      id = id.trim();
      if (id.isEmpty)
        continue;

156 157 158 159 160 161 162 163
      try {
        final String deviceName = await iMobileDevice.getInfoForDevice(id, 'DeviceName');
        final String sdkVersion = await iMobileDevice.getInfoForDevice(id, 'ProductVersion');
        devices.add(IOSDevice(id, name: deviceName, sdkVersion: sdkVersion));
      } on IOSDeviceNotFoundError catch (error) {
        // Unable to find device with given udid. Possibly a network device.
        printTrace('Error getting attached iOS device: $error');
      }
164 165 166 167 168 169
    }
    return devices;
  }

  static String _checkForCommand(
    String command, [
170
    String macInstructions = _kIdeviceinstallerInstructions,
171
  ]) {
172 173 174
    try {
      command = runCheckedSync(<String>['which', command]).trim();
    } catch (e) {
175
      if (platform.isMacOS) {
176 177 178
        printError('$command not found. $macInstructions');
      } else {
        printError('Cannot control iOS devices or simulators. $command is not available on your platform.');
179
      }
180 181 182
      return null;
    }
    return command;
183 184 185
  }

  @override
186
  Future<bool> isAppInstalled(ApplicationPackage app) async {
187
    try {
188
      final RunResult apps = await runCheckedAsync(<String>[_installerPath, '--list-apps']);
189
      if (RegExp(app.id, multiLine: true).hasMatch(apps.stdout)) {
190 191
        return true;
      }
192 193 194 195 196 197
    } catch (e) {
      return false;
    }
    return false;
  }

198
  @override
199
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
200

201
  @override
202
  Future<bool> installApp(ApplicationPackage app) async {
203 204
    final IOSApp iosApp = app;
    final Directory bundle = fs.directory(iosApp.deviceBundlePath);
205
    if (!bundle.existsSync()) {
206
      printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?');
207 208 209 210
      return false;
    }

    try {
211
      await runCheckedAsync(<String>[_installerPath, '-i', iosApp.deviceBundlePath]);
212 213 214 215 216
      return true;
    } catch (e) {
      return false;
    }
  }
217 218

  @override
219
  Future<bool> uninstallApp(ApplicationPackage app) async {
220
    try {
221
      await runCheckedAsync(<String>[_installerPath, '-U', app.id]);
222
      return true;
223 224 225 226 227
    } catch (e) {
      return false;
    }
  }

228 229 230
  @override
  bool isSupported() => true;

231
  @override
Devon Carew's avatar
Devon Carew committed
232
  Future<LaunchResult> startApp(
233
    ApplicationPackage package, {
234 235
    String mainPath,
    String route,
Devon Carew's avatar
Devon Carew committed
236
    DebuggingOptions debuggingOptions,
237
    Map<String, dynamic> platformArgs,
238 239 240
    bool prebuiltApplication = false,
    bool usesTerminalUi = true,
    bool ipv6 = false,
241
  }) async {
242
    if (!prebuiltApplication) {
243
      // TODO(chinmaygarde): Use mainPath, route.
244
      printTrace('Building ${package.name} for $id');
245

246 247 248
      final String cpuArchitecture = await iMobileDevice.getInfoForDevice(id, 'CPUArchitecture');
      final IOSArch iosArch = getIOSArchForName(cpuArchitecture);

249
      // Step 1: Build the precompiled/DBC application if necessary.
250
      final XcodeBuildResult buildResult = await buildXcodeProject(
251
          app: package,
252
          buildInfo: debuggingOptions.buildInfo,
253
          targetOverride: mainPath,
254 255
          buildForDevice: true,
          usesTerminalUi: usesTerminalUi,
256
          activeArch: iosArch,
257
      );
258 259
      if (!buildResult.success) {
        printError('Could not build the precompiled application for the device.');
xster's avatar
xster committed
260
        await diagnoseXcodeBuildFailure(buildResult);
261
        printError('');
262
        return LaunchResult.failed();
263
      }
264
    } else {
265
      if (!await installApp(package))
266
        return LaunchResult.failed();
267 268 269
    }

    // Step 2: Check that the application exists at the specified path.
270
    final IOSApp iosApp = package;
271
    final Directory bundle = fs.directory(iosApp.deviceBundlePath);
272
    if (!bundle.existsSync()) {
273
      printError('Could not find the built application bundle at ${bundle.path}.');
274
      return LaunchResult.failed();
275 276 277
    }

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

    if (debuggingOptions.startPaused)
281
      launchArguments.add('--start-paused');
282

283 284 285
    if (debuggingOptions.disableServiceAuthCodes)
      launchArguments.add('--disable-service-auth-codes');

286
    if (debuggingOptions.useTestFonts)
287
      launchArguments.add('--use-test-fonts');
288

289
    if (debuggingOptions.debuggingEnabled) {
290
      launchArguments.add('--enable-checked-mode');
291 292
      launchArguments.add('--verify-entry-points');
    }
293

294
    if (debuggingOptions.enableSoftwareRendering)
295 296
      launchArguments.add('--enable-software-rendering');

297 298 299
    if (debuggingOptions.skiaDeterministicRendering)
      launchArguments.add('--skia-deterministic-rendering');

300 301 302
    if (debuggingOptions.traceSkia)
      launchArguments.add('--trace-skia');

303 304 305
    if (debuggingOptions.dumpSkpOnShaderCompilation)
      launchArguments.add('--dump-skp-on-shader-compilation');

306 307 308 309
    if (debuggingOptions.verboseSystemLogs) {
      launchArguments.add('--verbose-logging');
    }

310 311 312
    if (platformArgs['trace-startup'] ?? false)
      launchArguments.add('--trace-startup');

313
    int installationResult = -1;
314
    Uri localObservatoryUri;
315

316
    final Status installStatus = logger.startProgress('Installing and launching...', timeout: timeoutConfiguration.slowOperation);
317

318
    if (!debuggingOptions.debuggingEnabled) {
319
      // If debugging is not enabled, just launch the application and continue.
320
      printTrace('Debugging is not enabled');
321 322 323 324
      installationResult = await const IOSDeploy().runApp(
        deviceId: id,
        bundlePath: bundle.path,
        launchArguments: launchArguments,
325
      );
326
    } else {
327 328
      // Debugging is enabled, look for the observatory server port post launch.
      printTrace('Debugging is enabled, connecting to observatory');
329

330 331
      // 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.
332
      final ProtocolDiscovery observatoryDiscovery = ProtocolDiscovery.observatory(
333
        getLogReader(app: package),
334 335 336 337
        portForwarder: portForwarder,
        hostPort: debuggingOptions.observatoryPort,
        ipv6: ipv6,
      );
338

339
      final Future<Uri> forwardObservatoryUri = observatoryDiscovery.uri;
340

341 342 343 344
      final Future<int> launch = const IOSDeploy().runApp(
        deviceId: id,
        bundlePath: bundle.path,
        launchArguments: launchArguments,
345
      );
346

347
      localObservatoryUri = await launch.then<Uri>((int result) async {
348 349 350
        installationResult = result;

        if (result != 0) {
351
          printTrace('Failed to launch the application on device.');
352
          return null;
353 354
        }

355
        printTrace('Application launched on the device. Waiting for observatory port.');
356
        return await forwardObservatoryUri;
357 358
      }).whenComplete(() {
        observatoryDiscovery.cancel();
359 360
      });
    }
361
    installStatus.stop();
362 363 364

    if (installationResult != 0) {
      printError('Could not install ${bundle.path} on $id.');
365
      printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
366
      printError('  open ios/Runner.xcworkspace');
367
      printError('');
368
      return LaunchResult.failed();
369 370
    }

371
    return LaunchResult.succeeded(observatoryUri: localObservatoryUri);
372 373
  }

374 375 376 377 378 379 380
  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  @override
381
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
382

383
  @override
384
  Future<String> get sdkNameAndVersion async => 'iOS $_sdkVersion';
385

386
  @override
387
  DeviceLogReader getLogReader({ ApplicationPackage app }) {
388
    _logReaders ??= <ApplicationPackage, _IOSDeviceLogReader>{};
389
    return _logReaders.putIfAbsent(app, () => _IOSDeviceLogReader(this, app));
390 391
  }

392
  @override
393
  DevicePortForwarder get portForwarder => _portForwarder ??= _IOSDevicePortForwarder(this);
394

395
  @override
396
  void clearLogs() { }
Devon Carew's avatar
Devon Carew committed
397 398

  @override
399
  bool get supportsScreenshot => iMobileDevice.isInstalled;
Devon Carew's avatar
Devon Carew committed
400 401

  @override
402
  Future<void> takeScreenshot(File outputFile) async {
403 404
    await iMobileDevice.takeScreenshot(outputFile);
  }
405 406 407 408 409

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.ios.existsSync();
  }
410 411
}

412
/// Decodes a vis-encoded syslog string to a UTF-8 representation.
413 414 415 416 417 418 419 420 421
///
/// Apple's syslog logs are encoded in 7-bit form. Input bytes are encoded as follows:
/// 1. 0x00 to 0x19: non-printing range. Some ignored, some encoded as <...>.
/// 2. 0x20 to 0x7f: as-is, with the exception of 0x5c (backslash).
/// 3. 0x5c (backslash): octal representation \134.
/// 4. 0x80 to 0x9f: \M^x (using control-character notation for range 0x00 to 0x40).
/// 5. 0xa0: octal representation \240.
/// 6. 0xa1 to 0xf7: \M-x (where x is the input byte stripped of its high-order bit).
/// 7. 0xf8 to 0xff: unused in 4-byte UTF-8.
422 423
///
/// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3)
424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440
String decodeSyslog(String line) {
  // UTF-8 values for \, M, -, ^.
  const int kBackslash = 0x5c;
  const int kM = 0x4d;
  const int kDash = 0x2d;
  const int kCaret = 0x5e;

  // Mask for the UTF-8 digit range.
  const int kNum = 0x30;

  // Returns true when `byte` is within the UTF-8 7-bit digit range (0x30 to 0x39).
  bool isDigit(int byte) => (byte & 0xf0) == kNum;

  // Converts a three-digit ASCII (UTF-8) representation of an octal number `xyz` to an integer.
  int decodeOctal(int x, int y, int z) => (x & 0x3) << 6 | (y & 0x7) << 3 | z & 0x7;

  try {
441
    final List<int> bytes = utf8.encode(line);
442
    final List<int> out = <int>[];
443
    for (int i = 0; i < bytes.length;) {
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464
      if (bytes[i] != kBackslash || i > bytes.length - 4) {
        // Unmapped byte: copy as-is.
        out.add(bytes[i++]);
      } else {
        // Mapped byte: decode next 4 bytes.
        if (bytes[i + 1] == kM && bytes[i + 2] == kCaret) {
          // \M^x form: bytes in range 0x80 to 0x9f.
          out.add((bytes[i + 3] & 0x7f) + 0x40);
        } else if (bytes[i + 1] == kM && bytes[i + 2] == kDash) {
          // \M-x form: bytes in range 0xa0 to 0xf7.
          out.add(bytes[i + 3] | 0x80);
        } else if (bytes.getRange(i + 1, i + 3).every(isDigit)) {
          // \ddd form: octal representation (only used for \134 and \240).
          out.add(decodeOctal(bytes[i + 1], bytes[i + 2], bytes[i + 3]));
        } else {
          // Unknown form: copy as-is.
          out.addAll(bytes.getRange(0, 4));
        }
        i += 4;
      }
    }
465
    return utf8.decode(out);
466 467 468 469 470 471
  } catch (_) {
    // Unable to decode line: return as-is.
    return line;
  }
}

472
class _IOSDeviceLogReader extends DeviceLogReader {
473
  _IOSDeviceLogReader(this.device, ApplicationPackage app) {
474
    _linesController = StreamController<String>.broadcast(
475
      onListen: _start,
476
      onCancel: _stop,
477 478 479 480 481
    );

    // Match for lines for the runner in syslog.
    //
    // iOS 9 format:  Runner[297] <Notice>:
482
    // iOS 10 format: Runner(Flutter)[297] <Notice>:
483
    final String appName = app == null ? '' : app.name.replaceAll('.app', '');
484
    _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: ');
485 486 487
    // Similar to above, but allows ~arbitrary components instead of "Runner"
    // and "Flutter". The regex tries to strike a balance between not producing
    // false positives and not producing false negatives.
488
    _anyLineRegex = RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: ');
Devon Carew's avatar
Devon Carew committed
489
  }
490 491 492

  final IOSDevice device;

493 494 495 496 497
  // Matches a syslog line from the runner.
  RegExp _runnerLineRegex;
  // Matches a syslog line from any app.
  RegExp _anyLineRegex;

Devon Carew's avatar
Devon Carew committed
498
  StreamController<String> _linesController;
499
  Process _process;
500

501
  @override
Devon Carew's avatar
Devon Carew committed
502
  Stream<String> get logLines => _linesController.stream;
503

504
  @override
505 506
  String get name => device.name;

Devon Carew's avatar
Devon Carew committed
507
  void _start() {
508
    iMobileDevice.startLogger(device.id).then<void>((Process process) {
Devon Carew's avatar
Devon Carew committed
509
      _process = process;
510 511
      _process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newLineHandler());
      _process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newLineHandler());
512
      _process.exitCode.whenComplete(() {
Devon Carew's avatar
Devon Carew committed
513 514 515 516
        if (_linesController.hasListener)
          _linesController.close();
      });
    });
517 518
  }

519 520 521 522 523 524 525 526 527 528 529 530 531 532 533
  // Returns a stateful line handler to properly capture multi-line output.
  //
  // For multi-line log messages, any line after the first is logged without
  // any specific prefix. To properly capture those, we enter "printing" mode
  // after matching a log line from the runner. When in printing mode, we print
  // all lines until we find the start of another log message (from any app).
  Function _newLineHandler() {
    bool printing = false;

    return (String line) {
      if (printing) {
        if (!_anyLineRegex.hasMatch(line)) {
          _linesController.add(decodeSyslog(line));
          return;
        }
534

535 536 537 538 539 540 541 542 543 544 545 546 547
        printing = false;
      }

      final Match match = _runnerLineRegex.firstMatch(line);

      if (match != null) {
        final String logLine = line.substring(match.end);
        // Only display the log line after the initial device and executable information.
        _linesController.add(decodeSyslog(logLine));

        printing = true;
      }
    };
548 549
  }

Devon Carew's avatar
Devon Carew committed
550 551
  void _stop() {
    _process?.kill();
552 553
  }
}
554 555

class _IOSDevicePortForwarder extends DevicePortForwarder {
556
  _IOSDevicePortForwarder(this.device) : _forwardedPorts = <ForwardedPort>[];
557 558 559

  final IOSDevice device;

560 561
  final List<ForwardedPort> _forwardedPorts;

562
  @override
563
  List<ForwardedPort> get forwardedPorts => _forwardedPorts;
564

565
  static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1);
566

567
  @override
568
  Future<int> forward(int devicePort, { int hostPort }) async {
569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590
    final bool autoselect = hostPort == null || hostPort == 0;
    if (autoselect)
      hostPort = 1024;

    Process process;

    bool connected = false;
    while (!connected) {
      printTrace('attempting to forward device port $devicePort to host port $hostPort');
      // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID
      process = await runCommand(<String>[
        device._iproxyPath,
        hostPort.toString(),
        devicePort.toString(),
        device.id,
      ]);
      // TODO(ianh): This is a flakey race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674
      connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false);
      if (!connected) {
        if (autoselect) {
          hostPort += 1;
          if (hostPort > 65535)
591
            throw Exception('Could not find open port on host.');
592
        } else {
593
          throw Exception('Port $hostPort is not available.');
594 595
        }
      }
596
    }
597 598
    assert(connected);
    assert(process != null);
599

600
    final ForwardedPort forwardedPort = ForwardedPort.withContext(
601 602
      hostPort, devicePort, process,
    );
603
    printTrace('Forwarded port $forwardedPort');
604
    _forwardedPorts.add(forwardedPort);
605
    return hostPort;
606 607
  }

608
  @override
609
  Future<void> unforward(ForwardedPort forwardedPort) async {
610 611
    if (!_forwardedPorts.remove(forwardedPort)) {
      // Not in list. Nothing to remove.
612
      return;
613 614
    }

615
    printTrace('Unforwarding port $forwardedPort');
616

617
    final Process process = forwardedPort.context;
618 619

    if (process != null) {
620
      processManager.killPid(process.pid);
621
    } else {
622
      printError('Forwarded port did not have a valid process');
623
    }
624 625
  }
}