device_ios.dart 17 KB
Newer Older
1 2 3 4 5 6 7 8 9 10
// Copyright 2016 The Chromium 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 'dart:io';

import 'package:path/path.dart' as path;

import '../application_package.dart';
11
import '../base/common.dart';
12
import '../base/globals.dart';
13 14 15
import '../base/process.dart';
import '../build_configuration.dart';
import '../device.dart';
16
import '../services.dart';
17
import '../toolchain.dart';
18 19 20 21 22
import 'simulator.dart';

const String _ideviceinstallerInstructions =
    'To work with iOS devices, please install ideviceinstaller.\n'
    'If you use homebrew, you can install it with "\$ brew install ideviceinstaller".';
23

24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
class IOSDeviceDiscovery extends DeviceDiscovery {
  List<Device> _devices = <Device>[];

  bool get supportsPlatform => Platform.isMacOS;

  Future init() {
    _devices = IOSDevice.getAttachedDevices();
    return new Future.value();
  }

  List<Device> get devices => _devices;
}

class IOSSimulatorDiscovery extends DeviceDiscovery {
  List<Device> _devices = <Device>[];

  bool get supportsPlatform => Platform.isMacOS;

  Future init() {
    _devices = IOSSimulator.getAttachedDevices();
    return new Future.value();
  }

  List<Device> get devices => _devices;
}

50
class IOSDevice extends Device {
51 52 53 54 55 56 57 58 59 60 61 62 63
  IOSDevice(String id, { this.name }) : super(id) {
    _installerPath = _checkForCommand('ideviceinstaller');
    _listerPath = _checkForCommand('idevice_id');
    _informerPath = _checkForCommand('ideviceinfo');
    _debuggerPath = _checkForCommand('idevicedebug');
    _loggerPath = _checkForCommand('idevicesyslog');
    _pusherPath = _checkForCommand(
        'ios-deploy',
        'To copy files to iOS devices, please install ios-deploy. '
        'You can do this using homebrew as follows:\n'
        '\$ brew tap flutter/flutter\n'
        '\$ brew install ios-deploy');
  }
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82

  String _installerPath;
  String get installerPath => _installerPath;

  String _listerPath;
  String get listerPath => _listerPath;

  String _informerPath;
  String get informerPath => _informerPath;

  String _debuggerPath;
  String get debuggerPath => _debuggerPath;

  String _loggerPath;
  String get loggerPath => _loggerPath;

  String _pusherPath;
  String get pusherPath => _pusherPath;

83
  final String name;
84

85 86
  bool get supportsStartPaused => false;

87 88 89 90
  static List<IOSDevice> getAttachedDevices([IOSDevice mockIOS]) {
    List<IOSDevice> devices = [];
    for (String id in _getAttachedDeviceIDs(mockIOS)) {
      String name = _getDeviceName(id, mockIOS);
91
      devices.add(new IOSDevice(id, name: name));
92 93 94 95 96
    }
    return devices;
  }

