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

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

  final Platform _platform;
  final IOSWorkflow _iosWorkflow;
49
  final Logger _logger;
50

51 52 53
  @visibleForTesting
  final XCDevice xcdevice;

54
  @override
55
  bool get supportsPlatform => _platform.isMacOS;
56

57
  @override
58
  bool get canListAnything => _iosWorkflow.canListDevices;
59

60 61 62
  @override
  bool get requiresExtendedWirelessDeviceDiscovery => true;

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
  StreamSubscription<XCDeviceEventNotification>? _observedDeviceEventsSubscription;

  /// Cache for all devices found by `xcdevice list`, including not connected
  /// devices. Used to minimize the need to call `xcdevice list`.
  ///
  /// Separate from `deviceNotifier` since `deviceNotifier` should only contain
  /// connected devices.
  final Map<String, IOSDevice> _cachedPolledDevices = <String, IOSDevice>{};

  /// Maps device id to a map of the device's observed connections. When the
  /// mapped connection is `true`, that means that observed events indicated
  /// the device is connected via that particular interface.
  ///
  /// The device id must be missing from the map or both interfaces must be
  /// false for the device to be considered disconnected.
  ///
  /// Example:
  /// {
  ///   device-id: {
  ///     usb: false,
  ///     wifi: false,
  ///   },
  /// }
  final Map<String, Map<XCDeviceEventInterface, bool>> _observedConnectionsByDeviceId =
      <String, Map<XCDeviceEventInterface, bool>>{};
88 89 90 91 92 93 94 95

  @override
  Future<void> startPolling() async {
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }
96
    if (!xcdevice.isInstalled) {
97 98
      return;
    }
99 100 101 102

    deviceNotifier ??= ItemListNotifier<Device>();

    // Start by populating all currently attached devices.
103 104
    _updateCachedDevices(await pollingGetDevices());
    _updateNotifierFromCache();
105 106 107

    // cancel any outstanding subscriptions.
    await _observedDeviceEventsSubscription?.cancel();
108
    _observedDeviceEventsSubscription = xcdevice.observedDeviceEvents()?.listen(
109
      onDeviceEvent,
110
      onError: (Object error, StackTrace stack) {
111 112 113 114 115 116 117 118 119 120 121 122
        _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,
    );
  }

123 124
  @visibleForTesting
  Future<void> onDeviceEvent(XCDeviceEventNotification event) async {
125 126 127 128 129 130
    final ItemListNotifier<Device>? notifier = deviceNotifier;
    if (notifier == null) {
      return;
    }
    Device? knownDevice;
    for (final Device device in notifier.items) {
131
      if (device.id == event.deviceIdentifier) {
132 133 134
        knownDevice = device;
      }
    }
135

136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186
    final Map<XCDeviceEventInterface, bool> deviceObservedConnections =
        _observedConnectionsByDeviceId[event.deviceIdentifier] ??
            <XCDeviceEventInterface, bool>{
              XCDeviceEventInterface.usb: false,
              XCDeviceEventInterface.wifi: false,
            };

    if (event.eventType == XCDeviceEvent.attach) {
      // Update device's observed connections.
      deviceObservedConnections[event.eventInterface] = true;
      _observedConnectionsByDeviceId[event.deviceIdentifier] = deviceObservedConnections;

      // If device was not already in notifier, add it.
      if (knownDevice == null) {
        if (_cachedPolledDevices[event.deviceIdentifier] == null) {
          // If device is not found in cache, there's no way to get details
          // for an individual attached device, so repopulate them all.
          _updateCachedDevices(await pollingGetDevices());
        }
        _updateNotifierFromCache();
      }
    } else {
      // Update device's observed connections.
      deviceObservedConnections[event.eventInterface] = false;
      _observedConnectionsByDeviceId[event.deviceIdentifier] = deviceObservedConnections;

      // If device is in the notifier and does not have other observed
      // connections, remove it.
      if (knownDevice != null &&
          !_deviceHasObservedConnection(deviceObservedConnections)) {
        notifier.removeItem(knownDevice);
      }
    }
  }

  /// Adds or updates devices in cache. Does not remove devices from cache.
  void _updateCachedDevices(List<Device> devices) {
    for (final Device device in devices) {
      if (device is! IOSDevice) {
        continue;
      }
      _cachedPolledDevices[device.id] = device;
    }
  }

  /// Updates notifier with devices found in the cache that are determined
  /// to be connected.
  void _updateNotifierFromCache() {
    final ItemListNotifier<Device>? notifier = deviceNotifier;
    if (notifier == null) {
      return;
187
    }
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
    // Device is connected if it has either an observed usb or wifi connection
    // or it has not been observed but was found as connected in the cache.
    final List<Device> connectedDevices = _cachedPolledDevices.values.where((Device device) {
      final Map<XCDeviceEventInterface, bool>? deviceObservedConnections =
          _observedConnectionsByDeviceId[device.id];
      return (deviceObservedConnections != null &&
              _deviceHasObservedConnection(deviceObservedConnections)) ||
          (deviceObservedConnections == null && device.isConnected);
    }).toList();

    notifier.updateWithNewList(connectedDevices);
  }

  bool _deviceHasObservedConnection(
    Map<XCDeviceEventInterface, bool> deviceObservedConnections,
  ) {
    return (deviceObservedConnections[XCDeviceEventInterface.usb] ?? false) ||
        (deviceObservedConnections[XCDeviceEventInterface.wifi] ?? false);
206 207 208 209 210 211 212
  }

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

