devices.dart 29 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

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

11
import '../base/common.dart';
12
import '../base/file_system.dart';
13
import '../base/io.dart';
14
import '../base/logger.dart';
15
import '../base/os.dart';
16
import '../base/platform.dart';
17
import '../base/utils.dart';
18
import '../build_info.dart';
19
import '../convert.dart';
20
import '../device.dart';
21
import '../device_port_forwarder.dart';
22
import '../globals.dart' as globals;
23
import '../macos/xcdevice.dart';
24
import '../project.dart';
25
import '../protocol_discovery.dart';
26
import '../vmservice.dart';
27
import 'application_package.dart';
28
import 'ios_deploy.dart';
29
import 'ios_workflow.dart';
30
import 'iproxy.dart';
31 32 33
import 'mac.dart';

class IOSDevices extends PollingDeviceDiscovery {
34
  IOSDevices({
35 36 37 38
    required Platform platform,
    required XCDevice xcdevice,
    required IOSWorkflow iosWorkflow,
    required Logger logger,
39 40 41 42
  }) : _platform = platform,
       _xcdevice = xcdevice,
       _iosWorkflow = iosWorkflow,
       _logger = logger,
43 44 45 46 47
       super('iOS devices');

  final Platform _platform;
  final XCDevice _xcdevice;
  final IOSWorkflow _iosWorkflow;
48
  final Logger _logger;
49

50
  @override
51
  bool get supportsPlatform => _platform.isMacOS;
52

53
  @override
54
  bool get canListAnything => _iosWorkflow.canListDevices;
55

56
  StreamSubscription<Map<XCDeviceEvent, String>>? _observedDeviceEventsSubscription;
57 58 59 60 61 62 63 64

  @override
  Future<void> startPolling() async {
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }
65 66 67
    if (!_xcdevice.isInstalled) {
      return;
    }
68 69 70 71

    deviceNotifier ??= ItemListNotifier<Device>();

    // Start by populating all currently attached devices.
72
    deviceNotifier!.updateWithNewList(await pollingGetDevices());
73 74 75

    // cancel any outstanding subscriptions.
    await _observedDeviceEventsSubscription?.cancel();
76
    _observedDeviceEventsSubscription = _xcdevice.observedDeviceEvents()?.listen(
77
      _onDeviceEvent,
78
      onError: (Object error, StackTrace stack) {
79 80 81 82 83 84 85 86 87 88 89 90 91 92
        _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;
93 94 95 96 97 98 99 100 101 102 103
    final String? deviceIdentifier = event[eventType];
    final ItemListNotifier<Device>? notifier = deviceNotifier;
    if (notifier == null) {
      return;
    }
    Device? knownDevice;
    for (final Device device in notifier.items) {
      if (device.id == deviceIdentifier) {
        knownDevice = device;
      }
    }
104 105 106 107 108 109

    // 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();
110
      notifier.updateWithNewList(devices);
111
    } else if (eventType == XCDeviceEvent.detach && knownDevice != null) {
112
      notifier.removeItem(knownDevice);
113 114 115 116 117 118 119 120
    }
  }

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

121
  @override
122
  Future<List<Device>> pollingGetDevices({ Duration? timeout }) async {
123 124 125 126 127 128
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }

129
    return _xcdevice.getAvailableIOSDevices(timeout: timeout);
130
  }
131 132

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

140
    return _xcdevice.getDiagnostics();
141
  }
142 143 144

  @override
  List<String> get wellKnownIds => const <String>[];
145 146 147
}

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

179
  final String? _sdkVersion;
180 181
  final IOSDeploy _iosDeploy;
  final FileSystem _fileSystem;
182 183
  final Logger _logger;
  final Platform _platform;
184
  final IMobileDevice _iMobileDevice;
185
  final IProxy _iproxy;
186

