blob: 32d2b0804c3ec7add3455b0a16a585a4af041572 [file] [log] [blame]
// Copyright 2020 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 'dart:convert';
import 'dart:io' as io;
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:process/process.dart';
import 'package:logging/logging.dart';
import 'package:platform/platform.dart';
class DiagnoseCommand extends Command<bool> {
DiagnoseCommand({
this.processManager = const LocalProcessManager(),
Logger? loggerOverride,
}) : logger = loggerOverride ?? Logger.root;
final Logger logger;
final ProcessManager processManager;
final String name = 'diagnose';
final String description = 'Diagnose whether attached iOS devices have errors.';
Future<bool> run() async {
final List<String> command = <String>['xcrun', 'xcdevice', 'list'];
final io.ProcessResult result = await processManager.run(
command,
);
if (result.exitCode != 0) {
logger.severe(
'$command failed with exit code ${result.exitCode}\n${result.stderr}',
);
return false;
}
final String stdout = result.stdout as String;
logger.info(stdout);
final Iterable<XCDevice> devices = XCDevice.parseJson(stdout);
final Iterable<XCDevice> devicesWithErrors = devices.where((XCDevice device) => device.hasError);
if (devicesWithErrors.isNotEmpty) {
logger.severe('Found devices with errors!');
for (final XCDevice device in devicesWithErrors) {
logger.severe('${device.name}: ${device.error}');
}
return false;
}
return true;
}
}
class RecoverCommand extends Command<bool> {
RecoverCommand({
this.processManager = const LocalProcessManager(),
Logger? loggerOverride,
this.fs = const LocalFileSystem(),
this.platform = const LocalPlatform(),
}) : logger = loggerOverride ?? Logger.root {
argParser
..addOption(
'cocoon-root',
help: 'Path to the root of the Cocoon repo. This is used to find the Build dashboard macos project, which is '
'then used to open Xcode.',
mandatory: true,
)
..addOption(
'timeout',
help: 'Integer number of seconds to allow Xcode to run before killing it.',
defaultsTo: '300',
);
}
final Logger logger;
final ProcessManager processManager;
final FileSystem fs;
final Platform platform;
final String name = 'recover';
final String description = 'Open Xcode UI to allow it to sync debug symbols from the iPhone';
/// Xcode Project workspace file for the build dashboard Flutter app.
///
/// Should be located at //cocoon/dashboard/ios/Runner.xcodeproj/project.xcworkspace.
Directory get dashboardXcWorkspace {
final String cocoonRootPath = argResults!['cocoon-root'];
final Directory cocoonRoot = fs.directory(cocoonRootPath);
final Directory dashboardXcWorkspace = cocoonRoot
.childDirectory('dashboard')
.childDirectory('ios')
.childDirectory('Runner.xcodeproj')
.childDirectory('project.xcworkspace')
.absolute;
if (!dashboardXcWorkspace.existsSync()) {
throw StateError(
'You provided the --cocoon-root option with "$cocoonRootPath", and the device doctor tried to '
"locate the build dashboard's project.xcworkspace directory at \"${dashboardXcWorkspace.path}\" "
'but that path does not exist on disk.',
);
}
return dashboardXcWorkspace;
}
@override
Future<bool> run() async {
final int? timeoutSeconds = int.tryParse(argResults!['timeout']);
if (timeoutSeconds == null) {
throw ArgumentError('Could not parse an integer from the option --timeout="${argResults!['timeout']}"');
}
_deleteSymbols();
// Prompt Xcode to first setup without opening the app.
// This will return very quickly if there is no work to do.
logger.info('Running Xcode first launch...');
final io.ProcessResult runFirstLaunchResult = await processManager.run(<String>[
'xcrun',
'xcodebuild',
'-runFirstLaunch',
]);
final String runFirstLaunchStdout = runFirstLaunchResult.stdout.trim();
if (runFirstLaunchStdout.isNotEmpty) {
logger.info('stdout from `xcodebuild -runFirstLaunch`:\n$runFirstLaunchStdout\n');
}
final String runFirstLaunchStderr = runFirstLaunchResult.stderr.trim();
if (runFirstLaunchStderr.isNotEmpty) {
logger.info('stderr from `xcodebuild -runFirstLaunch`:\n$runFirstLaunchStderr\n');
}
final int runFirstLaunchCode = runFirstLaunchResult.exitCode;
if (runFirstLaunchCode != 0) {
logger.info('Failed running `xcodebuild -runFirstLaunch` with code $runFirstLaunchCode!');
return false;
}
final Duration timeout = Duration(seconds: timeoutSeconds);
logger.info('Launching Xcode...');
final Future<io.ProcessResult> xcodeFuture = processManager.run(<String>[
'open',
'-n', // Opens a new instance of the application even if one is already running
'-F', // Opens the application "fresh," without restoring windows
'-W', // Wait for the opened application (Xcode) to close
dashboardXcWorkspace.path,
]);
unawaited(
xcodeFuture.then((io.ProcessResult result) {
logger.info('Open closed...');
final String stdout = result.stdout.trim();
if (stdout.isNotEmpty) {
logger.info('stdout from `open`:\n$stdout\n');
}
final String stderr = result.stderr.trim();
if (stderr.isNotEmpty) {
logger.info('stderr from `open`:\n$stderr\n');
}
if (result.exitCode != 0) {
throw Exception('Failed opening Xcode!');
}
}),
);
logger.info('Waiting for $timeoutSeconds seconds');
await Future.delayed(timeout);
logger.info('Waited for $timeoutSeconds seconds, now killing Xcode');
final io.ProcessResult result = await processManager.run(<String>['killall', '-9', 'Xcode']);
if (result.exitCode != 0) {
logger.severe('Failed killing Xcode!');
return false;
}
return true;
}
/// Delete all symbols by deleting the `iOS DeviceSupport` directory.
/// Xcode will regenerate this folder and symbols for connected devices
/// when Xcode is opened.
void _deleteSymbols() {
final String? home = platform.environment['HOME'];
if (home == null) {
logger.warning('\$HOME path was not found');
return;
}
final Directory deviceSupportDirectory = fs.directory('$home/Library/Developer/Xcode/iOS DeviceSupport');
if (!deviceSupportDirectory.existsSync()) {
logger.warning('iOS Device Support directory was not found at ${deviceSupportDirectory.path}');
return;
}
logger.info('Deleting iOS DeviceSupport...');
try {
deviceSupportDirectory.deleteSync(recursive: true);
} on FileSystemException catch (e) {
// Error that indicates why files cannot be deleted, such as
// another program having the files open.
logger.severe('${e.message}: ${e.osError}');
}
}
}
/// A Device configuration as output by `xcrun xcdevice list`.
///
/// As more fields are needed, they can be added to this class. It is
/// recommended to make all fields nullable in case a different version of Xcode
/// does not implement it.
class XCDevice {
const XCDevice._({
required this.error,
required this.name,
});
static const String _debugSymbolDescriptionPattern = r' is busy: Fetching debug symbols for ';
static final RegExp _preparingPhoneForDevelopmentPattern = RegExp(
r'Preparing .* for development\. Xcode will continue when .* is finished\.',
);
/// Parse subset of JSON from `parseJson` associated with a particular XCDevice.
factory XCDevice.fromMap(Map<String, Object?> map) {
final Map<String, Object?>? error = map['error'] as Map<String, Object?>?;
// We should only specifically pattern match on known fatal errors, and
// ignore the rest.
bool validError = false;
if (error != null) {
final String description = error['description'] as String;
if (description.contains(_debugSymbolDescriptionPattern) ||
_preparingPhoneForDevelopmentPattern.hasMatch(description)) {
validError = true;
} else {
print('not matching pattern: $description');
}
}
return XCDevice._(
error: validError ? error : null,
name: map['name'] as String,
);
}
final Map<String, Object?>? error;
final String name;
bool get hasError => error != null;
/// Parse the complete output of `xcrun xcdevice list`.
static Iterable<XCDevice> parseJson(String jsonString) {
final List<Object?> devices = json.decode(jsonString) as List<Object?>;
return devices.map<XCDevice>((Object? obj) {
return XCDevice.fromMap(obj as Map<String, Object?>);
});
}
}