  static Iterable<String> _getAttachedDeviceIDs([IOSDevice mockIOS]) {
97
    String listerPath = (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id');
98
    try {
99 100
      String output = runSync([listerPath, '-l']);
      return output.trim().split('\n').where((String s) => s != null && s.isNotEmpty);
101
    } catch (e) {
102
      return <String>[];
103 104 105 106 107 108 109
    }
  }

  static String _getDeviceName(String deviceID, [IOSDevice mockIOS]) {
    String informerPath = (mockIOS != null)
        ? mockIOS.informerPath
        : _checkForCommand('ideviceinfo');
110
    return runSync([informerPath, '-k', 'DeviceName', '-u', deviceID]).trim();
111 112 113 114 115
  }

  static final Map<String, String> _commandMap = {};
  static String _checkForCommand(
    String command, [
116
    String macInstructions = _ideviceinstallerInstructions
117 118 119 120 121 122
  ]) {
    return _commandMap.putIfAbsent(command, () {
      try {
        command = runCheckedSync(['which', command]).trim();
      } catch (e) {
        if (Platform.isMacOS) {
123
          printError('$command not found. $macInstructions');
124
        } else {
125
          printError('Cannot control iOS devices or simulators. $command is not available on your platform.');
126 127 128 129 130 131 132 133 134
        }
      }
      return command;
    });
  }

  @override
  bool installApp(ApplicationPackage app) {
    try {
135
      runCheckedSync([installerPath, '-i', app.localPath]);
136 137 138 139 140 141 142 143
      return true;
    } catch (e) {
      return false;
    }
    return false;
  }

  @override
144
  bool isConnected() => _getAttachedDeviceIDs().contains(id);
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165

  @override
  bool isAppInstalled(ApplicationPackage app) {
    try {
      String apps = runCheckedSync([installerPath, '--list-apps']);
      if (new RegExp(app.id, multiLine: true).hasMatch(apps)) {
        return true;
      }
    } catch (e) {
      return false;
    }
    return false;
  }

  @override
  Future<bool> startApp(
    ApplicationPackage app,
    Toolchain toolchain, {
    String mainPath,
    String route,
    bool checked: true,
Devon Carew's avatar
Devon Carew committed
166
    bool clearLogs: false,
167 168
    bool startPaused: false,
    int debugPort: observatoryDefaultPort,
169 170
    Map<String, dynamic> platformArgs
  }) async {
Devon Carew's avatar
Devon Carew committed
171
    // TODO(chinmaygarde): Use checked, mainPath, route, clearLogs.
172
    // TODO(devoncarew): Handle startPaused, debugPort.
173
    printTrace('Building ${app.name} for $id');
174 175 176 177

    // Step 1: Install the precompiled application if necessary
    bool buildResult = await _buildIOSXcodeProject(app, true);
    if (!buildResult) {
178
      printError('Could not build the precompiled application for the device');
179 180 181 182 183
      return false;
    }

    // Step 2: Check that the application exists at the specified path
    Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphoneos', 'Runner.app'));
Devon Carew's avatar
Devon Carew committed
184
    bool bundleExists = bundle.existsSync();
185
    if (!bundleExists) {
186
      printError('Could not find the built application bundle at ${bundle.path}');
187 188 189
      return false;
    }

190 191 192
    // Step 2.5: Copy any third-party sevices to the app bundle.
    await _addServicesToBundle(bundle);

193 194 195 196 197 198 199 200 201 202 203
    // Step 3: Attempt to install the application on the device
    int installationResult = await runCommandAndStreamOutput([
      '/usr/bin/env',
      'ios-deploy',
      '--id',
      id,
      '--bundle',
      bundle.path,
    ]);

    if (installationResult != 0) {
204
      printError('Could not install ${bundle.path} on $id');
205 206 207
      return false;
    }

208
    printTrace('Installation successful');
209 210 211 212 213 214 215 216 217
    return true;
  }

  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

Devon Carew's avatar
Devon Carew committed
218
  Future<bool> pushFile(ApplicationPackage app, String localFile, String targetFile) async {
219
    if (Platform.isMacOS) {
220
      runSync(<String>[
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
        pusherPath,
        '-t',
        '1',
        '--bundle_id',
        app.id,
        '--upload',
        localFile,
        '--to',
        targetFile
      ]);
      return true;
    } else {
      return false;
    }
    return false;
  }

  @override
  TargetPlatform get platform => TargetPlatform.iOS;

Devon Carew's avatar
Devon Carew committed
241
  DeviceLogReader createLogReader() => new _IOSDeviceLogReader(this);
242 243 244
}

class IOSSimulator extends Device {
245
  IOSSimulator(String id, { this.name }) : super(id);
246

247 248
  static List<IOSSimulator> getAttachedDevices() {
    return SimControl.getConnectedDevices().map((SimDevice device) {
249
      return new IOSSimulator(device.udid, name: device.name);
250 251 252
    }).toList();
  }

253
  final String name;
254

255
  String get xcrunPath => path.join('/usr', 'bin', 'xcrun');
256 257

  String _getSimulatorPath() {
258
    return path.join(_homeDirectory, 'Library', 'Developer', 'CoreSimulator', 'Devices', id);
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273
  }