187 188
  /// May be 0 if version cannot be parsed.
  int get majorSdkVersion {
189
    final String? majorVersionString = _sdkVersion?.split('.').first.trim();
190 191 192
    return majorVersionString != null ? int.tryParse(majorVersionString) ?? 0 : 0;
  }

193
  @override
194
  bool get supportsHotReload => interfaceType == IOSDeviceConnectionInterface.usb;
195 196

  @override
197
  bool get supportsHotRestart => interfaceType == IOSDeviceConnectionInterface.usb;
198 199

  @override
200
  bool get supportsFlutterExit => interfaceType == IOSDeviceConnectionInterface.usb;
201

202
  @override
203 204
  final String name;

205 206 207
  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

208 209
  final DarwinArch cpuArchitecture;

210
  final IOSDeviceConnectionInterface interfaceType;
211

212
  final Map<IOSApp?, DeviceLogReader> _logReaders = <IOSApp?, DeviceLogReader>{};
213

214
  DevicePortForwarder? _portForwarder;
215

216
  @visibleForTesting
217
  IOSDeployDebugger? iosDeployDebugger;
218

219
  @override
220
  Future<bool> get isLocalEmulator async => false;
221

222
  @override
223
  Future<String?> get emulatorId async => null;
224

225
  @override
226 227 228
  bool get supportsStartPaused => false;

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

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

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

260
    int installationResult;
261
    try {
262 263 264
      installationResult = await _iosDeploy.installApp(
        deviceId: id,
        bundlePath: bundle.path,
265
        appDeltaDirectory: app.appDeltaDirectory,
266
        launchArguments: <String>[],
267
        interfaceType: interfaceType,
268
      );
269
    } on ProcessException catch (e) {
270
      _logger.printError(e.message);
271 272
      return false;
    }
273
    if (installationResult != 0) {
274 275 276 277
      _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('');
278 279 280
      return false;
    }
    return true;
281
  }
282 283

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

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

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

