• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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