From e6300da2c39ff230d943eeccb80ba3ccfa1e3759 Mon Sep 17 00:00:00 2001 From: hellohuanlin <41930132+hellohuanlin@users.noreply.github.com> Date: Wed, 2 Nov 2022 19:38:17 -0700 Subject: [PATCH] [tools]validation basic Xcode settings for build ipa (#113412) --- .../lib/src/commands/build_ios.dart | 39 ++++++ .../lib/src/ios/application_package.dart | 6 + .../lib/src/ios/plist_parser.dart | 5 +- .../lib/src/macos/application_package.dart | 2 +- .../hermetic/build_ipa_test.dart | 116 ++++++++++++++++-- 5 files changed, 158 insertions(+), 10 deletions(-) diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart index 250188dcee..c506fc69b3 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios.dart @@ -15,6 +15,7 @@ import '../convert.dart'; import '../globals.dart' as globals; import '../ios/application_package.dart'; import '../ios/mac.dart'; +import '../ios/plist_parser.dart'; import '../runner/flutter_command.dart'; import 'build.dart'; @@ -129,12 +130,49 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { return super.validateCommand(); } + Future _validateXcodeBuildSettingsAfterArchive() async { + final BuildableIOSApp app = await buildableIOSApp; + + final String plistPath = app.builtInfoPlistPathAfterArchive; + + if (!globals.fs.file(plistPath).existsSync()) { + globals.printError('Invalid iOS archive. Does not contain Info.plist.'); + return; + } + + final Map xcodeProjectSettingsMap = {}; + + xcodeProjectSettingsMap['Version Number'] = globals.plistParser.getStringValueFromFile(plistPath, PlistParser.kCFBundleShortVersionStringKey); + xcodeProjectSettingsMap['Build Number'] = globals.plistParser.getStringValueFromFile(plistPath, PlistParser.kCFBundleVersionKey); + xcodeProjectSettingsMap['Display Name'] = globals.plistParser.getStringValueFromFile(plistPath, PlistParser.kCFBundleDisplayNameKey); + xcodeProjectSettingsMap['Deployment Target'] = globals.plistParser.getStringValueFromFile(plistPath, PlistParser.kMinimumOSVersionKey); + xcodeProjectSettingsMap['Bundle Identifier'] = globals.plistParser.getStringValueFromFile(plistPath, PlistParser.kCFBundleIdentifierKey); + + final StringBuffer buffer = StringBuffer(); + xcodeProjectSettingsMap.forEach((String title, String? info) { + buffer.writeln('$title: ${info ?? "Missing"}'); + }); + + final String message; + if (xcodeProjectSettingsMap.values.any((String? element) => element == null)) { + buffer.writeln('\nYou must set up the missing settings'); + buffer.write('Instructions: https://docs.flutter.dev/deployment/ios'); + message = buffer.toString(); + } else { + // remove the new line + message = buffer.toString().trim(); + } + globals.printBox(message, title: 'App Settings'); + } + @override Future runCommand() async { final BuildInfo buildInfo = await cachedBuildInfo; displayNullSafetyMode(buildInfo); final FlutterCommandResult xcarchiveResult = await super.runCommand(); + await _validateXcodeBuildSettingsAfterArchive(); + // xcarchive failed or not at expected location. if (xcarchiveResult.exitStatus != ExitStatus.success) { globals.printStatus('Skipping IPA.'); @@ -289,6 +327,7 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { /// The result of the Xcode build command. Null until it finishes. @protected XcodeBuildResult? xcodeBuildResult; + EnvironmentType get environmentType; bool get configOnly; diff --git a/packages/flutter_tools/lib/src/ios/application_package.dart b/packages/flutter_tools/lib/src/ios/application_package.dart index 9e4aaa3498..f2fd28485c 100644 --- a/packages/flutter_tools/lib/src/ios/application_package.dart +++ b/packages/flutter_tools/lib/src/ios/application_package.dart @@ -145,6 +145,12 @@ class BuildableIOSApp extends IOSApp { String get archiveBundleOutputPath => globals.fs.path.setExtension(archiveBundlePath, '.xcarchive'); + String get builtInfoPlistPathAfterArchive => globals.fs.path.join(archiveBundleOutputPath, + 'Products', + 'Applications', + _hostAppBundleName == null ? 'Runner.app' : _hostAppBundleName!, + 'Info.plist'); + String get ipaOutputPath => globals.fs.path.join(getIosBuildDirectory(), 'ipa'); diff --git a/packages/flutter_tools/lib/src/ios/plist_parser.dart b/packages/flutter_tools/lib/src/ios/plist_parser.dart index e17e83b7d8..84f9acf9b1 100644 --- a/packages/flutter_tools/lib/src/ios/plist_parser.dart +++ b/packages/flutter_tools/lib/src/ios/plist_parser.dart @@ -26,7 +26,10 @@ class PlistParser { static const String kCFBundleIdentifierKey = 'CFBundleIdentifier'; static const String kCFBundleShortVersionStringKey = 'CFBundleShortVersionString'; - static const String kCFBundleExecutable = 'CFBundleExecutable'; + static const String kCFBundleExecutableKey = 'CFBundleExecutable'; + static const String kCFBundleVersionKey = 'CFBundleVersion'; + static const String kCFBundleDisplayNameKey = 'CFBundleDisplayName'; + static const String kMinimumOSVersionKey = 'MinimumOSVersion'; /// Returns the content, converted to XML, of the plist file located at /// [plistFilePath]. diff --git a/packages/flutter_tools/lib/src/macos/application_package.dart b/packages/flutter_tools/lib/src/macos/application_package.dart index 54228ce374..37fb60cee4 100644 --- a/packages/flutter_tools/lib/src/macos/application_package.dart +++ b/packages/flutter_tools/lib/src/macos/application_package.dart @@ -87,7 +87,7 @@ abstract class MacOSApp extends ApplicationPackage { } final Map propertyValues = globals.plistParser.parseFile(plistPath); final String? id = propertyValues[PlistParser.kCFBundleIdentifierKey] as String?; - final String? executableName = propertyValues[PlistParser.kCFBundleExecutable] as String?; + final String? executableName = propertyValues[PlistParser.kCFBundleExecutableKey] as String?; if (id == null) { globals.printError('Invalid prebuilt macOS app. Info.plist does not contain bundle identifier'); return null; diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart index e82a2b14fc..32d022fc1f 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart @@ -9,8 +9,10 @@ import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/commands/build.dart'; import 'package:flutter_tools/src/commands/build_ios.dart'; +import 'package:flutter_tools/src/ios/plist_parser.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/reporting/reporting.dart'; +import 'package:test/fake.dart'; import '../../general.shard/ios/xcresult_test_data.dart'; import '../../src/common.dart'; @@ -50,10 +52,20 @@ final Platform notMacosPlatform = FakePlatform( } ); +class FakePlistUtils extends Fake implements PlistParser { + final Map> fileContents = >{}; + + @override + String? getStringValueFromFile(String plistFilePath, String key) { + return fileContents[plistFilePath]![key] as String?; + } +} + void main() { late FileSystem fileSystem; late TestUsage usage; late FakeProcessManager fakeProcessManager; + late FakePlistUtils plistUtils; setUpAll(() { Cache.disableLocking(); @@ -63,6 +75,7 @@ void main() { fileSystem = MemoryFileSystem.test(); usage = TestUsage(); fakeProcessManager = FakeProcessManager.empty(); + plistUtils = FakePlistUtils(); }); // Sets up the minimal mock project files necessary to look like a Flutter project. @@ -246,8 +259,7 @@ void main() { FileSystem: () => fileSystem, ProcessManager: () => FakeProcessManager.any(), Platform: () => macosPlatform, - XcodeProjectInterpreter: () => - FakeXcodeProjectInterpreterWithBuildSettings(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); testUsingContext('ipa build fails when --export-options-plist and --export-method are used together', () async { @@ -270,8 +282,7 @@ void main() { FileSystem: () => fileSystem, ProcessManager: () => FakeProcessManager.any(), Platform: () => macosPlatform, - XcodeProjectInterpreter: () => - FakeXcodeProjectInterpreterWithBuildSettings(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); testUsingContext('ipa build reports when IPA fails', () async { @@ -521,8 +532,7 @@ void main() { FileSystem: () => fileSystem, ProcessManager: () => FakeProcessManager.any(), Platform: () => macosPlatform, - XcodeProjectInterpreter: () => - FakeXcodeProjectInterpreterWithBuildSettings(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); testUsingContext('Performs code size analysis and sends analytics', () async { @@ -601,8 +611,7 @@ void main() { FileSystem: () => fileSystem, ProcessManager: () => fakeProcessManager, Platform: () => macosPlatform, - XcodeProjectInterpreter: () => - FakeXcodeProjectInterpreterWithBuildSettings(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); testUsingContext('Trace error if xcresult is empty.', () async { @@ -735,6 +744,97 @@ void main() { Platform: () => macosPlatform, XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); + + testUsingContext( + 'Validate basic Xcode settings with missing settings', () async { + + const String plistPath = 'build/ios/archive/Runner.xcarchive/Products/Applications/Runner.app/Info.plist'; + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.file(plistPath).createSync(recursive: true); + }), + exportArchiveCommand(exportOptionsPlist: _exportOptionsPlist), + ]); + + createMinimalMockProjectFiles(); + + plistUtils.fileContents[plistPath] = { + 'CFBundleIdentifier': 'io.flutter.someProject', + }; + + final BuildCommand command = BuildCommand(); + await createTestCommandRunner(command).run( + ['build', 'ipa', '--no-pub']); + + expect( + testLogger.statusText, + contains( + '┌─ App Settings ────────────────────────────────────────┐\n' + '│ Version Number: Missing │\n' + '│ Build Number: Missing │\n' + '│ Display Name: Missing │\n' + '│ Deployment Target: Missing │\n' + '│ Bundle Identifier: io.flutter.someProject │\n' + '│ │\n' + '│ You must set up the missing settings │\n' + '│ Instructions: https://docs.flutter.dev/deployment/ios │\n' + '└───────────────────────────────────────────────────────┘' + ) + ); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + PlistParser: () => plistUtils, + }); + + testUsingContext( + 'Validate basic Xcode settings with full settings', () async { + const String plistPath = 'build/ios/archive/Runner.xcarchive/Products/Applications/Runner.app/Info.plist'; + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.file(plistPath).createSync(recursive: true); + }), + exportArchiveCommand(exportOptionsPlist: _exportOptionsPlist), + ]); + + createMinimalMockProjectFiles(); + + plistUtils.fileContents[plistPath] = { + 'CFBundleIdentifier': 'io.flutter.someProject', + 'CFBundleDisplayName': 'Awesome Gallery', + 'MinimumOSVersion': '11.0', + 'CFBundleVersion': '666', + 'CFBundleShortVersionString': '12.34.56', + }; + + final BuildCommand command = BuildCommand(); + await createTestCommandRunner(command).run( + ['build', 'ipa', '--no-pub']); + + expect( + testLogger.statusText, + contains( + '┌─ App Settings ────────────────────────────┐\n' + '│ Version Number: 12.34.56 │\n' + '│ Build Number: 666 │\n' + '│ Display Name: Awesome Gallery │\n' + '│ Deployment Target: 11.0 │\n' + '│ Bundle Identifier: io.flutter.someProject │\n' + '└───────────────────────────────────────────┘\n' + ) + ); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + PlistParser: () => plistUtils, + }); + } const String _xcBundleFilePath = '/.tmp_rand0/flutter_ios_build_temp_dirrand0/temporary_xcresult_bundle';