213
  @override
214
  Future<List<Device>> pollingGetDevices({ Duration? timeout }) async {
215 216 217 218 219 220
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }

221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
    return xcdevice.getAvailableIOSDevices(timeout: timeout);
  }

  Future<Device?> waitForDeviceToConnect(
    IOSDevice device,
    Logger logger,
  ) async {
    final XCDeviceEventNotification? eventDetails =
        await xcdevice.waitForDeviceToConnect(device.id);

    if (eventDetails != null) {
      device.isConnected = true;
      device.connectionInterface = eventDetails.eventInterface.connectionInterface;
      return device;
    }
    return null;
  }

  void cancelWaitForDeviceToConnect() {
    xcdevice.cancelWaitForDeviceToConnect();
241
  }
242 243

  @override
244 245 246
  Future<List<String>> getDiagnostics() async {
    if (!_platform.isMacOS) {
      return const <String>[
247
        'Control of iOS devices or simulators only supported on macOS.',
248 249 250
      ];
    }

251
    return xcdevice.getDiagnostics();
252
  }
253 254 255

  @override
  List<String> get wellKnownIds => const <String>[];
256 257 258
}

class IOSDevice extends Device {
259
  IOSDevice(super.id, {
260 261 262
    required FileSystem fileSystem,
    required this.name,
    required this.cpuArchitecture,
263
    required this.connectionInterface,
264
    required this.isConnected,
265
    required this.devModeEnabled,
266 267 268 269 270 271
    String? sdkVersion,
    required Platform platform,
    required IOSDeploy iosDeploy,
    required IMobileDevice iMobileDevice,
    required IProxy iProxy,
    required Logger logger,
272
  })
273 274 275
    : _sdkVersion = sdkVersion,
      _iosDeploy = iosDeploy,
      _iMobileDevice = iMobileDevice,
276
      _iproxy = iProxy,
277 278 279
      _fileSystem = fileSystem,
      _logger = logger,
      _platform = platform,
280 281 282 283 284
        super(
          category: Category.mobile,
          platformType: PlatformType.ios,
          ephemeral: true,
      ) {
285
    if (!_platform.isMacOS) {
286
      assert(false, 'Control of iOS devices or simulators only supported on Mac OS.');
287 288
      return;
    }
289 290
  }

291
  final String? _sdkVersion;
292 293
  final IOSDeploy _iosDeploy;
  final FileSystem _fileSystem;
294 295
  final Logger _logger;
  final Platform _platform;
296
  final IMobileDevice _iMobileDevice;
297
  final IProxy _iproxy;
298

299 300 301 302
  Version? get sdkVersion {
    return Version.parse(_sdkVersion);
  }

303 304
  /// May be 0 if version cannot be parsed.
  int get majorSdkVersion {
305
    return sdkVersion?.major ?? 0;
306 307
  }

308
  @override
309 310
  final String name;

311 312 313
  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

314 315
  final DarwinArch cpuArchitecture;

316
  @override
317 318 319 320 321 322 323 324 325
  /// The [connectionInterface] provided from `XCDevice.getAvailableIOSDevices`
  /// may not be accurate. Sometimes if it doesn't have a long enough time
  /// to connect, wireless devices will have an interface of `usb`/`attached`.
  /// This may change after waiting for the device to connect in
  /// `waitForDeviceToConnect`.
  DeviceConnectionInterface connectionInterface;

