xcdevice.dart 18.9 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 309
        // Only support USB devices, skip "network" interface (Xcode > Window > Devices and Simulators > Connect via network).
        // TODO(jmagman): Remove this check once wirelessly detected devices can be observed and attached, https://github.com/flutter/flutter/issues/15072.
310
        if (interface != IOSDeviceConnectionInterface.usb) {
311 312 313
          continue;
        }

314
        String? sdkVersion = _sdkVersion(device);
315 316

        if (sdkVersion != null) {
317
          final String? buildVersion = _buildVersion(device);
318 319 320 321 322
          if (buildVersion != null) {
            sdkVersion = '$sdkVersion $buildVersion';
          }
        }

323
        devices.add(IOSDevice(
324 325
          identifier,
          name: name,
326 327
          cpuArchitecture: _cpuArchitecture(device),
          interfaceType: interface,
328
          sdkVersion: sdkVersion,
329 330 331 332 333 334 335
          iProxy: _iProxy,
          fileSystem: globals.fs,
          logger: _logger,
          iosDeploy: _iosDeploy,
          iMobileDevice: _iMobileDevice,
          platform: globals.platform,
        ));
336 337 338
      }
    }
    return devices;
339

340 341 342 343
  }

  /// Despite the name, com.apple.platform.iphoneos includes iPhone, iPads, and all iOS devices.
  /// Excludes simulators.
344 345 346
  static bool _isIPhoneOSDevice(Map<String, Object?> deviceProperties) {
    final Object? platform = deviceProperties['platform'];
    if (platform is String) {
347 348 349 350 351
      return platform == 'com.apple.platform.iphoneos';
    }
    return false;
  }

352 353 354
  static Map<String, Object?>? _errorProperties(Map<String, Object?> deviceProperties) {
    final Object? error = deviceProperties['error'];
    return error is Map<String, Object?> ? error : null;
355 356
  }

357 358 359 360
  static int? _errorCode(Map<String, Object?>? errorProperties) {
    if (errorProperties == null) {
      return null;
    }
361 362
    final Object? code = errorProperties['code'];
    return code is int ? code : null;
363 364
  }

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

377
    return IOSDeviceConnectionInterface.none;
378 379
  }

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

394 395 396
  static String? _buildVersion(Map<String, Object?> deviceProperties) {
    final Object? operatingSystemVersion = deviceProperties['operatingSystemVersion'];
    if (operatingSystemVersion is String) {
397 398 399
      // 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('[()]'), '');
400 401 402 403
    }
    return null;
  }

404 405 406 407
  DarwinArch _cpuArchitecture(Map<String, Object?> deviceProperties) {
    DarwinArch? cpuArchitecture;
    final Object? architecture = deviceProperties['architecture'];
    if (architecture is String) {
408 409 410 411 412 413 414 415 416 417 418 419
      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;
        }
420
        _logger.printWarning(
421 422 423 424 425
          'Unknown architecture $architecture, defaulting to '
          '${getNameForDarwinArch(cpuArchitecture)}',
        );
      }
    }
426
    return cpuArchitecture ?? DarwinArch.arm64;
427 428 429
  }

  /// Error message parsed from xcdevice. null if no error.
430
  static String? _parseErrorMessage(Map<String, Object?>? errorProperties) {
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 479 480 481 482 483 484
    //  {
    //    "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: ');

485 486
    final Object? description = errorProperties['description'];
    if (description is String) {
487 488 489 490 491 492 493 494
      errorMessage.write(description);
      if (!description.endsWith('.')) {
        errorMessage.write('.');
      }
    } else {
      errorMessage.write('Xcode pairing error.');
    }

495 496
    final Object? recoverySuggestion = errorProperties['recoverySuggestion'];
    if (recoverySuggestion is String) {
497 498 499
      errorMessage.write(' $recoverySuggestion');
    }

500
    final int? code = _errorCode(errorProperties);
501 502 503 504 505 506 507 508 509
    if (code != null) {
      errorMessage.write(' (code $code)');
    }

    return errorMessage.toString();
  }

  /// List of all devices reporting errors.
  Future<List<String>> getDiagnostics() async {
510
    final List<Object>? allAvailableDevices = await _getAllDevices(
511 512 513 514 515 516 517 518 519
      useCache: true,
      timeout: const Duration(seconds: 2)
    );

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

    final List<String> diagnostics = <String>[];
520 521
    for (final Object deviceProperties in allAvailableDevices) {
      if (deviceProperties is! Map<String, Object?>) {
522 523
        continue;
      }
524 525
      final Map<String, Object?>? errorProperties = _errorProperties(deviceProperties);
      final String? errorMessage = _parseErrorMessage(errorProperties);
526
      if (errorMessage != null) {
527 528 529 530 531 532 533 534
        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;
        }

535 536 537 538 539 540
        diagnostics.add(errorMessage);
      }
    }
    return diagnostics;
  }
}