xcdevice.dart 18.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:process/process.dart';

import '../artifacts.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../build_info.dart';
import '../cache.dart';
import '../convert.dart';
17
import '../globals.dart' as globals;
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
import '../ios/devices.dart';
import '../ios/ios_deploy.dart';
import '../ios/iproxy.dart';
import '../ios/mac.dart';
import '../reporting/reporting.dart';
import 'xcode.dart';

enum XCDeviceEvent {
  attach,
  detach,
}

/// A utility class for interacting with Xcode xcdevice command line tools.
class XCDevice {
  XCDevice({
33 34 35 36 37 38 39
    required Artifacts artifacts,
    required Cache cache,
    required ProcessManager processManager,
    required Logger logger,
    required Xcode xcode,
    required Platform platform,
    required IProxy iproxy,
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
  }) : _processUtils = ProcessUtils(logger: logger, processManager: processManager),
      _logger = logger,
      _iMobileDevice = IMobileDevice(
        artifacts: artifacts,
        cache: cache,
        logger: logger,
        processManager: processManager,
      ),
      _iosDeploy = IOSDeploy(
        artifacts: artifacts,
        cache: cache,
        logger: logger,
        platform: platform,
        processManager: processManager,
      ),
      _iProxy = iproxy,
      _xcode = xcode {

    _setupDeviceIdentifierByEventStream();
  }

  void dispose() {
    _deviceObservationProcess?.kill();
  }

  final ProcessUtils _processUtils;
  final Logger _logger;
  final IMobileDevice _iMobileDevice;
  final IOSDeploy _iosDeploy;
  final Xcode _xcode;
  final IProxy _iProxy;

72 73 74
  List<Object>? _cachedListResults;
  Process? _deviceObservationProcess;
  StreamController<Map<XCDeviceEvent, String>>? _deviceIdentifierByEvent;
75 76 77 78 79 80 81 82 83 84 85 86

  void _setupDeviceIdentifierByEventStream() {
    // _deviceIdentifierByEvent Should always be available for listeners
    // in case polling needs to be stopped and restarted.
    _deviceIdentifierByEvent = StreamController<Map<XCDeviceEvent, String>>.broadcast(
      onListen: _startObservingTetheredIOSDevices,
      onCancel: _stopObservingTetheredIOSDevices,
    );
  }

  bool get isInstalled => _xcode.isInstalledAndMeetsVersionCheck;

87
  Future<List<Object>?> _getAllDevices({
88
    bool useCache = false,
89
    required Duration timeout
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
  }) async {
    if (!isInstalled) {
      _logger.printTrace("Xcode not found. Run 'flutter doctor' for more information.");
      return null;
    }
    if (useCache && _cachedListResults != null) {
      return _cachedListResults;
    }
    try {
      // USB-tethered devices should be found quickly. 1 second timeout is faster than the default.
      final RunResult result = await _processUtils.run(
        <String>[
          ..._xcode.xcrunCommand(),
          'xcdevice',
          'list',
          '--timeout',
          timeout.inSeconds.toString(),
        ],
        throwOnError: true,
      );
      if (result.exitCode == 0) {
111 112
        final String listOutput = result.stdout;
        try {
113
          final List<Object> listResults = (json.decode(result.stdout) as List<Object?>).whereType<Object>().toList();
114 115 116 117 118 119 120
          _cachedListResults = listResults;
          return listResults;
        } on FormatException {
          // xcdevice logs errors and crashes to stdout.
          _logger.printError('xcdevice returned non-JSON response: $listOutput');
          return null;
        }
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
      }
      _logger.printTrace('xcdevice returned an error:\n${result.stderr}');
    } on ProcessException catch (exception) {
      _logger.printTrace('Process exception running xcdevice list:\n$exception');
    } on ArgumentError catch (exception) {
      _logger.printTrace('Argument exception running xcdevice list:\n$exception');
    }

    return null;
  }

