devices.dart 27.8 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
import 'package:vm_service/vm_service.dart' as vm_service;
11

12
import '../application_package.dart';
13
import '../artifacts.dart';
14
import '../base/file_system.dart';
15
import '../base/io.dart';
16
import '../base/logger.dart';
17
import '../base/platform.dart';
18
import '../base/process.dart';
19
import '../base/utils.dart';
20
import '../build_info.dart';
21
import '../convert.dart';
22
import '../device.dart';
23
import '../globals.dart' as globals;
24
import '../macos/xcode.dart';
25
import '../mdns_discovery.dart';
26
import '../project.dart';
27
import '../protocol_discovery.dart';
28
import 'fallback_discovery.dart';
29
import 'ios_deploy.dart';
30
import 'ios_workflow.dart';
31 32 33
import 'mac.dart';

class IOSDevices extends PollingDeviceDiscovery {
34 35 36 37 38
  // TODO(fujino): make these required and remove fallbacks once internal invocations migrated
  IOSDevices({
    Platform platform,
    XCDevice xcdevice,
    IOSWorkflow iosWorkflow,
39
    Logger logger,
40 41 42
  }) : _platform = platform ?? globals.platform,
       _xcdevice = xcdevice ?? globals.xcdevice,
       _iosWorkflow = iosWorkflow ?? globals.iosWorkflow,
43
       _logger = logger ?? globals.logger,
44 45
       super('iOS devices');

46 47 48 49 50
  @override
  void dispose() {
    _observedDeviceEventsSubscription?.cancel();
  }

51 52 53
  final Platform _platform;
  final XCDevice _xcdevice;
  final IOSWorkflow _iosWorkflow;
54
  final Logger _logger;
55

56
  @override
57
  bool get supportsPlatform => _platform.isMacOS;
58

59
  @override
60
  bool get canListAnything => _iosWorkflow.canListDevices;
61

62 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 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
  StreamSubscription<Map<XCDeviceEvent, String>> _observedDeviceEventsSubscription;

  @override
  Future<void> startPolling() async {
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }

    deviceNotifier ??= ItemListNotifier<Device>();

    // Start by populating all currently attached devices.
    deviceNotifier.updateWithNewList(await pollingGetDevices());

    // cancel any outstanding subscriptions.
    await _observedDeviceEventsSubscription?.cancel();
    _observedDeviceEventsSubscription = _xcdevice.observedDeviceEvents().listen(
      _onDeviceEvent,
      onError: (dynamic error, StackTrace stack) {
        _logger.printTrace('Process exception running xcdevice observe:\n$error\n$stack');
      }, onDone: () {
        // If xcdevice is killed or otherwise dies, polling will be stopped.
        // No retry is attempted and the polling client will have to restart polling
        // (restart the IDE). Avoid hammering on a process that is
        // continuously failing.
        _logger.printTrace('xcdevice observe stopped');
      },
      cancelOnError: true,
    );
  }

  Future<void> _onDeviceEvent(Map<XCDeviceEvent, String> event) async {
    final XCDeviceEvent eventType = event.containsKey(XCDeviceEvent.attach) ? XCDeviceEvent.attach : XCDeviceEvent.detach;
    final String deviceIdentifier = event[eventType];
    final Device knownDevice = deviceNotifier.items
      .firstWhere((Device device) => device.id == deviceIdentifier, orElse: () => null);

    // Ignore already discovered devices (maybe populated at the beginning).
    if (eventType == XCDeviceEvent.attach && knownDevice == null) {
      // There's no way to get details for an individual attached device,
      // so repopulate them all.
      final List<Device> devices = await pollingGetDevices();
      deviceNotifier.updateWithNewList(devices);
    } else if (eventType == XCDeviceEvent.detach && knownDevice != null) {
      deviceNotifier.removeItem(knownDevice);
    }
  }

  @override
  Future<void> stopPolling() async {
    await _observedDeviceEventsSubscription?.cancel();
  }

116
  @override
117 118 119 120 121 122 123
  Future<List<Device>> pollingGetDevices({ Duration timeout }) async {
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }

124
    return await _xcdevice.getAvailableIOSDevices(timeout: timeout);
125
  }
126 127

  @override
128 129 130 131 132 133 134 135 136
  Future<List<String>> getDiagnostics() async {
    if (!_platform.isMacOS) {
      return const <String>[
        'Control of iOS devices or simulators only supported on macOS.'
      ];
    }

    return await _xcdevice.getDiagnostics();
  }
137 138
}

139 140 141 142 143 144
enum IOSDeviceInterface {
  none,
  usb,
  network,
}

145
class IOSDevice extends Device {
146
  IOSDevice(String id, {
147
    @required FileSystem fileSystem,
148 149
    @required this.name,
    @required this.cpuArchitecture,
150
    @required this.interfaceType,
151
    @required String sdkVersion,
152 153 154
    @required Platform platform,
    @required Artifacts artifacts,
    @required IOSDeploy iosDeploy,
155
    @required IMobileDevice iMobileDevice,
156
    @required Logger logger,
157
    @required VmServiceConnector vmServiceConnectUri,
158
  })
159 160 161 162 163 164
    : _sdkVersion = sdkVersion,
      _iosDeploy = iosDeploy,
      _iMobileDevice = iMobileDevice,
      _fileSystem = fileSystem,
      _logger = logger,
      _platform = platform,
165
      _vmServiceConnectUri = vmServiceConnectUri,
166 167 168 169 170 171
        super(
          id,
          category: Category.mobile,
          platformType: PlatformType.ios,
          ephemeral: true,
      ) {
172
    if (!platform.isMacOS) {
173
      assert(false, 'Control of iOS devices or simulators only supported on Mac OS.');
174 175
      return;
    }
176
    _iproxyPath = artifacts.getArtifactPath(
177
      Artifact.iproxy,
178
      platform: TargetPlatform.ios,
179
    );
180 181
  }

182
  String _iproxyPath;
183

184
  final String _sdkVersion;
185 186
  final IOSDeploy _iosDeploy;
  final FileSystem _fileSystem;
187 188
  final Logger _logger;
  final Platform _platform;
189
  final IMobileDevice _iMobileDevice;
190
  final VmServiceConnector _vmServiceConnectUri;
191

192 193 194 195 196 197
  /// May be 0 if version cannot be parsed.
  int get majorSdkVersion {
    final String majorVersionString = _sdkVersion?.split('.')?.first?.trim();
    return majorVersionString != null ? int.tryParse(majorVersionString) ?? 0 : 0;
  }

198
  @override
199
  bool get supportsHotReload => interfaceType == IOSDeviceInterface.usb;
200 201

  @override
202 203 204 205
  bool get supportsHotRestart => interfaceType == IOSDeviceInterface.usb;

  @override
  bool get supportsFlutterExit => interfaceType == IOSDeviceInterface.usb;
206

207
  @override
208 209
  final String name;

210 211 212
  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

213 214
  final DarwinArch cpuArchitecture;

215 216
  final IOSDeviceInterface interfaceType;

217
  Map<IOSApp, DeviceLogReader> _logReaders;
218

219
  DevicePortForwarder _portForwarder;
220

221
  @override
222
  Future<bool> get isLocalEmulator async => false;
223

224 225 226
  @override
  Future<String> get emulatorId async => null;

227
  @override
228 229 230
  bool get supportsStartPaused => false;