  @override
  bool isConnected;
326

327
  final Map<IOSApp?, DeviceLogReader> _logReaders = <IOSApp?, DeviceLogReader>{};
328

329
  DevicePortForwarder? _portForwarder;
330

331 332
  bool devModeEnabled = false;

333
  @visibleForTesting
334
  IOSDeployDebugger? iosDeployDebugger;
335

336
  @override
337
  Future<bool> get isLocalEmulator async => false;
338

339
  @override
340
  Future<String?> get emulatorId async => null;
341

342
  @override
343 344 345
  bool get supportsStartPaused => false;

  @override
346
  Future<bool> isAppInstalled(
347
    ApplicationPackage app, {
348
    String? userIdentifier,
349
  }) async {
350
    bool result;
351
    try {
352 353 354
      result = await _iosDeploy.isAppInstalled(
        bundleId: app.id,
        deviceId: id,
355
      );
356
    } on ProcessException catch (e) {
357
      _logger.printError(e.message);
358 359
      return false;
    }
360
    return result;
361 362
  }

363
  @override
364
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
365

366
  @override
367
  Future<bool> installApp(
368
    covariant IOSApp app, {
369
    String? userIdentifier,
370
  }) async {
371
    final Directory bundle = _fileSystem.directory(app.deviceBundlePath);
372
    if (!bundle.existsSync()) {
373
      _logger.printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?');
374 375 376
      return false;
    }

377
    int installationResult;
378
    try {
379 380 381
      installationResult = await _iosDeploy.installApp(
        deviceId: id,
        bundlePath: bundle.path,
382
        appDeltaDirectory: app.appDeltaDirectory,
383
        launchArguments: <String>[],
384
        interfaceType: connectionInterface,
385
      );
386
    } on ProcessException catch (e) {
387
      _logger.printError(e.message);
388 389
      return false;
    }
390
    if (installationResult != 0) {
391 392 393 394
      _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('');
395 396 397
      return false;
    }
    return true;
398
  }
399 400

