simulators.dart 29.1 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// 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:math' as math;
7

8
import 'package:meta/meta.dart';
9
import 'package:process/process.dart';
10

11
import '../application_package.dart';
12
import '../base/common.dart';
13
import '../base/file_system.dart';
14
import '../base/io.dart';
15
import '../base/logger.dart';
16
import '../base/process.dart';
17
import '../base/utils.dart';
18
import '../build_info.dart';
19
import '../convert.dart';
20
import '../device.dart';
21
import '../globals.dart' as globals;
22
import '../macos/xcode.dart';
23
import '../project.dart';
24
import '../protocol_discovery.dart';
25
import 'mac.dart';
26
import 'plist_parser.dart';
27 28

const String _xcrunPath = '/usr/bin/xcrun';
29
const String iosSimulatorId = 'apple_ios_simulator';
30 31

class IOSSimulators extends PollingDeviceDiscovery {
32 33 34 35 36 37
  IOSSimulators({
    @required IOSSimulatorUtils iosSimulatorUtils,
  }) : _iosSimulatorUtils = iosSimulatorUtils,
       super('iOS simulators');

  final IOSSimulatorUtils _iosSimulatorUtils;
38

39
  @override
40
  bool get supportsPlatform => globals.platform.isMacOS;
41

42
  @override
43
  bool get canListAnything => globals.iosWorkflow.canListDevices;
44

45
  @override
46
  Future<List<Device>> pollingGetDevices({ Duration timeout }) async => _iosSimulatorUtils.getAttachedDevices();
47 48 49
}

class IOSSimulatorUtils {
50 51
  IOSSimulatorUtils({
    @required Xcode xcode,
52 53 54 55
    @required Logger logger,
    @required ProcessManager processManager,
  }) : _simControl = SimControl(logger: logger, processManager: processManager),
      _xcode = xcode;
56 57 58

  final SimControl _simControl;
  final Xcode _xcode;
59

60
  Future<List<IOSSimulator>> getAttachedDevices() async {
61
    if (!_xcode.isInstalledAndMeetsVersionCheck) {
62
      return <IOSSimulator>[];
63
    }
64

65
    final List<SimDevice> connected = await _simControl.getConnectedDevices();
66
    return connected.map<IOSSimulator>((SimDevice device) {
67 68 69 70 71 72 73
      return IOSSimulator(
        device.udid,
        name: device.name,
        simControl: _simControl,
        simulatorCategory: device.category,
        xcode: _xcode,
      );
74 75
    }).toList();
  }
76 77 78 79
}

/// A wrapper around the `simctl` command line tool.
class SimControl {
80 81 82 83 84 85 86 87
  SimControl({
    @required Logger logger,
    @required ProcessManager processManager,
  }) : _logger = logger,
       _processUtils = ProcessUtils(processManager: processManager, logger: logger);

  final Logger _logger;
  final ProcessUtils _processUtils;
88

89 90
  /// Runs `simctl list --json` and returns the JSON of the corresponding
  /// [section].
91
  Future<Map<String, dynamic>> _list(SimControlListSection section) async {
92 93
    // Sample output from `simctl list --json`:
    //
94
    // {
95 96
    //   "devicetypes": { ... },
    //   "runtimes": { ... },
97 98 99 100 101 102 103 104 105
    //   "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"
    //       },
    //       ...
106 107
    //   },
    //   "pairs": { ... },
108

109
    final List<String> command = <String>[_xcrunPath, 'simctl', 'list', '--json', section.name];
110 111
    _logger.printTrace(command.join(' '));
    final RunResult results = await _processUtils.run(command);
112
    if (results.exitCode != 0) {
113
      _logger.printError('Error executing simctl: ${results.exitCode}\n${results.stderr}');
114
      return <String, Map<String, dynamic>>{};
115
    }
116
    try {
117 118 119 120
      final Object decodeResult = json.decode(results.stdout?.toString())[section.name];
      if (decodeResult is Map<String, dynamic>) {
        return decodeResult;
      }
121
      _logger.printError('simctl returned unexpected JSON response: ${results.stdout}');
122
      return <String, dynamic>{};
123 124 125 126
    } on FormatException {
      // We failed to parse the simctl output, or it returned junk.
      // One known message is "Install Started" isn't valid JSON but is
      // returned sometimes.
127
      _logger.printError('simctl returned non-JSON response: ${results.stdout}');
128 129
      return <String, dynamic>{};
    }
130 131 132
  }