  String _getSimulatorAppHomeDirectory(ApplicationPackage app) {
    String simulatorPath = _getSimulatorPath();
    if (simulatorPath == null)
      return null;
    return path.join(simulatorPath, 'data');
  }

  @override
  bool installApp(ApplicationPackage app) {
    if (!isConnected())
      return false;

    try {
274
      SimControl.install(id, app.localPath);
275 276 277 278 279 280 281 282 283 284
      return true;
    } catch (e) {
      return false;
    }
  }

  @override
  bool isConnected() {
    if (!Platform.isMacOS)
      return false;
285
    return SimControl.getConnectedDevices().any((SimDevice device) => device.udid == id);
286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304
  }

  @override
  bool isAppInstalled(ApplicationPackage app) {
    try {
      String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app);
      return FileSystemEntity.isDirectorySync(simulatorHomeDirectory);
    } catch (e) {
      return false;
    }
  }

  @override
  Future<bool> startApp(
    ApplicationPackage app,
    Toolchain toolchain, {
    String mainPath,
    String route,
    bool checked: true,
Devon Carew's avatar
Devon Carew committed
305
    bool clearLogs: false,
306 307
    bool startPaused: false,
    int debugPort: observatoryDefaultPort,
308 309
    Map<String, dynamic> platformArgs
  }) async {
310
    // TODO(chinmaygarde): Use mainPath, route.
311
    printTrace('Building ${app.name} for $id');
312

Devon Carew's avatar
Devon Carew committed
313 314 315
    if (clearLogs)
      this.clearLogs();

316 317 318
    // Step 1: Build the Xcode project
    bool buildResult = await _buildIOSXcodeProject(app, false);
    if (!buildResult) {
319
      printError('Could not build the application for the simulator');
320 321 322 323 324 325 326
      return false;
    }

    // Step 2: Assert that the Xcode project was successfully built
    Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphonesimulator', 'Runner.app'));
    bool bundleExists = await bundle.exists();
    if (!bundleExists) {
327
      printError('Could not find the built application bundle at ${bundle.path}');
328 329 330
      return false;
    }

331 332 333
    // Step 2.5: Copy any third-party sevices to the app bundle.
    await _addServicesToBundle(bundle);

334
    // Step 3: Install the updated bundle to the simulator
335
    SimControl.install(id, path.absolute(bundle.path));
336

337 338 339 340
    // Step 4: Prepare launch arguments
    List<String> args = [];

    if (checked) {
341
      args.add("--enable-checked-mode");
342 343 344
    }

    if (startPaused) {
345
      args.add("--start-paused");
346 347 348
    }

    if (debugPort != observatoryDefaultPort) {
349
      args.add("--observatory-port=$debugPort");
350 351 352 353
    }

    // Step 5: Launch the updated application in the simulator
    SimControl.launch(id, app.id, args);
354

355
    printTrace('Successfully started ${app.name} on $id');
356

357 358 359 360 361 362 363 364 365 366 367 368 369
    return true;
  }

  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  Future<bool> pushFile(
      ApplicationPackage app, String localFile, String targetFile) async {
    if (Platform.isMacOS) {
      String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app);
370
      runCheckedSync(<String>['cp', localFile, path.join(simulatorHomeDirectory, targetFile)]);
371 372 373 374 375
      return true;
    }
    return false;
  }

Devon Carew's avatar
Devon Carew committed
376 377 378 379
  String get logFilePath {
    return path.join(_homeDirectory, 'Library', 'Logs', 'CoreSimulator', id, 'system.log');
  }

380 381 382
  @override
  TargetPlatform get platform => TargetPlatform.iOSSimulator;

Devon Carew's avatar
Devon Carew committed
383 384 385 386
  DeviceLogReader createLogReader() => new _IOSSimulatorLogReader(this);

  void clearLogs() {
    File logFile = new File(logFilePath);
387 388 389 390 391
    if (logFile.existsSync()) {
      RandomAccessFile randomFile = logFile.openSync(mode: FileMode.WRITE);
      randomFile.truncateSync(0);
      randomFile.closeSync();
    }
Devon Carew's avatar
Devon Carew committed
392 393 394 395 396 397 398 399 400 401 402 403 404
  }
}