  @override
401
  Future<bool> uninstallApp(
402
    ApplicationPackage app, {
403
    String? userIdentifier,
404
  }) async {
405
    int uninstallationResult;
406
    try {
407 408 409
      uninstallationResult = await _iosDeploy.uninstallApp(
        deviceId: id,
        bundleId: app.id,
410
      );
411
    } on ProcessException catch (e) {
412
      _logger.printError(e.message);
413 414
      return false;
    }
415
    if (uninstallationResult != 0) {
416
      _logger.printError('Could not uninstall ${app.id} on $id.');
417 418 419
      return false;
    }
    return true;
420 421
  }

422
  @override
423 424
  // 32-bit devices are not supported.
  bool isSupported() => cpuArchitecture == DarwinArch.arm64;
425

426
  @override
Devon Carew's avatar
Devon Carew committed
427
  Future<LaunchResult> startApp(
428
    IOSApp package, {
429 430 431 432
    String? mainPath,
    String? route,
    required DebuggingOptions debuggingOptions,
    Map<String, Object?> platformArgs = const <String, Object?>{},
433 434
    bool prebuiltApplication = false,
    bool ipv6 = false,
435 436
    String? userIdentifier,
    @visibleForTesting Duration? discoveryTimeout,
437
  }) async {
438
    String? packageId;
439
    if (isWirelesslyConnected &&
440 441 442 443
        debuggingOptions.debuggingEnabled &&
        debuggingOptions.disablePortPublication) {
      throwToolExit('Cannot start app on wirelessly tethered iOS device. Try running again with the --publish-port flag');
    }
444
    if (!prebuiltApplication) {
445
      _logger.printTrace('Building ${package.name} for $id');
446

447
      // Step 1: Build the precompiled/DBC application if necessary.
448
      final XcodeBuildResult buildResult = await buildXcodeProject(
449 450 451 452 453
          app: package as BuildableIOSApp,
          buildInfo: debuggingOptions.buildInfo,
          targetOverride: mainPath,
          activeArch: cpuArchitecture,
          deviceID: id,
454
      );
455
      if (!buildResult.success) {
456
        _logger.printError('Could not build the precompiled application for the device.');
457
        await diagnoseXcodeBuildFailure(buildResult, globals.flutterUsage, _logger);
458
        _logger.printError('');
459
        return LaunchResult.failed();
460
      }
461
      packageId = buildResult.xcodeBuildExecution?.buildSettings[IosProject.kProductBundleIdKey];
462 463
    }

464 465
    packageId ??= package.id;

466
    // Step 2: Check that the application exists at the specified path.
467
    final Directory bundle = _fileSystem.directory(package.deviceBundlePath);
468
    if (!bundle.existsSync()) {
469
      _logger.printError('Could not find the built application bundle at ${bundle.path}.');
470
      return LaunchResult.failed();
471 472 473
    }

    // Step 3: Attempt to install the application on the device.
474 475 476 477
    final List<String> launchArguments = debuggingOptions.getIOSLaunchArguments(
      EnvironmentType.physical,
      route,
      platformArgs,
478
      ipv6: ipv6,
479
      interfaceType: connectionInterface,
480
    );
481
    Status startAppStatus = _logger.startProgress(
482 483
      'Installing and launching...',
    );
484
    try {
485
      ProtocolDiscovery? vmServiceDiscovery;
486
      int installationResult = 1;
487
      if (debuggingOptions.debuggingEnabled) {
488
        _logger.printTrace('Debugging is enabled, connecting to vmService');
489 490 491 492
        final DeviceLogReader deviceLogReader = getLogReader(
          app: package,
          usingCISystem: debuggingOptions.usingCISystem,
        );
493 494 495 496 497 498 499

        // 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,
500
            appDeltaDirectory: package.appDeltaDirectory,
501
            launchArguments: launchArguments,
502
            interfaceType: connectionInterface,
503
            uninstallFirst: debuggingOptions.uninstallFirst,
504 505 506 507 508
          );
          if (deviceLogReader is IOSDeviceLogReader) {
            deviceLogReader.debuggerStream = iosDeployDebugger;
          }
        }
509
        // Don't port foward if debugging with a wireless device.
510
        vmServiceDiscovery = ProtocolDiscovery.vmService(
511
          deviceLogReader,
512
          portForwarder: isWirelesslyConnected ? null : portForwarder,
513
          hostPort: debuggingOptions.hostVmServicePort,
514
          devicePort: debuggingOptions.deviceVmServicePort,
515
          ipv6: ipv6,
516
          logger: _logger,
517 518
        );
      }
519 520 521 522
      if (iosDeployDebugger == null) {
        installationResult = await _iosDeploy.launchApp(
          deviceId: id,
          bundlePath: bundle.path,
523
          appDeltaDirectory: package.appDeltaDirectory,
524
          launchArguments: launchArguments,
525
          interfaceType: connectionInterface,
526
          uninstallFirst: debuggingOptions.uninstallFirst,
527 528
        );
      } else {
529
        installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
530
      }
531
      if (installationResult != 0) {
532 533 534 535
        _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('');
536
        await dispose();
537 538
        return LaunchResult.failed();
      }
539

540 541 542
      if (!debuggingOptions.debuggingEnabled) {
        return LaunchResult.succeeded();
      }
543

544 545
      _logger.printTrace('Application launched on the device. Waiting for Dart VM Service url.');

546
      final int defaultTimeout = isWirelesslyConnected ? 45 : 30;
547 548 549 550 551
      final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: defaultTimeout), () {
        _logger.printError('The Dart VM Service was not discovered after $defaultTimeout seconds. This is taking much longer than expected...');

        // If debugging with a wireless device and the timeout is reached, remind the
        // user to allow local network permissions.
552
        if (isWirelesslyConnected) {
553 554 555 556 557 558 559
          _logger.printError(
            '\nClick "Allow" to the prompt asking if you would like to find and connect devices on your local network. '
            'This is required for wireless debugging. If you selected "Don\'t Allow", '
            'you can turn it on in Settings > Your App Name > Local Network. '
            "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again."
          );
        } else {
560
          iosDeployDebugger?.checkForSymbolsFiles(_fileSystem);
561 562
          iosDeployDebugger?.pauseDumpBacktraceResume();
        }
563
      });
564 565

      Uri? localUri;
566
      if (isWirelesslyConnected) {
567
        // Wait for Dart VM Service to start up.
568
        final Uri? serviceURL = await vmServiceDiscovery?.uri;
569 570
        if (serviceURL == null) {
          await iosDeployDebugger?.stopAndDumpBacktrace();
571
          await dispose();
572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591
          return LaunchResult.failed();
        }

        // If Dart VM Service URL with the device IP is not found within 5 seconds,
        // change the status message to prompt users to click Allow. Wait 5 seconds because it
        // should only show this message if they have not already approved the permissions.
        // MDnsVmServiceDiscovery usually takes less than 5 seconds to find it.
        final Timer mDNSLookupTimer = Timer(const Duration(seconds: 5), () {
          startAppStatus.stop();
          startAppStatus = _logger.startProgress(
            'Waiting for approval of local network permissions...',
          );
        });

        // Get Dart VM Service URL with the device IP as the host.
        localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
          packageId,
          this,
          usesIpv6: ipv6,
          deviceVmservicePort: serviceURL.port,
592
          useDeviceIPAsHost: true,
593 594 595 596
        );

        mDNSLookupTimer.cancel();
      } else {
597
        localUri = await vmServiceDiscovery?.uri;
598
      }
