devices.dart 54.2 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/process.dart';
19
import '../base/utils.dart';
20
import '../base/version.dart';
21
import '../build_info.dart';
22
import '../convert.dart';
23
import '../device.dart';
24
import '../device_port_forwarder.dart';
25
import '../globals.dart' as globals;
26
import '../macos/xcdevice.dart';
27
import '../mdns_discovery.dart';
28
import '../project.dart';
29
import '../protocol_discovery.dart';
30
import '../vmservice.dart';
31
import 'application_package.dart';
32
import 'core_devices.dart';
33
import 'ios_deploy.dart';
34
import 'ios_workflow.dart';
35
import 'iproxy.dart';
36
import 'mac.dart';
37
import 'xcode_build_settings.dart';
38 39
import 'xcode_debug.dart';
import 'xcodeproj.dart';
40 41

class IOSDevices extends PollingDeviceDiscovery {
42
  IOSDevices({
43
    required Platform platform,
44
    required this.xcdevice,
45 46
    required IOSWorkflow iosWorkflow,
    required Logger logger,
47 48 49
  }) : _platform = platform,
       _iosWorkflow = iosWorkflow,
       _logger = logger,
50 51 52 53
       super('iOS devices');

  final Platform _platform;
  final IOSWorkflow _iosWorkflow;
54
  final Logger _logger;
55

56 57 58
  @visibleForTesting
  final XCDevice xcdevice;

59
  @override
60
  bool get supportsPlatform => _platform.isMacOS;
61

62
  @override
63
  bool get canListAnything => _iosWorkflow.canListDevices;
64

65 66 67
  @override
  bool get requiresExtendedWirelessDeviceDiscovery => true;

68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
  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>>{};
93 94 95 96 97 98 99 100

  @override
  Future<void> startPolling() async {
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }
101
    if (!xcdevice.isInstalled) {
102 103
      return;
    }
104 105 106 107

    deviceNotifier ??= ItemListNotifier<Device>();

    // Start by populating all currently attached devices.
108 109
    _updateCachedDevices(await pollingGetDevices());
    _updateNotifierFromCache();
110 111 112

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

128 129
  @visibleForTesting
  Future<void> onDeviceEvent(XCDeviceEventNotification event) async {
130 131 132 133 134 135
    final ItemListNotifier<Device>? notifier = deviceNotifier;
    if (notifier == null) {
      return;
    }
    Device? knownDevice;
    for (final Device device in notifier.items) {
136
      if (device.id == event.deviceIdentifier) {
137 138 139
        knownDevice = device;
      }
    }
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 187 188 189 190 191
    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;
192
    }
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210
    // 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);
211 212 213 214 215 216 217
  }

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

218
  @override
219
  Future<List<Device>> pollingGetDevices({ Duration? timeout }) async {
220 221 222 223 224 225
    if (!_platform.isMacOS) {
      throw UnsupportedError(
        'Control of iOS devices or simulators only supported on macOS.'
      );
    }

226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245
    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();
246
  }
247 248

  @override
249 250 251
  Future<List<String>> getDiagnostics() async {
    if (!_platform.isMacOS) {
      return const <String>[
252
        'Control of iOS devices or simulators only supported on macOS.',
253 254 255
      ];
    }

256
    return xcdevice.getDiagnostics();
257
  }
258 259 260

  @override
  List<String> get wellKnownIds => const <String>[];
261 262 263
}

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

301
  final String? _sdkVersion;
302 303
  final IOSDeploy _iosDeploy;
  final FileSystem _fileSystem;
304 305
  final Logger _logger;
  final Platform _platform;
306
  final IMobileDevice _iMobileDevice;
307 308
  final IOSCoreDeviceControl _coreDeviceControl;
  final XcodeDebug _xcodeDebug;
309
  final IProxy _iproxy;
310

311 312 313 314
  Version? get sdkVersion {
    return Version.parse(_sdkVersion);
  }

315 316
  /// May be 0 if version cannot be parsed.
  int get majorSdkVersion {
317
    return sdkVersion?.major ?? 0;
318 319
  }

320
  @override
321 322
  final String name;

323 324 325
  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;

326 327
  final DarwinArch cpuArchitecture;

328
  @override
329 330 331 332 333 334 335 336 337
  /// 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;
338

339 340 341 342
  /// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices
  /// with iOS 17 or greater are CoreDevices.
  final bool isCoreDevice;

343
  final Map<IOSApp?, DeviceLogReader> _logReaders = <IOSApp?, DeviceLogReader>{};
344

345
  DevicePortForwarder? _portForwarder;
346

347 348
  bool devModeEnabled = false;

349
  @visibleForTesting
350
  IOSDeployDebugger? iosDeployDebugger;
351

352
  @override
353
  Future<bool> get isLocalEmulator async => false;
354

355
  @override
356
  Future<String?> get emulatorId async => null;
357

358
  @override
359 360
  bool get supportsStartPaused => false;

361 362 363
  @override
  bool get supportsFlavors => true;

364
  @override
365
  Future<bool> isAppInstalled(
366
    ApplicationPackage app, {
367
    String? userIdentifier,
368
  }) async {
369
    bool result;
370
    try {
371 372 373 374 375 376 377 378 379 380 381
      if (isCoreDevice) {
        result = await _coreDeviceControl.isAppInstalled(
          bundleId: app.id,
          deviceId: id,
        );
      } else {
        result = await _iosDeploy.isAppInstalled(
          bundleId: app.id,
          deviceId: id,
        );
      }
382
    } on ProcessException catch (e) {
383
      _logger.printError(e.message);
384 385
      return false;
    }
386
    return result;
387 388
  }

389
  @override
390
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
391

392
  @override