  @override
231 232 233 234
  Future<bool> isAppInstalled(
    IOSApp app, {
    String userIdentifier,
  }) async {
235
    bool result;
236
    try {
237 238 239
      result = await _iosDeploy.isAppInstalled(
        bundleId: app.id,
        deviceId: id,
240
      );
241
    } on ProcessException catch (e) {
242
      _logger.printError(e.message);
243 244
      return false;
    }
245
    return result;
246 247
  }

248
  @override
249
  Future<bool> isLatestBuildInstalled(IOSApp app) async => false;
250

251
  @override
252 253 254 255
  Future<bool> installApp(
    IOSApp app, {
    String userIdentifier,
  }) async {
256
    final Directory bundle = _fileSystem.directory(app.deviceBundlePath);
257
    if (!bundle.existsSync()) {
258
      _logger.printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?');
259 260 261
      return false;
    }

262
    int installationResult;
263
    try {
264 265 266 267
      installationResult = await _iosDeploy.installApp(
        deviceId: id,
        bundlePath: bundle.path,
        launchArguments: <String>[],
268
        interfaceType: interfaceType,
269
      );
270
    } on ProcessException catch (e) {
271
      _logger.printError(e.message);
272 273
      return false;
    }
274
    if (installationResult != 0) {
275 276 277 278
      _logger.printError('Could not install ${bundle.path} on $id.');
      _logger.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
      _logger.printError('  open ios/Runner.xcworkspace');
      _logger.printError('');
279 280 281
      return false;
    }
    return true;
282
  }
283 284