599
      timer.cancel();
600
      if (localUri == null) {
601
        await iosDeployDebugger?.stopAndDumpBacktrace();
602
        await dispose();
603
        return LaunchResult.failed();
604
      }
605
      return LaunchResult.succeeded(vmServiceUri: localUri);
606
    } on ProcessException catch (e) {
607
      await iosDeployDebugger?.stopAndDumpBacktrace();
608
      _logger.printError(e.message);
609
      await dispose();
610
      return LaunchResult.failed();
611
    } finally {
612
      startAppStatus.stop();
613
    }
614 615
  }

616
  @override
617
  Future<bool> stopApp(
618
    ApplicationPackage? app, {
619
    String? userIdentifier,
620
  }) async {
621
    // If the debugger is not attached, killing the ios-deploy process won't stop the app.
622 623
    final IOSDeployDebugger? deployDebugger = iosDeployDebugger;
    if (deployDebugger != null && deployDebugger.debuggerAttached) {
624
      return deployDebugger.exit();
625 626
    }
    return false;
627 628 629
  }

  @override
630
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
631

632
  @override
633
  Future<String> get sdkNameAndVersion async => 'iOS ${_sdkVersion ?? 'unknown version'}';
634

635
  @override
636
  DeviceLogReader getLogReader({
637
    covariant IOSApp? app,
638
    bool includePastLogs = false,
639
    bool usingCISystem = false,
640 641
  }) {
    assert(!includePastLogs, 'Past log reading not supported on iOS devices.');
642 643 644
    return _logReaders.putIfAbsent(app, () => IOSDeviceLogReader.create(
      device: this,
      app: app,
645
      iMobileDevice: _iMobileDevice,
646
      usingCISystem: usingCISystem,
647
    ));
648 649
  }

650
  @visibleForTesting
651
  void setLogReader(IOSApp app, DeviceLogReader logReader) {
652 653 654
    _logReaders[app] = logReader;
  }

655
  @override
656
  DevicePortForwarder get portForwarder => _portForwarder ??= IOSDevicePortForwarder(
657
    logger: _logger,
658
    iproxy: _iproxy,
659
    id: id,
660
    operatingSystemUtils: globals.os,
661
  );
662

663 664 665 666 667
  @visibleForTesting
  set portForwarder(DevicePortForwarder forwarder) {
    _portForwarder = forwarder;
  }

668
  @override
669
  void clearLogs() { }
Devon Carew's avatar
Devon Carew committed
670 671

  @override
672
  bool get supportsScreenshot => _iMobileDevice.isInstalled;
Devon Carew's avatar
Devon Carew committed
673 674

  @override
675
  Future<void> takeScreenshot(File outputFile) async {
676
    await _iMobileDevice.takeScreenshot(outputFile, id, connectionInterface);
677
  }
678 679 680 681 682

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.ios.existsSync();
  }
683 684

  @override
685
  Future<void> dispose() async {
686
    for (final DeviceLogReader logReader in _logReaders.values) {
687
      logReader.dispose();
688 689
    }
    _logReaders.clear();
690
    await _portForwarder?.dispose();
691
  }
692 693
}

694
/// Decodes a vis-encoded syslog string to a UTF-8 representation.
695 696 697 698 699 700 701 702 703
///
/// 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.
704 705
///
/// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3)
706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722
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 {
723
    final List<int> bytes = utf8.encode(line);
724
    final List<int> out = <int>[];
725
    for (int i = 0; i < bytes.length;) {
726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746
      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;
      }
    }
747
    return utf8.decode(out);
748
  } on Exception {
749 750 751 752 753
    // Unable to decode line: return as-is.
    return line;
  }
}

754
class IOSDeviceLogReader extends DeviceLogReader {
755 756 757 758 759 760
  IOSDeviceLogReader._(
    this._iMobileDevice,
    this._majorSdkVersion,
    this._deviceId,
    this.name,
    String appName,
761
    bool usingCISystem,
762 763 764 765
  ) : // Match for lines for the runner in syslog.
      //
      // iOS 9 format:  Runner[297] <Notice>:
      // iOS 10 format: Runner(Flutter)[297] <Notice>:
766 767
      _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '),
      _usingCISystem = usingCISystem;
768

769 770
  /// Create a new [IOSDeviceLogReader].
  factory IOSDeviceLogReader.create({
771 772 773
    required IOSDevice device,
    IOSApp? app,
    required IMobileDevice iMobileDevice,
774
    bool usingCISystem = false,
775
  }) {
776
    final String appName = app?.name?.replaceAll('.app', '') ?? '';
777 778 779 780 781 782
    return IOSDeviceLogReader._(
      iMobileDevice,
      device.majorSdkVersion,
      device.id,
      device.name,
      appName,
783
      usingCISystem,
784 785 786 787 788
    );
  }