393
  Future<bool> installApp(
394
    covariant IOSApp app, {
395
    String? userIdentifier,
396
  }) async {
397
    final Directory bundle = _fileSystem.directory(app.deviceBundlePath);
398
    if (!bundle.existsSync()) {
399
      _logger.printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?');
400 401 402
      return false;
    }

403
    int installationResult;
404
    try {
405 406 407 408 409 410 411 412 413 414 415 416 417 418
      if (isCoreDevice) {
        installationResult = await _coreDeviceControl.installApp(
          deviceId: id,
          bundlePath: bundle.path,
        ) ? 0 : 1;
      } else {
        installationResult = await _iosDeploy.installApp(
          deviceId: id,
          bundlePath: bundle.path,
          appDeltaDirectory: app.appDeltaDirectory,
          launchArguments: <String>[],
          interfaceType: connectionInterface,
        );
      }
419
    } on ProcessException catch (e) {
420
      _logger.printError(e.message);
421 422
      return false;
    }
423
    if (installationResult != 0) {
424 425 426 427
      _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('');
428 429 430
      return false;
    }
    return true;
431
  }
432 433

  @override
434
  Future<bool> uninstallApp(
435
    ApplicationPackage app, {
436
    String? userIdentifier,
437
  }) async {
438
    int uninstallationResult;
439
    try {
440 441 442 443 444 445 446 447 448 449 450
      if (isCoreDevice) {
        uninstallationResult = await _coreDeviceControl.uninstallApp(
          deviceId: id,
          bundleId: app.id,
        ) ? 0 : 1;
      } else {
        uninstallationResult = await _iosDeploy.uninstallApp(
          deviceId: id,
          bundleId: app.id,
        );
      }
451
    } on ProcessException catch (e) {
452
      _logger.printError(e.message);
453 454
      return false;
    }
455
    if (uninstallationResult != 0) {
456
      _logger.printError('Could not uninstall ${app.id} on $id.');
457 458 459
      return false;
    }
    return true;
460 461
  }

462
  @override
463 464
  // 32-bit devices are not supported.
  bool isSupported() => cpuArchitecture == DarwinArch.arm64;
465

466
  @override
Devon Carew's avatar
Devon Carew committed
467
  Future<LaunchResult> startApp(
468
    IOSApp package, {
469 470 471 472
    String? mainPath,
    String? route,
    required DebuggingOptions debuggingOptions,
    Map<String, Object?> platformArgs = const <String, Object?>{},
473 474
    bool prebuiltApplication = false,
    bool ipv6 = false,
475 476
    String? userIdentifier,
    @visibleForTesting Duration? discoveryTimeout,
477
    @visibleForTesting ShutdownHooks? shutdownHooks,
478
  }) async {
479
    String? packageId;
480
    if (isWirelesslyConnected &&
481 482 483 484
        debuggingOptions.debuggingEnabled &&
        debuggingOptions.disablePortPublication) {
      throwToolExit('Cannot start app on wirelessly tethered iOS device. Try running again with the --publish-port flag');
    }
485 486 487 488 489 490 491 492 493 494 495 496

    // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128)
    // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+).
    // Force the use of XcodeDebug workflow in CI to test from older versions
    // since devicelab has not yet been updated to iOS 17 and Xcode 15.
    bool forceXcodeDebugWorkflow = false;
    if (debuggingOptions.usingCISystem &&
        debuggingOptions.debuggingEnabled &&
        _platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true') {
      forceXcodeDebugWorkflow = true;
    }

497
    if (!prebuiltApplication) {
498
      _logger.printTrace('Building ${package.name} for $id');
499

500
      // Step 1: Build the precompiled/DBC application if necessary.
501
      final XcodeBuildResult buildResult = await buildXcodeProject(
502 503 504 505 506
          app: package as BuildableIOSApp,
          buildInfo: debuggingOptions.buildInfo,
          targetOverride: mainPath,
          activeArch: cpuArchitecture,
          deviceID: id,
507
          disablePortPublication: debuggingOptions.usingCISystem && debuggingOptions.disablePortPublication,
508
      );
509
      if (!buildResult.success) {
510
        _logger.printError('Could not build the precompiled application for the device.');
511
        await diagnoseXcodeBuildFailure(buildResult, globals.flutterUsage, _logger, globals.analytics);
512
        _logger.printError('');
513
        return LaunchResult.failed();
514
      }
515
      packageId = buildResult.xcodeBuildExecution?.buildSettings[IosProject.kProductBundleIdKey];
516 517
    }

518 519
    packageId ??= package.id;

520
    // Step 2: Check that the application exists at the specified path.
521
    final Directory bundle = _fileSystem.directory(package.deviceBundlePath);
522
    if (!bundle.existsSync()) {
523
      _logger.printError('Could not find the built application bundle at ${bundle.path}.');
524
      return LaunchResult.failed();
525 526 527
    }

    // Step 3: Attempt to install the application on the device.
528 529 530 531
    final List<String> launchArguments = debuggingOptions.getIOSLaunchArguments(
      EnvironmentType.physical,
      route,
      platformArgs,
532
      ipv6: ipv6,
533
      interfaceType: connectionInterface,
534
      isCoreDevice: isCoreDevice,
535
    );
536
    Status startAppStatus = _logger.startProgress(
537 538
      'Installing and launching...',
    );
539
    try {
540
      ProtocolDiscovery? vmServiceDiscovery;
541
      int installationResult = 1;
542
      if (debuggingOptions.debuggingEnabled) {
543
        _logger.printTrace('Debugging is enabled, connecting to vmService');
544 545 546 547 548
        vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
          package: package,
          bundle: bundle,
          debuggingOptions: debuggingOptions,
          launchArguments: launchArguments,
549
          ipv6: ipv6,
550
          uninstallFirst: debuggingOptions.uninstallFirst,
551 552
        );
      }
553 554 555 556 557 558

      if (isCoreDevice || forceXcodeDebugWorkflow) {
        installationResult = await _startAppOnCoreDevice(
          debuggingOptions: debuggingOptions,
          package: package,
          launchArguments: launchArguments,
559
          mainPath: mainPath,
560 561 562 563
          discoveryTimeout: discoveryTimeout,
          shutdownHooks: shutdownHooks ?? globals.shutdownHooks,
        ) ? 0 : 1;
      } else if (iosDeployDebugger == null) {
564 565 566
        installationResult = await _iosDeploy.launchApp(
          deviceId: id,
          bundlePath: bundle.path,
567
          appDeltaDirectory: package.appDeltaDirectory,
568
          launchArguments: launchArguments,
569
          interfaceType: connectionInterface,
570
          uninstallFirst: debuggingOptions.uninstallFirst,
571 572
        );
      } else {
573
        installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
574
      }
575
      if (installationResult != 0) {
576
        _printInstallError(bundle);
577
        await dispose();
578 579
        return LaunchResult.failed();
      }
580

581 582 583
      if (!debuggingOptions.debuggingEnabled) {
        return LaunchResult.succeeded();
      }
584

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

587 588 589 590 591 592 593 594 595 596 597
      final int defaultTimeout;
      if ((isCoreDevice || forceXcodeDebugWorkflow) && debuggingOptions.debuggingEnabled) {
        // Core devices with debugging enabled takes longer because this
        // includes time to install and launch the app on the device.
        defaultTimeout = isWirelesslyConnected ? 75 : 60;
      } else if (isWirelesslyConnected) {
        defaultTimeout = 45;
      } else {
        defaultTimeout = 30;
      }

598 599
      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...');
600 601 602 603 604 605 606
        if (isCoreDevice && debuggingOptions.debuggingEnabled) {
          _logger.printError(
            'Open the Xcode window the project is opened in to ensure the app '
            'is running. If the app is not running, try selecting "Product > Run" '
            'to fix the problem.',
          );
        }
607 608
        // If debugging with a wireless device and the timeout is reached, remind the
        // user to allow local network permissions.
609
        if (isWirelesslyConnected) {
610 611 612 613 614 615 616
          _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 {
617
          iosDeployDebugger?.checkForSymbolsFiles(_fileSystem);
618 619
          iosDeployDebugger?.pauseDumpBacktraceResume();
        }
620
      });