322
    if (!prebuiltApplication) {
323
      _logger.printTrace('Building ${package.name} for $id');
324

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

342 343
    packageId ??= package.id;

344
    // Step 2: Check that the application exists at the specified path.
345
    final Directory bundle = _fileSystem.directory(package.deviceBundlePath);
346
    if (!bundle.existsSync()) {
347
      _logger.printError('Could not find the built application bundle at ${bundle.path}.');
348
      return LaunchResult.failed();
349 350 351
    }

    // Step 3: Attempt to install the application on the device.
352
    final String dartVmFlags = computeDartVmFlags(debuggingOptions);
353 354
    final List<String> launchArguments = <String>[
      '--enable-dart-profiling',
355
      '--disable-service-auth-codes',
356
      if (debuggingOptions.disablePortPublication) '--disable-observatory-publication',
357
      if (debuggingOptions.startPaused) '--start-paused',
358
      if (dartVmFlags.isNotEmpty) '--dart-flags="$dartVmFlags"',
359
      if (debuggingOptions.useTestFonts) '--use-test-fonts',
360
      if (debuggingOptions.debuggingEnabled) ...<String>[
361 362 363 364 365 366
        '--enable-checked-mode',
        '--verify-entry-points',
      ],
      if (debuggingOptions.enableSoftwareRendering) '--enable-software-rendering',
      if (debuggingOptions.skiaDeterministicRendering) '--skia-deterministic-rendering',
      if (debuggingOptions.traceSkia) '--trace-skia',
367
      if (debuggingOptions.traceAllowlist != null) '--trace-allowlist="${debuggingOptions.traceAllowlist}"',
368
      if (debuggingOptions.traceSkiaAllowlist != null) '--trace-skia-allowlist="${debuggingOptions.traceSkiaAllowlist}"',
369
      if (debuggingOptions.endlessTraceBuffer) '--endless-trace-buffer',
370 371
      if (debuggingOptions.dumpSkpOnShaderCompilation) '--dump-skp-on-shader-compilation',
      if (debuggingOptions.verboseSystemLogs) '--verbose-logging',
372
      if (debuggingOptions.cacheSkSL) '--cache-sksl',
373
      if (debuggingOptions.purgePersistentCache) '--purge-persistent-cache',
374
      if (platformArgs['trace-startup'] as bool? ?? false) '--trace-startup',
375
    ];
376

377
    final Status installStatus = _logger.startProgress(
378 379
      'Installing and launching...',
    );
380
    try {
381
      ProtocolDiscovery? observatoryDiscovery;
382
      int installationResult = 1;
383
      if (debuggingOptions.debuggingEnabled) {
384
        _logger.printTrace('Debugging is enabled, connecting to observatory');
385 386 387 388 389 390 391 392
        final DeviceLogReader deviceLogReader = getLogReader(app: package);

        // If the device supports syslog reading, prefer launching the app without
        // attaching the debugger to avoid the overhead of the unnecessary extra running process.
        if (majorSdkVersion >= IOSDeviceLogReader.minimumUniversalLoggingSdkVersion) {
          iosDeployDebugger = _iosDeploy.prepareDebuggerForLaunch(
            deviceId: id,
            bundlePath: bundle.path,
393
            appDeltaDirectory: package.appDeltaDirectory,
394 395 396 397 398 399 400
            launchArguments: launchArguments,
            interfaceType: interfaceType,
          );
          if (deviceLogReader is IOSDeviceLogReader) {
            deviceLogReader.debuggerStream = iosDeployDebugger;
          }
        }
401
        observatoryDiscovery = ProtocolDiscovery.observatory(
402
          deviceLogReader,
403
          portForwarder: portForwarder,
404
          hostPort: debuggingOptions.hostVmServicePort,
405
          devicePort: debuggingOptions.deviceVmServicePort,
406
          ipv6: ipv6,
407
          logger: _logger,
408 409
        );
      }
410 411 412 413
      if (iosDeployDebugger == null) {
        installationResult = await _iosDeploy.launchApp(
          deviceId: id,
          bundlePath: bundle.path,
414
          appDeltaDirectory: package.appDeltaDirectory,
415 416 417 418
          launchArguments: launchArguments,
          interfaceType: interfaceType,
        );
      } else {
419
        installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
420
      }
421
      if (installationResult != 0) {
422 423 424 425
        _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('');
426 427
        return LaunchResult.failed();
      }
428

429 430 431
      if (!debuggingOptions.debuggingEnabled) {
        return LaunchResult.succeeded();
      }
432

433 434 435 436
      _logger.printTrace('Application launched on the device. Waiting for observatory url.');
      final Timer timer = Timer(discoveryTimeout ?? const Duration(seconds: 30), () {
        _logger.printError('iOS Observatory not discovered after 30 seconds. This is taking much longer than expected...');
      });
437
      final Uri? localUri = await observatoryDiscovery?.uri;
438
      timer.cancel();
439
      if (localUri == null) {
440
        iosDeployDebugger?.detach();
441
        return LaunchResult.failed();
442
      }
443
      return LaunchResult.succeeded(observatoryUri: localUri);
444
    } on ProcessException catch (e) {
445
      iosDeployDebugger?.detach();
446
      _logger.printError(e.message);
447
      return LaunchResult.failed();
448 449
    } finally {
      installStatus.stop();
450
    }
451 452
  }

453
  @override
454 455
  Future<bool> stopApp(
    IOSApp app, {
456
    String? userIdentifier,
457
  }) async {
458
    // If the debugger is not attached, killing the ios-deploy process won't stop the app.
459 460 461
    final IOSDeployDebugger? deployDebugger = iosDeployDebugger;
    if (deployDebugger != null && deployDebugger.debuggerAttached) {
      return deployDebugger.exit() == true;
462 463
    }
    return false;
464 465 466
  }

  @override
467
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
468

469
  @override
470
  Future<String> get sdkNameAndVersion async => 'iOS ${_sdkVersion ?? 'unknown version'}';
