1// Copyright 2016 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6 7import 'package:meta/meta.dart'; 8 9import '../application_package.dart'; 10import '../artifacts.dart'; 11import '../base/file_system.dart'; 12import '../base/io.dart'; 13import '../base/logger.dart'; 14import '../base/platform.dart'; 15import '../base/process.dart'; 16import '../base/process_manager.dart'; 17import '../build_info.dart'; 18import '../convert.dart'; 19import '../device.dart'; 20import '../globals.dart'; 21import '../project.dart'; 22import '../protocol_discovery.dart'; 23import '../reporting/reporting.dart'; 24import 'code_signing.dart'; 25import 'ios_workflow.dart'; 26import 'mac.dart'; 27 28class IOSDeploy { 29 const IOSDeploy(); 30 31 /// Installs and runs the specified app bundle using ios-deploy, then returns 32 /// the exit code. 33 Future<int> runApp({ 34 @required String deviceId, 35 @required String bundlePath, 36 @required List<String> launchArguments, 37 }) async { 38 final String iosDeployPath = artifacts.getArtifactPath(Artifact.iosDeploy, platform: TargetPlatform.ios); 39 // TODO(fujino): remove fallback once g3 updated 40 const List<String> fallbackIosDeployPath = <String>[ 41 '/usr/bin/env', 42 'ios-deploy', 43 ]; 44 final List<String> commandList = iosDeployPath != null ? <String>[iosDeployPath] : fallbackIosDeployPath; 45 final List<String> launchCommand = <String>[ 46 ...commandList, 47 '--id', 48 deviceId, 49 '--bundle', 50 bundlePath, 51 '--no-wifi', 52 '--justlaunch', 53 ]; 54 if (launchArguments.isNotEmpty) { 55 launchCommand.add('--args'); 56 launchCommand.add('${launchArguments.join(" ")}'); 57 } 58 59 // Push /usr/bin to the front of PATH to pick up default system python, package 'six'. 60 // 61 // ios-deploy transitively depends on LLDB.framework, which invokes a 62 // Python script that uses package 'six'. LLDB.framework relies on the 63 // python at the front of the path, which may not include package 'six'. 64 // Ensure that we pick up the system install of python, which does include 65 // it. 66 final Map<String, String> iosDeployEnv = Map<String, String>.from(platform.environment); 67 iosDeployEnv['PATH'] = '/usr/bin:${iosDeployEnv['PATH']}'; 68 iosDeployEnv.addEntries(<MapEntry<String, String>>[cache.dyLdLibEntry]); 69 70 return await runCommandAndStreamOutput( 71 launchCommand, 72 mapFunction: _monitorInstallationFailure, 73 trace: true, 74 environment: iosDeployEnv, 75 ); 76 } 77 78 // Maps stdout line stream. Must return original line. 79 String _monitorInstallationFailure(String stdout) { 80 // Installation issues. 81 if (stdout.contains('Error 0xe8008015') || stdout.contains('Error 0xe8000067')) { 82 printError(noProvisioningProfileInstruction, emphasis: true); 83 84 // Launch issues. 85 } else if (stdout.contains('e80000e2')) { 86 printError(''' 87═══════════════════════════════════════════════════════════════════════════════════ 88Your device is locked. Unlock your device first before running. 89═══════════════════════════════════════════════════════════════════════════════════''', 90 emphasis: true); 91 } else if (stdout.contains('Error 0xe8000022')) { 92 printError(''' 93═══════════════════════════════════════════════════════════════════════════════════ 94Error launching app. Try launching from within Xcode via: 95 open ios/Runner.xcworkspace 96 97Your Xcode version may be too old for your iOS version. 98═══════════════════════════════════════════════════════════════════════════════════''', 99 emphasis: true); 100 } 101 102 return stdout; 103 } 104} 105 106class IOSDevices extends PollingDeviceDiscovery { 107 IOSDevices() : super('iOS devices'); 108 109 @override 110 bool get supportsPlatform => platform.isMacOS; 111 112 @override 113 bool get canListAnything => iosWorkflow.canListDevices; 114 115 @override 116 Future<List<Device>> pollingGetDevices() => IOSDevice.getAttachedDevices(); 117} 118 119class IOSDevice extends Device { 120 IOSDevice(String id, { this.name, String sdkVersion }) 121 : _sdkVersion = sdkVersion, 122 super( 123 id, 124 category: Category.mobile, 125 platformType: PlatformType.ios, 126 ephemeral: true, 127 ) { 128 if (!platform.isMacOS) { 129 assert(false, 'Control of iOS devices or simulators only supported on Mac OS.'); 130 return; 131 } 132 _installerPath = artifacts.getArtifactPath( 133 Artifact.ideviceinstaller, 134 platform: TargetPlatform.ios, 135 ) ?? 'ideviceinstaller'; // TODO(fujino): remove fallback once g3 updated 136 _iproxyPath = artifacts.getArtifactPath( 137 Artifact.iproxy, 138 platform: TargetPlatform.ios 139 ) ?? 'iproxy'; // TODO(fujino): remove fallback once g3 updated 140 } 141 142 String _installerPath; 143 String _iproxyPath; 144 145 final String _sdkVersion; 146 147 @override 148 bool get supportsHotReload => true; 149 150 @override 151 bool get supportsHotRestart => true; 152 153 @override 154 final String name; 155 156 Map<ApplicationPackage, DeviceLogReader> _logReaders; 157 158 DevicePortForwarder _portForwarder; 159 160 @override 161 Future<bool> get isLocalEmulator async => false; 162 163 @override 164 Future<String> get emulatorId async => null; 165 166 @override 167 bool get supportsStartPaused => false; 168 169 static Future<List<IOSDevice>> getAttachedDevices() async { 170 if (!platform.isMacOS) { 171 throw UnsupportedError('Control of iOS devices or simulators only supported on Mac OS.'); 172 } 173 if (!iMobileDevice.isInstalled) 174 return <IOSDevice>[]; 175 176 final List<IOSDevice> devices = <IOSDevice>[]; 177 for (String id in (await iMobileDevice.getAvailableDeviceIDs()).split('\n')) { 178 id = id.trim(); 179 if (id.isEmpty) 180 continue; 181 182 try { 183 final String deviceName = await iMobileDevice.getInfoForDevice(id, 'DeviceName'); 184 final String sdkVersion = await iMobileDevice.getInfoForDevice(id, 'ProductVersion'); 185 devices.add(IOSDevice(id, name: deviceName, sdkVersion: sdkVersion)); 186 } on IOSDeviceNotFoundError catch (error) { 187 // Unable to find device with given udid. Possibly a network device. 188 printTrace('Error getting attached iOS device: $error'); 189 } on IOSDeviceNotTrustedError catch (error) { 190 printTrace('Error getting attached iOS device information: $error'); 191 UsageEvent('device', 'ios-trust-failure').send(); 192 } 193 } 194 return devices; 195 } 196 197 @override 198 Future<bool> isAppInstalled(ApplicationPackage app) async { 199 RunResult apps; 200 try { 201 apps = await runCheckedAsync( 202 <String>[_installerPath, '--list-apps'], 203 environment: Map<String, String>.fromEntries( 204 <MapEntry<String, String>>[cache.dyLdLibEntry], 205 ), 206 ); 207 } on ProcessException { 208 return false; 209 } 210 return RegExp(app.id, multiLine: true).hasMatch(apps.stdout); 211 } 212 213 @override 214 Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false; 215 216 @override 217 Future<bool> installApp(ApplicationPackage app) async { 218 final IOSApp iosApp = app; 219 final Directory bundle = fs.directory(iosApp.deviceBundlePath); 220 if (!bundle.existsSync()) { 221 printError('Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?'); 222 return false; 223 } 224 225 try { 226 await runCheckedAsync( 227 <String>[_installerPath, '-i', iosApp.deviceBundlePath], 228 environment: Map<String, String>.fromEntries( 229 <MapEntry<String, String>>[cache.dyLdLibEntry], 230 ), 231 ); 232 return true; 233 } on ProcessException { 234 return false; 235 } 236 } 237 238 @override 239 Future<bool> uninstallApp(ApplicationPackage app) async { 240 try { 241 await runCheckedAsync( 242 <String>[_installerPath, '-U', app.id], 243 environment: Map<String, String>.fromEntries( 244 <MapEntry<String, String>>[cache.dyLdLibEntry], 245 ), 246 ); 247 return true; 248 } on ProcessException { 249 return false; 250 } 251 } 252 253 @override 254 bool isSupported() => true; 255 256 @override 257 Future<LaunchResult> startApp( 258 ApplicationPackage package, { 259 String mainPath, 260 String route, 261 DebuggingOptions debuggingOptions, 262 Map<String, dynamic> platformArgs, 263 bool prebuiltApplication = false, 264 bool usesTerminalUi = true, 265 bool ipv6 = false, 266 }) async { 267 if (!prebuiltApplication) { 268 // TODO(chinmaygarde): Use mainPath, route. 269 printTrace('Building ${package.name} for $id'); 270 271 final String cpuArchitecture = await iMobileDevice.getInfoForDevice(id, 'CPUArchitecture'); 272 final DarwinArch iosArch = getIOSArchForName(cpuArchitecture); 273 274 // Step 1: Build the precompiled/DBC application if necessary. 275 final XcodeBuildResult buildResult = await buildXcodeProject( 276 app: package, 277 buildInfo: debuggingOptions.buildInfo, 278 targetOverride: mainPath, 279 buildForDevice: true, 280 usesTerminalUi: usesTerminalUi, 281 activeArch: iosArch, 282 ); 283 if (!buildResult.success) { 284 printError('Could not build the precompiled application for the device.'); 285 await diagnoseXcodeBuildFailure(buildResult); 286 printError(''); 287 return LaunchResult.failed(); 288 } 289 } else { 290 if (!await installApp(package)) 291 return LaunchResult.failed(); 292 } 293 294 // Step 2: Check that the application exists at the specified path. 295 final IOSApp iosApp = package; 296 final Directory bundle = fs.directory(iosApp.deviceBundlePath); 297 if (!bundle.existsSync()) { 298 printError('Could not find the built application bundle at ${bundle.path}.'); 299 return LaunchResult.failed(); 300 } 301 302 // Step 3: Attempt to install the application on the device. 303 final List<String> launchArguments = <String>['--enable-dart-profiling']; 304 305 if (debuggingOptions.startPaused) 306 launchArguments.add('--start-paused'); 307 308 if (debuggingOptions.disableServiceAuthCodes) 309 launchArguments.add('--disable-service-auth-codes'); 310 311 if (debuggingOptions.dartFlags.isNotEmpty) { 312 final String dartFlags = debuggingOptions.dartFlags; 313 launchArguments.add('--dart-flags="$dartFlags"'); 314 } 315 316 if (debuggingOptions.useTestFonts) 317 launchArguments.add('--use-test-fonts'); 318 319 // "--enable-checked-mode" and "--verify-entry-points" should always be 320 // passed when we launch debug build via "ios-deploy". However, we don't 321 // pass them if a certain environment variable is set to enable the 322 // "system_debug_ios" integration test in the CI, which simulates a 323 // home-screen launch. 324 if (debuggingOptions.debuggingEnabled && 325 platform.environment['FLUTTER_TOOLS_DEBUG_WITHOUT_CHECKED_MODE'] != 'true') { 326 launchArguments.add('--enable-checked-mode'); 327 launchArguments.add('--verify-entry-points'); 328 } 329 330 if (debuggingOptions.enableSoftwareRendering) 331 launchArguments.add('--enable-software-rendering'); 332 333 if (debuggingOptions.skiaDeterministicRendering) 334 launchArguments.add('--skia-deterministic-rendering'); 335 336 if (debuggingOptions.traceSkia) 337 launchArguments.add('--trace-skia'); 338 339 if (debuggingOptions.dumpSkpOnShaderCompilation) 340 launchArguments.add('--dump-skp-on-shader-compilation'); 341 342 if (debuggingOptions.verboseSystemLogs) { 343 launchArguments.add('--verbose-logging'); 344 } 345 346 if (platformArgs['trace-startup'] ?? false) 347 launchArguments.add('--trace-startup'); 348 349 final Status installStatus = logger.startProgress( 350 'Installing and launching...', 351 timeout: timeoutConfiguration.slowOperation); 352 try { 353 ProtocolDiscovery observatoryDiscovery; 354 if (debuggingOptions.debuggingEnabled) { 355 // Debugging is enabled, look for the observatory server port post launch. 356 printTrace('Debugging is enabled, connecting to observatory'); 357 358 // TODO(danrubel): The Android device class does something similar to this code below. 359 // The various Device subclasses should be refactored and common code moved into the superclass. 360 observatoryDiscovery = ProtocolDiscovery.observatory( 361 getLogReader(app: package), 362 portForwarder: portForwarder, 363 hostPort: debuggingOptions.observatoryPort, 364 ipv6: ipv6, 365 ); 366 } 367 368 final int installationResult = await const IOSDeploy().runApp( 369 deviceId: id, 370 bundlePath: bundle.path, 371 launchArguments: launchArguments, 372 ); 373 if (installationResult != 0) { 374 printError('Could not install ${bundle.path} on $id.'); 375 printError('Try launching Xcode and selecting "Product > Run" to fix the problem:'); 376 printError(' open ios/Runner.xcworkspace'); 377 printError(''); 378 return LaunchResult.failed(); 379 } 380 381 if (!debuggingOptions.debuggingEnabled) { 382 return LaunchResult.succeeded(); 383 } 384 385 try { 386 printTrace('Application launched on the device. Waiting for observatory port.'); 387 final Uri localUri = await observatoryDiscovery.uri; 388 return LaunchResult.succeeded(observatoryUri: localUri); 389 } catch (error) { 390 printError('Failed to establish a debug connection with $id: $error'); 391 return LaunchResult.failed(); 392 } finally { 393 await observatoryDiscovery?.cancel(); 394 } 395 } finally { 396 installStatus.stop(); 397 } 398 } 399 400 @override 401 Future<bool> stopApp(ApplicationPackage app) async { 402 // Currently we don't have a way to stop an app running on iOS. 403 return false; 404 } 405 406 @override 407 Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios; 408 409 @override 410 Future<String> get sdkNameAndVersion async => 'iOS $_sdkVersion'; 411 412 @override 413 DeviceLogReader getLogReader({ ApplicationPackage app }) { 414 _logReaders ??= <ApplicationPackage, DeviceLogReader>{}; 415 return _logReaders.putIfAbsent(app, () => _IOSDeviceLogReader(this, app)); 416 } 417 418 @visibleForTesting 419 void setLogReader(ApplicationPackage app, DeviceLogReader logReader) { 420 _logReaders ??= <ApplicationPackage, DeviceLogReader>{}; 421 _logReaders[app] = logReader; 422 } 423 424 @override 425 DevicePortForwarder get portForwarder => _portForwarder ??= _IOSDevicePortForwarder(this); 426 427 @visibleForTesting 428 set portForwarder(DevicePortForwarder forwarder) { 429 _portForwarder = forwarder; 430 } 431 432 @override 433 void clearLogs() { } 434 435 @override 436 bool get supportsScreenshot => iMobileDevice.isInstalled; 437 438 @override 439 Future<void> takeScreenshot(File outputFile) async { 440 await iMobileDevice.takeScreenshot(outputFile); 441 } 442 443 @override 444 bool isSupportedForProject(FlutterProject flutterProject) { 445 return flutterProject.ios.existsSync(); 446 } 447} 448 449/// Decodes a vis-encoded syslog string to a UTF-8 representation. 450/// 451/// Apple's syslog logs are encoded in 7-bit form. Input bytes are encoded as follows: 452/// 1. 0x00 to 0x19: non-printing range. Some ignored, some encoded as <...>. 453/// 2. 0x20 to 0x7f: as-is, with the exception of 0x5c (backslash). 454/// 3. 0x5c (backslash): octal representation \134. 455/// 4. 0x80 to 0x9f: \M^x (using control-character notation for range 0x00 to 0x40). 456/// 5. 0xa0: octal representation \240. 457/// 6. 0xa1 to 0xf7: \M-x (where x is the input byte stripped of its high-order bit). 458/// 7. 0xf8 to 0xff: unused in 4-byte UTF-8. 459/// 460/// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3) 461String decodeSyslog(String line) { 462 // UTF-8 values for \, M, -, ^. 463 const int kBackslash = 0x5c; 464 const int kM = 0x4d; 465 const int kDash = 0x2d; 466 const int kCaret = 0x5e; 467 468 // Mask for the UTF-8 digit range. 469 const int kNum = 0x30; 470 471 // Returns true when `byte` is within the UTF-8 7-bit digit range (0x30 to 0x39). 472 bool isDigit(int byte) => (byte & 0xf0) == kNum; 473 474 // Converts a three-digit ASCII (UTF-8) representation of an octal number `xyz` to an integer. 475 int decodeOctal(int x, int y, int z) => (x & 0x3) << 6 | (y & 0x7) << 3 | z & 0x7; 476 477 try { 478 final List<int> bytes = utf8.encode(line); 479 final List<int> out = <int>[]; 480 for (int i = 0; i < bytes.length;) { 481 if (bytes[i] != kBackslash || i > bytes.length - 4) { 482 // Unmapped byte: copy as-is. 483 out.add(bytes[i++]); 484 } else { 485 // Mapped byte: decode next 4 bytes. 486 if (bytes[i + 1] == kM && bytes[i + 2] == kCaret) { 487 // \M^x form: bytes in range 0x80 to 0x9f. 488 out.add((bytes[i + 3] & 0x7f) + 0x40); 489 } else if (bytes[i + 1] == kM && bytes[i + 2] == kDash) { 490 // \M-x form: bytes in range 0xa0 to 0xf7. 491 out.add(bytes[i + 3] | 0x80); 492 } else if (bytes.getRange(i + 1, i + 3).every(isDigit)) { 493 // \ddd form: octal representation (only used for \134 and \240). 494 out.add(decodeOctal(bytes[i + 1], bytes[i + 2], bytes[i + 3])); 495 } else { 496 // Unknown form: copy as-is. 497 out.addAll(bytes.getRange(0, 4)); 498 } 499 i += 4; 500 } 501 } 502 return utf8.decode(out); 503 } catch (_) { 504 // Unable to decode line: return as-is. 505 return line; 506 } 507} 508 509class _IOSDeviceLogReader extends DeviceLogReader { 510 _IOSDeviceLogReader(this.device, ApplicationPackage app) { 511 _linesController = StreamController<String>.broadcast( 512 onListen: _start, 513 onCancel: _stop, 514 ); 515 516 // Match for lines for the runner in syslog. 517 // 518 // iOS 9 format: Runner[297] <Notice>: 519 // iOS 10 format: Runner(Flutter)[297] <Notice>: 520 final String appName = app == null ? '' : app.name.replaceAll('.app', ''); 521 _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '); 522 // Similar to above, but allows ~arbitrary components instead of "Runner" 523 // and "Flutter". The regex tries to strike a balance between not producing 524 // false positives and not producing false negatives. 525 _anyLineRegex = RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: '); 526 } 527 528 final IOSDevice device; 529 530 // Matches a syslog line from the runner. 531 RegExp _runnerLineRegex; 532 // Matches a syslog line from any app. 533 RegExp _anyLineRegex; 534 535 StreamController<String> _linesController; 536 Process _process; 537 538 @override 539 Stream<String> get logLines => _linesController.stream; 540 541 @override 542 String get name => device.name; 543 544 void _start() { 545 iMobileDevice.startLogger(device.id).then<void>((Process process) { 546 _process = process; 547 _process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newLineHandler()); 548 _process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_newLineHandler()); 549 _process.exitCode.whenComplete(() { 550 if (_linesController.hasListener) 551 _linesController.close(); 552 }); 553 }); 554 } 555 556 // Returns a stateful line handler to properly capture multi-line output. 557 // 558 // For multi-line log messages, any line after the first is logged without 559 // any specific prefix. To properly capture those, we enter "printing" mode 560 // after matching a log line from the runner. When in printing mode, we print 561 // all lines until we find the start of another log message (from any app). 562 Function _newLineHandler() { 563 bool printing = false; 564 565 return (String line) { 566 if (printing) { 567 if (!_anyLineRegex.hasMatch(line)) { 568 _linesController.add(decodeSyslog(line)); 569 return; 570 } 571 572 printing = false; 573 } 574 575 final Match match = _runnerLineRegex.firstMatch(line); 576 577 if (match != null) { 578 final String logLine = line.substring(match.end); 579 // Only display the log line after the initial device and executable information. 580 _linesController.add(decodeSyslog(logLine)); 581 582 printing = true; 583 } 584 }; 585 } 586 587 void _stop() { 588 _process?.kill(); 589 } 590} 591 592class _IOSDevicePortForwarder extends DevicePortForwarder { 593 _IOSDevicePortForwarder(this.device) : _forwardedPorts = <ForwardedPort>[]; 594 595 final IOSDevice device; 596 597 final List<ForwardedPort> _forwardedPorts; 598 599 @override 600 List<ForwardedPort> get forwardedPorts => _forwardedPorts; 601 602 static const Duration _kiProxyPortForwardTimeout = Duration(seconds: 1); 603 604 @override 605 Future<int> forward(int devicePort, { int hostPort }) async { 606 final bool autoselect = hostPort == null || hostPort == 0; 607 if (autoselect) 608 hostPort = 1024; 609 610 Process process; 611 612 bool connected = false; 613 while (!connected) { 614 printTrace('attempting to forward device port $devicePort to host port $hostPort'); 615 // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID 616 process = await runCommand( 617 <String>[ 618 device._iproxyPath, 619 hostPort.toString(), 620 devicePort.toString(), 621 device.id, 622 ], 623 environment: Map<String, String>.fromEntries( 624 <MapEntry<String, String>>[cache.dyLdLibEntry], 625 ), 626 ); 627 // TODO(ianh): This is a flakey race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674 628 connected = !await process.stdout.isEmpty.timeout(_kiProxyPortForwardTimeout, onTimeout: () => false); 629 if (!connected) { 630 if (autoselect) { 631 hostPort += 1; 632 if (hostPort > 65535) 633 throw Exception('Could not find open port on host.'); 634 } else { 635 throw Exception('Port $hostPort is not available.'); 636 } 637 } 638 } 639 assert(connected); 640 assert(process != null); 641 642 final ForwardedPort forwardedPort = ForwardedPort.withContext( 643 hostPort, devicePort, process, 644 ); 645 printTrace('Forwarded port $forwardedPort'); 646 _forwardedPorts.add(forwardedPort); 647 return hostPort; 648 } 649 650 @override 651 Future<void> unforward(ForwardedPort forwardedPort) async { 652 if (!_forwardedPorts.remove(forwardedPort)) { 653 // Not in list. Nothing to remove. 654 return; 655 } 656 657 printTrace('Unforwarding port $forwardedPort'); 658 659 final Process process = forwardedPort.context; 660 661 if (process != null) { 662 processManager.killPid(process.pid); 663 } else { 664 printError('Forwarded port did not have a valid process'); 665 } 666 } 667} 668