621 622

      Uri? localUri;
623
      if (isWirelesslyConnected) {
624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649
        // When using a CoreDevice, device logs are unavailable and therefore
        // cannot be used to get the Dart VM url. Instead, get the Dart VM
        // Service by finding services matching the app bundle id and the
        // device name.
        //
        // If not using a CoreDevice, wait for the Dart VM url to be discovered
        // via logs and then get the Dart VM Service by finding services matching
        // the app bundle id and the Dart VM port.
        //
        // Then in both cases, get the device IP from the Dart VM Service to
        // construct the Dart VM url using the device IP as the host.
        if (isCoreDevice) {
          localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
            packageId,
            this,
            usesIpv6: ipv6,
            useDeviceIPAsHost: true,
          );
        } else {
          // Wait for Dart VM Service to start up.
          final Uri? serviceURL = await vmServiceDiscovery?.uri;
          if (serviceURL == null) {
            await iosDeployDebugger?.stopAndDumpBacktrace();
            await dispose();
            return LaunchResult.failed();
          }
650

651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668
          // 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,
            useDeviceIPAsHost: true,
669 670
          );

671 672
          mDNSLookupTimer.cancel();
        }
673
      } else {
674
        if ((isCoreDevice || forceXcodeDebugWorkflow) && vmServiceDiscovery != null) {
675 676 677 678 679 680 681 682 683 684 685
          // When searching for the Dart VM url, search for it via ProtocolDiscovery
          // (device logs) and mDNS simultaneously, since both can be flaky at times.
          final Future<Uri?> vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
            packageId,
            this,
            usesIpv6: ipv6,
          );
          final Future<Uri?> vmUrlFromLogs = vmServiceDiscovery.uri;
          localUri = await Future.any(
            <Future<Uri?>>[vmUrlFromMDns, vmUrlFromLogs]
          );
686 687 688 689 690 691 692 693

          // If the first future to return is null, wait for the other to complete.
          if (localUri == null) {
            final List<Uri?> vmUrls = await Future.wait(
              <Future<Uri?>>[vmUrlFromMDns, vmUrlFromLogs]
            );
            localUri = vmUrls.where((Uri? vmUrl) => vmUrl != null).firstOrNull;
          }
694 695
        } else {
          localUri = await vmServiceDiscovery?.uri;
696 697 698 699 700 701 702 703 704 705 706 707 708 709 710
          // If the `ios-deploy` debugger loses connection before it finds the
          // Dart Service VM url, try starting the debugger and launching the
          // app again.
          if (localUri == null &&
              debuggingOptions.usingCISystem &&
              iosDeployDebugger != null &&
              iosDeployDebugger!.lostConnection) {
            _logger.printStatus('Lost connection to device. Trying to connect again...');
            await dispose();
            vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
              package: package,
              bundle: bundle,
              debuggingOptions: debuggingOptions,
              launchArguments: launchArguments,
              ipv6: ipv6,
711
              uninstallFirst: false,
712 713 714 715 716 717 718 719 720 721
              skipInstall: true,
            );
            installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
            if (installationResult != 0) {
              _printInstallError(bundle);
              await dispose();
              return LaunchResult.failed();
            }
            localUri = await vmServiceDiscovery.uri;
          }
722
        }
723
      }
724
      timer.cancel();
725
      if (localUri == null) {
726
        await iosDeployDebugger?.stopAndDumpBacktrace();
727
        await dispose();
728
        return LaunchResult.failed();
729
      }
730
      return LaunchResult.succeeded(vmServiceUri: localUri);
