flutter/packages/flutter_tools/bin/xcode_backend.dart
auto-submit[bot] 21766a4f9f
Reverts "Support conditional bundling of assets based on --flavor" (#139787)
Reverts flutter/flutter#132985
Initiated by: christopherfujino
This change reverts the following previous change:
Original Description:
Provides support for conditional bundling of assets through the existing `--flavor` option for `flutter build` and `flutter run`. Closes https://github.com/flutter/flutter/issues/21682. Resolves https://github.com/flutter/flutter/issues/136092

## Change
Within the `assets` section pubspec.yaml, the user can now specify one or more `flavors` that an asset belongs to. Consider this example:

```yaml
# pubspec.yaml
flutter:
  assets:
    - assets/normal-asset.png
    - path: assets/vanilla/ice-cream.png
      flavors: 
        - vanilla
    - path: assets/strawberry/ice-cream.png
      flavors:
        - strawberry
```

With this pubspec,
* `flutter run --flavor vanilla` will not include `assets/strawberry/ice-cream.png` in the build output.
* `flutter run --flavor strawberry` will not include `assets/vanilla/ice-cream.png`.
* `flutter run` will only include `assets/normal-asset.png`.

## Open questions

* Should this be supported for all platforms, or should this change be limited to ones with documented `--flavor` support (Android, iOS, and (implicitly) MacOS)? This PR currently only enables this feature for officially supported platforms.

## Design thoughts, what this PR does not do, etc.

### This does not provide an automatic mapping/resolution of asset keys/paths to others based on flavor at runtime.

The implementation in this PR represents a simplest approach. Notably, it does not give Flutter the ability to dynamically choose an asset based on flavor using a single asset key. For example, one can't use `Image.asset('config.json')` to dynamically choose between different "flavors" of `config.json` (such as `dev-flavor/config.json` or `prod-flavor/config.json`). However, a user could always implement such a mechanism in their project or in a library by examining the flavor at runtime.

### When multiple entries affect the same file and 1) at least one of these entries have a `flavors` list provided and 2) these lists are not equivalent, we always consider the manifest to be ambiguous and will throw a `ToolExit`. 

<details>
For example, these manifests would all be considered ambiguous:

```yaml
assets:
  - assets/
  - path: assets/vanilla.png
    flavors: 
      - vanilla

assets:
  - path: assets/vanilla/
    flavors: 
      - vanilla
  - path: assets/vanilla/cherry.png
     flavor:
      - cherry

# Thinking towards the future where we might add glob/regex support and more conditions other than flavor:
assets:
  - path: assets/vanilla/**
    flavors:
      - vanilla
  - path: assets/**/ios/**
    platforms: 
       - ios

# Ambiguous in the case of assets like "assets/vanilla/ios/icon.svg" since we 
# don't know if flavor `vanilla` and platform `ios` should be combined using or-logic or and-logic.
```

See [this review comment thread](https://github.com/flutter/flutter/pull/132985#discussion_r1381909942) for the full story on how I arrived at this decision.
</details>

### This does not support Android's multidimensional flavors feature (in an intuitive way)

<details>

Conder this excerpt from a Flutter project's android/app/build.gradle file:

```groovy
android {
    // ...

    flavorDimensions "mode", "api"

    productFlavors {
        free {
            dimension "mode"
            applicationIdSuffix ".free"
        }

        premium {
            dimension "mode"
            applicationIdSuffix ".premium"
        }

        minApi23 {
            dimension "api"
            versionNameSuffix "-minApi23"
        }

        minApi21 {
            dimension "api"
            versionNameSuffix "-minApi21"
        }
    }
}
```

In this setup, the following values are valid `--flavor` are valid `freeMinApi21`, `freeMinApi23`, `premiumMinApi21`, and `premiumMinApi23`. We call these values "flavor combinations". Consider the following from the Android documentation[^1]:

> In addition to the source set directories you can create for each individual product flavor and build variant, you can also create source set directories for each combination of product flavors. For example, you can create and add Java sources to the src/demoMinApi24/java/ directory, and Gradle uses those sources only when building a variant that combines those two product flavors.
> 
> Source sets you create for product flavor combinations have a higher priority than source sets that belong to each individual product flavor. To learn more about source sets and how Gradle merges resources, read the section about how to [create source sets](https://developer.android.com/build/build-variants#sourcesets).

This feature will not behave in this way. If a user utilizes this feature and also Android's multidimensional flavors feature, they will have to list out all flavor combinations that contain the flavor they want to limit an asset to:

```yaml
assets:
  - assets/free/
    flavors:
      - freeMinApi21
      - freeMinApi23
```

This is mostly due to a technical limitation in the hot-reload feature of `flutter run`. During a hot reload, the tool will try to update the asset bundle on the device, but the tool does not know the flavors contained within the flavor combination (that the user passes to `--flavor`). Gradle is the source of truth of what flavors were involved in the build, and `flutter run` currently does not access to that information since it's an implementation detail of the build process. We could bubble up this information, but it would require a nontrivial amount of engineering work, and it's unclear how desired this functionality is. It might not be worth implementing.

</details>

See https://flutter.dev/go/flavor-specific-assets for the (outdated) design document. 

<summary>Pre-launch Checklist</summary>

</details>

[^1]: https://developer.android.com/build/build-variants#flavor-dimensions
2023-12-08 06:40:28 +00:00

455 lines
15 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 'dart:io';
void main(List<String> arguments) {
File? scriptOutputStreamFile;
final String? scriptOutputStreamFileEnv = Platform.environment['SCRIPT_OUTPUT_STREAM_FILE'];
if (scriptOutputStreamFileEnv != null && scriptOutputStreamFileEnv.isNotEmpty) {
scriptOutputStreamFile = File(scriptOutputStreamFileEnv);
}
Context(
arguments: arguments,
environment: Platform.environment,
scriptOutputStreamFile: scriptOutputStreamFile,
).run();
}
/// Container for script arguments and environment variables.
///
/// All interactions with the platform are broken into individual methods that
/// can be overridden in tests.
class Context {
Context({
required this.arguments,
required this.environment,
File? scriptOutputStreamFile,
}) {
if (scriptOutputStreamFile != null) {
scriptOutputStream = scriptOutputStreamFile.openSync(mode: FileMode.write);
}
}
final Map<String, String> environment;
final List<String> arguments;
RandomAccessFile? scriptOutputStream;
void run() {
if (arguments.isEmpty) {
// Named entry points were introduced in Flutter v0.0.7.
stderr.write(
'error: Your Xcode project is incompatible with this version of Flutter. '
'Run "rm -rf ios/Runner.xcodeproj" and "flutter create ." to regenerate.\n');
exit(-1);
}
final String subCommand = arguments.first;
switch (subCommand) {
case 'build':
buildApp();
case 'thin':
// No-op, thinning is handled during the bundle asset assemble build target.
break;
case 'embed':
embedFlutterFrameworks();
case 'embed_and_thin':
// Thinning is handled during the bundle asset assemble build target, so just embed.
embedFlutterFrameworks();
case 'test_vm_service_bonjour_service':
// Exposed for integration testing only.
addVmServiceBonjourService();
}
}
bool existsFile(String path) {
final File file = File(path);
return file.existsSync();
}
/// Run given command in a synchronous subprocess.
///
/// Will throw [Exception] if the exit code is not 0.
ProcessResult runSync(
String bin,
List<String> args, {
bool verbose = false,
bool allowFail = false,
String? workingDirectory,
}) {
if (verbose) {
print('$bin ${args.join(' ')}');
}
final ProcessResult result = Process.runSync(
bin,
args,
workingDirectory: workingDirectory,
);
if (verbose) {
print((result.stdout as String).trim());
}
final String resultStderr = result.stderr.toString().trim();
if (resultStderr.isNotEmpty) {
final StringBuffer errorOutput = StringBuffer();
if (result.exitCode != 0) {
// "error:" prefix makes this show up as an Xcode compilation error.
errorOutput.write('error: ');
}
errorOutput.write(resultStderr);
echoError(errorOutput.toString());
}
if (!allowFail && result.exitCode != 0) {
throw Exception(
'Command "$bin ${args.join(' ')}" exited with code ${result.exitCode}',
);
}
return result;
}
/// Log message to stderr.
void echoError(String message) {
stderr.writeln(message);
}
/// Log message to stdout.
void echo(String message) {
stdout.write(message);
}
/// Exit the application with the given exit code.
///
/// Exists to allow overriding in tests.
Never exitApp(int code) {
exit(code);
}
/// Return value from environment if it exists, else throw [Exception].
String environmentEnsure(String key) {
final String? value = environment[key];
if (value == null) {
throw Exception(
'Expected the environment variable "$key" to exist, but it was not found',
);
}
return value;
}
// When provided with a pipe by the host Flutter build process, output to the
// pipe goes to stdout of the Flutter build process directly.
void streamOutput(String output) {
scriptOutputStream?.writeStringSync('$output\n');
}
String parseFlutterBuildMode() {
// Use FLUTTER_BUILD_MODE if it's set, otherwise use the Xcode build configuration name
// This means that if someone wants to use an Xcode build config other than Debug/Profile/Release,
// they _must_ set FLUTTER_BUILD_MODE so we know what type of artifact to build.
final String? buildMode = (environment['FLUTTER_BUILD_MODE'] ?? environment['CONFIGURATION'])?.toLowerCase();
if (buildMode != null) {
if (buildMode.contains('release')) {
return 'release';
}
if (buildMode.contains('profile')) {
return 'profile';
}
if (buildMode.contains('debug')) {
return 'debug';
}
}
echoError('========================================================================');
echoError('ERROR: Unknown FLUTTER_BUILD_MODE: $buildMode.');
echoError("Valid values are 'Debug', 'Profile', or 'Release' (case insensitive).");
echoError('This is controlled by the FLUTTER_BUILD_MODE environment variable.');
echoError('If that is not set, the CONFIGURATION environment variable is used.');
echoError('');
echoError('You can fix this by either adding an appropriately named build');
echoError('configuration, or adding an appropriate value for FLUTTER_BUILD_MODE to the');
echoError('.xcconfig file for the current build configuration (${environment['CONFIGURATION']}).');
echoError('========================================================================');
exitApp(-1);
}
/// Copies all files from [source] to [destination].
///
/// Does not copy `.DS_Store`.
///
/// If [delete], delete extraneous files from [destination].
void runRsync(
String source,
String destination, {
List<String> extraArgs = const <String>[],
bool delete = false,
}) {
runSync(
'rsync',
<String>[
'-8', // Avoid mangling filenames with encodings that do not match the current locale.
'-av',
if (delete) '--delete',
'--filter',
'- .DS_Store',
...extraArgs,
source,
destination,
],
);
}
// Adds the App.framework as an embedded binary and the flutter_assets as
// resources.
void embedFlutterFrameworks() {
// Embed App.framework from Flutter into the app (after creating the Frameworks directory
// if it doesn't already exist).
final String xcodeFrameworksDir = '${environment['TARGET_BUILD_DIR']}/${environment['FRAMEWORKS_FOLDER_PATH']}';
runSync(
'mkdir',
<String>[
'-p',
'--',
xcodeFrameworksDir,
]
);
runRsync(
delete: true,
'${environment['BUILT_PRODUCTS_DIR']}/App.framework',
xcodeFrameworksDir,
);
// Embed the actual Flutter.framework that the Flutter app expects to run against,
// which could be a local build or an arch/type specific build.
runRsync(
delete: true,
'${environment['BUILT_PRODUCTS_DIR']}/Flutter.framework',
'$xcodeFrameworksDir/',
);
// Copy the native assets. These do not have to be codesigned here because,
// they are already codesigned in buildNativeAssetsMacOS.
final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
String projectPath = '$sourceRoot/..';
if (environment['FLUTTER_APPLICATION_PATH'] != null) {
projectPath = environment['FLUTTER_APPLICATION_PATH']!;
}
final String flutterBuildDir = environment['FLUTTER_BUILD_DIR']!;
final String nativeAssetsPath = '$projectPath/$flutterBuildDir/native_assets/ios/';
final bool verbose = (environment['VERBOSE_SCRIPT_LOGGING'] ?? '').isNotEmpty;
if (Directory(nativeAssetsPath).existsSync()) {
if (verbose) {
print('♦ Copying native assets from $nativeAssetsPath.');
}
runRsync(
extraArgs: <String>[
'--filter',
'- native_assets.yaml',
],
nativeAssetsPath,
xcodeFrameworksDir,
);
} else if (verbose) {
print("♦ No native assets to bundle. $nativeAssetsPath doesn't exist.");
}
addVmServiceBonjourService();
}
// Add the vmService publisher Bonjour service to the produced app bundle Info.plist.
void addVmServiceBonjourService() {
// Skip adding Bonjour service settings when DISABLE_PORT_PUBLICATION is YES.
// These settings are not needed if port publication is disabled.
if (environment['DISABLE_PORT_PUBLICATION'] == 'YES') {
return;
}
final String buildMode = parseFlutterBuildMode();
// Debug and profile only.
if (buildMode == 'release') {
return;
}
final String builtProductsPlist = '${environment['BUILT_PRODUCTS_DIR'] ?? ''}/${environment['INFOPLIST_PATH'] ?? ''}';
if (!existsFile(builtProductsPlist)) {
// Very occasionally Xcode hasn't created an Info.plist when this runs.
// The file will be present on re-run.
echo(
'${environment['INFOPLIST_PATH'] ?? ''} does not exist. Skipping '
'_dartVmService._tcp NSBonjourServices insertion. Try re-building to '
'enable "flutter attach".');
return;
}
// If there are already NSBonjourServices specified by the app (uncommon),
// insert the vmService service name to the existing list.
ProcessResult result = runSync(
'plutil',
<String>[
'-extract',
'NSBonjourServices',
'xml1',
'-o',
'-',
builtProductsPlist,
],
allowFail: true,
);
if (result.exitCode == 0) {
runSync(
'plutil',
<String>[
'-insert',
'NSBonjourServices.0',
'-string',
'_dartVmService._tcp',
builtProductsPlist,
],
);
} else {
// Otherwise, add the NSBonjourServices key and vmService service name.
runSync(
'plutil',
<String>[
'-insert',
'NSBonjourServices',
'-json',
'["_dartVmService._tcp"]',
builtProductsPlist,
],
);
//fi
}
// Don't override the local network description the Flutter app developer
// specified (uncommon). This text will appear below the "Your app would
// like to find and connect to devices on your local network" permissions
// popup.
result = runSync(
'plutil',
<String>[
'-extract',
'NSLocalNetworkUsageDescription',
'xml1',
'-o',
'-',
builtProductsPlist,
],
allowFail: true,
);
if (result.exitCode != 0) {
runSync(
'plutil',
<String>[
'-insert',
'NSLocalNetworkUsageDescription',
'-string',
'Allow Flutter tools on your computer to connect and debug your application. This prompt will not appear on release builds.',
builtProductsPlist,
],
);
}
}
void buildApp() {
final bool verbose = environment['VERBOSE_SCRIPT_LOGGING'] != null && environment['VERBOSE_SCRIPT_LOGGING'] != '';
final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
String projectPath = '$sourceRoot/..';
if (environment['FLUTTER_APPLICATION_PATH'] != null) {
projectPath = environment['FLUTTER_APPLICATION_PATH']!;
}
String targetPath = 'lib/main.dart';
if (environment['FLUTTER_TARGET'] != null) {
targetPath = environment['FLUTTER_TARGET']!;
}
final String buildMode = parseFlutterBuildMode();
// Warn the user if not archiving (ACTION=install) in release mode.
final String? action = environment['ACTION'];
if (action == 'install' && buildMode != 'release') {
echo(
'warning: Flutter archive not built in Release mode. Ensure '
'FLUTTER_BUILD_MODE is set to release or run "flutter build ios '
'--release", then re-run Archive from Xcode.',
);
}
final List<String> flutterArgs = <String>[];
if (verbose) {
flutterArgs.add('--verbose');
}
if (environment['FLUTTER_ENGINE'] != null && environment['FLUTTER_ENGINE']!.isNotEmpty) {
flutterArgs.add('--local-engine-src-path=${environment['FLUTTER_ENGINE']}');
}
if (environment['LOCAL_ENGINE'] != null && environment['LOCAL_ENGINE']!.isNotEmpty) {
flutterArgs.add('--local-engine=${environment['LOCAL_ENGINE']}');
}
if (environment['LOCAL_ENGINE_HOST'] != null && environment['LOCAL_ENGINE_HOST']!.isNotEmpty) {
flutterArgs.add('--local-engine-host=${environment['LOCAL_ENGINE_HOST']}');
}
flutterArgs.addAll(<String>[
'assemble',
'--no-version-check',
'--output=${environment['BUILT_PRODUCTS_DIR'] ?? ''}/',
'-dTargetPlatform=ios',
'-dTargetFile=$targetPath',
'-dBuildMode=$buildMode',
'-dIosArchs=${environment['ARCHS'] ?? ''}',
'-dSdkRoot=${environment['SDKROOT'] ?? ''}',
'-dSplitDebugInfo=${environment['SPLIT_DEBUG_INFO'] ?? ''}',
'-dTreeShakeIcons=${environment['TREE_SHAKE_ICONS'] ?? ''}',
'-dTrackWidgetCreation=${environment['TRACK_WIDGET_CREATION'] ?? ''}',
'-dDartObfuscation=${environment['DART_OBFUSCATION'] ?? ''}',
'-dAction=${environment['ACTION'] ?? ''}',
'-dFrontendServerStarterPath=${environment['FRONTEND_SERVER_STARTER_PATH'] ?? ''}',
'--ExtraGenSnapshotOptions=${environment['EXTRA_GEN_SNAPSHOT_OPTIONS'] ?? ''}',
'--DartDefines=${environment['DART_DEFINES'] ?? ''}',
'--ExtraFrontEndOptions=${environment['EXTRA_FRONT_END_OPTIONS'] ?? ''}',
]);
if (environment['PERFORMANCE_MEASUREMENT_FILE'] != null && environment['PERFORMANCE_MEASUREMENT_FILE']!.isNotEmpty) {
flutterArgs.add('--performance-measurement-file=${environment['PERFORMANCE_MEASUREMENT_FILE']}');
}
final String? expandedCodeSignIdentity = environment['EXPANDED_CODE_SIGN_IDENTITY'];
if (expandedCodeSignIdentity != null && expandedCodeSignIdentity.isNotEmpty && environment['CODE_SIGNING_REQUIRED'] != 'NO') {
flutterArgs.add('-dCodesignIdentity=$expandedCodeSignIdentity');
}
if (environment['BUNDLE_SKSL_PATH'] != null && environment['BUNDLE_SKSL_PATH']!.isNotEmpty) {
flutterArgs.add('-dBundleSkSLPath=${environment['BUNDLE_SKSL_PATH']}');
}
if (environment['CODE_SIZE_DIRECTORY'] != null && environment['CODE_SIZE_DIRECTORY']!.isNotEmpty) {
flutterArgs.add('-dCodeSizeDirectory=${environment['CODE_SIZE_DIRECTORY']}');
}
flutterArgs.add('${buildMode}_ios_bundle_flutter_assets');
final ProcessResult result = runSync(
'${environmentEnsure('FLUTTER_ROOT')}/bin/flutter',
flutterArgs,
verbose: verbose,
allowFail: true,
workingDirectory: projectPath, // equivalent of RunCommand pushd "${project_path}"
);
if (result.exitCode != 0) {
echoError('Failed to package $projectPath.');
exitApp(-1);
}
streamOutput('done');
streamOutput(' └─Compiling, linking and signing...');
echo('Project $projectPath built and packaged successfully.');
}
}