devices.dart 29.7 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/file_system.dart';
12
import '../base/io.dart';
13
import '../base/logger.dart';
14
import '../base/os.dart';
15
import '../base/platform.dart';
16
import '../base/utils.dart';
17
import '../build_info.dart';
18
import '../convert.dart';
19
import '../device.dart';
20
import '../device_port_forwarder.dart';
21
import '../globals.dart' as globals;
22
import '../macos/xcdevice.dart';
23
import '../project.dart';
24
import '../protocol_discovery.dart';
25
import '../vmservice.dart';
26
import 'application_package.dart';
27
import 'ios_deploy.dart';
28
import 'ios_workflow.dart';
29
import 'iproxy.dart';
30 31 32
import 'mac.dart';

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

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

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

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

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

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

    deviceNotifier ??= ItemListNotifier<Device>();

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

    // cancel any outstanding subscriptions.
    await _observedDeviceEventsSubscription?.cancel();
75
    _observedDeviceEventsSubscription = _xcdevice.observedDeviceEvents()?.listen(
76
      _onDeviceEvent,
77
      onError: (Object error, StackTrace stack) {
78 79 80 81 82 83 84 85 86 87 88 89 90 91
        _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;
92 93 94 95 96 97 98 99 100 101 102
    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;
      }
    }
103 104 105 106 107 108

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

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

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

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

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

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

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

class IOSDevice extends Device {
147
  IOSDevice(super.id, {
148 149 150 151 152 153 154 155 156 157
    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,
158
  })
159 160 161
    : _sdkVersion = sdkVersion,
      _iosDeploy = iosDeploy,
      _iMobileDevice = iMobileDevice,
162
      _iproxy = iProxy,
163 164 165
      _fileSystem = fileSystem,
      _logger = logger,
      _platform = platform,
166 167 168 169 170
        super(
          category: Category.mobile,
          platformType: PlatformType.ios,
          ephemeral: true,
      ) {
171
    if (!_platform.isMacOS) {
172
      assert(false, 'Control of iOS devices or simulators only supported on Mac OS.');
173 174
      return;
    }
175 176
  }

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

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

191
  @override
192
  bool get supportsHotReload => interfaceType == IOSDeviceConnectionInterface.usb;
193 194

  @override
195
  bool get supportsHotRestart => interfaceType == IOSDeviceConnectionInterface.usb;
196 197

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

200
  @override
201 202
  final String name;

203 204 205
  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

206 207
  final DarwinArch cpuArchitecture;

208
  final IOSDeviceConnectionInterface interfaceType;
209

210
  final Map<IOSApp?, DeviceLogReader> _logReaders = <IOSApp?, DeviceLogReader>{};
211

212
  DevicePortForwarder? _portForwarder;
213

214
  @visibleForTesting
215
  IOSDeployDebugger? iosDeployDebugger;
216

217
  @override
218
  Future<bool> get isLocalEmulator async => false;
219

220
  @override
221
  Future<String?> get emulatorId async => null;
222

223
  @override
224 225 226
  bool get supportsStartPaused => false;

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

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

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

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

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

303
  @override
304 305
  // 32-bit devices are not supported.
  bool isSupported() => cpuArchitecture == DarwinArch.arm64;
306

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

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

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

341 342
    packageId ??= package.id;

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