  /// Returns a list of all available devices, both potential and connected.
133
  Future<List<SimDevice>> getDevices() async {
134
    final List<SimDevice> devices = <SimDevice>[];
135

136
    final Map<String, dynamic> devicesSection = await _list(SimControlListSection.devices);
137

138
    for (final String deviceCategory in devicesSection.keys) {
139 140
      final Object devicesData = devicesSection[deviceCategory];
      if (devicesData != null && devicesData is List<dynamic>) {
141
        for (final Map<String, dynamic> data in devicesData.map<Map<String, dynamic>>(castStringKeyedMap)) {
142 143
          devices.add(SimDevice(deviceCategory, data));
        }
144 145 146 147 148 149 150
      }
    }

    return devices;
  }

  /// Returns all the connected simulator devices.
151 152 153
  Future<List<SimDevice>> getConnectedDevices() async {
    final List<SimDevice> simDevices = await getDevices();
    return simDevices.where((SimDevice device) => device.isBooted).toList();
154 155
  }

156
  Future<bool> isInstalled(String deviceId, String appId) {
157
    return _processUtils.exitsHappy(<String>[
158 159 160
      _xcrunPath,
      'simctl',
      'get_app_container',
161
      deviceId,
162 163 164 165
      appId,
    ]);
  }

166 167
  Future<RunResult> install(String deviceId, String appPath) async {
    RunResult result;
168
    try {
169
      result = await _processUtils.run(
170 171 172
        <String>[_xcrunPath, 'simctl', 'install', deviceId, appPath],
        throwOnError: true,
      );
173
    } on ProcessException catch (exception) {
174
      throwToolExit('Unable to install $appPath on $deviceId. This is sometimes caused by a malformed plist file:\n$exception');
175 176
    }
    return result;
177 178
  }

179 180
  Future<RunResult> uninstall(String deviceId, String appId) async {
    RunResult result;
181
    try {
182
      result = await _processUtils.run(
183 184 185
        <String>[_xcrunPath, 'simctl', 'uninstall', deviceId, appId],
        throwOnError: true,
      );
186 187 188 189
    } on ProcessException catch (exception) {
      throwToolExit('Unable to uninstall $appId from $deviceId:\n$exception');
    }
    return result;
190 191
  }

192 193
  Future<RunResult> launch(String deviceId, String appIdentifier, [ List<String> launchArgs ]) async {
    RunResult result;
194
    try {
195
      result = await _processUtils.run(
196 197 198 199 200 201 202 203 204 205
        <String>[
          _xcrunPath,
          'simctl',
          'launch',
          deviceId,
          appIdentifier,
          ...?launchArgs,
        ],
        throwOnError: true,
      );
206 207 208 209
    } on ProcessException catch (exception) {
      throwToolExit('Unable to launch $appIdentifier on $deviceId:\n$exception');
    }
    return result;
210
  }
211

212
  Future<void> takeScreenshot(String deviceId, String outputPath) async {
213
    try {
214
      await _processUtils.run(
215 216 217
        <String>[_xcrunPath, 'simctl', 'io', deviceId, 'screenshot', outputPath],
        throwOnError: true,
      );
218
    } on ProcessException catch (exception) {
219
      _logger.printError('Unable to take screenshot of $deviceId:\n$exception');
220
    }
221
  }
222 223
}

224 225
/// Enumerates all data sections of `xcrun simctl list --json` command.
class SimControlListSection {
Ian Hickson's avatar
Ian Hickson committed
226
  const SimControlListSection._(this.name);
227 228

  final String name;
Ian Hickson's avatar
Ian Hickson committed
229

230 231 232 233
  static const SimControlListSection devices = SimControlListSection._('devices');
  static const SimControlListSection devicetypes = SimControlListSection._('devicetypes');
  static const SimControlListSection runtimes = SimControlListSection._('runtimes');
  static const SimControlListSection pairs = SimControlListSection._('pairs');
234 235
}

236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
/// A simulated device type.
///
/// Simulated device types can be listed using the command
/// `xcrun simctl list devicetypes`.
class SimDeviceType {
  SimDeviceType(this.name, this.identifier);

  /// The name of the device type.
  ///
  /// Examples:
  ///
  ///     "iPhone 6s"
  ///     "iPhone 6 Plus"
  final String name;