class _IOSDeviceLogReader extends DeviceLogReader {
  _IOSDeviceLogReader(this.device);

  final IOSDevice device;

  String get name => device.name;

  // TODO(devoncarew): Support [clear].
  Future<int> logs({ bool clear: false }) async {
    if (!device.isConnected())
405 406
      return 2;

Devon Carew's avatar
Devon Carew committed
407
    return await runCommandAndStreamOutput(
408
      <String>[device.loggerPath],
Devon Carew's avatar
Devon Carew committed
409 410
      prefix: '[$name] ',
      filter: new RegExp(r'(FlutterRunner|flutter.runner.Runner)')
411
    );
Devon Carew's avatar
Devon Carew committed
412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
  }

  int get hashCode => name.hashCode;

  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
    if (other is! _IOSDeviceLogReader)
      return false;
    return other.name == name;
  }
}

class _IOSSimulatorLogReader extends DeviceLogReader {
  _IOSSimulatorLogReader(this.device);

  final IOSSimulator device;

  String get name => device.name;

432
  Future<int> logs({ bool clear: false }) async {
Devon Carew's avatar
Devon Carew committed
433 434
    if (!device.isConnected())
      return 2;
435

436
    if (clear)
Devon Carew's avatar
Devon Carew committed
437 438 439 440 441 442
      device.clearLogs();

    // Match the log prefix (in order to shorten it):
    //   'Jan 29 01:31:44 devoncarew-macbookpro3 SpringBoard[96648]: ...'
    RegExp mapRegex = new RegExp(r'\S+ +\S+ +\S+ \S+ (.+)\[\d+\]\)?: (.*)$');
    // Jan 31 19:23:28 --- last message repeated 1 time ---
443
    RegExp lastMessageRegex = new RegExp(r'\S+ +\S+ +\S+ --- (.*) ---$');
Devon Carew's avatar
Devon Carew committed
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462

    // This filter matches many Flutter lines in the log:
    // new RegExp(r'(FlutterRunner|flutter.runner.Runner|$id)'), but it misses
    // a fair number, including ones that would be useful in diagnosing crashes.
    // For now, we're not filtering the log file (but do clear it with each run).

    Future<int> result = runCommandAndStreamOutput(
      <String>['tail', '-n', '+0', '-F', device.logFilePath],
      prefix: '[$name] ',
      mapFunction: (String string) {
        Match match = mapRegex.matchAsPrefix(string);
        if (match != null) {
          // Filter out some messages that clearly aren't related to Flutter.
          if (string.contains(': could not find icon for representation -> com.apple.'))
            return null;
          String category = match.group(1);
          String content = match.group(2);
          if (category == 'Game Center' || category == 'itunesstored' || category == 'nanoregistrylaunchd')
            return null;
463 464
          if (category == 'FlutterRunner')
            return content;
Devon Carew's avatar
Devon Carew committed
465 466 467 468
          return '$category: $content';
        }
        match = lastMessageRegex.matchAsPrefix(string);
        if (match != null)
469
          return '(${match.group(1)})';
Devon Carew's avatar
Devon Carew committed
470 471
        return string;
      }
472
    );
Devon Carew's avatar
Devon Carew committed
473 474 475 476 477 478 479 480 481 482 483 484 485

    // Track system.log crashes.
    // ReportCrash[37965]: Saved crash report for FlutterRunner[37941]...
    runCommandAndStreamOutput(
      <String>['tail', '-F', '/private/var/log/system.log'],
      prefix: '[$name] ',
      filter: new RegExp(r' FlutterRunner\[\d+\] '),
      mapFunction: (String string) {
        Match match = mapRegex.matchAsPrefix(string);
        return match == null ? string : '${match.group(1)}: ${match.group(2)}';
      }
    );

486
    return await result;
Devon Carew's avatar
Devon Carew committed
487 488 489 490 491 492 493 494 495 496
  }