  /// Create an [IOSDeviceLogReader] for testing.
  factory IOSDeviceLogReader.test({
789
    required IMobileDevice iMobileDevice,
790
    bool useSyslog = true,
791 792
    bool usingCISystem = false,
    int? majorSdkVersion,
793
  }) {
794 795 796 797 798 799
    final int sdkVersion;
    if (majorSdkVersion != null) {
      sdkVersion = majorSdkVersion;
    } else {
      sdkVersion = useSyslog ? 12 : 13;
    }
800
    return IOSDeviceLogReader._(
801
      iMobileDevice, sdkVersion, '1234', 'test', 'Runner', usingCISystem);
802 803 804 805 806 807 808
  }

  @override
  final String name;
  final int _majorSdkVersion;
  final String _deviceId;
  final IMobileDevice _iMobileDevice;
809
  final bool _usingCISystem;
810

811 812
  // Matches a syslog line from the runner.
  RegExp _runnerLineRegex;
813 814 815 816 817

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

819 820 821 822 823 824 825
  // 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:]*] (.*)');

826 827
  @visibleForTesting
  late final StreamController<String> linesController = StreamController<String>.broadcast(
828 829 830
    onListen: _listenToSysLog,
    onCancel: dispose,
  );
831 832 833 834

  // 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.
835
  void _addToLinesController(String message, IOSDeviceLogSource source) {
836
    if (!linesController.isClosed) {
837 838 839
      if (_excludeLog(message, source)) {
        return;
      }
840 841 842 843
      linesController.add(message);
    }
  }

844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870
  /// Used to track messages prefixed with "flutter:" when [useBothLogDeviceReaders]
  /// is true.
  final List<String> _streamFlutterMessages = <String>[];

  /// When using both `idevicesyslog` and `ios-deploy`, exclude logs with the
  /// "flutter:" prefix if they have already been added to the stream. This is
  /// to prevent duplicates from being printed.
  ///
  /// If a message does not have the prefix, exclude it if the message's
  /// source is `idevicesyslog`. This is done because `ios-deploy` and
  /// `idevicesyslog` often have different prefixes on non-flutter messages
  /// and are often not critical for CI tests.
  bool _excludeLog(String message, IOSDeviceLogSource source) {
    if (!useBothLogDeviceReaders) {
      return false;
    }
    if (message.startsWith('flutter:')) {
      if (_streamFlutterMessages.contains(message)) {
        return true;
      }
      _streamFlutterMessages.add(message);
    } else if (source == IOSDeviceLogSource.idevicesyslog) {
      return true;
    }
    return false;
  }

871
  final List<StreamSubscription<void>> _loggingSubscriptions = <StreamSubscription<void>>[];
872

873
  @override
874
  Stream<String> get logLines => linesController.stream;
875

876
  @override
877 878
  FlutterVmService? get connectedVMService => _connectedVMService;
  FlutterVmService? _connectedVMService;
879 880

  @override
881 882 883 884
  set connectedVMService(FlutterVmService? connectedVmService) {
    if (connectedVmService != null) {
      _listenToUnifiedLoggingEvents(connectedVmService);
    }
885
    _connectedVMService = connectedVmService;
886 887
  }

888
  static const int minimumUniversalLoggingSdkVersion = 13;
889

890 891 892 893
  /// Listen to Dart VM for logs on iOS 13 or greater.
  ///
  /// Only send logs to stream if [_iosDeployDebugger] is null or
  /// the [_iosDeployDebugger] debugger is not attached.
894
  Future<void> _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async {
895
    if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
896 897
      return;
    }
898
    try {
899 900
      // The VM service will not publish logging events unless the debug stream is being listened to.
      // Listen to this stream as a side effect.
901
      unawaited(connectedVmService.service.streamListen('Debug'));
902

903
      await Future.wait(<Future<void>>[
904 905
        connectedVmService.service.streamListen(vm_service.EventStreams.kStdout),
        connectedVmService.service.streamListen(vm_service.EventStreams.kStderr),
906
      ]);
907 908 909
    } on vm_service.RPCError {
      // Do nothing, since the tool is already subscribed.
    }
910 911