731
    } on ProcessException catch (e) {
732
      await iosDeployDebugger?.stopAndDumpBacktrace();
733
      _logger.printError(e.message);
734
      await dispose();
735
      return LaunchResult.failed();
736
    } finally {
737
      startAppStatus.stop();
738 739 740 741 742 743 744 745 746 747 748 749

      if ((isCoreDevice || forceXcodeDebugWorkflow) && debuggingOptions.debuggingEnabled && package is BuildableIOSApp) {
        // When debugging via Xcode, after the app launches, reset the Generated
        // settings to not include the custom configuration build directory.
        // This is to prevent confusion if the project is later ran via Xcode
        // rather than the Flutter CLI.
        await updateGeneratedXcodeProperties(
          project: FlutterProject.current(),
          buildInfo: debuggingOptions.buildInfo,
          targetOverride: mainPath,
        );
      }
750
    }
751
  }
752 753 754 755 756 757 758 759 760 761 762 763 764 765

  void _printInstallError(Directory bundle) {
    _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('');
  }

  ProtocolDiscovery _setupDebuggerAndVmServiceDiscovery({
    required IOSApp package,
    required Directory bundle,
    required DebuggingOptions debuggingOptions,
    required List<String> launchArguments,
    required bool ipv6,
766
    required bool uninstallFirst,
767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782
    bool skipInstall = false,
  }) {
    final DeviceLogReader deviceLogReader = getLogReader(
      app: package,
      usingCISystem: debuggingOptions.usingCISystem,
    );

    // 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,
        appDeltaDirectory: package.appDeltaDirectory,
        launchArguments: launchArguments,
        interfaceType: connectionInterface,
783
        uninstallFirst: uninstallFirst,
784 785 786 787 788 789
        skipInstall: skipInstall,
      );
      if (deviceLogReader is IOSDeviceLogReader) {
        deviceLogReader.debuggerStream = iosDeployDebugger;
      }
    }
790
    // Don't port forward if debugging with a wireless device.
791 792 793 794 795 796 797 798 799
    return ProtocolDiscovery.vmService(
      deviceLogReader,
      portForwarder: isWirelesslyConnected ? null : portForwarder,
      hostPort: debuggingOptions.hostVmServicePort,
      devicePort: debuggingOptions.deviceVmServicePort,
      ipv6: ipv6,
      logger: _logger,
    );
  }
800

801 802 803 804 805 806 807 808 809 810 811 812 813 814
  /// Starting with Xcode 15 and iOS 17, `ios-deploy` stopped working due to
  /// the new CoreDevice connectivity stack. Previously, `ios-deploy` was used
  /// to install the app, launch the app, and start `debugserver`.
  /// Xcode 15 introduced a new command line tool called `devicectl` that
  /// includes much of the functionality supplied by `ios-deploy`. However,
  /// `devicectl` lacks the ability to start a `debugserver` and therefore `ptrace`, which are needed
  /// for debug mode due to using a JIT Dart VM.
  ///
  /// Therefore, when starting an app on a CoreDevice, use `devicectl` when
  /// debugging is not enabled. Otherwise, use Xcode automation.
  Future<bool> _startAppOnCoreDevice({
    required DebuggingOptions debuggingOptions,
    required IOSApp package,
    required List<String> launchArguments,
815
    required String? mainPath,
816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853
    required ShutdownHooks shutdownHooks,
    @visibleForTesting Duration? discoveryTimeout,
  }) async {
    if (!debuggingOptions.debuggingEnabled) {
      // Release mode

      // Install app to device
      final bool installSuccess = await _coreDeviceControl.installApp(
        deviceId: id,
        bundlePath: package.deviceBundlePath,
      );
      if (!installSuccess) {
        return installSuccess;
      }

      // Launch app to device
      final bool launchSuccess = await _coreDeviceControl.launchApp(
        deviceId: id,
        bundleId: package.id,
        launchArguments: launchArguments,
      );

      return launchSuccess;
    } else {
      _logger.printStatus(
        'You may be prompted to give access to control Xcode. Flutter uses Xcode '
        'to run your app. If access is not allowed, you can change this through '
        'your Settings > Privacy & Security > Automation.',
      );
      final int launchTimeout = isWirelesslyConnected ? 45 : 30;
      final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () {
        _logger.printError(
          'Xcode is taking longer than expected to start debugging the app. '
          'Ensure the project is opened in Xcode.',
        );
      });

      XcodeDebugProject debugProject;
854
      final FlutterProject flutterProject = FlutterProject.current();
855 856 857 858 859 860 861 862

      if (package is PrebuiltIOSApp) {
        debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle(
          package.deviceBundlePath,
          templateRenderer: globals.templateRenderer,
          verboseLogging: _logger.isVerbose,
        );
      } else if (package is BuildableIOSApp) {
863 864 865 866 867 868 869 870 871 872 873 874 875
        // Before installing/launching/debugging with Xcode, update the build
        // settings to use a custom configuration build directory so Xcode
        // knows where to find the app bundle to launch.
        final Directory bundle = _fileSystem.directory(
          package.deviceBundlePath,
        );
        await updateGeneratedXcodeProperties(
          project: flutterProject,
          buildInfo: debuggingOptions.buildInfo,
          targetOverride: mainPath,
          configurationBuildDir: bundle.parent.absolute.path,
        );

876 877 878 879 880 881 882 883 884 885 886 887 888 889 890
        final IosProject project = package.project;
        final XcodeProjectInfo? projectInfo = await project.projectInfo();
        if (projectInfo == null) {
          globals.printError('Xcode project not found.');
          return false;
        }
        if (project.xcodeWorkspace == null) {
          globals.printError('Unable to get Xcode workspace.');
          return false;
        }
        final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo);
        if (scheme == null) {
          projectInfo.reportFlavorNotFoundAndExit();
        }

891 892
        _xcodeDebug.ensureXcodeDebuggerLaunchAction(project.xcodeProjectSchemeFile(scheme: scheme));

893 894 895 896
        debugProject = XcodeDebugProject(
          scheme: scheme,
          xcodeProject: project.xcodeProject,
          xcodeWorkspace: project.xcodeWorkspace!,
897 898
          hostAppProjectName: project.hostAppProjectName,
          expectedConfigurationBuildDir: bundle.parent.absolute.path,
899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923
          verboseLogging: _logger.isVerbose,
        );
      } else {
        // This should not happen. Currently, only PrebuiltIOSApp and
        // BuildableIOSApp extend from IOSApp.
        _logger.printError('IOSApp type ${package.runtimeType} is not recognized.');
        return false;
      }

      final bool debugSuccess = await _xcodeDebug.debugApp(
        project: debugProject,
        deviceId: id,
        launchArguments:launchArguments,
      );
      timer.cancel();

      // Kill Xcode on shutdown when running from CI
      if (debuggingOptions.usingCISystem) {
        shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true));
      }

      return debugSuccess;
    }
  }