    // Step 3: Attempt to install the application on the device.
351
    final String dartVmFlags = computeDartVmFlags(debuggingOptions);
352 353
    final List<String> launchArguments = <String>[
      '--enable-dart-profiling',
354
      '--disable-service-auth-codes',
355
      if (debuggingOptions.disablePortPublication) '--disable-observatory-publication',
356
      if (debuggingOptions.startPaused) '--start-paused',
357
      if (dartVmFlags.isNotEmpty) '--dart-flags="$dartVmFlags"',
358
      if (debuggingOptions.useTestFonts) '--use-test-fonts',
359
      if (debuggingOptions.debuggingEnabled) ...<String>[
360 361 362 363
        '--enable-checked-mode',
        '--verify-entry-points',
      ],
      if (debuggingOptions.enableSoftwareRendering) '--enable-software-rendering',
364
      if (debuggingOptions.traceSystrace) '--trace-systrace',
365 366
      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 (route != null) '--route=$route',
375
      if (platformArgs['trace-startup'] as bool? ?? false) '--trace-startup',
376
      if (debuggingOptions.enableImpeller) '--enable-impeller',
377
    ];
378

379
    final Status installStatus = _logger.startProgress(
380 381
      'Installing and launching...',
    );
382
    try {
383
      ProtocolDiscovery? observatoryDiscovery;
384
      int installationResult = 1;
385
      if (debuggingOptions.debuggingEnabled) {
386
        _logger.printTrace('Debugging is enabled, connecting to observatory');
387 388 389 390 391 392 393 394
        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,
395
            appDeltaDirectory: package.appDeltaDirectory,
396 397
            launchArguments: launchArguments,
            interfaceType: interfaceType,
398
            uninstallFirst: debuggingOptions.uninstallFirst,
399 400 401 402 403
          );
          if (deviceLogReader is IOSDeviceLogReader) {
            deviceLogReader.debuggerStream = iosDeployDebugger;
          }
        }
404
        observatoryDiscovery = ProtocolDiscovery.observatory(
405
          deviceLogReader,
406
          portForwarder: portForwarder,
407
          hostPort: debuggingOptions.hostVmServicePort,
408
          devicePort: debuggingOptions.deviceVmServicePort,
409
          ipv6: ipv6,
410
          logger: _logger,
411 412
        );
      }
413 414 415 416
      if (iosDeployDebugger == null) {
        installationResult = await _iosDeploy.launchApp(
          deviceId: id,
          bundlePath: bundle.path,
417
          appDeltaDirectory: package.appDeltaDirectory,
418 419
          launchArguments: launchArguments,
          interfaceType: interfaceType,
420
          uninstallFirst: debuggingOptions.uninstallFirst,
421 422
        );
      } else {
423
        installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
424
      }
425
      if (installationResult != 0) {
426 427 428 429
        _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('');
430 431
        return LaunchResult.failed();
      }
432

433 434 435
      if (!debuggingOptions.debuggingEnabled) {
        return LaunchResult.succeeded();
      }
436

437 438 439
      _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...');
440
        iosDeployDebugger?.pauseDumpBacktraceResume();
441
      });
442
      final Uri? localUri = await observatoryDiscovery?.uri;
443
      timer.cancel();
444
      if (localUri == null) {
445
        await iosDeployDebugger?.stopAndDumpBacktrace();
446
        return LaunchResult.failed();
447
      }
448
      return LaunchResult.succeeded(observatoryUri: localUri);
449
    } on ProcessException catch (e) {
450
      await iosDeployDebugger?.stopAndDumpBacktrace();
451
      _logger.printError(e.message);
452
      return LaunchResult.failed();
453 454
    } finally {
      installStatus.stop();
455
    }
456 457
  }

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

  @override
472
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
473

474
  @override
475
  Future<String> get sdkNameAndVersion async => 'iOS ${_sdkVersion ?? 'unknown version'}';
476

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

490
  @visibleForTesting
491
  void setLogReader(IOSApp app, DeviceLogReader logReader) {
492 493 494
    _logReaders[app] = logReader;
  }

495
  @override
496
  DevicePortForwarder get portForwarder => _portForwarder ??= IOSDevicePortForwarder(
497
    logger: _logger,
498
    iproxy: _iproxy,
499
    id: id,
500
    operatingSystemUtils: globals.os,
501
  );
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;
Devon Carew's avatar
Devon Carew committed
513 514

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

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

  @override
525
  Future<void> dispose() async {
526
    for (final DeviceLogReader logReader in _logReaders.values) {
527
      logReader.dispose();
528 529
    }
    _logReaders.clear();
530
    await _portForwarder?.dispose();
531
  }
532 533
}

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

594 595
@visibleForTesting
class IOSDeviceLogReader extends DeviceLogReader {
596 597 598 599 600 601
  IOSDeviceLogReader._(
    this._iMobileDevice,
    this._majorSdkVersion,
    this._deviceId,
    this.name,
    String appName,
602 603 604 605 606
  ) : // 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]+>: ');
607

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