  /// The identifier of the device type.
  ///
  /// Examples:
  ///
  ///     "com.apple.CoreSimulator.SimDeviceType.iPhone-6s"
  ///     "com.apple.CoreSimulator.SimDeviceType.iPhone-6-Plus"
  final String identifier;
}

260 261 262 263
class SimDevice {
  SimDevice(this.category, this.data);

  final String category;
264
  final Map<String, dynamic> data;
265

266 267 268 269
  String get state => data['state']?.toString();
  String get availability => data['availability']?.toString();
  String get name => data['name']?.toString();
  String get udid => data['udid']?.toString();
270 271 272 273 274

  bool get isBooted => state == 'Booted';
}

class IOSSimulator extends Device {
275 276 277 278 279 280 281 282 283 284 285 286 287 288
  IOSSimulator(
    String id, {
      this.name,
      this.simulatorCategory,
      @required SimControl simControl,
      @required Xcode xcode,
    }) : _simControl = simControl,
         _xcode = xcode,
         super(
           id,
           category: Category.mobile,
           platformType: PlatformType.ios,
           ephemeral: true,
         );
289

290
  @override
291 292
  final String name;

293
  final String simulatorCategory;
294

295 296 297
  final SimControl _simControl;
  final Xcode _xcode;

298
  @override
299
  Future<bool> get isLocalEmulator async => true;
300

301 302 303
  @override
  Future<String> get emulatorId async => iosSimulatorId;

304
  @override
305 306 307 308
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => true;
309

310 311 312 313 314 315
  @override
  Future<bool> get supportsHardwareRendering async => false;

  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode == BuildMode.debug;

316
  Map<ApplicationPackage, _IOSSimulatorLogReader> _logReaders;
317
  _IOSSimulatorDevicePortForwarder _portForwarder;
318

319
  String get xcrunPath => globals.fs.path.join('/usr', 'bin', 'xcrun');
320

321
  @override
322 323 324 325
  Future<bool> isAppInstalled(
    ApplicationPackage app, {
    String userIdentifier,
  }) {
326
    return _simControl.isInstalled(id, app.id);
327 328
  }

329
  @override
330
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
331

332
  @override
333 334 335 336
  Future<bool> installApp(
    covariant IOSApp app, {
    String userIdentifier,
  }) async {
337
    try {
338
      final IOSApp iosApp = app;
339
      await _simControl.install(id, iosApp.simulatorBundlePath);
340
      return true;
341
    } on Exception {
342 343 344 345
      return false;
    }
  }

346
  @override
347 348 349 350
  Future<bool> uninstallApp(
    ApplicationPackage app, {
    String userIdentifier,
  }) async {
351
    try {
352
      await _simControl.uninstall(id, app.id);
353
      return true;
354
    } on Exception {
355 356 357 358
      return false;
    }
  }

359 360
  @override
  bool isSupported() {
361
    if (!globals.platform.isMacOS) {
362
      _supportMessage = 'iOS devices require a Mac host machine.';
363 364 365
      return false;
    }

366
    // Check if the device is part of a blocked category.
367
    // We do not yet support WatchOS or tvOS devices.
368 369
    final RegExp blocklist = RegExp(r'Apple (TV|Watch)', caseSensitive: false);
    if (blocklist.hasMatch(name)) {
370
      _supportMessage = 'Flutter does not support Apple TV or Apple Watch.';
371 372 373
      return false;
    }
    return true;
374 375 376 377 378 379
  }

  String _supportMessage;

  @override
  String supportMessage() {
380
    if (isSupported()) {
381
      return 'Supported';
382
    }
383

384
    return _supportMessage ?? 'Unknown';
385 386 387
  }

