blob: dbcb952d2414537e77eebfc0e09facf2e5ebaaac [file] [log] [blame] [edit]
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert';
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:platform/platform.dart';
import 'core.dart';
import 'output_utils.dart';
import 'process_runner.dart';
const String _xcodeBuildCommand = 'xcodebuild';
const String _xcRunCommand = 'xcrun';
/// A utility class for interacting with the installed version of Xcode.
class Xcode {
/// Creates an instance that runs commands with the given [processRunner].
///
/// If [log] is true, commands run by this instance will long various status
/// messages.
Xcode({this.processRunner = const ProcessRunner(), this.log = false});
/// The [ProcessRunner] used to run commands. Overridable for testing.
final ProcessRunner processRunner;
/// Whether or not to log when running commands.
final bool log;
/// Runs an `xcodebuild` in [directory] with the given parameters.
Future<int> runXcodeBuild(
Directory exampleDirectory,
String targetPlatform, {
List<String> actions = const <String>['build'],
required String workspace,
required String scheme,
String? configuration,
List<String> extraFlags = const <String>[],
required Platform hostPlatform,
}) async {
final FileSystem fileSystem = exampleDirectory.fileSystem;
String? resultBundlePath;
final Directory? logsDirectory = ciLogsDirectory(hostPlatform, fileSystem);
Directory? resultBundleTemp;
try {
if (logsDirectory != null) {
resultBundleTemp = fileSystem.systemTempDirectory.createTempSync(
'flutter_xcresult.',
);
resultBundlePath = resultBundleTemp.childDirectory('result').path;
}
File? disabledSandboxEntitlementFile;
if (actions.contains('test') && targetPlatform.toLowerCase() == 'macos') {
disabledSandboxEntitlementFile = _createDisabledSandboxEntitlementFile(
exampleDirectory.childDirectory(targetPlatform.toLowerCase()),
configuration ?? 'Debug',
);
}
final args = <String>[
_xcodeBuildCommand,
...actions,
...<String>['-workspace', workspace],
...<String>['-scheme', scheme],
if (resultBundlePath != null) ...<String>[
'-resultBundlePath',
resultBundlePath,
],
if (configuration != null) ...<String>['-configuration', configuration],
...extraFlags,
if (disabledSandboxEntitlementFile != null)
'CODE_SIGN_ENTITLEMENTS=${disabledSandboxEntitlementFile.path}',
];
final completeTestCommand = '$_xcRunCommand ${args.join(' ')}';
if (log) {
print(completeTestCommand);
}
final int resultExit = await processRunner.runAndStream(
_xcRunCommand,
args,
workingDir: exampleDirectory,
);
if (resultExit != 0 && resultBundleTemp != null) {
final Directory xcresultBundle = resultBundleTemp.childDirectory(
'result.xcresult',
);
if (logsDirectory != null) {
if (xcresultBundle.existsSync()) {
// Zip the test results to the artifacts directory for upload.
final File zipPath = logsDirectory.childFile(
'xcodebuild-${DateTime.now().toLocal().toIso8601String()}.zip',
);
await processRunner.run('zip', <String>[
'-r',
'-9',
'-q',
zipPath.path,
xcresultBundle.basename,
], workingDir: resultBundleTemp);
} else {
print(
'xcresult bundle ${xcresultBundle.path} does not exist, skipping upload',
);
}
}
}
return resultExit;
} finally {
resultBundleTemp?.deleteSync(recursive: true);
}
}
/// Returns true if [project], which should be an .xcodeproj directory,
/// contains a target called [target], false if it does not, and null if the
/// check fails (e.g., if [project] is not an Xcode project).
Future<bool?> projectHasTarget(Directory project, String target) async {
final io.ProcessResult result = await processRunner.run(
_xcRunCommand,
<String>[_xcodeBuildCommand, '-list', '-json', '-project', project.path],
);
if (result.exitCode != 0) {
return null;
}
Map<String, dynamic>? projectInfo;
try {
projectInfo =
(jsonDecode(result.stdout as String)
as Map<String, dynamic>)['project']
as Map<String, dynamic>?;
} on FormatException {
return null;
}
if (projectInfo == null) {
return null;
}
final List<String>? targets = (projectInfo['targets'] as List<dynamic>?)
?.cast<String>();
return targets?.contains(target) ?? false;
}
/// Returns the newest available simulator (highest OS version, with ties
/// broken in favor of newest device), if any.
Future<String?> findBestAvailableIphoneSimulator() async {
final findSimulatorsArguments = <String>[
'simctl',
'list',
'devices',
'runtimes',
'available',
'--json',
];
final findSimulatorCompleteCommand =
'$_xcRunCommand ${findSimulatorsArguments.join(' ')}';
if (log) {
print('Looking for available simulators...');
print(findSimulatorCompleteCommand);
}
final io.ProcessResult findSimulatorsResult = await processRunner.run(
_xcRunCommand,
findSimulatorsArguments,
);
if (findSimulatorsResult.exitCode != 0) {
if (log) {
printError(
'Error occurred while running "$findSimulatorCompleteCommand":\n'
'${findSimulatorsResult.stderr}',
);
}
return null;
}
final simulatorListJson =
jsonDecode(findSimulatorsResult.stdout as String)
as Map<String, dynamic>;
final List<Map<String, dynamic>> runtimes =
(simulatorListJson['runtimes'] as List<dynamic>)
.cast<Map<String, dynamic>>();
final Map<String, Object> devices =
(simulatorListJson['devices'] as Map<String, dynamic>)
.cast<String, Object>();
if (runtimes.isEmpty || devices.isEmpty) {
return null;
}
String? id;
// Looking for runtimes, trying to find one with highest OS version.
for (final Map<String, dynamic> rawRuntimeMap in runtimes.reversed) {
final Map<String, Object> runtimeMap = rawRuntimeMap
.cast<String, Object>();
if ((runtimeMap['name'] as String?)?.contains('iOS') != true) {
continue;
}
final runtimeID = runtimeMap['identifier'] as String?;
if (runtimeID == null) {
continue;
}
final List<Map<String, dynamic>>? devicesForRuntime =
(devices[runtimeID] as List<dynamic>?)?.cast<Map<String, dynamic>>();
if (devicesForRuntime == null || devicesForRuntime.isEmpty) {
continue;
}
// Looking for runtimes, trying to find latest version of device.
for (final Map<String, dynamic> rawDevice in devicesForRuntime.reversed) {
final Map<String, Object> device = rawDevice.cast<String, Object>();
id = device['udid'] as String?;
if (id == null) {
continue;
}
if (log) {
print('device selected: $device');
}
return id;
}
}
return null;
}
/// Finds and copies macOS entitlements file. In the copy, disables sandboxing.
/// If entitlements file is not found, returns null.
///
/// As of macOS 14, testing a macOS sandbox app may prompt the user to grant
/// access to the app. To workaround this in CI, we create and use a entitlements
/// file with sandboxing disabled. See
/// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox.
File? _createDisabledSandboxEntitlementFile(
Directory macOSDirectory,
String configuration,
) {
final entitlementDefaultFileName = configuration == 'Release'
? 'Release'
: 'DebugProfile';
final File entitlementFile = macOSDirectory
.childDirectory('Runner')
.childFile('$entitlementDefaultFileName.entitlements');
if (!entitlementFile.existsSync()) {
print('Unable to find entitlements file at ${entitlementFile.path}');
return null;
}
final String originalEntitlementFileContents = entitlementFile
.readAsStringSync();
final File disabledSandboxEntitlementFile = macOSDirectory
.fileSystem
.systemTempDirectory
.createTempSync('flutter_disable_sandbox_entitlement.')
.childFile(
'${entitlementDefaultFileName}WithDisabledSandboxing.entitlements',
);
disabledSandboxEntitlementFile.createSync(recursive: true);
disabledSandboxEntitlementFile.writeAsStringSync(
originalEntitlementFileContents.replaceAll(
RegExp(
r'<key>com\.apple\.security\.app-sandbox<\/key>[\S\s]*?<true\/>',
),
'''
<key>com.apple.security.app-sandbox</key>
<false/>''',
),
);
return disabledSandboxEntitlementFile;
}
}