  /// Create an [IOSDeviceLogReader] for testing.
  factory IOSDeviceLogReader.test({
626
    required IMobileDevice iMobileDevice,
627 628 629 630 631 632 633 634 635 636 637
    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;
638

639 640
  // Matches a syslog line from the runner.
  RegExp _runnerLineRegex;
641 642 643 644 645

  // 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]+>: ');
646

647 648 649 650 651 652 653
  // 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:]*] (.*)');

654 655
  @visibleForTesting
  late final StreamController<String> linesController = StreamController<String>.broadcast(
656 657 658
    onListen: _listenToSysLog,
    onCancel: dispose,
  );
659 660 661 662 663 664 665 666 667 668

  // Sometimes (race condition?) we try to send a log after the controller has
  // been closed. See https://github.com/flutter/flutter/issues/99021 for more
  // context.
  void _addToLinesController(String message) {
    if (!linesController.isClosed) {
      linesController.add(message);
    }
  }

669
  final List<StreamSubscription<void>> _loggingSubscriptions = <StreamSubscription<void>>[];
670

671
  @override
672
  Stream<String> get logLines => linesController.stream;
673

674
  @override
675 676
  FlutterVmService? get connectedVMService => _connectedVMService;
  FlutterVmService? _connectedVMService;
677 678

  @override
679 680 681 682
  set connectedVMService(FlutterVmService? connectedVmService) {
    if (connectedVmService != null) {
      _listenToUnifiedLoggingEvents(connectedVmService);
    }
683
    _connectedVMService = connectedVmService;
684 685
  }

686
  static const int minimumUniversalLoggingSdkVersion = 13;
687

688
  Future<void> _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async {
689
    if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
690 691
      return;
    }
692
    try {
693 694
      // The VM service will not publish logging events unless the debug stream is being listened to.
      // Listen to this stream as a side effect.
695
      unawaited(connectedVmService.service.streamListen('Debug'));
696

697
      await Future.wait(<Future<void>>[
698 699
        connectedVmService.service.streamListen(vm_service.EventStreams.kStdout),
        connectedVmService.service.streamListen(vm_service.EventStreams.kStderr),
700
      ]);
701 702 703
    } on vm_service.RPCError {
      // Do nothing, since the tool is already subscribed.
    }
704 705

    void logMessage(vm_service.Event event) {
706
      if (_iosDeployDebugger != null && _iosDeployDebugger!.debuggerAttached) {
707
        // Prefer the more complete logs from the attached debugger.
708 709
        return;
      }
710
      final String message = processVmServiceMessage(event);
711
      if (message.isNotEmpty) {
712
        _addToLinesController(message);
713
      }
714 715 716
    }

    _loggingSubscriptions.addAll(<StreamSubscription<void>>[
717 718
      connectedVmService.service.onStdoutEvent.listen(logMessage),
      connectedVmService.service.onStderrEvent.listen(logMessage),
719
    ]);
720 721
  }

722
  /// Log reader will listen to [debugger.logLines] and will detach debugger on dispose.
723 724
  IOSDeployDebugger? get debuggerStream => _iosDeployDebugger;
  set debuggerStream(IOSDeployDebugger? debugger) {
725 726 727 728 729
    // Logging is gathered from syslog on iOS 13 and earlier.
    if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
      return;
    }
    _iosDeployDebugger = debugger;
730 731 732
    if (debugger == null) {
      return;
    }
733 734
    // Add the debugger logs to the controller created on initialization.
    _loggingSubscriptions.add(debugger.logLines.listen(
735 736 737
      (String line) => _addToLinesController(_debuggerLineHandler(line)),
      onError: linesController.addError,
      onDone: linesController.close,
738 739 740
      cancelOnError: true,
    ));
  }
741
  IOSDeployDebugger? _iosDeployDebugger;
742 743

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

746
  void _listenToSysLog() {
747
    // syslog is not written on iOS 13+.
748
    if (_majorSdkVersion >= minimumUniversalLoggingSdkVersion) {
749 750
      return;
    }
751
    _iMobileDevice.startLogger(_deviceId).then<void>((Process process) {
752 753
      process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
      process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
754
      process.exitCode.whenComplete(() {
755 756
        if (linesController.hasListener) {
          linesController.close();
757
        }
Devon Carew's avatar
Devon Carew committed
758
      });
759 760
      assert(idevicesyslogProcess == null);
      idevicesyslogProcess = process;
Devon Carew's avatar
Devon Carew committed
761
    });
762 763
  }

764
  @visibleForTesting
765
  Process? idevicesyslogProcess;
766

767
  // Returns a stateful line handler to properly capture multiline output.
768
  //
769
  // For multiline log messages, any line after the first is logged without
770 771 772
  // 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).
773
  void Function(String line) _newSyslogLineHandler() {
774 775 776 777 778
    bool printing = false;

    return (String line) {
      if (printing) {
        if (!_anyLineRegex.hasMatch(line)) {
779
          _addToLinesController(decodeSyslog(line));
780 781
          return;
        }
782

783 784 785
        printing = false;
      }

786
      final Match? match = _runnerLineRegex.firstMatch(line);
787 788 789 790

      if (match != null) {
        final String logLine = line.substring(match.end);
        // Only display the log line after the initial device and executable information.
791
        _addToLinesController(decodeSyslog(logLine));
792 793 794 795

        printing = true;
      }
    };
796 797
  }

798 799
  @override
  void dispose() {
800
    for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) {
801 802
      loggingSubscription.cancel();
    }
803
    idevicesyslogProcess?.kill();
804
    _iosDeployDebugger?.detach();
805 806
  }
}
807