  /// Observe identifiers (UDIDs) of devices as they attach and detach.
  ///
  /// Each attach and detach event is a tuple of one event type
  /// and identifier.
136
  Stream<Map<XCDeviceEvent, String>>? observedDeviceEvents() {
137 138 139 140
    if (!isInstalled) {
      _logger.printTrace("Xcode not found. Run 'flutter doctor' for more information.");
      return null;
    }
141
    return _deviceIdentifierByEvent?.stream;
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
  }

  // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
  // Attach: 00008027-00192736010F802E
  // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
  final RegExp _observationIdentifierPattern = RegExp(r'^(\w*): ([\w-]*)$');

  Future<void> _startObservingTetheredIOSDevices() async {
    try {
      if (_deviceObservationProcess != null) {
        throw Exception('xcdevice observe restart failed');
      }

      // Run in interactive mode (via script) to convince
      // xcdevice it has a terminal attached in order to redirect stdout.
      _deviceObservationProcess = await _processUtils.start(
        <String>[
          'script',
          '-t',
          '0',
          '/dev/null',
          ..._xcode.xcrunCommand(),
          'xcdevice',
          'observe',
          '--both',
        ],
      );

170
      final StreamSubscription<String> stdoutSubscription = _deviceObservationProcess!.stdout
171 172 173 174 175 176 177 178 179 180 181
        .transform<String>(utf8.decoder)
        .transform<String>(const LineSplitter())
        .listen((String line) {

        // xcdevice observe example output of UDIDs:
        //
        // Listening for all devices, on both interfaces.
        // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
        // Attach: 00008027-00192736010F802E
        // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
        // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418
182
        final RegExpMatch? match = _observationIdentifierPattern.firstMatch(line);
183
        if (match != null && match.groupCount == 2) {
184 185
          final String verb = match.group(1)!.toLowerCase();
          final String identifier = match.group(2)!;
186
          if (verb.startsWith('attach')) {
187
            _deviceIdentifierByEvent?.add(<XCDeviceEvent, String>{
188
              XCDeviceEvent.attach: identifier,
189 190
            });
          } else if (verb.startsWith('detach')) {
191
            _deviceIdentifierByEvent?.add(<XCDeviceEvent, String>{
192
              XCDeviceEvent.detach: identifier,
193 194 195 196
            });
          }
        }
      });
197
      final StreamSubscription<String> stderrSubscription = _deviceObservationProcess!.stderr
198 199 200 201 202
        .transform<String>(utf8.decoder)
        .transform<String>(const LineSplitter())
        .listen((String line) {
        _logger.printTrace('xcdevice observe error: $line');
      });
203
      unawaited(_deviceObservationProcess?.exitCode.then((int status) {
204 205 206 207
        _logger.printTrace('xcdevice exited with code $exitCode');
        unawaited(stdoutSubscription.cancel());
        unawaited(stderrSubscription.cancel());
      }).whenComplete(() async {
208
        if (_deviceIdentifierByEvent?.hasListener ?? false) {
209
          // Tell listeners the process died.
210
          await _deviceIdentifierByEvent?.close();
211 212 213 214 215 216 217
        }
        _deviceObservationProcess = null;

        // Reopen it so new listeners can resume polling.
        _setupDeviceIdentifierByEventStream();
      }));
    } on ProcessException catch (exception, stackTrace) {
218
      _deviceIdentifierByEvent?.addError(exception, stackTrace);
219
    } on ArgumentError catch (exception, stackTrace) {
220
      _deviceIdentifierByEvent?.addError(exception, stackTrace);
221 222 223 224 225 226 227 228
    }
  }

  void _stopObservingTetheredIOSDevices() {
    _deviceObservationProcess?.kill();
  }