471

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

485
  @visibleForTesting
486
  void setLogReader(IOSApp app, DeviceLogReader logReader) {
487 488 489
    _logReaders[app] = logReader;
  }

490
  @override
491
  DevicePortForwarder get portForwarder => _portForwarder ??= IOSDevicePortForwarder(
492
    logger: _logger,
493
    iproxy: _iproxy,
494
    id: id,
495
    operatingSystemUtils: globals.os,
496
  );
497

498 499 500 501 502
  @visibleForTesting
  set portForwarder(DevicePortForwarder forwarder) {
    _portForwarder = forwarder;
  }

503
  @override
504
  void clearLogs() { }
Devon Carew's avatar
Devon Carew committed
505 506

  @override
507
  bool get supportsScreenshot => _iMobileDevice.isInstalled;
Devon Carew's avatar
Devon Carew committed
508 509

  @override
510
  Future<void> takeScreenshot(File outputFile) async {
511
    await _iMobileDevice.takeScreenshot(outputFile, id, interfaceType);
512
  }
513 514 515 516 517

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.ios.existsSync();
  }
518 519

  @override
520
  Future<void> dispose() async {
521
    for (final DeviceLogReader logReader in _logReaders.values) {
522
      logReader.dispose();
523 524
    }
    _logReaders.clear();
525
    await _portForwarder?.dispose();
526
  }
527 528
}

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

589 590
@visibleForTesting
class IOSDeviceLogReader extends DeviceLogReader {
591 592 593 594 595 596
  IOSDeviceLogReader._(
    this._iMobileDevice,
    this._majorSdkVersion,
    this._deviceId,
    this.name,
    String appName,
597 598 599 600 601
  ) : // Match for lines for the runner in syslog.
      //
      // iOS 9 format:  Runner[297] <Notice>:
      // iOS 10 format: Runner(Flutter)[297] <Notice>:
      _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: ');
602

603 604
  /// Create a new [IOSDeviceLogReader].
  factory IOSDeviceLogReader.create({
605 606 607
    required IOSDevice device,
    IOSApp? app,
    required IMobileDevice iMobileDevice,
608
  }) {
609
    final String appName = app?.name?.replaceAll('.app', '') ?? '';
610 611 612 613 614 615 616 617 618 619 620
    return IOSDeviceLogReader._(
      iMobileDevice,
      device.majorSdkVersion,
      device.id,
      device.name,
      appName,
    );
  }