  @override
Devon Carew's avatar
Devon Carew committed
388
  Future<LaunchResult> startApp(
389
    covariant IOSApp package, {
390 391
    String mainPath,
    String route,
Devon Carew's avatar
Devon Carew committed
392
    DebuggingOptions debuggingOptions,
393
    Map<String, dynamic> platformArgs,
394 395
    bool prebuiltApplication = false,
    bool ipv6 = false,
396
    String userIdentifier,
397
  }) async {
398
    if (!prebuiltApplication && package is BuildableIOSApp) {
399
      globals.printTrace('Building ${package.name} for $id.');
400

401
      try {
402
        await _setupUpdatedApplicationBundle(package, debuggingOptions.buildInfo, mainPath);
403
      } on ToolExit catch (e) {
404
        globals.printError(e.message);
405
        return LaunchResult.failed();
406
      }
407
    } else {
408
      if (!await installApp(package)) {
409
        return LaunchResult.failed();
410
      }
411
    }
412

413
    // Prepare launch arguments.
414
    final String dartVmFlags = computeDartVmFlags(debuggingOptions);
415 416 417 418
    final List<String> args = <String>[
      '--enable-dart-profiling',
      if (debuggingOptions.debuggingEnabled) ...<String>[
        if (debuggingOptions.buildInfo.isDebug) ...<String>[
419
          '--enable-checked-mode',
420
          '--verify-entry-points',
421 422 423 424 425
        ],
        if (debuggingOptions.startPaused) '--start-paused',
        if (debuggingOptions.disableServiceAuthCodes) '--disable-service-auth-codes',
        if (debuggingOptions.skiaDeterministicRendering) '--skia-deterministic-rendering',
        if (debuggingOptions.useTestFonts) '--use-test-fonts',
426
        if (debuggingOptions.traceAllowlist != null) '--trace-allowlist="${debuggingOptions.traceAllowlist}"',
427
        if (dartVmFlags.isNotEmpty) '--dart-flags=$dartVmFlags'
428
        '--observatory-port=${debuggingOptions.hostVmServicePort ?? 0}',
429
      ],
430
    ];
431

432
    ProtocolDiscovery observatoryDiscovery;
433
    if (debuggingOptions.debuggingEnabled) {
434
      observatoryDiscovery = ProtocolDiscovery.observatory(
435 436 437 438 439
        getLogReader(app: package),
        ipv6: ipv6,
        hostPort: debuggingOptions.hostVmServicePort,
        devicePort: debuggingOptions.deviceVmServicePort,
      );
440
    }
441

442
    // Launch the updated application in the simulator.
443
    try {
444 445 446 447
      // Use the built application's Info.plist to get the bundle identifier,
      // which should always yield the correct value and does not require
      // parsing the xcodeproj or configuration files.
      // See https://github.com/flutter/flutter/issues/31037 for more information.
448
      final String plistPath = globals.fs.path.join(package.simulatorBundlePath, 'Info.plist');
449
      final String bundleIdentifier = globals.plistParser.getValueFromFile(plistPath, PlistParser.kCFBundleIdentifierKey);
450

451
      await _simControl.launch(id, bundleIdentifier, args);
452
    } on Exception catch (error) {
453
      globals.printError('$error');
454
      return LaunchResult.failed();
455 456
    }

Devon Carew's avatar
Devon Carew committed
457
    if (!debuggingOptions.debuggingEnabled) {
458
      return LaunchResult.succeeded();
459
    }
Devon Carew's avatar
Devon Carew committed
460

461 462
    // Wait for the service protocol port here. This will complete once the
    // device has printed "Observatory is listening on..."
463
    globals.printTrace('Waiting for observatory port to be available...');
464 465

    try {
466
      final Uri deviceUri = await observatoryDiscovery.uri;
467 468 469 470 471 472 473
      if (deviceUri != null) {
        return LaunchResult.succeeded(observatoryUri: deviceUri);
      }
      globals.printError(
        'Error waiting for a debug connection: '
        'The log reader failed unexpectedly',
      );
474
    } on Exception catch (error) {
475
      globals.printError('Error waiting for a debug connection: $error');
476
    } finally {
477
      await observatoryDiscovery?.cancel();
Devon Carew's avatar
Devon Carew committed
478
    }
479
    return LaunchResult.failed();
480 481
  }

482
  Future<void> _setupUpdatedApplicationBundle(covariant BuildableIOSApp app, BuildInfo buildInfo, String mainPath) async {
483
    // Step 1: Build the Xcode project.
484
    // The build mode for the simulator is always debug.
485
    assert(buildInfo.isDebug);
486

487 488
    final XcodeBuildResult buildResult = await buildXcodeProject(
      app: app,
489
      buildInfo: buildInfo,
490 491
      targetOverride: mainPath,
      buildForDevice: false,
492
      deviceID: id,
493
    );
494
    if (!buildResult.success) {
495
      throwToolExit('Could not build the application for the simulator.');
496
    }
497 498

    // Step 2: Assert that the Xcode project was successfully built.
499
    final Directory bundle = globals.fs.directory(app.simulatorBundlePath);
500
    final bool bundleExists = bundle.existsSync();
501
    if (!bundleExists) {
502
      throwToolExit('Could not find the built application bundle at ${bundle.path}.');
503
    }
504 505

    // Step 3: Install the updated bundle to the simulator.
506
    await _simControl.install(id, globals.fs.path.absolute(bundle.path));
507 508
  }

509
  @override
510 511 512 513
  Future<bool> stopApp(
    ApplicationPackage app, {
    String userIdentifier,
  }) async {
514 515 516 517 518
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  String get logFilePath {
519
    return globals.platform.environment.containsKey('IOS_SIMULATOR_LOG_FILE_PATH')
520 521 522 523 524 525 526 527 528
      ? globals.platform.environment['IOS_SIMULATOR_LOG_FILE_PATH'].replaceAll('%{id}', id)
      : globals.fs.path.join(
          globals.fsUtils.homeDirPath,
          'Library',
          'Logs',
          'CoreSimulator',
          id,
          'system.log',
        );
529 530 531
  }

  @override
532
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
533

534
  @override
535
  Future<String> get sdkNameAndVersion async => simulatorCategory;
536

537
  final RegExp _iosSdkRegExp = RegExp(r'iOS( |-)(\d+)');
538 539 540

  Future<int> get sdkMajorVersion async {
    final Match sdkMatch = _iosSdkRegExp.firstMatch(await sdkNameAndVersion);
541
    return int.parse(sdkMatch?.group(2) ?? '11');
542 543
  }

544
  @override
545 546 547 548
  DeviceLogReader getLogReader({
    covariant IOSApp app,
    bool includePastLogs = false,
  }) {
549
    assert(app == null || app is IOSApp);
550
    assert(!includePastLogs, 'Past log reading not supported on iOS simulators.');
551
    _logReaders ??= <ApplicationPackage, _IOSSimulatorLogReader>{};
552
    return _logReaders.putIfAbsent(app, () => _IOSSimulatorLogReader(this, app));
553
  }
554

555
  @override
556
  DevicePortForwarder get portForwarder => _portForwarder ??= _IOSSimulatorDevicePortForwarder(this);
557

558
  @override
559
  void clearLogs() {
560
    final File logFile = globals.fs.file(logFilePath);
561
    if (logFile.existsSync()) {
562
      final RandomAccessFile randomFile = logFile.openSync(mode: FileMode.write);
563 564 565 566 567
      randomFile.truncateSync(0);
      randomFile.closeSync();
    }
  }

568
  Future<void> ensureLogsExists() async {
569
    if (await sdkMajorVersion < 11) {
570
      final File logFile = globals.fs.file(logFilePath);
571
      if (!logFile.existsSync()) {
572
        logFile.writeAsBytesSync(<int>[]);
573
      }
574
    }
575
  }
Devon Carew's avatar
Devon Carew committed
576

577
  bool get _xcodeVersionSupportsScreenshot {
578
    return _xcode.majorVersion > 8 || (_xcode.majorVersion == 8 && _xcode.minorVersion >= 2);
579
  }
Devon Carew's avatar
Devon Carew committed
580 581

  @override
582
  bool get supportsScreenshot => _xcodeVersionSupportsScreenshot;
Devon Carew's avatar
Devon Carew committed
583

584
  @override
585
  Future<void> takeScreenshot(File outputFile) {
586
    return _simControl.takeScreenshot(id, outputFile.path);
Devon Carew's avatar
Devon Carew committed
587
  }
588 589 590 591 592

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.ios.existsSync();
  }
593 594 595 596 597 598 599 600 601 602