  /// [timeout] defaults to 2 seconds.
229 230
  Future<List<IOSDevice>> getAvailableIOSDevices({ Duration? timeout }) async {
    final List<Object>? allAvailableDevices = await _getAllDevices(timeout: timeout ?? const Duration(seconds: 2));
231

232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273
    if (allAvailableDevices == null) {
      return const <IOSDevice>[];
    }

    // [
    //  {
    //    "simulator" : true,
    //    "operatingSystemVersion" : "13.3 (17K446)",
    //    "available" : true,
    //    "platform" : "com.apple.platform.appletvsimulator",
    //    "modelCode" : "AppleTV5,3",
    //    "identifier" : "CBB5E1ED-2172-446E-B4E7-F2B5823DBBA6",
    //    "architecture" : "x86_64",
    //    "modelName" : "Apple TV",
    //    "name" : "Apple TV"
    //  },
    //  {
    //    "simulator" : false,
    //    "operatingSystemVersion" : "13.3 (17C54)",
    //    "interface" : "usb",
    //    "available" : true,
    //    "platform" : "com.apple.platform.iphoneos",
    //    "modelCode" : "iPhone8,1",
    //    "identifier" : "d83d5bc53967baa0ee18626ba87b6254b2ab5418",
    //    "architecture" : "arm64",
    //    "modelName" : "iPhone 6s",
    //    "name" : "iPhone"
    //  },
    //  {
    //    "simulator" : true,
    //    "operatingSystemVersion" : "6.1.1 (17S445)",
    //    "available" : true,
    //    "platform" : "com.apple.platform.watchsimulator",
    //    "modelCode" : "Watch5,4",
    //    "identifier" : "2D74FB11-88A0-44D0-B81E-C0C142B1C94A",
    //    "architecture" : "i386",
    //    "modelName" : "Apple Watch Series 5 - 44mm",
    //    "name" : "Apple Watch Series 5 - 44mm"
    //  },
    // ...

    final List<IOSDevice> devices = <IOSDevice>[];
274 275
    for (final Object device in allAvailableDevices) {
      if (device is Map<String, Object?>) {
276 277 278 279
        // Only include iPhone, iPad, iPod, or other iOS devices.
        if (!_isIPhoneOSDevice(device)) {
          continue;
        }
280 281 282 283 284
        final String? identifier = device['identifier'] as String?;
        final String? name = device['name'] as String?;
        if (identifier == null || name == null) {
          continue;
        }
285

286
        final Map<String, Object?>? errorProperties = _errorProperties(device);
287
        if (errorProperties != null) {
288 289 290 291 292 293
          final String? errorMessage = _parseErrorMessage(errorProperties);
          if (errorMessage != null) {
            if (errorMessage.contains('not paired')) {
              UsageEvent('device', 'ios-trust-failure', flutterUsage: globals.flutterUsage).send();
            }
            _logger.printTrace(errorMessage);
294
          }
295

296
          final int? code = _errorCode(errorProperties);
297 298 299 300 301 302 303

          // Temporary error -10: iPhone is busy: Preparing debugger support for iPhone.
          // Sometimes the app launch will fail on these devices until Xcode is done setting up the device.
          // Other times this is a false positive and the app will successfully launch despite the error.
          if (code != -10) {
            continue;
          }
304 305
        }

306
        final IOSDeviceConnectionInterface interface = _interfaceType(device);
307

308
        String? sdkVersion = _sdkVersion(device);
309 310

        if (sdkVersion != null) {
311
          final String? buildVersion = _buildVersion(device);
312 313 314 315 316
          if (buildVersion != null) {
            sdkVersion = '$sdkVersion $buildVersion';
          }
        }

317
        devices.add(IOSDevice(
318 319
          identifier,
          name: name,
320 321
          cpuArchitecture: _cpuArchitecture(device),
          interfaceType: interface,
322
          sdkVersion: sdkVersion,
323 324 325 326 327 328 329
          iProxy: _iProxy,
          fileSystem: globals.fs,
          logger: _logger,
          iosDeploy: _iosDeploy,
          iMobileDevice: _iMobileDevice,
          platform: globals.platform,
        ));
330 331 332
      }
    }
    return devices;
333

334 335 336 337
  }