  /// Create an [IOSDeviceLogReader] for testing.
  factory IOSDeviceLogReader.test({
621
    required IMobileDevice iMobileDevice,
622 623 624 625 626 627 628 629 630 631 632
    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;
633

634 635
  // Matches a syslog line from the runner.
  RegExp _runnerLineRegex;
636 637 638 639 640

  // 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.
  final RegExp _anyLineRegex = RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: ');
641

642 643 644 645 646 647 648
  // Logging from native code/Flutter engine is prefixed by timestamp and process metadata:
  // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] Did finish launching.
  // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] [Category] Did finish launching.
  //
  // Logging from the dart code has no prefixing metadata.
  final RegExp _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)');

649 650 651 652 653
  late final StreamController<String> _linesController = StreamController<String>.broadcast(
    onListen: _listenToSysLog,
    onCancel: dispose,
  );
  final List<StreamSubscription<void>> _loggingSubscriptions = <StreamSubscription<void>>[];
654

655
  @override
Devon Carew's avatar
Devon Carew committed
656
  Stream<String> get logLines => _linesController.stream;
657

658
  @override
659 660
  FlutterVmService? get connectedVMService => _connectedVMService;
  FlutterVmService? _connectedVMService;
661 662

  @override
663 664 665 666
  set connectedVMService(FlutterVmService? connectedVmService) {
    if (connectedVmService != null) {
      _listenToUnifiedLoggingEvents(connectedVmService);
    }
667
    _connectedVMService = connectedVmService;
668 669
  }

670
  static const int minimumUniversalLoggingSdkVersion = 13;
671

672
  Future<void> _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async {
673
    if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
674 675
      return;
    }
676
    try {
677 678
      // The VM service will not publish logging events unless the debug stream is being listened to.
      // Listen to this stream as a side effect.
679
      unawaited(connectedVmService.service.streamListen('Debug'));
680

681
      await Future.wait(<Future<void>>[
682 683
        connectedVmService.service.streamListen(vm_service.EventStreams.kStdout),
        connectedVmService.service.streamListen(vm_service.EventStreams.kStderr),
684
      ]);
685 686 687
    } on vm_service.RPCError {
      // Do nothing, since the tool is already subscribed.
    }
688 689

    void logMessage(vm_service.Event event) {
690
      if (_iosDeployDebugger != null && _iosDeployDebugger!.debuggerAttached) {
691
        // Prefer the more complete logs from the  attached debugger.
692 693
        return;
      }
694
      final String message = processVmServiceMessage(event);
695 696
      if (message.isNotEmpty) {
        _linesController.add(message);
697
      }
698 699 700
    }

    _loggingSubscriptions.addAll(<StreamSubscription<void>>[
701 702
      connectedVmService.service.onStdoutEvent.listen(logMessage),
      connectedVmService.service.onStderrEvent.listen(logMessage),
703
    ]);
704 705
  }

706
  /// Log reader will listen to [debugger.logLines] and will detach debugger on dispose.
707 708
  IOSDeployDebugger? get debuggerStream => _iosDeployDebugger;
  set debuggerStream(IOSDeployDebugger? debugger) {
709 710 711 712 713
    // Logging is gathered from syslog on iOS 13 and earlier.
    if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
      return;
    }
    _iosDeployDebugger = debugger;
714 715 716
    if (debugger == null) {
      return;
    }
717 718 719 720 721 722 723 724
    // Add the debugger logs to the controller created on initialization.
    _loggingSubscriptions.add(debugger.logLines.listen(
      (String line) => _linesController.add(_debuggerLineHandler(line)),
      onError: _linesController.addError,
      onDone: _linesController.close,
      cancelOnError: true,
    ));
  }
725
  IOSDeployDebugger? _iosDeployDebugger;
726 727

  // Strip off the logging metadata (leave the category), or just echo the line.
728
  String _debuggerLineHandler(String line) => _debuggerLoggingRegex.firstMatch(line)?.group(1) ?? line;
729

730
  void _listenToSysLog() {
731
    // syslog is not written on iOS 13+.
732
    if (_majorSdkVersion >= minimumUniversalLoggingSdkVersion) {
733 734
      return;
    }
735
    _iMobileDevice.startLogger(_deviceId).then<void>((Process process) {
736 737
      process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
      process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
738
      process.exitCode.whenComplete(() {
739
        if (_linesController.hasListener) {
Devon Carew's avatar
Devon Carew committed
740
          _linesController.close();
741
        }
Devon Carew's avatar
Devon Carew committed
742
      });
743 744
      assert(idevicesyslogProcess == null);
      idevicesyslogProcess = process;
Devon Carew's avatar
Devon Carew committed
745
    });
746 747
  }

748
  @visibleForTesting
749
  Process? idevicesyslogProcess;
750

751
  // Returns a stateful line handler to properly capture multiline output.
752
  //
753
  // For multiline log messages, any line after the first is logged without
754 755 756
  // 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).
757
  void Function(String line) _newSyslogLineHandler() {
758 759 760 761 762 763 764 765
    bool printing = false;

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

767 768 769
        printing = false;
      }

770
      final Match? match = _runnerLineRegex.firstMatch(line);
771 772 773 774 775 776 777 778 779

      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;
      }
    };
780 781
  }

782 783
  @override
  void dispose() {
784
    for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) {
785 786
      loggingSubscription.cancel();
    }