    void logMessage(vm_service.Event event) {
912
      if (_iosDeployDebugger != null && _iosDeployDebugger!.debuggerAttached) {
913
        // Prefer the more complete logs from the attached debugger.
914 915
        return;
      }
916
      final String message = processVmServiceMessage(event);
917
      if (message.isNotEmpty) {
918
        _addToLinesController(message, IOSDeviceLogSource.unifiedLogging);
919
      }
920 921 922
    }

    _loggingSubscriptions.addAll(<StreamSubscription<void>>[
923 924
      connectedVmService.service.onStdoutEvent.listen(logMessage),
      connectedVmService.service.onStderrEvent.listen(logMessage),
925
    ]);
926 927
  }

928
  /// Log reader will listen to [debugger.logLines] and will detach debugger on dispose.
929
  IOSDeployDebugger? get debuggerStream => _iosDeployDebugger;
930 931

  /// Send messages from ios-deploy debugger stream to device log reader stream.
932
  set debuggerStream(IOSDeployDebugger? debugger) {
933
    // Logging is gathered from syslog on iOS earlier than 13.
934 935 936 937
    if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
      return;
    }
    _iosDeployDebugger = debugger;
938 939 940
    if (debugger == null) {
      return;
    }
941 942
    // Add the debugger logs to the controller created on initialization.
    _loggingSubscriptions.add(debugger.logLines.listen(
943 944 945 946
      (String line) => _addToLinesController(
        _debuggerLineHandler(line),
        IOSDeviceLogSource.iosDeploy,
      ),
947 948
      onError: linesController.addError,
      onDone: linesController.close,
949 950 951
      cancelOnError: true,
    ));
  }
952
  IOSDeployDebugger? _iosDeployDebugger;
953 954

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

957 958 959 960 961 962 963 964 965 966
  /// Use both logs from `idevicesyslog` and `ios-deploy` when debugging from CI system
  /// since sometimes `ios-deploy` does not return the device logs:
  /// https://github.com/flutter/flutter/issues/121231
  @visibleForTesting
  bool get useBothLogDeviceReaders {
    return _usingCISystem && _majorSdkVersion >= 16;
  }

  /// Start and listen to idevicesyslog to get device logs for iOS versions
  /// prior to 13 or if [useBothLogDeviceReaders] is true.
967
  void _listenToSysLog() {
968 969 970 971 972
    // Syslog stopped working on iOS 13 (https://github.com/flutter/flutter/issues/41133).
    // However, from at least iOS 16, it has began working again. It's unclear
    // why it started working again so only use syslogs for iOS versions prior
    // to 13 unless [useBothLogDeviceReaders] is true.
    if (!useBothLogDeviceReaders && _majorSdkVersion >= minimumUniversalLoggingSdkVersion) {
973 974
      return;
    }
975
    _iMobileDevice.startLogger(_deviceId).then<void>((Process process) {
976 977
      process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
      process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
978
      process.exitCode.whenComplete(() {
979 980 981 982 983 984 985 986
        if (!linesController.hasListener) {
          return;
        }
        // When using both log readers, do not close the stream on exit.
        // This is to allow ios-deploy to be the source of authority to close
        // the stream.
        if (useBothLogDeviceReaders && debuggerStream != null) {
          return;
987
        }
988
        linesController.close();
Devon Carew's avatar
Devon Carew committed
989
      });
990 991
      assert(idevicesyslogProcess == null);
      idevicesyslogProcess = process;
Devon Carew's avatar
Devon Carew committed
992
    });
993 994
  }

995
  @visibleForTesting
996
  Process? idevicesyslogProcess;
997

998
  // Returns a stateful line handler to properly capture multiline output.
999
  //
1000
  // For multiline log messages, any line after the first is logged without
1001 1002 1003
  // 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).
1004
  void Function(String line) _newSyslogLineHandler() {
1005 1006 1007 1008 1009
    bool printing = false;

    return (String line) {
      if (printing) {
        if (!_anyLineRegex.hasMatch(line)) {
1010
          _addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog);
1011 1012
          return;
        }
1013

1014 1015 1016
        printing = false;
      }

1017
      final Match? match = _runnerLineRegex.firstMatch(line);
1018 1019 1020 1021

      if (match != null) {
        final String logLine = line.substring(match.end);
        // Only display the log line after the initial device and executable information.
1022
        _addToLinesController(decodeSyslog(logLine), IOSDeviceLogSource.idevicesyslog);
1023 1024 1025
        printing = true;
      }
    };
1026 1027
  }

1028 1029
  @override
  void dispose() {
1030
    for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) {
1031 1032
      loggingSubscription.cancel();
    }