924
  @override
925
  Future<bool> stopApp(
926
    ApplicationPackage? app, {
927
    String? userIdentifier,
928
  }) async {
929
    // If the debugger is not attached, killing the ios-deploy process won't stop the app.
930 931
    final IOSDeployDebugger? deployDebugger = iosDeployDebugger;
    if (deployDebugger != null && deployDebugger.debuggerAttached) {
932
      return deployDebugger.exit();
933
    }
934 935 936
    if (_xcodeDebug.debugStarted) {
      return _xcodeDebug.exit();
    }
937
    return false;
938 939 940
  }

  @override
941
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
942

943
  @override
944
  Future<String> get sdkNameAndVersion async => 'iOS ${_sdkVersion ?? 'unknown version'}';
945

946
  @override
947
  DeviceLogReader getLogReader({
948
    covariant IOSApp? app,
949
    bool includePastLogs = false,
950
    bool usingCISystem = false,
951 952
  }) {
    assert(!includePastLogs, 'Past log reading not supported on iOS devices.');
953 954 955
    return _logReaders.putIfAbsent(app, () => IOSDeviceLogReader.create(
      device: this,
      app: app,
956
      iMobileDevice: _iMobileDevice,
957
      usingCISystem: usingCISystem,
958
    ));
959 960
  }

961
  @visibleForTesting
962
  void setLogReader(IOSApp app, DeviceLogReader logReader) {
963 964 965
    _logReaders[app] = logReader;
  }

966
  @override
967
  DevicePortForwarder get portForwarder => _portForwarder ??= IOSDevicePortForwarder(
968
    logger: _logger,
969
    iproxy: _iproxy,
970
    id: id,
971
    operatingSystemUtils: globals.os,
972
  );
973

974 975 976 977 978
  @visibleForTesting
  set portForwarder(DevicePortForwarder forwarder) {
    _portForwarder = forwarder;
  }

979
  @override
980
  void clearLogs() { }
Devon Carew's avatar
Devon Carew committed
981 982

  @override
983 984 985 986 987 988 989 990
  bool get supportsScreenshot {
    if (isCoreDevice) {
      // `idevicescreenshot` stopped working with iOS 17 / Xcode 15
      // (https://github.com/flutter/flutter/issues/128598).
      return false;
    }
    return _iMobileDevice.isInstalled;
  }
Devon Carew's avatar
Devon Carew committed
991 992

  @override
993
  Future<void> takeScreenshot(File outputFile) async {
994
    await _iMobileDevice.takeScreenshot(outputFile, id, connectionInterface);
995
  }
996 997 998 999 1000

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.ios.existsSync();
  }
1001 1002

  @override
1003
  Future<void> dispose() async {
1004
    for (final DeviceLogReader logReader in _logReaders.values) {
1005
      logReader.dispose();
1006 1007
    }
    _logReaders.clear();
1008
    await _portForwarder?.dispose();
1009
  }
1010 1011
}

1012
/// Decodes a vis-encoded syslog string to a UTF-8 representation.
1013 1014 1015 1016 1017 1018 1019 1020 1021
///
/// 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.
1022 1023
///
/// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3)
1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040
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 {
1041
    final List<int> bytes = utf8.encode(line);
1042
    final List<int> out = <int>[];
1043
    for (int i = 0; i < bytes.length;) {
1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064
      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;
      }
    }
1065
    return utf8.decode(out);
1066
  } on Exception {
1067 1068 1069 1070 1071
    // Unable to decode line: return as-is.
    return line;
  }
}

1072
class IOSDeviceLogReader extends DeviceLogReader {
1073 1074 1075 1076 1077
  IOSDeviceLogReader._(
    this._iMobileDevice,
    this._majorSdkVersion,
    this._deviceId,
    this.name,
1078 1079
    this._isWirelesslyConnected,
    this._isCoreDevice,
1080
    String appName,
1081 1082 1083
    bool usingCISystem, {
    bool forceXcodeDebug = false,
  }) : // Match for lines for the runner in syslog.
1084 1085 1086
      //
      // iOS 9 format:  Runner[297] <Notice>:
      // iOS 10 format: Runner(Flutter)[297] <Notice>:
1087
      _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '),
1088 1089
      _usingCISystem = usingCISystem,
      _forceXcodeDebug = forceXcodeDebug;
1090

1091 1092
  /// Create a new [IOSDeviceLogReader].
  factory IOSDeviceLogReader.create({
1093 1094 1095
    required IOSDevice device,
    IOSApp? app,
    required IMobileDevice iMobileDevice,
1096
    bool usingCISystem = false,
1097
  }) {
1098
    final String appName = app?.name?.replaceAll('.app', '') ?? '';
1099 1100 1101 1102 1103
    return IOSDeviceLogReader._(
      iMobileDevice,
      device.majorSdkVersion,
      device.id,
      device.name,
1104 1105
      device.isWirelesslyConnected,
      device.isCoreDevice,
1106
      appName,
1107
      usingCISystem,
1108
      forceXcodeDebug: device._platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true',
1109 1110 1111 1112 1113
    );
  }