787
    idevicesyslogProcess?.kill();
788
    _iosDeployDebugger?.detach();
789 790
  }
}
791

792
/// A [DevicePortForwarder] specialized for iOS usage with iproxy.
793
class IOSDevicePortForwarder extends DevicePortForwarder {
794

795 796
  /// Create a new [IOSDevicePortForwarder].
  IOSDevicePortForwarder({
797 798 799 800
    required Logger logger,
    required String id,
    required IProxy iproxy,
    required OperatingSystemUtils operatingSystemUtils,
801 802
  }) : _logger = logger,
       _id = id,
803
       _iproxy = iproxy,
804
       _operatingSystemUtils = operatingSystemUtils;
805 806 807 808 809 810

  /// Create a [IOSDevicePortForwarder] for testing.
  ///
  /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as
  /// 'DYLD_LIBRARY_PATH: /path/to/libs'.
  ///
811
  /// The device id may be provided, but otherwise defaults to '1234'.
812
  factory IOSDevicePortForwarder.test({
813 814 815 816
    required ProcessManager processManager,
    required Logger logger,
    String? id,
    required OperatingSystemUtils operatingSystemUtils,
817 818 819
  }) {
    return IOSDevicePortForwarder(
      logger: logger,
820 821 822
      iproxy: IProxy.test(
        logger: logger,
        processManager: processManager,
823
      ),
824
      id: id ?? '1234',
825
      operatingSystemUtils: operatingSystemUtils,
826 827
    );
  }
828

829 830
  final Logger _logger;
  final String _id;
831
  final IProxy _iproxy;
832
  final OperatingSystemUtils _operatingSystemUtils;
833

834
  @override
835
  List<ForwardedPort> forwardedPorts = <ForwardedPort>[];
836

837
  @visibleForTesting
838 839
  void addForwardedPorts(List<ForwardedPort> ports) {
    ports.forEach(forwardedPorts.add);
840 841
  }

842
  static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1);
843

844
  @override
845
  Future<int> forward(int devicePort, { int? hostPort }) async {
846
    final bool autoselect = hostPort == null || hostPort == 0;
847
    if (autoselect) {
848
      final int freePort = await _operatingSystemUtils.findFreePort();
849
      // Dynamic port range 49152 - 65535.
850
      hostPort = freePort == 0 ? 49152 : freePort;
851
    }
852

853
    Process? process;
854 855 856

    bool connected = false;
    while (!connected) {
857
      _logger.printTrace('Attempting to forward device port $devicePort to host port $hostPort');
858
      process = await _iproxy.forward(devicePort, hostPort!, _id);
859
      // TODO(ianh): This is a flaky race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674
860 861
      connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false);
      if (!connected) {
862
        process.kill();
863 864
        if (autoselect) {
          hostPort += 1;
865
          if (hostPort > 65535) {
866
            throw Exception('Could not find open port on host.');
867
          }
868
        } else {
869
          throw Exception('Port $hostPort is not available.');
870 871
        }
      }
872
    }
873 874
    assert(connected);
    assert(process != null);
875

876
    final ForwardedPort forwardedPort = ForwardedPort.withContext(
877
      hostPort!, devicePort, process,
878
    );
879 880
    _logger.printTrace('Forwarded port $forwardedPort');
    forwardedPorts.add(forwardedPort);
881
    return hostPort;
882 883
  }

884
  @override
885
  Future<void> unforward(ForwardedPort forwardedPort) async {
886
    if (!forwardedPorts.remove(forwardedPort)) {
887
      // Not in list. Nothing to remove.
888
      return;
889 890
    }

891
    _logger.printTrace('Un-forwarding port $forwardedPort');
892 893
    forwardedPort.dispose();
  }
894

895 896
  @override
  Future<void> dispose() async {
897
    for (final ForwardedPort forwardedPort in forwardedPorts) {
898
      forwardedPort.dispose();
899
    }
900 901
  }
}