  @override
285 286 287 288
  Future<bool> uninstallApp(
    IOSApp app, {
    String userIdentifier,
  }) async {
289
    int uninstallationResult;
290
    try {
291 292 293
      uninstallationResult = await _iosDeploy.uninstallApp(
        deviceId: id,
        bundleId: app.id,
294
      );
295
    } on ProcessException catch (e) {
296
      _logger.printError(e.message);
297 298
      return false;
    }
299
    if (uninstallationResult != 0) {
300
      _logger.printError('Could not uninstall ${app.id} on $id.');
301 302 303
      return false;
    }
    return true;
304 305
  }

306 307 308
  @override
  bool isSupported() => true;

309
  @override
Devon Carew's avatar
Devon Carew committed
310
  Future<LaunchResult> startApp(
311
    IOSApp package, {
312 313
    String mainPath,
    String route,
Devon Carew's avatar
Devon Carew committed
314
    DebuggingOptions debuggingOptions,
315
    Map<String, dynamic> platformArgs,
316 317
    bool prebuiltApplication = false,
    bool ipv6 = false,
318
    @visibleForTesting Duration fallbackPollingDelay,
319
    String userIdentifier,
320
  }) async {
321 322
    String packageId;

323
    if (!prebuiltApplication) {
324
      // TODO(chinmaygarde): Use mainPath, route.
325
      _logger.printTrace('Building ${package.name} for $id');
326 327

      // Step 1: Build the precompiled/DBC application if necessary.
328
      final XcodeBuildResult buildResult = await buildXcodeProject(
329
          app: package as BuildableIOSApp,
330
          buildInfo: debuggingOptions.buildInfo,
331
          targetOverride: mainPath,
332
          buildForDevice: true,
333
          activeArch: cpuArchitecture,
334
          deviceID: id,
335
      );
336
      if (!buildResult.success) {
337
        _logger.printError('Could not build the precompiled application for the device.');
338
        await diagnoseXcodeBuildFailure(buildResult, globals.flutterUsage, _logger);
339
        _logger.printError('');
340
        return LaunchResult.failed();
341
      }
342
      packageId = buildResult.xcodeBuildExecution?.buildSettings['PRODUCT_BUNDLE_IDENTIFIER'];
343
    } else {
344
      if (!await installApp(package)) {
345
        return LaunchResult.failed();
346
      }
347 348
    }

349 350
    packageId ??= package.id;

351
    // Step 2: Check that the application exists at the specified path.
352
    final Directory bundle = _fileSystem.directory(package.deviceBundlePath);
353
    if (!bundle.existsSync()) {
354
      _logger.printError('Could not find the built application bundle at ${bundle.path}.');
355
      return LaunchResult.failed();
356 357
    }

358 359 360 361 362 363
    // Step 2.5: Generate a potential open port using the provided argument,
    // or randomly with the package name as a seed. Intentionally choose
    // ports within the ephemeral port range.
    final int assumedObservatoryPort = debuggingOptions?.deviceVmServicePort
      ?? math.Random(packageId.hashCode).nextInt(16383) + 49152;

364
    // Step 3: Attempt to install the application on the device.
365 366
    final List<String> launchArguments = <String>[
      '--enable-dart-profiling',
367 368 369 370 371
      // These arguments are required to support the fallback connection strategy
      // described in fallback_discovery.dart.
      '--enable-service-port-fallback',
      '--disable-service-auth-codes',
      '--observatory-port=$assumedObservatoryPort',
372 373 374 375 376 377 378 379 380
      if (debuggingOptions.startPaused) '--start-paused',
      if (debuggingOptions.dartFlags.isNotEmpty) '--dart-flags="${debuggingOptions.dartFlags}"',
      if (debuggingOptions.useTestFonts) '--use-test-fonts',
      // "--enable-checked-mode" and "--verify-entry-points" should always be
      // passed when we launch debug build via "ios-deploy". However, we don't
      // pass them if a certain environment variable is set to enable the
      // "system_debug_ios" integration test in the CI, which simulates a
      // home-screen launch.
      if (debuggingOptions.debuggingEnabled &&
381
          _platform.environment['FLUTTER_TOOLS_DEBUG_WITHOUT_CHECKED_MODE'] != 'true') ...<String>[
382 383 384 385 386 387
        '--enable-checked-mode',
        '--verify-entry-points',
      ],
      if (debuggingOptions.enableSoftwareRendering) '--enable-software-rendering',
      if (debuggingOptions.skiaDeterministicRendering) '--skia-deterministic-rendering',
      if (debuggingOptions.traceSkia) '--trace-skia',
388
      if (debuggingOptions.traceWhitelist != null) '--trace-whitelist="${debuggingOptions.traceWhitelist}"',
389
      if (debuggingOptions.endlessTraceBuffer) '--endless-trace-buffer',
390 391
      if (debuggingOptions.dumpSkpOnShaderCompilation) '--dump-skp-on-shader-compilation',
      if (debuggingOptions.verboseSystemLogs) '--verbose-logging',
392
      if (debuggingOptions.cacheSkSL) '--cache-sksl',
393
      if (platformArgs['trace-startup'] as bool ?? false) '--trace-startup',
394
    ];
395

396
    final Status installStatus = _logger.startProgress(
397 398 399 400 401
        'Installing and launching...',
        timeout: timeoutConfiguration.slowOperation);
    try {
      ProtocolDiscovery observatoryDiscovery;
      if (debuggingOptions.debuggingEnabled) {
402
        _logger.printTrace('Debugging is enabled, connecting to observatory');
403 404 405
        observatoryDiscovery = ProtocolDiscovery.observatory(
          getLogReader(app: package),
          portForwarder: portForwarder,
406 407
          hostPort: debuggingOptions.hostVmServicePort,
          devicePort: debuggingOptions.deviceVmServicePort,
408 409 410
          ipv6: ipv6,
        );
      }
411
      final int installationResult = await _iosDeploy.runApp(
412 413 414
        deviceId: id,
        bundlePath: bundle.path,
        launchArguments: launchArguments,
415
        interfaceType: interfaceType,
416
      );
417
      if (installationResult != 0) {
418 419 420 421
        _logger.printError('Could not run ${bundle.path} on $id.');
        _logger.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
        _logger.printError('  open ios/Runner.xcworkspace');
        _logger.printError('');
422 423
        return LaunchResult.failed();
      }
424

425 426 427
      if (!debuggingOptions.debuggingEnabled) {
        return LaunchResult.succeeded();
      }
428

429
      _logger.printTrace('Application launched on the device. Waiting for observatory port.');
430
      final FallbackDiscovery fallbackDiscovery = FallbackDiscovery(
431
        logger: _logger,
432 433 434
        mDnsObservatoryDiscovery: MDnsObservatoryDiscovery.instance,
        portForwarder: portForwarder,
        protocolDiscovery: observatoryDiscovery,
435
        flutterUsage: globals.flutterUsage,
436
        pollingDelay: fallbackPollingDelay,
437
        vmServiceConnectUri: _vmServiceConnectUri,
438 439 440 441 442 443 444 445 446 447 448
      );
      final Uri localUri = await fallbackDiscovery.discover(
        assumedDevicePort: assumedObservatoryPort,
        deivce: this,
        usesIpv6: ipv6,
        hostVmservicePort: debuggingOptions.hostVmServicePort,
        packageId: packageId,
        packageName: FlutterProject.current().manifest.appName,
      );
      if (localUri == null) {
        return LaunchResult.failed();
449
      }
450
      return LaunchResult.succeeded(observatoryUri: localUri);
451
    } on ProcessException catch (e) {
452
      _logger.printError(e.message);
453
      return LaunchResult.failed();
454 455
    } finally {
      installStatus.stop();
456
    }
457 458
  }

459
  @override
460 461 462 463
  Future<bool> stopApp(
    IOSApp app, {
    String userIdentifier,
  }) async {
464 465 466 467 468
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  @override
469
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
470

471
  @override
472
  Future<String> get sdkNameAndVersion async => 'iOS $_sdkVersion';
473

474
  @override
475 476 477 478 479
  DeviceLogReader getLogReader({
    IOSApp app,
    bool includePastLogs = false,
  }) {
    assert(!includePastLogs, 'Past log reading not supported on iOS devices.');
480
    _logReaders ??= <IOSApp, DeviceLogReader>{};
481 482 483
    return _logReaders.putIfAbsent(app, () => IOSDeviceLogReader.create(
      device: this,
      app: app,
484
      iMobileDevice: _iMobileDevice,
485
    ));
486 487
  }

488
  @visibleForTesting
489 490
  void setLogReader(IOSApp app, DeviceLogReader logReader) {
    _logReaders ??= <IOSApp, DeviceLogReader>{};
491 492 493
    _logReaders[app] = logReader;
  }

494
  @override
495 496
  DevicePortForwarder get portForwarder => _portForwarder ??= IOSDevicePortForwarder(
    processManager: globals.processManager,
497
    logger: _logger,
498 499 500 501
    dyLdLibEntry: globals.cache.dyLdLibEntry,
    id: id,
    iproxyPath: _iproxyPath,
  );
502

503 504 505 506 507
  @visibleForTesting
  set portForwarder(DevicePortForwarder forwarder) {
    _portForwarder = forwarder;
  }

508
  @override
509
  void clearLogs() { }
Devon Carew's avatar
Devon Carew committed
510 511

  @override
512
  bool get supportsScreenshot => _iMobileDevice.isInstalled && interfaceType == IOSDeviceInterface.usb;
Devon Carew's avatar
Devon Carew committed
513 514

  @override
515
  Future<void> takeScreenshot(File outputFile) async {
516
    await _iMobileDevice.takeScreenshot(outputFile);
517
  }
518 519 520 521 522

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.ios.existsSync();
  }
523 524