1033
    idevicesyslogProcess?.kill();
1034
    _iosDeployDebugger?.detach();
1035 1036
  }
}
1037

1038 1039 1040 1041 1042 1043 1044 1045 1046
enum IOSDeviceLogSource {
  /// Gets logs from ios-deploy debugger.
  iosDeploy,
  /// Gets logs from idevicesyslog.
  idevicesyslog,
  /// Gets logs from the Dart VM Service.
  unifiedLogging,
}

1047
/// A [DevicePortForwarder] specialized for iOS usage with iproxy.
1048
class IOSDevicePortForwarder extends DevicePortForwarder {
1049

1050 1051
  /// Create a new [IOSDevicePortForwarder].
  IOSDevicePortForwarder({
1052 1053 1054 1055
    required Logger logger,
    required String id,
    required IProxy iproxy,
    required OperatingSystemUtils operatingSystemUtils,
1056 1057
  }) : _logger = logger,
       _id = id,
1058
       _iproxy = iproxy,
1059
       _operatingSystemUtils = operatingSystemUtils;
1060 1061 1062 1063 1064 1065

  /// Create a [IOSDevicePortForwarder] for testing.
  ///
  /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as
  /// 'DYLD_LIBRARY_PATH: /path/to/libs'.
  ///
1066
  /// The device id may be provided, but otherwise defaults to '1234'.
1067
  factory IOSDevicePortForwarder.test({
1068 1069 1070 1071
    required ProcessManager processManager,
    required Logger logger,
    String? id,
    required OperatingSystemUtils operatingSystemUtils,
1072 1073 1074
  }) {
    return IOSDevicePortForwarder(
      logger: logger,
1075 1076 1077
      iproxy: IProxy.test(
        logger: logger,
        processManager: processManager,
1078
      ),
1079
      id: id ?? '1234',
1080
      operatingSystemUtils: operatingSystemUtils,
1081 1082
    );
  }
1083

1084 1085
  final Logger _logger;
  final String _id;
1086
  final IProxy _iproxy;
1087
  final OperatingSystemUtils _operatingSystemUtils;
1088

1089
  @override
1090
  List<ForwardedPort> forwardedPorts = <ForwardedPort>[];
1091

1092
  @visibleForTesting
1093 1094
  void addForwardedPorts(List<ForwardedPort> ports) {
    ports.forEach(forwardedPorts.add);
1095 1096
  }

1097
  static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1);
1098

1099
  @override
1100
  Future<int> forward(int devicePort, { int? hostPort }) async {
1101
    final bool autoselect = hostPort == null || hostPort == 0;
1102
    if (autoselect) {
1103
      final int freePort = await _operatingSystemUtils.findFreePort();
1104
      // Dynamic port range 49152 - 65535.
1105
      hostPort = freePort == 0 ? 49152 : freePort;
1106
    }
1107

1108
    Process? process;
1109 1110 1111

    bool connected = false;
    while (!connected) {
1112
      _logger.printTrace('Attempting to forward device port $devicePort to host port $hostPort');
1113
      process = await _iproxy.forward(devicePort, hostPort!, _id);
1114
      // TODO(ianh): This is a flaky race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674
1115 1116
      connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false);
      if (!connected) {
1117
        process.kill();
1118 1119
        if (autoselect) {
          hostPort += 1;
1120
          if (hostPort > 65535) {
1121
            throw Exception('Could not find open port on host.');
1122
          }
1123
        } else {
1124
          throw Exception('Port $hostPort is not available.');
1125 1126
        }
      }
1127
    }
1128 1129
    assert(connected);
    assert(process != null);
1130

1131
    final ForwardedPort forwardedPort = ForwardedPort.withContext(
1132
      hostPort!, devicePort, process,
1133
    );
1134 1135
    _logger.printTrace('Forwarded port $forwardedPort');
    forwardedPorts.add(forwardedPort);
1136
    return hostPort;
1137 1138
  }

1139
  @override
1140
  Future<void> unforward(ForwardedPort forwardedPort) async {
1141
    if (!forwardedPorts.remove(forwardedPort)) {
1142
      // Not in list. Nothing to remove.
1143
      return;
1144 1145
    }

1146
    _logger.printTrace('Un-forwarding port $forwardedPort');
1147 1148
    forwardedPort.dispose();
  }
1149

1150 1151
  @override
  Future<void> dispose() async {
1152
    for (final ForwardedPort forwardedPort in forwardedPorts) {
1153
      forwardedPort.dispose();
1154
    }
1155 1156
  }
}