  @override
  Future<void> dispose() async {
    _logReaders?.forEach(
      (ApplicationPackage application, _IOSSimulatorLogReader logReader) {
        logReader.dispose();
      },
    );
    await _portForwarder?.dispose();
  }
603 604
}

605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632
/// Launches the device log reader process on the host and parses the syslog.
@visibleForTesting
Future<Process> launchDeviceSystemLogTool(IOSSimulator device) async {
  return processUtils.start(<String>['tail', '-n', '0', '-F', device.logFilePath]);
}

/// Launches the device log reader process on the host and parses unified logging.
@visibleForTesting
Future<Process> launchDeviceUnifiedLogging (IOSSimulator device, String appName) async {
  // Make NSPredicate concatenation easier to read.
  String orP(List<String> clauses) => '(${clauses.join(" OR ")})';
  String andP(List<String> clauses) => clauses.join(' AND ');
  String notP(String clause) => 'NOT($clause)';

  final String predicate = andP(<String>[
    'eventType = logEvent',
    if (appName != null) 'processImagePath ENDSWITH "$appName"',
    // Either from Flutter or Swift (maybe assertion or fatal error) or from the app itself.
    orP(<String>[
      'senderImagePath ENDSWITH "/Flutter"',
      'senderImagePath ENDSWITH "/libswiftCore.dylib"',
      'processImageUUID == senderImageUUID',
    ]),
    // Filter out some messages that clearly aren't related to Flutter.
    notP('eventMessage CONTAINS ": could not find icon for representation -> com.apple."'),
    notP('eventMessage BEGINSWITH "assertion failed: "'),
    notP('eventMessage CONTAINS " libxpc.dylib "'),
  ]);
633

634
  return processUtils.start(<String>[
635
    _xcrunPath, 'simctl', 'spawn', device.id, 'log', 'stream', '--style', 'json', '--predicate', predicate,
636 637 638
  ]);
}