  /// Despite the name, com.apple.platform.iphoneos includes iPhone, iPads, and all iOS devices.
  /// Excludes simulators.
338 339 340
  static bool _isIPhoneOSDevice(Map<String, Object?> deviceProperties) {
    final Object? platform = deviceProperties['platform'];
    if (platform is String) {
341 342 343 344 345
      return platform == 'com.apple.platform.iphoneos';
    }
    return false;
  }

346 347 348
  static Map<String, Object?>? _errorProperties(Map<String, Object?> deviceProperties) {
    final Object? error = deviceProperties['error'];
    return error is Map<String, Object?> ? error : null;
349 350
  }

351 352 353 354
  static int? _errorCode(Map<String, Object?>? errorProperties) {
    if (errorProperties == null) {
      return null;
    }
355 356
    final Object? code = errorProperties['code'];
    return code is int ? code : null;
357 358
  }

359
  static IOSDeviceConnectionInterface _interfaceType(Map<String, Object?> deviceProperties) {
360 361
    // Interface can be "usb", "network", or "none" for simulators
    // and unknown future interfaces.
362 363 364
    final Object? interface = deviceProperties['interface'];
    if (interface is String) {
      if (interface.toLowerCase() == 'network') {
365
        return IOSDeviceConnectionInterface.network;
366
      } else {
367
        return IOSDeviceConnectionInterface.usb;
368 369 370
      }
    }

371
    return IOSDeviceConnectionInterface.none;
372 373
  }

374 375 376
  static String? _sdkVersion(Map<String, Object?> deviceProperties) {
    final Object? operatingSystemVersion = deviceProperties['operatingSystemVersion'];
    if (operatingSystemVersion is String) {
377 378 379
      // Parse out the OS version, ignore the build number in parentheses.
      // "13.3 (17C54)"
      final RegExp operatingSystemRegex = RegExp(r'(.*) \(.*\)$');
380
      if (operatingSystemRegex.hasMatch(operatingSystemVersion.trim())) {
381 382 383 384 385 386 387
        return operatingSystemRegex.firstMatch(operatingSystemVersion.trim())?.group(1);
      }
      return operatingSystemVersion;
    }
    return null;
  }

388 389 390
  static String? _buildVersion(Map<String, Object?> deviceProperties) {
    final Object? operatingSystemVersion = deviceProperties['operatingSystemVersion'];
    if (operatingSystemVersion is String) {
391 392 393
      // Parse out the build version, for example 17C54 from "13.3 (17C54)".
      final RegExp buildVersionRegex = RegExp(r'\(.*\)$');
      return buildVersionRegex.firstMatch(operatingSystemVersion)?.group(0)?.replaceAll(RegExp('[()]'), '');
394 395 396 397
    }
    return null;
  }

398 399 400 401
  DarwinArch _cpuArchitecture(Map<String, Object?> deviceProperties) {
    DarwinArch? cpuArchitecture;
    final Object? architecture = deviceProperties['architecture'];
    if (architecture is String) {
402 403 404 405 406 407 408 409 410 411 412 413
      try {
        cpuArchitecture = getIOSArchForName(architecture);
      } on Exception {
        // Fallback to default iOS architecture. Future-proof against a
        // theoretical version of Xcode that changes this string to something
        // slightly different like "ARM64", or armv7 variations like
        // armv7s and armv7f.
        if (architecture.startsWith('armv7')) {
          cpuArchitecture = DarwinArch.armv7;
        } else {
          cpuArchitecture = DarwinArch.arm64;
        }
414
        _logger.printWarning(
415 416 417 418 419
          'Unknown architecture $architecture, defaulting to '
          '${getNameForDarwinArch(cpuArchitecture)}',
        );
      }
    }
420
    return cpuArchitecture ?? DarwinArch.arm64;
421 422 423
  }