  @override
525
  Future<void> dispose() async {
526
    _logReaders?.forEach((IOSApp application, DeviceLogReader logReader) {
527 528
      logReader.dispose();
    });
529
    await _portForwarder?.dispose();
530
  }
531 532
}

533
/// Decodes a vis-encoded syslog string to a UTF-8 representation.
534 535 536 537 538 539 540 541 542
///
/// 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.
543 544
///
/// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3)
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561
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 {
562
    final List<int> bytes = utf8.encode(line);
563
    final List<int> out = <int>[];
564
    for (int i = 0; i < bytes.length;) {
565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585
      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;
      }
    }
586
    return utf8.decode(out);
587
  } on Exception {
588 589 590 591 592
    // Unable to decode line: return as-is.
    return line;
  }
}

593 594
@visibleForTesting
class IOSDeviceLogReader extends DeviceLogReader {
595 596 597 598 599 600 601
  IOSDeviceLogReader._(
    this._iMobileDevice,
    this._majorSdkVersion,
    this._deviceId,
    this.name,
    String appName,
  ) {
602
    _linesController = StreamController<String>.broadcast(
603
      onListen: _listenToSysLog,
604
      onCancel: dispose,
605 606 607 608 609
    );

    // Match for lines for the runner in syslog.
    //
    // iOS 9 format:  Runner[297] <Notice>:
610
    // iOS 10 format: Runner(Flutter)[297] <Notice>:
611
    _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: ');
612 613 614
    // 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.
615
    _anyLineRegex = RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: ');
616
    _loggingSubscriptions = <StreamSubscription<void>>[];
Devon Carew's avatar
Devon Carew committed
617
  }
618

619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648
  /// Create a new [IOSDeviceLogReader].
  factory IOSDeviceLogReader.create({
    @required IOSDevice device,
    @required IOSApp app,
    @required IMobileDevice iMobileDevice,
  }) {
    final String appName = app == null ? '' : app.name.replaceAll('.app', '');
    return IOSDeviceLogReader._(
      iMobileDevice,
      device.majorSdkVersion,
      device.id,
      device.name,
      appName,
    );
  }