639
@visibleForTesting
640 641
Future<Process> launchSystemLogTool(IOSSimulator device) async {
  // Versions of iOS prior to 11 tail the simulator syslog file.
642
  if (await device.sdkMajorVersion < 11) {
643
    return processUtils.start(<String>['tail', '-n', '0', '-F', '/private/var/log/system.log']);
644
  }
645 646 647 648 649

  // For iOS 11 and later, all relevant detail is in the device log.
  return null;
}

650
class _IOSSimulatorLogReader extends DeviceLogReader {
651
  _IOSSimulatorLogReader(this.device, IOSApp app) {
652
    _linesController = StreamController<String>.broadcast(
653
      onListen: _start,
654
      onCancel: _stop,
Devon Carew's avatar
Devon Carew committed
655
    );
656
    _appName = app == null ? null : app.name.replaceAll('.app', '');
Devon Carew's avatar
Devon Carew committed
657
  }
658 659 660

  final IOSSimulator device;

661 662
  String _appName;

Devon Carew's avatar
Devon Carew committed
663
  StreamController<String> _linesController;
664

Devon Carew's avatar
Devon Carew committed
665
  // We log from two files: the device and the system log.
666 667
  Process _deviceProcess;
  Process _systemProcess;
668

669
  @override
Devon Carew's avatar
Devon Carew committed
670
  Stream<String> get logLines => _linesController.stream;
671

672
  @override
673 674
  String get name => device.name;

675
  Future<void> _start() async {
676 677 678 679 680 681 682 683 684 685 686 687
    // Unified logging iOS 11 and greater (introduced in iOS 10).
    if (await device.sdkMajorVersion >= 11) {
      _deviceProcess = await launchDeviceUnifiedLogging(device, _appName);
      _deviceProcess.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onUnifiedLoggingLine);
      _deviceProcess.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onUnifiedLoggingLine);
    } else {
      // Fall back to syslog parsing.
      await device.ensureLogsExists();
      _deviceProcess = await launchDeviceSystemLogTool(device);
      _deviceProcess.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSysLogDeviceLine);
      _deviceProcess.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSysLogDeviceLine);
    }
688 689 690

    // Track system.log crashes.
    // ReportCrash[37965]: Saved crash report for FlutterRunner[37941]...
691 692
    _systemProcess = await launchSystemLogTool(device);
    if (_systemProcess != null) {
693 694
      _systemProcess.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSystemLine);
      _systemProcess.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSystemLine);
695
    }
696

697 698
    // We don't want to wait for the process or its callback. Best effort
    // cleanup in the callback.
699
    unawaited(_deviceProcess.exitCode.whenComplete(() {
700
      if (_linesController.hasListener) {
Devon Carew's avatar
Devon Carew committed
701
        _linesController.close();
702
      }
703
    }));
704 705 706
  }

  // Match the log prefix (in order to shorten it):
707 708
  // * Xcode 8: Sep 13 15:28:51 cbracken-macpro localhost Runner[37195]: (Flutter) Observatory listening on http://127.0.0.1:57701/
  // * Xcode 9: 2017-09-13 15:26:57.228948-0700  localhost Runner[37195]: (Flutter) Observatory listening on http://127.0.0.1:57701/