  /// Error message parsed from xcdevice. null if no error.
424
  static String? _parseErrorMessage(Map<String, Object?>? errorProperties) {
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478
    //  {
    //    "simulator" : false,
    //    "operatingSystemVersion" : "13.3 (17C54)",
    //    "interface" : "usb",
    //    "available" : false,
    //    "platform" : "com.apple.platform.iphoneos",
    //    "modelCode" : "iPhone8,1",
    //    "identifier" : "98206e7a4afd4aedaff06e687594e089dede3c44",
    //    "architecture" : "arm64",
    //    "modelName" : "iPhone 6s",
    //    "name" : "iPhone",
    //    "error" : {
    //      "code" : -9,
    //      "failureReason" : "",
    //      "underlyingErrors" : [
    //        {
    //          "code" : 5,
    //          "failureReason" : "allowsSecureServices: 1. isConnected: 0. Platform: <DVTPlatform:0x7f804ce32880:'com.apple.platform.iphoneos':<DVTFilePath:0x7f804ce32800:'\/Users\/magder\/Applications\/Xcode_11-3-1.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform'>>. DTDKDeviceIdentifierIsIDID: 0",
    //          "description" : "📱<DVTiOSDevice (0x7f801f190450), iPhone, iPhone, 13.3 (17C54), d83d5bc53967baa0ee18626ba87b6254b2ab5418> -- Failed _shouldMakeReadyForDevelopment check even though device is not locked by passcode.",
    //          "recoverySuggestion" : "",
    //          "domain" : "com.apple.platform.iphoneos"
    //        }
    //      ],
    //      "description" : "iPhone is not paired with your computer.",
    //      "recoverySuggestion" : "To use iPhone with Xcode, unlock it and choose to trust this computer when prompted.",
    //      "domain" : "com.apple.platform.iphoneos"
    //    }
    //  },
    //  {
    //    "simulator" : false,
    //    "operatingSystemVersion" : "13.3 (17C54)",
    //    "interface" : "usb",
    //    "available" : false,
    //    "platform" : "com.apple.platform.iphoneos",
    //    "modelCode" : "iPhone8,1",
    //    "identifier" : "d83d5bc53967baa0ee18626ba87b6254b2ab5418",
    //    "architecture" : "arm64",
    //    "modelName" : "iPhone 6s",
    //    "name" : "iPhone",
    //    "error" : {
    //      "code" : -9,
    //      "failureReason" : "",
    //      "description" : "iPhone is not paired with your computer.",
    //      "domain" : "com.apple.platform.iphoneos"
    //    }
    //  }
    // ...

    if (errorProperties == null) {
      return null;
    }

    final StringBuffer errorMessage = StringBuffer('Error: ');

479 480
    final Object? description = errorProperties['description'];
    if (description is String) {
481 482 483 484 485 486 487 488
      errorMessage.write(description);
      if (!description.endsWith('.')) {
        errorMessage.write('.');
      }
    } else {
      errorMessage.write('Xcode pairing error.');
    }

489 490
    final Object? recoverySuggestion = errorProperties['recoverySuggestion'];
    if (recoverySuggestion is String) {
491 492 493
      errorMessage.write(' $recoverySuggestion');
    }

494
    final int? code = _errorCode(errorProperties);
495 496 497 498 499 500 501 502 503
    if (code != null) {
      errorMessage.write(' (code $code)');
    }

    return errorMessage.toString();
  }

  /// List of all devices reporting errors.
  Future<List<String>> getDiagnostics() async {
504
    final List<Object>? allAvailableDevices = await _getAllDevices(
505 506 507 508 509 510 511 512 513
      useCache: true,
      timeout: const Duration(seconds: 2)
    );

    if (allAvailableDevices == null) {
      return const <String>[];
    }

    final List<String> diagnostics = <String>[];
514 515
    for (final Object deviceProperties in allAvailableDevices) {
      if (deviceProperties is! Map<String, Object?>) {
516 517
        continue;
      }
518 519
      final Map<String, Object?>? errorProperties = _errorProperties(deviceProperties);
      final String? errorMessage = _parseErrorMessage(errorProperties);
520
      if (errorMessage != null) {
521 522 523 524 525 526 527 528
        final int? code = _errorCode(errorProperties);
        // Error -13: iPhone is not connected. Xcode will continue when iPhone is connected.
        // This error is confusing since the device is not connected and maybe has not been connected
        // for a long time. Avoid showing it.
        if (code == -13 && errorMessage.contains('not connected')) {
          continue;
        }

529 530 531 532 533 534
        diagnostics.add(errorMessage);
      }
    }
    return diagnostics;
  }
}