  /// Create an [IOSDeviceLogReader] for testing.
  factory IOSDeviceLogReader.test({
    @required IMobileDevice iMobileDevice,
    bool useSyslog = true,
  }) {
    return IOSDeviceLogReader._(
      iMobileDevice, useSyslog ? 12 : 13, '1234', 'test', 'Runner');
  }

  @override
  final String name;
  final int _majorSdkVersion;
  final String _deviceId;
  final IMobileDevice _iMobileDevice;
649

650 651 652 653 654
  // 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
655
  StreamController<String> _linesController;
656
  List<StreamSubscription<void>> _loggingSubscriptions;
657

658
  @override
Devon Carew's avatar
Devon Carew committed
659
  Stream<String> get logLines => _linesController.stream;
660

661
  @override
662 663
  vm_service.VmService get connectedVMService => _connectedVMService;
  vm_service.VmService _connectedVMService;
664 665

  @override
666
  set connectedVMService(vm_service.VmService connectedVmService) {
667 668
    _listenToUnifiedLoggingEvents(connectedVmService);
    _connectedVMService = connectedVmService;
669 670 671 672
  }

  static const int _minimumUniversalLoggingSdkVersion = 13;

673
  Future<void> _listenToUnifiedLoggingEvents(vm_service.VmService connectedVmService) async {
674
    if (_majorSdkVersion < _minimumUniversalLoggingSdkVersion) {
675 676
      return;
    }
677
    try {
678 679 680 681
      await Future.wait(<Future<void>>[
        connectedVmService.streamListen(vm_service.EventStreams.kStdout),
        connectedVmService.streamListen(vm_service.EventStreams.kStderr),
      ]);
682 683 684
    } on vm_service.RPCError {
      // Do nothing, since the tool is already subscribed.
    }
685 686

    void logMessage(vm_service.Event event) {
687 688 689
      final String message = utf8.decode(base64.decode(event.bytes));
      if (message.isNotEmpty) {
        _linesController.add(message);
690
      }
691 692 693 694 695 696
    }

    _loggingSubscriptions.addAll(<StreamSubscription<void>>[
      connectedVmService.onStdoutEvent.listen(logMessage),
      connectedVmService.onStderrEvent.listen(logMessage),
    ]);
697 698
  }

699
  void _listenToSysLog() {
700
    // syslog is not written on iOS 13+.
701
    if (_majorSdkVersion >= _minimumUniversalLoggingSdkVersion) {
702 703
      return;
    }
704
    _iMobileDevice.startLogger(_deviceId).then<void>((Process process) {
705 706
      process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
      process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
707
      process.exitCode.whenComplete(() {
708
        if (_linesController.hasListener) {
Devon Carew's avatar
Devon Carew committed
709
          _linesController.close();
710
        }
Devon Carew's avatar
Devon Carew committed
711
      });
712 713
      assert(_idevicesyslogProcess == null);
      _idevicesyslogProcess = process;
Devon Carew's avatar
Devon Carew committed
714
    });
715 716
  }

717 718 719 720
  @visibleForTesting
  set idevicesyslogProcess(Process process) => _idevicesyslogProcess = process;
  Process _idevicesyslogProcess;

721
  // Returns a stateful line handler to properly capture multiline output.
722
  //
723
  // For multiline log messages, any line after the first is logged without
724 725 726
  // 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).
727
  void Function(String line) _newSyslogLineHandler() {
728 729 730 731 732 733 734 735
    bool printing = false;

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

737 738 739 740 741 742 743 744 745 746 747 748 749
        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;
      }
    };