709
  static final RegExp _mapRegex = RegExp(r'\S+ +\S+ +(?:\S+) (.+?(?=\[))\[\d+\]\)?: (\(.*?\))? *(.*)$');
710 711

  // Jan 31 19:23:28 --- last message repeated 1 time ---
712 713
  static final RegExp _lastMessageSingleRegex = RegExp(r'\S+ +\S+ +\S+ --- last message repeated 1 time ---$');
  static final RegExp _lastMessageMultipleRegex = RegExp(r'\S+ +\S+ +\S+ --- last message repeated (\d+) times ---$');
714

715
  static final RegExp _flutterRunnerRegex = RegExp(r' FlutterRunner\[\d+\] ');
716

717 718 719 720
  // Remember what we did with the last line, in case we need to process
  // a multiline record
  bool _lastLineMatched = false;

721
  String _filterDeviceLine(String string) {
722
    final Match match = _mapRegex.matchAsPrefix(string);
723
    if (match != null) {
724

725 726 727 728 729 730 731 732 733
      // The category contains the text between the date and the PID. Depending on which version of iOS being run,
      // it can contain "hostname App Name" or just "App Name".
      final String category = match.group(1);
      final String tag = match.group(2);
      final String content = match.group(3);

      // Filter out log lines from an app other than this one (category doesn't match the app name).
      // If the hostname is included in the category, check that it doesn't end with the app name.
      if (_appName != null && !category.endsWith(_appName)) {
734
        return null;
735
      }
736

737
      if (tag != null && tag != '(Flutter)') {
738
        return null;
739
      }
740

741
      // Filter out some messages that clearly aren't related to Flutter.
742
      if (string.contains(': could not find icon for representation -> com.apple.')) {
743
        return null;
744
      }
745

746
      // assertion failed: 15G1212 13E230: libxpc.dylib + 57882 [66C28065-C9DB-3C8E-926F-5A40210A6D1B]: 0x7d
747
      if (content.startsWith('assertion failed: ') && content.contains(' libxpc.dylib ')) {
748
        return null;
749
      }
750

751
      if (_appName == null) {
752
        return '$category: $content';
753
      } else if (category == _appName || category.endsWith(' $_appName')) {
754
        return content;
755
      }
756 757

      return null;
758
    }
759

760
    if (string.startsWith('Filtering the log data using ')) {
761
      return null;
762
    }
763

764
    if (string.startsWith('Timestamp                       (process)[PID]')) {
765
      return null;
766
    }
767

768
    if (_lastMessageSingleRegex.matchAsPrefix(string) != null) {
769
      return null;
770
    }
771

772
    if (RegExp(r'assertion failed: .* libxpc.dylib .* 0x7d$').matchAsPrefix(string) != null) {
773
      return null;
774
    }
775

776 777 778 779 780
    // Starts with space(s) - continuation of the multiline message
    if (RegExp(r'\s+').matchAsPrefix(string) != null && !_lastLineMatched) {
      return null;
    }

781 782 783
    return string;
  }

784 785
  String _lastLine;

786
  void _onSysLogDeviceLine(String line) {
787
    globals.printTrace('[DEVICE LOG] $line');
788
    final Match multi = _lastMessageMultipleRegex.matchAsPrefix(line);
789 790 791 792 793

    if (multi != null) {
      if (_lastLine != null) {
        int repeat = int.parse(multi.group(1));
        repeat = math.max(0, math.min(100, repeat));
794
        for (int i = 1; i < repeat; i++) {
795
          _linesController.add(_lastLine);
796
        }
797 798 799
      }
    } else {
      _lastLine = _filterDeviceLine(line);
800
      if (_lastLine != null) {
801
        _linesController.add(_lastLine);
802 803 804
        _lastLineMatched = true;
      } else {
        _lastLineMatched = false;
805
      }
806
    }
807 808
  }

809 810 811 812 813 814 815 816 817 818 819 820 821
  //   "eventMessage" : "flutter: 21",
  static final RegExp _unifiedLoggingEventMessageRegex = RegExp(r'.*"eventMessage" : (".*")');
  void _onUnifiedLoggingLine(String line) {
    // The log command predicate handles filtering, so every log eventMessage should be decoded and added.
    final Match eventMessageMatch = _unifiedLoggingEventMessageRegex.firstMatch(line);
    if (eventMessageMatch != null) {
      final dynamic decodedJson = jsonDecode(eventMessageMatch.group(1));
      if (decodedJson is String) {
        _linesController.add(decodedJson);
      }
    }
  }