  /// Create an [IOSDeviceLogReader] for testing.
  factory IOSDeviceLogReader.test({
1114
    required IMobileDevice iMobileDevice,
1115
    bool useSyslog = true,
1116 1117
    bool usingCISystem = false,
    int? majorSdkVersion,
1118 1119
    bool isWirelesslyConnected = false,
    bool isCoreDevice = false,
1120
  }) {
1121 1122 1123 1124 1125 1126
    final int sdkVersion;
    if (majorSdkVersion != null) {
      sdkVersion = majorSdkVersion;
    } else {
      sdkVersion = useSyslog ? 12 : 13;
    }
1127
    return IOSDeviceLogReader._(
1128
      iMobileDevice, sdkVersion, '1234', 'test', isWirelesslyConnected, isCoreDevice, 'Runner', usingCISystem);
1129 1130 1131 1132 1133 1134
  }

  @override
  final String name;
  final int _majorSdkVersion;
  final String _deviceId;
1135 1136
  final bool _isWirelesslyConnected;
  final bool _isCoreDevice;
1137
  final IMobileDevice _iMobileDevice;
1138
  final bool _usingCISystem;
1139

1140 1141 1142 1143
  // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128)
  /// Whether XcodeDebug workflow is being forced.
  final bool _forceXcodeDebug;

1144 1145
  // Matches a syslog line from the runner.
  RegExp _runnerLineRegex;
1146 1147 1148 1149 1150

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

1152 1153 1154 1155 1156 1157 1158
  // 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:]*] (.*)');

1159 1160
  @visibleForTesting
  late final StreamController<String> linesController = StreamController<String>.broadcast(
1161 1162 1163
    onListen: _listenToSysLog,
    onCancel: dispose,
  );
1164 1165 1166 1167

  // 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.
1168 1169
  @visibleForTesting
  void addToLinesController(String message, IOSDeviceLogSource source) {
1170
    if (!linesController.isClosed) {
1171 1172 1173
      if (_excludeLog(message, source)) {
        return;
      }
1174 1175 1176 1177
      linesController.add(message);
    }
  }

1178 1179 1180 1181 1182
  /// Used to track messages prefixed with "flutter:" from the fallback log source.
  final List<String> _fallbackStreamFlutterMessages = <String>[];

  /// Used to track if a message prefixed with "flutter:" has been received from the primary log.
  bool primarySourceFlutterLogReceived = false;
1183

1184 1185
  /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`,
  /// and Unified Logging (Dart VM). When using more than one of these logging
1186 1187
  /// sources at a time, prefer to use the primary source. However, if the
  /// primary source is not working, use the fallback.
1188
  bool _excludeLog(String message, IOSDeviceLogSource source) {
1189 1190
    // If no fallback, don't exclude any logs.
    if (logSources.fallbackSource == null) {
1191 1192
      return false;
    }
1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204

    // If log is from primary source, don't exclude it unless the fallback was
    // quicker and added the message first.
    if (source == logSources.primarySource) {
      if (!primarySourceFlutterLogReceived && message.startsWith('flutter:')) {
        primarySourceFlutterLogReceived = true;
      }

      // If the message was already added by the fallback, exclude it to
      // prevent duplicates.
      final bool foundAndRemoved = _fallbackStreamFlutterMessages.remove(message);
      if (foundAndRemoved) {
1205 1206
        return true;
      }
1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220
      return false;
    }

    // If a flutter log was received from the primary source, that means it's
    // working so don't use any messages from the fallback.
    if (primarySourceFlutterLogReceived) {
      return true;
    }

    // When using logs from fallbacks, skip any logs not prefixed with "flutter:".
    // This is done because different sources often have different prefixes for
    // non-flutter messages, which makes duplicate matching difficult. Also,
    // non-flutter messages are not critical for CI tests.
    if (!message.startsWith('flutter:')) {
1221 1222
      return true;
    }
1223 1224

    _fallbackStreamFlutterMessages.add(message);
1225 1226 1227
    return false;
  }

1228
  final List<StreamSubscription<void>> _loggingSubscriptions = <StreamSubscription<void>>[];
1229

1230
  @override
1231
  Stream<String> get logLines => linesController.stream;
1232

1233
  @override
1234 1235
  FlutterVmService? get connectedVMService => _connectedVMService;
  FlutterVmService? _connectedVMService;
1236 1237

  @override
1238 1239 1240 1241
  set connectedVMService(FlutterVmService? connectedVmService) {
    if (connectedVmService != null) {
      _listenToUnifiedLoggingEvents(connectedVmService);
    }
1242
    _connectedVMService = connectedVmService;
1243 1244
  }

1245
  static const int minimumUniversalLoggingSdkVersion = 13;
1246

1247
  /// Determine the primary and fallback source for device logs.
1248
  ///
1249 1250
  /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`,
  /// and Unified Logging (Dart VM).
1251
  @visibleForTesting
1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266
  _IOSDeviceLogSources get logSources {
    // `ios-deploy` stopped working with iOS 17 / Xcode 15, so use `idevicesyslog` instead.
    // However, `idevicesyslog` is sometimes unreliable so use Dart VM as a fallback.
    // Also, `idevicesyslog` does not work with iOS 17 wireless devices, so use the
    // Dart VM for wireless devices.
    if (_isCoreDevice || _forceXcodeDebug) {
      if (_isWirelesslyConnected) {
        return _IOSDeviceLogSources(
          primarySource: IOSDeviceLogSource.unifiedLogging,
        );
      }
      return _IOSDeviceLogSources(
        primarySource: IOSDeviceLogSource.idevicesyslog,
        fallbackSource: IOSDeviceLogSource.unifiedLogging,
      );
1267 1268
    }

1269 1270 1271 1272 1273 1274 1275 1276
    // Use `idevicesyslog` for iOS 12 or less.
    // 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.
    if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
      return _IOSDeviceLogSources(
        primarySource: IOSDeviceLogSource.idevicesyslog,
      );
1277 1278
    }

1279 1280
    // Use `idevicesyslog` as a fallback to `ios-deploy` when debugging from
    // CI system since sometimes `ios-deploy` does not return the device logs:
1281 1282
    // https://github.com/flutter/flutter/issues/121231
    if (_usingCISystem && _majorSdkVersion >= 16) {
1283 1284 1285 1286
      return _IOSDeviceLogSources(
        primarySource: IOSDeviceLogSource.iosDeploy,
        fallbackSource: IOSDeviceLogSource.idevicesyslog,
      );
1287
    }
1288 1289 1290 1291 1292 1293 1294 1295 1296 1297