750 751
  }

752 753
  @override
  void dispose() {
754
    for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) {
755 756
      loggingSubscription.cancel();
    }
757
    _idevicesyslogProcess?.kill();
758 759
  }
}
760

761
/// A [DevicePortForwarder] specialized for iOS usage with iproxy.
762
class IOSDevicePortForwarder extends DevicePortForwarder {
763

764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797
  /// Create a new [IOSDevicePortForwarder].
  IOSDevicePortForwarder({
    @required ProcessManager processManager,
    @required Logger logger,
    @required MapEntry<String, String> dyLdLibEntry,
    @required String id,
    @required String iproxyPath,
  }) : _logger = logger,
       _dyLdLibEntry = dyLdLibEntry,
       _id = id,
       _iproxyPath = iproxyPath,
       _processUtils = ProcessUtils(processManager: processManager, logger: logger);

  /// Create a [IOSDevicePortForwarder] for testing.
  ///
  /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as
  /// 'DYLD_LIBRARY_PATH: /path/to/libs'.
  ///
  /// The device id may be provided, but otherwise defaultts to '1234'.
  factory IOSDevicePortForwarder.test({
    @required ProcessManager processManager,
    @required Logger logger,
    String id,
  }) {
    return IOSDevicePortForwarder(
      processManager: processManager,
      logger: logger,
      iproxyPath: 'iproxy',
      id: id ?? '1234',
      dyLdLibEntry: const MapEntry<String, String>(
        'DYLD_LIBRARY_PATH', '/path/to/libs',
      ),
    );
  }
798

799 800 801 802 803
  final ProcessUtils _processUtils;
  final Logger _logger;
  final MapEntry<String, String> _dyLdLibEntry;
  final String _id;
  final String _iproxyPath;
804

805
  @override
806
  List<ForwardedPort> forwardedPorts = <ForwardedPort>[];
807

808
  @visibleForTesting
809 810
  void addForwardedPorts(List<ForwardedPort> ports) {
    ports.forEach(forwardedPorts.add);
811 812
  }

813
  static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1);
814

815
  @override
816
  Future<int> forward(int devicePort, { int hostPort }) async {
817
    final bool autoselect = hostPort == null || hostPort == 0;
818
    if (autoselect) {
819
      hostPort = 1024;
820
    }
821 822 823 824 825

    Process process;

    bool connected = false;
    while (!connected) {
826
      _logger.printTrace('Attempting to forward device port $devicePort to host port $hostPort');
827
      // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID
828
      process = await _processUtils.start(
829
        <String>[
830
          _iproxyPath,
831 832
          hostPort.toString(),
          devicePort.toString(),
833
          _id,
834 835
        ],
        environment: Map<String, String>.fromEntries(
836
          <MapEntry<String, String>>[_dyLdLibEntry],
837 838
        ),
      );
839 840 841
      // 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) {
842
        process.kill();
843 844
        if (autoselect) {
          hostPort += 1;
845
          if (hostPort > 65535) {
846
            throw Exception('Could not find open port on host.');
847
          }
848
        } else {
849
          throw Exception('Port $hostPort is not available.');
850 851
        }
      }
852
    }
853 854
    assert(connected);
    assert(process != null);
855

856
    final ForwardedPort forwardedPort = ForwardedPort.withContext(
857 858
      hostPort, devicePort, process,
    );
859 860
    _logger.printTrace('Forwarded port $forwardedPort');
    forwardedPorts.add(forwardedPort);
861
    return hostPort;
862 863
  }

864
  @override
865
  Future<void> unforward(ForwardedPort forwardedPort) async {
866
    if (!forwardedPorts.remove(forwardedPort)) {
867
      // Not in list. Nothing to remove.
868
      return;
869 870
    }

871
    _logger.printTrace('Unforwarding port $forwardedPort');
872 873
    forwardedPort.dispose();
  }
874

875 876
  @override
  Future<void> dispose() async {
877
    for (final ForwardedPort forwardedPort in forwardedPorts) {
878
      forwardedPort.dispose();
879
    }
880 881
  }
}