  int get hashCode => device.logFilePath.hashCode;

  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
    if (other is! _IOSSimulatorLogReader)
      return false;
    return other.device.logFilePath == device.logFilePath;
497 498 499
  }
}

500 501 502
final RegExp _xcodeVersionRegExp = new RegExp(r'Xcode (\d+)\..*');
final String _xcodeRequirement = 'Xcode 7.0 or greater is required to develop for iOS.';

Devon Carew's avatar
Devon Carew committed
503 504
String get _homeDirectory => path.absolute(Platform.environment['HOME']);

505 506 507 508
bool _checkXcodeVersion() {
  if (!Platform.isMacOS)
    return false;
  try {
509
    String version = runCheckedSync(<String>['xcodebuild', '-version']);
510 511
    Match match = _xcodeVersionRegExp.firstMatch(version);
    if (int.parse(match[1]) < 7) {
512
      printError('Found "${match[0]}". $_xcodeRequirement');
513 514 515
      return false;
    }
  } catch (e) {
516
    printError('Cannot find "xcodebuid". $_xcodeRequirement');
517 518 519 520 521
    return false;
  }
  return true;
}

522
Future<bool> _buildIOSXcodeProject(ApplicationPackage app, bool isDevice) async {
523
  if (!FileSystemEntity.isDirectorySync(app.localPath)) {
524
    printError('Path "${path.absolute(app.localPath)}" does not exist.\nDid you run `flutter ios --init`?');
525 526 527 528 529 530
    return false;
  }

  if (!_checkXcodeVersion())
    return false;

531
  List<String> commands = <String>[
Devon Carew's avatar
Devon Carew committed
532
    '/usr/bin/env', 'xcrun', 'xcodebuild', '-target', 'Runner', '-configuration', 'Release'
533 534
  ];

535 536 537
  if (isDevice) {
    commands.addAll(<String>['-sdk', 'iphoneos', '-arch', 'arm64']);
  } else {
538
    commands.addAll(<String>['-sdk', 'iphonesimulator', '-arch', 'x86_64']);
539 540
  }

Devon Carew's avatar
Devon Carew committed
541 542 543 544 545 546
  try {
    runCheckedSync(commands, workingDirectory: app.localPath);
    return true;
  } catch (error) {
    return false;
  }
547
}
548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597

bool enabled = false;
Future _addServicesToBundle(Directory bundle) async {
  if (enabled) {
    List<Map<String, String>> services = [];
    await parseServiceConfigs(services);
    await _fetchFrameworks(services);
    _copyFrameworksToBundle(bundle.path, services);

    generateServiceDefinitions(bundle.path, services, ios: true);
  }
}

Future _fetchFrameworks(List<Map<String, String>> services) async {
  for (Map<String, String> service in services) {
    String frameworkUrl = service['framework'];
    service['framework-path'] = await getServiceFromUrl(
       frameworkUrl, service['root'], service['name'], unzip: true);
  }
}

void _copyFrameworksToBundle(String destDir, List<Map<String, String>> services) {
  // TODO(mpcomplete): check timestamps.
  for (Map<String, String> service in services) {
    String basename = path.basename(service['framework-path']);
    String destPath = path.join(destDir, basename);
    _copyDirRecursive(service['framework-path'], destPath);
  }
}

void _copyDirRecursive(String fromPath, String toPath) {
  Directory fromDir = new Directory(fromPath);
  if (!fromDir.existsSync())
    throw new Exception('Source directory "${fromDir.path}" does not exist');

  Directory toDir = new Directory(toPath);
  if (!toDir.existsSync())
    toDir.createSync(recursive: true);

  for (FileSystemEntity entity in fromDir.listSync()) {
    String newPath = '${toDir.path}/${path.basename(entity.path)}';
    if (entity is File) {
      entity.copySync(newPath);
    } else if (entity is Directory) {
      _copyDirRecursive(entity.path, newPath);
    } else {
      throw new Exception('Unsupported file type for recursive copy.');
    }
  };
}