    // Use `ios-deploy` to stream logs from the device when the device is not a
    // CoreDevice and has iOS 13 or greater.
    // When using `ios-deploy` and the Dart VM, prefer the more complete logs
    // from the attached debugger, if available.
    if (connectedVMService != null && (_iosDeployDebugger == null || !_iosDeployDebugger!.debuggerAttached)) {
      return _IOSDeviceLogSources(
        primarySource: IOSDeviceLogSource.unifiedLogging,
        fallbackSource: IOSDeviceLogSource.iosDeploy,
      );
1298
    }
1299 1300 1301 1302
    return _IOSDeviceLogSources(
      primarySource: IOSDeviceLogSource.iosDeploy,
      fallbackSource: IOSDeviceLogSource.unifiedLogging,
    );
1303 1304
  }

1305
  /// Whether `idevicesyslog` is used as either the primary or fallback source for device logs.
1306
  @visibleForTesting
1307 1308 1309
  bool get useSyslogLogging {
    return logSources.primarySource == IOSDeviceLogSource.idevicesyslog ||
        logSources.fallbackSource == IOSDeviceLogSource.idevicesyslog;
1310 1311
  }

1312
  /// Whether the Dart VM is used as either the primary or fallback source for device logs.
1313
  ///
1314 1315 1316 1317 1318
  /// Unified Logging only works after the Dart VM has been connected to.
  @visibleForTesting
  bool get useUnifiedLogging {
    return logSources.primarySource == IOSDeviceLogSource.unifiedLogging ||
        logSources.fallbackSource == IOSDeviceLogSource.unifiedLogging;
1319 1320 1321
  }


1322
  /// Whether `ios-deploy` is used as either the primary or fallback source for device logs.
1323 1324
  @visibleForTesting
  bool get useIOSDeployLogging {
1325 1326
    return logSources.primarySource == IOSDeviceLogSource.iosDeploy ||
        logSources.fallbackSource == IOSDeviceLogSource.iosDeploy;
1327 1328
  }

1329
  /// Listen to Dart VM for logs on iOS 13 or greater.
1330
  Future<void> _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async {
1331
    if (!useUnifiedLogging) {
1332 1333
      return;
    }
1334
    try {
1335 1336
      // The VM service will not publish logging events unless the debug stream is being listened to.
      // Listen to this stream as a side effect.
1337
      unawaited(connectedVmService.service.streamListen('Debug'));
1338

1339
      await Future.wait(<Future<void>>[
1340 1341
        connectedVmService.service.streamListen(vm_service.EventStreams.kStdout),
        connectedVmService.service.streamListen(vm_service.EventStreams.kStderr),
1342
      ]);
1343 1344 1345
    } on vm_service.RPCError {
      // Do nothing, since the tool is already subscribed.
    }
1346 1347

    void logMessage(vm_service.Event event) {
1348
      final String message = processVmServiceMessage(event);
1349
      if (message.isNotEmpty) {
1350
        addToLinesController(message, IOSDeviceLogSource.unifiedLogging);
1351
      }
1352 1353 1354
    }

    _loggingSubscriptions.addAll(<StreamSubscription<void>>[
1355 1356
      connectedVmService.service.onStdoutEvent.listen(logMessage),
      connectedVmService.service.onStderrEvent.listen(logMessage),
1357
    ]);
1358 1359
  }

1360
  /// Log reader will listen to [debugger.logLines] and will detach debugger on dispose.
1361
  IOSDeployDebugger? get debuggerStream => _iosDeployDebugger;
1362 1363

  /// Send messages from ios-deploy debugger stream to device log reader stream.
1364
  set debuggerStream(IOSDeployDebugger? debugger) {
1365
    // Logging is gathered from syslog on iOS earlier than 13.
1366
    if (!useIOSDeployLogging) {
1367 1368 1369
      return;
    }
    _iosDeployDebugger = debugger;
1370 1371 1372
    if (debugger == null) {
      return;
    }
1373 1374
    // Add the debugger logs to the controller created on initialization.
    _loggingSubscriptions.add(debugger.logLines.listen(
1375
      (String line) => addToLinesController(
1376 1377 1378
        _debuggerLineHandler(line),
        IOSDeviceLogSource.iosDeploy,
      ),
1379 1380
      onError: linesController.addError,
      onDone: linesController.close,
1381 1382 1383
      cancelOnError: true,
    ));
  }
1384
  IOSDeployDebugger? _iosDeployDebugger;
1385 1386

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

1389 1390
  /// Start and listen to idevicesyslog to get device logs for iOS versions
  /// prior to 13 or if [useBothLogDeviceReaders] is true.
1391
  void _listenToSysLog() {
1392
    if (!useSyslogLogging) {
1393 1394
      return;
    }
1395
    _iMobileDevice.startLogger(_deviceId, _isWirelesslyConnected).then<void>((Process process) {
1396 1397
      process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
      process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newSyslogLineHandler());
1398
      process.exitCode.whenComplete(() {
1399 1400 1401 1402 1403 1404
        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.
1405
        if (useSyslogLogging && useIOSDeployLogging && debuggerStream != null) {
1406
          return;
1407
        }
1408
        linesController.close();
Devon Carew's avatar
Devon Carew committed
1409
      });
1410 1411
      assert(idevicesyslogProcess == null);
      idevicesyslogProcess = process;
Devon Carew's avatar
Devon Carew committed
1412
    });
1413 1414
  }

1415
  @visibleForTesting