822
  String _filterSystemLog(String string) {
823
    final Match match = _mapRegex.matchAsPrefix(string);
824 825 826 827
    return match == null ? string : '${match.group(1)}: ${match.group(2)}';
  }

  void _onSystemLine(String line) {
828
    globals.printTrace('[SYS LOG] $line');
829
    if (!_flutterRunnerRegex.hasMatch(line)) {
830
      return;
831
    }
832

833
    final String filteredLine = _filterSystemLog(line);
834
    if (filteredLine == null) {
835
      return;
836
    }
837

Devon Carew's avatar
Devon Carew committed
838
    _linesController.add(filteredLine);
839 840
  }

Devon Carew's avatar
Devon Carew committed
841 842 843
  void _stop() {
    _deviceProcess?.kill();
    _systemProcess?.kill();
844
  }
845 846 847 848 849

  @override
  void dispose() {
    _stop();
  }
850
}
851 852

int compareIosVersions(String v1, String v2) {
853 854
  final List<int> v1Fragments = v1.split('.').map<int>(int.parse).toList();
  final List<int> v2Fragments = v2.split('.').map<int>(int.parse).toList();
855 856

  int i = 0;
857
  while (i < v1Fragments.length && i < v2Fragments.length) {
858 859
    final int v1Fragment = v1Fragments[i];
    final int v2Fragment = v2Fragments[i];
860
    if (v1Fragment != v2Fragment) {
861
      return v1Fragment.compareTo(v2Fragment);
862
    }
863
    i += 1;
864 865 866 867 868 869 870
  }
  return v1Fragments.length.compareTo(v2Fragments.length);
}

/// Matches on device type given an identifier.
///
/// Example device type identifiers:
871 872 873 874 875 876
///
/// - ✓ com.apple.CoreSimulator.SimDeviceType.iPhone-5
/// - ✓ com.apple.CoreSimulator.SimDeviceType.iPhone-6
/// - ✓ com.apple.CoreSimulator.SimDeviceType.iPhone-6s-Plus
/// - ✗ com.apple.CoreSimulator.SimDeviceType.iPad-2
/// - ✗ com.apple.CoreSimulator.SimDeviceType.Apple-Watch-38mm
877
final RegExp _iosDeviceTypePattern =
878
    RegExp(r'com.apple.CoreSimulator.SimDeviceType.iPhone-(\d+)(.*)');
879 880

int compareIphoneVersions(String id1, String id2) {
881 882
  final Match m1 = _iosDeviceTypePattern.firstMatch(id1);
  final Match m2 = _iosDeviceTypePattern.firstMatch(id2);
883

884 885
  final int v1 = int.parse(m1[1]);
  final int v2 = int.parse(m2[1]);
886

887
  if (v1 != v2) {
888
    return v1.compareTo(v2);
889
  }
890 891

  // Sorted in the least preferred first order.
892
  const List<String> qualifiers = <String>['-Plus', '', 's-Plus', 's'];
893

894 895
  final int q1 = qualifiers.indexOf(m1[2]);
  final int q2 = qualifiers.indexOf(m2[2]);
896 897
  return q1.compareTo(q2);
}
898 899 900 901 902 903 904 905

class _IOSSimulatorDevicePortForwarder extends DevicePortForwarder {
  _IOSSimulatorDevicePortForwarder(this.device);

  final IOSSimulator device;

  final List<ForwardedPort> _ports = <ForwardedPort>[];

906
  @override
907
  List<ForwardedPort> get forwardedPorts => _ports;
908

909
  @override
910
  Future<int> forward(int devicePort, { int hostPort }) async {
911
    if (hostPort == null || hostPort == 0) {
912 913 914
      hostPort = devicePort;
    }
    assert(devicePort == hostPort);
915
    _ports.add(ForwardedPort(devicePort, hostPort));
916 917 918
    return hostPort;
  }

919
  @override
920
  Future<void> unforward(ForwardedPort forwardedPort) async {
921 922
    _ports.remove(forwardedPort);
  }
923 924 925

  @override
  Future<void> dispose() async {
926
    final List<ForwardedPort> portsCopy = List<ForwardedPort>.of(_ports);
927
    for (final ForwardedPort port in portsCopy) {
928 929 930
      await unforward(port);
    }
  }
931
}