808
/// A [DevicePortForwarder] specialized for iOS usage with iproxy.
809
class IOSDevicePortForwarder extends DevicePortForwarder {
810

811 812
  /// Create a new [IOSDevicePortForwarder].
  IOSDevicePortForwarder({
813 814 815 816
    required Logger logger,
    required String id,
    required IProxy iproxy,
    required OperatingSystemUtils operatingSystemUtils,
817 818
  }) : _logger = logger,
       _id = id,
819
       _iproxy = iproxy,
820
       _operatingSystemUtils = operatingSystemUtils;
821 822 823 824 825 826

  /// Create a [IOSDevicePortForwarder] for testing.
  ///
  /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as
  /// 'DYLD_LIBRARY_PATH: /path/to/libs'.
  ///
827
  /// The device id may be provided, but otherwise defaults to '1234'.
828
  factory IOSDevicePortForwarder.test({
829 830 831 832
    required ProcessManager processManager,
    required Logger logger,
    String? id,
    required OperatingSystemUtils operatingSystemUtils,
833 834 835
  }) {
    return IOSDevicePortForwarder(
      logger: logger,
836 837 838
      iproxy: IProxy.test(
        logger: logger,
        processManager: processManager,
839
      ),
840
      id: id ?? '1234',
841
      operatingSystemUtils: operatingSystemUtils,
842 843
    );
  }
844

845 846
  final Logger _logger;
  final String _id;
847
  final IProxy _iproxy;
848
  final OperatingSystemUtils _operatingSystemUtils;
849

850
  @override
851
  List<ForwardedPort> forwardedPorts = <ForwardedPort>[];
852

853
  @visibleForTesting
854 855
  void addForwardedPorts(List<ForwardedPort> ports) {
    ports.forEach(forwardedPorts.add);
856 857
  }

858
  static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1);
859

860
  @override
861
  Future<int> forward(int devicePort, { int? hostPort }) async {
862
    final bool autoselect = hostPort == null || hostPort == 0;
863
    if (autoselect) {
864
      final int freePort = await _operatingSystemUtils.findFreePort();
865
      // Dynamic port range 49152 - 65535.
866
      hostPort = freePort == 0 ? 49152 : freePort;
867
    }
868

869
    Process? process;
870 871 872

    bool connected = false;
    while (!connected) {
873
      _logger.printTrace('Attempting to forward device port $devicePort to host port $hostPort');
874
      process = await _iproxy.forward(devicePort, hostPort!, _id);
875
      // TODO(ianh): This is a flaky race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674
876 877
      connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false);
      if (!connected) {
878
        process.kill();
879 880
        if (autoselect) {
          hostPort += 1;
881
          if (hostPort > 65535) {
882
            throw Exception('Could not find open port on host.');
883
          }
884
        } else {
885
          throw Exception('Port $hostPort is not available.');
886 887
        }
      }
888
    }
889 890
    assert(connected);
    assert(process != null);
891

892
    final ForwardedPort forwardedPort = ForwardedPort.withContext(
893
      hostPort!, devicePort, process,
894
    );
895 896
    _logger.printTrace('Forwarded port $forwardedPort');
    forwardedPorts.add(forwardedPort);
897
    return hostPort;
898 899
  }

900
  @override
901
  Future<void> unforward(ForwardedPort forwardedPort) async {
902
    if (!forwardedPorts.remove(forwardedPort)) {
903
      // Not in list. Nothing to remove.
904
      return;
905 906
    }

907
    _logger.printTrace('Un-forwarding port $forwardedPort');
908 909
    forwardedPort.dispose();
  }
910

911 912
  @override
  Future<void> dispose() async {
913
    for (final ForwardedPort forwardedPort in forwardedPorts) {
914
      forwardedPort.dispose();
915
    }
916 917
  }
}