1416
  Process? idevicesyslogProcess;
1417

1418
  // Returns a stateful line handler to properly capture multiline output.
1419
  //
1420
  // For multiline log messages, any line after the first is logged without
1421 1422 1423
  // 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).
1424
  void Function(String line) _newSyslogLineHandler() {
1425 1426 1427 1428 1429
    bool printing = false;

    return (String line) {
      if (printing) {
        if (!_anyLineRegex.hasMatch(line)) {
1430
          addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog);
1431 1432
          return;
        }
1433

1434 1435 1436
        printing = false;
      }

1437
      final Match? match = _runnerLineRegex.firstMatch(line);
1438 1439 1440 1441

      if (match != null) {
        final String logLine = line.substring(match.end);
        // Only display the log line after the initial device and executable information.
1442
        addToLinesController(decodeSyslog(logLine), IOSDeviceLogSource.idevicesyslog);
1443 1444 1445
        printing = true;
      }
    };
1446 1447
  }

1448 1449
  @override
  void dispose() {
1450
    for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) {
1451 1452
      loggingSubscription.cancel();
    }
1453
    idevicesyslogProcess?.kill();
1454
    _iosDeployDebugger?.detach();
1455 1456
  }
}
1457

1458 1459 1460 1461 1462 1463 1464 1465 1466
enum IOSDeviceLogSource {
  /// Gets logs from ios-deploy debugger.
  iosDeploy,
  /// Gets logs from idevicesyslog.
  idevicesyslog,
  /// Gets logs from the Dart VM Service.
  unifiedLogging,
}

1467 1468 1469 1470 1471 1472 1473 1474 1475 1476
class _IOSDeviceLogSources {
  _IOSDeviceLogSources({
    required this.primarySource,
    this.fallbackSource,
  });

  final IOSDeviceLogSource primarySource;
  final IOSDeviceLogSource? fallbackSource;
}

1477
/// A [DevicePortForwarder] specialized for iOS usage with iproxy.
1478
class IOSDevicePortForwarder extends DevicePortForwarder {
1479

1480 1481
  /// Create a new [IOSDevicePortForwarder].
  IOSDevicePortForwarder({
1482 1483 1484 1485
    required Logger logger,
    required String id,
    required IProxy iproxy,
    required OperatingSystemUtils operatingSystemUtils,
1486 1487
  }) : _logger = logger,
       _id = id,
1488
       _iproxy = iproxy,
1489
       _operatingSystemUtils = operatingSystemUtils;
1490 1491 1492 1493 1494 1495

  /// Create a [IOSDevicePortForwarder] for testing.
  ///
  /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as
  /// 'DYLD_LIBRARY_PATH: /path/to/libs'.
  ///
1496
  /// The device id may be provided, but otherwise defaults to '1234'.
1497
  factory IOSDevicePortForwarder.test({
1498 1499 1500 1501
    required ProcessManager processManager,
    required Logger logger,
    String? id,
    required OperatingSystemUtils operatingSystemUtils,
1502 1503 1504
  }) {
    return IOSDevicePortForwarder(
      logger: logger,
1505 1506 1507
      iproxy: IProxy.test(
        logger: logger,
        processManager: processManager,
1508
      ),
1509
      id: id ?? '1234',
1510
      operatingSystemUtils: operatingSystemUtils,
1511 1512
    );
  }
1513

1514 1515
  final Logger _logger;
  final String _id;
1516
  final IProxy _iproxy;
1517
  final OperatingSystemUtils _operatingSystemUtils;
1518

1519
  @override
1520
  List<ForwardedPort> forwardedPorts = <ForwardedPort>[];
1521

1522
  @visibleForTesting
1523 1524
  void addForwardedPorts(List<ForwardedPort> ports) {
    ports.forEach(forwardedPorts.add);
1525 1526
  }

1527
  static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1);
1528

1529
  @override
1530
  Future<int> forward(int devicePort, { int? hostPort }) async {
1531
    final bool autoselect = hostPort == null || hostPort == 0;
1532
    if (autoselect) {
1533
      final int freePort = await _operatingSystemUtils.findFreePort();
1534
      // Dynamic port range 49152 - 65535.
1535
      hostPort = freePort == 0 ? 49152 : freePort;
1536
    }
1537

1538
    Process? process;
1539 1540 1541

    bool connected = false;
    while (!connected) {
1542
      _logger.printTrace('Attempting to forward device port $devicePort to host port $hostPort');
1543
      process = await _iproxy.forward(devicePort, hostPort!, _id);
1544
      // TODO(ianh): This is a flaky race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674
1545 1546
      connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false);
      if (!connected) {
1547
        process.kill();
1548 1549
        if (autoselect) {
          hostPort += 1;
1550
          if (hostPort > 65535) {
1551
            throw Exception('Could not find open port on host.');
1552
          }
1553
        } else {
1554
          throw Exception('Port $hostPort is not available.');
1555 1556
        }
      }
1557
    }
1558 1559
    assert(connected);
    assert(process != null);
1560

1561
    final ForwardedPort forwardedPort = ForwardedPort.withContext(
1562
      hostPort!, devicePort, process,
1563
    );
1564 1565
    _logger.printTrace('Forwarded port $forwardedPort');
    forwardedPorts.add(forwardedPort);
1566
    return hostPort;
1567 1568
  }

1569
  @override
1570
  Future<void> unforward(ForwardedPort forwardedPort) async {
1571
    if (!forwardedPorts.remove(forwardedPort)) {
1572
      // Not in list. Nothing to remove.
1573
      return;
1574 1575
    }

1576
    _logger.printTrace('Un-forwarding port $forwardedPort');
1577 1578
    forwardedPort.dispose();
  }
1579

1580 1581
  @override
  Future<void> dispose() async {
1582
    for (final ForwardedPort forwardedPort in forwardedPorts) {
1583
      forwardedPort.dispose();
1584
    }
1585 1586
  }
}