
Fixes: - https://github.com/dart-lang/tools/issues/234 Bumps the version for package:unified_analytics and removes TODOs and lint ignores
570 lines
19 KiB
Dart
570 lines
19 KiB
Dart
// Copyright 2014 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 'package:file/memory.dart';
|
|
import 'package:meta/meta.dart';
|
|
import 'package:process/process.dart';
|
|
import 'package:unified_analytics/unified_analytics.dart';
|
|
|
|
import '../base/common.dart';
|
|
import '../base/file_system.dart';
|
|
import '../base/io.dart';
|
|
import '../base/logger.dart';
|
|
import '../base/os.dart';
|
|
import '../base/platform.dart';
|
|
import '../base/process.dart';
|
|
import '../base/terminal.dart';
|
|
import '../base/utils.dart';
|
|
import '../base/version.dart';
|
|
import '../build_info.dart';
|
|
import '../reporting/reporting.dart';
|
|
|
|
final RegExp _settingExpr = RegExp(r'(\w+)\s*=\s*(.*)$');
|
|
final RegExp _varExpr = RegExp(r'\$\(([^)]*)\)');
|
|
|
|
/// Interpreter of Xcode projects.
|
|
class XcodeProjectInterpreter {
|
|
factory XcodeProjectInterpreter({
|
|
required Platform platform,
|
|
required ProcessManager processManager,
|
|
required Logger logger,
|
|
required FileSystem fileSystem,
|
|
required Usage usage,
|
|
required Analytics analytics,
|
|
}) {
|
|
return XcodeProjectInterpreter._(
|
|
platform: platform,
|
|
processManager: processManager,
|
|
logger: logger,
|
|
fileSystem: fileSystem,
|
|
usage: usage,
|
|
analytics: analytics,
|
|
);
|
|
}
|
|
|
|
XcodeProjectInterpreter._({
|
|
required Platform platform,
|
|
required ProcessManager processManager,
|
|
required Logger logger,
|
|
required FileSystem fileSystem,
|
|
required Usage usage,
|
|
required Analytics analytics,
|
|
Version? version,
|
|
String? build,
|
|
}) : _platform = platform,
|
|
_fileSystem = fileSystem,
|
|
_logger = logger,
|
|
_processUtils = ProcessUtils(logger: logger, processManager: processManager),
|
|
_operatingSystemUtils = OperatingSystemUtils(
|
|
fileSystem: fileSystem,
|
|
logger: logger,
|
|
platform: platform,
|
|
processManager: processManager,
|
|
),
|
|
_version = version,
|
|
_build = build,
|
|
_versionText = version?.toString(),
|
|
_usage = usage,
|
|
_analytics = analytics;
|
|
|
|
/// Create an [XcodeProjectInterpreter] for testing.
|
|
///
|
|
/// Defaults to installed with sufficient version,
|
|
/// a memory file system, fake platform, buffer logger,
|
|
/// test [Usage], and test [Terminal].
|
|
/// Set [version] to null to simulate Xcode not being installed.
|
|
factory XcodeProjectInterpreter.test({
|
|
required ProcessManager processManager,
|
|
Version? version = const Version.withText(1000, 0, 0, '1000.0.0'),
|
|
String? build = '13C100',
|
|
Analytics? analytics,
|
|
}) {
|
|
final Platform platform = FakePlatform(
|
|
operatingSystem: 'macos',
|
|
environment: <String, String>{},
|
|
);
|
|
return XcodeProjectInterpreter._(
|
|
fileSystem: MemoryFileSystem.test(),
|
|
platform: platform,
|
|
processManager: processManager,
|
|
usage: TestUsage(),
|
|
logger: BufferLogger.test(),
|
|
version: version,
|
|
build: build,
|
|
analytics: analytics ?? const NoOpAnalytics(),
|
|
);
|
|
}
|
|
|
|
final Platform _platform;
|
|
final FileSystem _fileSystem;
|
|
final ProcessUtils _processUtils;
|
|
final OperatingSystemUtils _operatingSystemUtils;
|
|
final Logger _logger;
|
|
final Usage _usage;
|
|
final Analytics _analytics;
|
|
static final RegExp _versionRegex = RegExp(r'Xcode ([0-9.]+).*Build version (\w+)');
|
|
|
|
void _updateVersion() {
|
|
if (!_platform.isMacOS || !_fileSystem.file('/usr/bin/xcodebuild').existsSync()) {
|
|
return;
|
|
}
|
|
try {
|
|
if (_versionText == null) {
|
|
final RunResult result = _processUtils.runSync(
|
|
<String>[...xcrunCommand(), 'xcodebuild', '-version'],
|
|
);
|
|
if (result.exitCode != 0) {
|
|
return;
|
|
}
|
|
_versionText = result.stdout.trim().replaceAll('\n', ', ');
|
|
}
|
|
final Match? match = _versionRegex.firstMatch(versionText!);
|
|
if (match == null) {
|
|
return;
|
|
}
|
|
final String version = match.group(1)!;
|
|
final List<String> components = version.split('.');
|
|
final int majorVersion = int.parse(components[0]);
|
|
final int minorVersion = components.length < 2 ? 0 : int.parse(components[1]);
|
|
final int patchVersion = components.length < 3 ? 0 : int.parse(components[2]);
|
|
_version = Version(majorVersion, minorVersion, patchVersion);
|
|
_build = match.group(2);
|
|
} on ProcessException {
|
|
// Ignored, leave values null.
|
|
}
|
|
}
|
|
|
|
bool get isInstalled => version != null;
|
|
|
|
String? _versionText;
|
|
String? get versionText {
|
|
if (_versionText == null) {
|
|
_updateVersion();
|
|
}
|
|
return _versionText;
|
|
}
|
|
|
|
Version? _version;
|
|
String? _build;
|
|
Version? get version {
|
|
if (_version == null) {
|
|
_updateVersion();
|
|
}
|
|
return _version;
|
|
}
|
|
|
|
String? get build {
|
|
if (_build == null) {
|
|
_updateVersion();
|
|
}
|
|
return _build;
|
|
}
|
|
|
|
/// The `xcrun` Xcode command to run or locate development
|
|
/// tools and properties.
|
|
///
|
|
/// Returns `xcrun` on x86 macOS.
|
|
/// Returns `/usr/bin/arch -arm64e xcrun` on ARM macOS to force Xcode commands
|
|
/// to run outside the x86 Rosetta translation, which may cause crashes.
|
|
List<String> xcrunCommand() {
|
|
final List<String> xcrunCommand = <String>[];
|
|
if (_operatingSystemUtils.hostPlatform == HostPlatform.darwin_arm64) {
|
|
// Force Xcode commands to run outside Rosetta.
|
|
xcrunCommand.addAll(<String>[
|
|
'/usr/bin/arch',
|
|
'-arm64e',
|
|
]);
|
|
}
|
|
xcrunCommand.add('xcrun');
|
|
return xcrunCommand;
|
|
}
|
|
|
|
/// Asynchronously retrieve xcode build settings. This one is preferred for
|
|
/// new call-sites.
|
|
///
|
|
/// If [scheme] is null, xcodebuild will return build settings for the first discovered
|
|
/// target (by default this is Runner).
|
|
Future<Map<String, String>> getBuildSettings(
|
|
String projectPath, {
|
|
required XcodeProjectBuildContext buildContext,
|
|
Duration timeout = const Duration(minutes: 1),
|
|
}) async {
|
|
final Status status = _logger.startSpinner();
|
|
final String? scheme = buildContext.scheme;
|
|
final String? configuration = buildContext.configuration;
|
|
final String? target = buildContext.target;
|
|
final String? deviceId = buildContext.deviceId;
|
|
final List<String> showBuildSettingsCommand = <String>[
|
|
...xcrunCommand(),
|
|
'xcodebuild',
|
|
'-project',
|
|
_fileSystem.path.absolute(projectPath),
|
|
if (scheme != null)
|
|
...<String>['-scheme', scheme],
|
|
if (configuration != null)
|
|
...<String>['-configuration', configuration],
|
|
if (target != null)
|
|
...<String>['-target', target],
|
|
if (buildContext.environmentType == EnvironmentType.simulator)
|
|
...<String>['-sdk', 'iphonesimulator'],
|
|
'-destination',
|
|
if (buildContext.isWatch && buildContext.environmentType == EnvironmentType.physical)
|
|
'generic/platform=watchOS'
|
|
else if (buildContext.isWatch)
|
|
'generic/platform=watchOS Simulator'
|
|
else if (deviceId != null)
|
|
'id=$deviceId'
|
|
else if (buildContext.environmentType == EnvironmentType.physical)
|
|
'generic/platform=iOS'
|
|
else
|
|
'generic/platform=iOS Simulator',
|
|
'-showBuildSettings',
|
|
'BUILD_DIR=${_fileSystem.path.absolute(getIosBuildDirectory())}',
|
|
...environmentVariablesAsXcodeBuildSettings(_platform),
|
|
];
|
|
try {
|
|
// showBuildSettings is reported to occasionally timeout. Here, we give it
|
|
// a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
|
|
// When there is a timeout, we retry once.
|
|
final RunResult result = await _processUtils.run(
|
|
showBuildSettingsCommand,
|
|
throwOnError: true,
|
|
workingDirectory: projectPath,
|
|
timeout: timeout,
|
|
timeoutRetries: 1,
|
|
);
|
|
final String out = result.stdout.trim();
|
|
return parseXcodeBuildSettings(out);
|
|
} on Exception catch (error) {
|
|
if (error is ProcessException && error.toString().contains('timed out')) {
|
|
BuildEvent('xcode-show-build-settings-timeout',
|
|
type: 'ios',
|
|
command: showBuildSettingsCommand.join(' '),
|
|
flutterUsage: _usage,
|
|
).send();
|
|
_analytics.send(Event.flutterBuildInfo(
|
|
label: 'xcode-show-build-settings-timeout',
|
|
buildType: 'ios',
|
|
command: showBuildSettingsCommand.join(' '),
|
|
));
|
|
}
|
|
_logger.printTrace('Unexpected failure to get Xcode build settings: $error.');
|
|
return const <String, String>{};
|
|
} finally {
|
|
status.stop();
|
|
}
|
|
}
|
|
|
|
/// Asynchronously retrieve xcode build settings for the generated Pods.xcodeproj plugins project.
|
|
///
|
|
/// Returns the stdout of the Xcode command.
|
|
Future<String?> pluginsBuildSettingsOutput(
|
|
Directory podXcodeProject, {
|
|
Duration timeout = const Duration(minutes: 1),
|
|
}) async {
|
|
if (!podXcodeProject.existsSync()) {
|
|
// No plugins.
|
|
return null;
|
|
}
|
|
final Status status = _logger.startSpinner();
|
|
final String buildDirectory = _fileSystem.path.absolute(getIosBuildDirectory());
|
|
final List<String> showBuildSettingsCommand = <String>[
|
|
...xcrunCommand(),
|
|
'xcodebuild',
|
|
'-alltargets',
|
|
'-sdk',
|
|
'iphonesimulator',
|
|
'-project',
|
|
podXcodeProject.path,
|
|
'-showBuildSettings',
|
|
'BUILD_DIR=$buildDirectory',
|
|
'OBJROOT=$buildDirectory',
|
|
];
|
|
try {
|
|
// showBuildSettings is reported to occasionally timeout. Here, we give it
|
|
// a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
|
|
// When there is a timeout, we retry once.
|
|
final RunResult result = await _processUtils.run(
|
|
showBuildSettingsCommand,
|
|
throwOnError: true,
|
|
workingDirectory: podXcodeProject.path,
|
|
timeout: timeout,
|
|
timeoutRetries: 1,
|
|
);
|
|
|
|
// Return the stdout only. Do not parse with parseXcodeBuildSettings, `-alltargets` prints the build settings
|
|
// for all targets (one per plugin), so it would require a Map of Maps.
|
|
return result.stdout.trim();
|
|
} on Exception catch (error) {
|
|
if (error is ProcessException && error.toString().contains('timed out')) {
|
|
BuildEvent('xcode-show-build-settings-timeout',
|
|
type: 'ios',
|
|
command: showBuildSettingsCommand.join(' '),
|
|
flutterUsage: _usage,
|
|
).send();
|
|
_analytics.send(Event.flutterBuildInfo(
|
|
label: 'xcode-show-build-settings-timeout',
|
|
buildType: 'ios',
|
|
command: showBuildSettingsCommand.join(' '),
|
|
));
|
|
}
|
|
_logger.printTrace('Unexpected failure to get Pod Xcode project build settings: $error.');
|
|
return null;
|
|
} finally {
|
|
status.stop();
|
|
}
|
|
}
|
|
|
|
Future<void> cleanWorkspace(String workspacePath, String scheme, { bool verbose = false }) async {
|
|
await _processUtils.run(<String>[
|
|
...xcrunCommand(),
|
|
'xcodebuild',
|
|
'-workspace',
|
|
workspacePath,
|
|
'-scheme',
|
|
scheme,
|
|
if (!verbose)
|
|
'-quiet',
|
|
'clean',
|
|
...environmentVariablesAsXcodeBuildSettings(_platform),
|
|
], workingDirectory: _fileSystem.currentDirectory.path);
|
|
}
|
|
|
|
Future<XcodeProjectInfo?> getInfo(String projectPath, {String? projectFilename}) async {
|
|
// The exit code returned by 'xcodebuild -list' when either:
|
|
// * -project is passed and the given project isn't there, or
|
|
// * no -project is passed and there isn't a project.
|
|
const int missingProjectExitCode = 66;
|
|
// The exit code returned by 'xcodebuild -list' when the project is corrupted.
|
|
const int corruptedProjectExitCode = 74;
|
|
bool allowedFailures(int c) => c == missingProjectExitCode || c == corruptedProjectExitCode;
|
|
final RunResult result = await _processUtils.run(
|
|
<String>[
|
|
...xcrunCommand(),
|
|
'xcodebuild',
|
|
'-list',
|
|
if (projectFilename != null) ...<String>['-project', projectFilename],
|
|
],
|
|
throwOnError: true,
|
|
allowedFailures: allowedFailures,
|
|
workingDirectory: projectPath,
|
|
);
|
|
if (allowedFailures(result.exitCode)) {
|
|
// User configuration error, tool exit instead of crashing.
|
|
throwToolExit('Unable to get Xcode project information:\n ${result.stderr}');
|
|
}
|
|
return XcodeProjectInfo.fromXcodeBuildOutput(result.toString(), _logger);
|
|
}
|
|
}
|
|
|
|
/// Environment variables prefixed by FLUTTER_XCODE_ will be passed as build configurations to xcodebuild.
|
|
/// This allows developers to pass arbitrary build settings in without the tool needing to make a flag
|
|
/// for or be aware of each one. This could be used to set code signing build settings in a CI
|
|
/// environment without requiring settings changes in the Xcode project.
|
|
List<String> environmentVariablesAsXcodeBuildSettings(Platform platform) {
|
|
const String xcodeBuildSettingPrefix = 'FLUTTER_XCODE_';
|
|
return platform.environment.entries.where((MapEntry<String, String> mapEntry) {
|
|
return mapEntry.key.startsWith(xcodeBuildSettingPrefix);
|
|
}).expand<String>((MapEntry<String, String> mapEntry) {
|
|
// Remove FLUTTER_XCODE_ prefix from the environment variable to get the build setting.
|
|
final String trimmedBuildSettingKey = mapEntry.key.substring(xcodeBuildSettingPrefix.length);
|
|
return <String>['$trimmedBuildSettingKey=${mapEntry.value}'];
|
|
}).toList();
|
|
}
|
|
|
|
Map<String, String> parseXcodeBuildSettings(String showBuildSettingsOutput) {
|
|
final Map<String, String> settings = <String, String>{};
|
|
for (final Match? match in showBuildSettingsOutput.split('\n').map<Match?>(_settingExpr.firstMatch)) {
|
|
if (match != null) {
|
|
settings[match[1]!] = match[2]!;
|
|
}
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
/// Substitutes variables in [str] with their values from the specified Xcode
|
|
/// project and target.
|
|
String substituteXcodeVariables(String str, Map<String, String> xcodeBuildSettings) {
|
|
final Iterable<Match> matches = _varExpr.allMatches(str);
|
|
if (matches.isEmpty) {
|
|
return str;
|
|
}
|
|
|
|
return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]!] ?? m[0]!);
|
|
}
|
|
|
|
@immutable
|
|
class XcodeProjectBuildContext {
|
|
const XcodeProjectBuildContext({
|
|
this.scheme,
|
|
this.configuration,
|
|
this.environmentType = EnvironmentType.physical,
|
|
this.deviceId,
|
|
this.target,
|
|
this.isWatch = false,
|
|
});
|
|
|
|
final String? scheme;
|
|
final String? configuration;
|
|
final EnvironmentType environmentType;
|
|
final String? deviceId;
|
|
final String? target;
|
|
final bool isWatch;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(scheme, configuration, environmentType, deviceId, target);
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
if (identical(other, this)) {
|
|
return true;
|
|
}
|
|
return other is XcodeProjectBuildContext &&
|
|
other.scheme == scheme &&
|
|
other.configuration == configuration &&
|
|
other.deviceId == deviceId &&
|
|
other.environmentType == environmentType &&
|
|
other.isWatch == isWatch &&
|
|
other.target == target;
|
|
}
|
|
}
|
|
|
|
/// Information about an Xcode project.
|
|
///
|
|
/// Represents the output of `xcodebuild -list`.
|
|
class XcodeProjectInfo {
|
|
const XcodeProjectInfo(
|
|
this.targets,
|
|
this.buildConfigurations,
|
|
this.schemes,
|
|
Logger logger
|
|
) : _logger = logger;
|
|
|
|
factory XcodeProjectInfo.fromXcodeBuildOutput(String output, Logger logger) {
|
|
final List<String> targets = <String>[];
|
|
final List<String> buildConfigurations = <String>[];
|
|
final List<String> schemes = <String>[];
|
|
List<String>? collector;
|
|
for (final String line in output.split('\n')) {
|
|
if (line.isEmpty) {
|
|
collector = null;
|
|
continue;
|
|
} else if (line.endsWith('Targets:')) {
|
|
collector = targets;
|
|
continue;
|
|
} else if (line.endsWith('Build Configurations:')) {
|
|
collector = buildConfigurations;
|
|
continue;
|
|
} else if (line.endsWith('Schemes:')) {
|
|
collector = schemes;
|
|
continue;
|
|
}
|
|
collector?.add(line.trim());
|
|
}
|
|
if (schemes.isEmpty) {
|
|
schemes.add('Runner');
|
|
}
|
|
return XcodeProjectInfo(targets, buildConfigurations, schemes, logger);
|
|
}
|
|
|
|
final List<String> targets;
|
|
final List<String> buildConfigurations;
|
|
final List<String> schemes;
|
|
final Logger _logger;
|
|
|
|
bool get definesCustomSchemes => !(schemes.contains('Runner') && schemes.length == 1);
|
|
|
|
/// The expected scheme for [buildInfo].
|
|
@visibleForTesting
|
|
static String expectedSchemeFor(BuildInfo? buildInfo) {
|
|
return sentenceCase(buildInfo?.flavor ?? 'runner');
|
|
}
|
|
|
|
/// The expected build configuration for [buildInfo] and [scheme].
|
|
static String expectedBuildConfigurationFor(BuildInfo buildInfo, String scheme) {
|
|
final String baseConfiguration = _baseConfigurationFor(buildInfo);
|
|
if (buildInfo.flavor == null) {
|
|
return baseConfiguration;
|
|
}
|
|
return '$baseConfiguration-$scheme';
|
|
}
|
|
|
|
/// Checks whether the [buildConfigurations] contains the specified string, without
|
|
/// regard to case.
|
|
bool hasBuildConfigurationForBuildMode(String buildMode) {
|
|
buildMode = buildMode.toLowerCase();
|
|
for (final String name in buildConfigurations) {
|
|
if (name.toLowerCase() == buildMode) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns unique scheme matching [buildInfo], or null, if there is no unique
|
|
/// best match.
|
|
String? schemeFor(BuildInfo? buildInfo) {
|
|
final String expectedScheme = expectedSchemeFor(buildInfo);
|
|
if (schemes.contains(expectedScheme)) {
|
|
return expectedScheme;
|
|
}
|
|
return _uniqueMatch(schemes, (String candidate) {
|
|
return candidate.toLowerCase() == expectedScheme.toLowerCase();
|
|
});
|
|
}
|
|
|
|
Never reportFlavorNotFoundAndExit() {
|
|
_logger.printError('');
|
|
if (definesCustomSchemes) {
|
|
_logger.printError('The Xcode project defines schemes: ${schemes.join(', ')}');
|
|
throwToolExit('You must specify a --flavor option to select one of the available schemes.');
|
|
} else {
|
|
throwToolExit('The Xcode project does not define custom schemes. You cannot use the --flavor option.');
|
|
}
|
|
}
|
|
|
|
/// Returns unique build configuration matching [buildInfo] and [scheme], or
|
|
/// null, if there is no unique best match.
|
|
String? buildConfigurationFor(BuildInfo? buildInfo, String scheme) {
|
|
if (buildInfo == null) {
|
|
return null;
|
|
}
|
|
final String expectedConfiguration = expectedBuildConfigurationFor(buildInfo, scheme);
|
|
if (hasBuildConfigurationForBuildMode(expectedConfiguration)) {
|
|
return expectedConfiguration;
|
|
}
|
|
final String baseConfiguration = _baseConfigurationFor(buildInfo);
|
|
return _uniqueMatch(buildConfigurations, (String candidate) {
|
|
candidate = candidate.toLowerCase();
|
|
if (buildInfo.flavor == null) {
|
|
return candidate == expectedConfiguration.toLowerCase();
|
|
}
|
|
return candidate.contains(baseConfiguration.toLowerCase()) && candidate.contains(scheme.toLowerCase());
|
|
});
|
|
}
|
|
|
|
static String _baseConfigurationFor(BuildInfo buildInfo) {
|
|
if (buildInfo.isDebug) {
|
|
return 'Debug';
|
|
}
|
|
if (buildInfo.isProfile) {
|
|
return 'Profile';
|
|
}
|
|
return 'Release';
|
|
}
|
|
|
|
static String? _uniqueMatch(Iterable<String> strings, bool Function(String s) matches) {
|
|
final List<String> options = strings.where(matches).toList();
|
|
if (options.length == 1) {
|
|
return options.first;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return 'XcodeProjectInfo($targets, $buildConfigurations, $schemes)';
|
|
}
|
|
}
|