From 9861a1c045bea9ebd820b9042d0e8e72dbc30ee6 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Mon, 23 Dec 2019 13:12:36 -0800 Subject: [PATCH] flutter build ios-framework generate Flutter.podspec (#47556) --- packages/flutter_tools/lib/src/cache.dart | 72 +++---- .../lib/src/commands/build_ios_framework.dart | 80 +++++++- packages/flutter_tools/lib/src/version.dart | 6 +- .../hermetic/build_ios_framework_test.dart | 191 ++++++++++++++++++ .../test/general.shard/cache_test.dart | 4 +- packages/flutter_tools/test/src/testbed.dart | 11 + 6 files changed, 321 insertions(+), 43 deletions(-) create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/build_ios_framework_test.dart diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart index db0dd78280..dc4031e814 100644 --- a/packages/flutter_tools/lib/src/cache.dart +++ b/packages/flutter_tools/lib/src/cache.dart @@ -235,6 +235,34 @@ class Cache { } String _engineRevision; + String get storageBaseUrl { + final String overrideUrl = platform.environment['FLUTTER_STORAGE_BASE_URL']; + if (overrideUrl == null) { + return 'https://storage.googleapis.com'; + } + // verify that this is a valid URI. + try { + Uri.parse(overrideUrl); + } on FormatException catch (err) { + throwToolExit('"FLUTTER_STORAGE_BASE_URL" contains an invalid URI:\n$err'); + } + _maybeWarnAboutStorageOverride(overrideUrl); + return overrideUrl; + } + + bool _hasWarnedAboutStorageOverride = false; + + void _maybeWarnAboutStorageOverride(String overrideUrl) { + if (_hasWarnedAboutStorageOverride) { + return; + } + logger.printStatus( + 'Flutter assets will be downloaded from $overrideUrl. Make sure you trust this source!', + emphasis: true, + ); + _hasWarnedAboutStorageOverride = true; + } + static Cache get instance => context.get(); /// Return the top-level directory in the cache; this is `bin/cache`. @@ -262,6 +290,9 @@ class Cache { /// Return the top-level mutable directory in the cache; this is `bin/cache/artifacts`. Directory getCacheArtifacts() => getCacheDir('artifacts'); + /// Location of LICENSE file. + File getLicenseFile() => fs.file(fs.path.join(flutterRoot, 'LICENSE')); + /// Get a named directory from with the cache's artifact directory; for example, /// `material_fonts` would return `bin/cache/artifacts/material_fonts`. Directory getArtifactDirectory(String name) { @@ -492,23 +523,7 @@ abstract class CachedArtifact extends ArtifactSet { /// Template method to perform artifact update. Future updateInner(); - @visibleForTesting - String get storageBaseUrl { - final String overrideUrl = platform.environment['FLUTTER_STORAGE_BASE_URL']; - if (overrideUrl == null) { - return 'https://storage.googleapis.com'; - } - // verify that this is a valid URI. - try { - Uri.parse(overrideUrl); - } on FormatException catch (err) { - throwToolExit('"FLUTTER_STORAGE_BASE_URL" contains an invalid URI:\n$err'); - } - _maybeWarnAboutStorageOverride(overrideUrl); - return overrideUrl; - } - - Uri _toStorageUri(String path) => Uri.parse('$storageBaseUrl/$path'); + Uri _toStorageUri(String path) => Uri.parse('${cache.storageBaseUrl}/$path'); /// Download an archive from the given [url] and unzip it to [location]. Future _downloadArchive(String message, Uri url, Directory location, bool verifier(File f), void extractor(File f, Directory d)) { @@ -549,19 +564,6 @@ abstract class CachedArtifact extends ArtifactSet { } } -bool _hasWarnedAboutStorageOverride = false; - -void _maybeWarnAboutStorageOverride(String overrideUrl) { - if (_hasWarnedAboutStorageOverride) { - return; - } - logger.printStatus( - 'Flutter assets will be downloaded from $overrideUrl. Make sure you trust this source!', - emphasis: true, - ); - _hasWarnedAboutStorageOverride = true; -} - /// A cached artifact containing fonts used for Material Design. class MaterialFonts extends CachedArtifact { MaterialFonts(Cache cache) : super( @@ -604,7 +606,7 @@ class FlutterWebSdk extends CachedArtifact { } else if (platform.isWindows) { platformName += 'windows-x64'; } - final Uri url = Uri.parse('$storageBaseUrl/flutter_infra/flutter/$version/$platformName.zip'); + final Uri url = Uri.parse('${cache.storageBaseUrl}/flutter_infra/flutter/$version/$platformName.zip'); await _downloadZipArchive('Downloading Web SDK...', url, location); // This is a temporary work-around for not being able to safely download into a shared directory. for (FileSystemEntity entity in location.listSync(recursive: true)) { @@ -669,7 +671,7 @@ abstract class EngineCachedArtifact extends CachedArtifact { @override Future updateInner() async { - final String url = '$storageBaseUrl/flutter_infra/flutter/$version/'; + final String url = '${cache.storageBaseUrl}/flutter_infra/flutter/$version/'; final Directory pkgDir = cache.getCacheDir('pkg'); for (String pkgName in getPackageDirs()) { @@ -695,7 +697,7 @@ abstract class EngineCachedArtifact extends CachedArtifact { } } - final File licenseSource = fs.file(fs.path.join(Cache.flutterRoot, 'LICENSE')); + final File licenseSource = cache.getLicenseFile(); for (String licenseDir in getLicenseDirs()) { final String licenseDestinationPath = fs.path.join(location.path, licenseDir, 'LICENSE'); await licenseSource.copy(licenseDestinationPath); @@ -704,7 +706,7 @@ abstract class EngineCachedArtifact extends CachedArtifact { Future checkForArtifacts(String engineVersion) async { engineVersion ??= version; - final String url = '$storageBaseUrl/flutter_infra/flutter/$engineVersion/'; + final String url = '${cache.storageBaseUrl}/flutter_infra/flutter/$engineVersion/'; bool exists = false; for (String pkgName in getPackageDirs()) { @@ -1210,7 +1212,7 @@ class IosUsbArtifacts extends CachedArtifact { } @visibleForTesting - Uri get archiveUri => Uri.parse('$storageBaseUrl/flutter_infra/ios-usb-dependencies${cache.useUnsignedMacBinaries ? '/unsigned' : ''}/$name/$version/$name.zip'); + Uri get archiveUri => Uri.parse('${cache.storageBaseUrl}/flutter_infra/ios-usb-dependencies${cache.useUnsignedMacBinaries ? '/unsigned' : ''}/$name/$version/$name.zip'); } // Many characters are problematic in filenames, especially on Windows. diff --git a/packages/flutter_tools/lib/src/commands/build_ios_framework.dart b/packages/flutter_tools/lib/src/commands/build_ios_framework.dart index f0436ccdd8..7420911262 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios_framework.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios_framework.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:file/file.dart'; +import 'package:meta/meta.dart'; import '../aot.dart'; import '../application_package.dart'; @@ -25,6 +26,7 @@ import '../macos/xcode.dart'; import '../plugins.dart'; import '../project.dart'; import '../runner/flutter_command.dart' show DevelopmentArtifact, FlutterCommandResult; +import '../version.dart'; import 'build.dart'; /// Produces a .framework for integration into a host iOS app. The .framework @@ -32,7 +34,7 @@ import 'build.dart'; /// be integrated into plain Xcode projects without using or other package /// managers. class BuildIOSFrameworkCommand extends BuildSubCommand { - BuildIOSFrameworkCommand({this.aotBuilder, this.bundleBuilder}) { + BuildIOSFrameworkCommand({this.aotBuilder, this.bundleBuilder, this.flutterVersion, this.cache}) { usesTargetOption(); usesFlavorOption(); usesPubOption(); @@ -65,6 +67,9 @@ class BuildIOSFrameworkCommand extends BuildSubCommand { ..addFlag('xcframework', help: 'Produce xcframeworks that include all valid architectures (Xcode 11 or later).', ) + ..addFlag('cocoapods', + help: 'Produce a Flutter.podspec instead of an engine Flutter.framework (recomended if host app uses CocoaPods).', + ) ..addOption('output', abbr: 'o', valueHelp: 'path/to/directory/', @@ -74,6 +79,8 @@ class BuildIOSFrameworkCommand extends BuildSubCommand { AotBuilder aotBuilder; BundleBuilder bundleBuilder; + FlutterVersion flutterVersion; + Cache cache; @override final String name = 'ios-framework'; @@ -150,6 +157,7 @@ class BuildIOSFrameworkCommand extends BuildSubCommand { aotBuilder ??= AotBuilder(); bundleBuilder ??= BundleBuilder(); + cache ??= Cache.instance; for (BuildMode mode in buildModes) { printStatus('Building framework for $iosProject in ${getNameForBuildMode(mode)} mode...'); @@ -162,8 +170,14 @@ class BuildIOSFrameworkCommand extends BuildSubCommand { final Directory iPhoneBuildOutput = modeDirectory.childDirectory('iphoneos'); final Directory simulatorBuildOutput = modeDirectory.childDirectory('iphonesimulator'); - // Copy Flutter.framework. - await _produceFlutterFramework(outputDirectory, mode, iPhoneBuildOutput, simulatorBuildOutput, modeDirectory); + if (boolArg('cocoapods')) { + // FlutterVersion.instance kicks off git processing which can sometimes fail, so don't try it until needed. + flutterVersion ??= FlutterVersion.instance; + produceFlutterPodspec(mode, modeDirectory); + } else { + // Copy Flutter.framework. + await _produceFlutterFramework(outputDirectory, mode, iPhoneBuildOutput, simulatorBuildOutput, modeDirectory); + } // Build aot, create module.framework and copy. await _produceAppFramework(mode, iPhoneBuildOutput, simulatorBuildOutput, modeDirectory); @@ -194,6 +208,64 @@ class BuildIOSFrameworkCommand extends BuildSubCommand { return null; } + /// Create podspec that will download and unzip remote engine assets so host apps can leverage CocoaPods + /// vendored framework caching. + @visibleForTesting + void produceFlutterPodspec(BuildMode mode, Directory modeDirectory) { + final Status status = logger.startProgress(' ├─Creating Flutter.podspec...', timeout: timeoutConfiguration.fastOperation); + try { + final GitTagVersion gitTagVersion = flutterVersion.gitTagVersion; + if (gitTagVersion.x == null || gitTagVersion.y == null || gitTagVersion.z == null || gitTagVersion.commits != 0) { + throwToolExit( + '--cocoapods is only supported on the dev, beta, or stable channels. Detected version is ${flutterVersion.frameworkVersion}'); + } + + // Podspecs use semantic versioning, which don't support hotfixes. + // Fake out a semantic version with major.minor.(patch * 100) + hotfix. + // A real increasing version is required to prompt CocoaPods to fetch + // new artifacts when the source URL changes. + final int minorHotfixVersion = gitTagVersion.z * 100 + (gitTagVersion.hotfix ?? 0); + + final File license = cache.getLicenseFile(); + if (!license.existsSync()) { + throwToolExit('Could not find license at ${license.path}'); + } + final String licenseSource = license.readAsStringSync(); + final String artifactsMode = mode == BuildMode.debug ? 'ios' : 'ios-${mode.name}'; + + final String podspecContents = ''' +Pod::Spec.new do |s| + s.name = 'Flutter' + s.version = '${gitTagVersion.x}.${gitTagVersion.y}.$minorHotfixVersion' # ${flutterVersion.frameworkVersion} + s.summary = 'Flutter Engine Framework' + s.description = <<-DESC +Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. +This pod vends the iOS Flutter engine framework. It is compatible with application frameworks created with this version of the engine and tools. +The pod version matches Flutter version major.minor.(patch * 100) + hotfix. +DESC + s.homepage = 'https://flutter.dev' + s.license = { :type => 'MIT', :text => <<-LICENSE +$licenseSource +LICENSE + } + s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } + s.source = { :http => '${cache.storageBaseUrl}/flutter_infra/flutter/${cache.engineRevision}/$artifactsMode/artifacts.zip' } + s.documentation_url = 'https://flutter.dev/docs' + s.platform = :ios, '8.0' + s.vendored_frameworks = 'Flutter.framework' + s.prepare_command = <<-CMD +unzip Flutter.framework -d Flutter.framework +CMD +end +'''; + + final File podspec = modeDirectory.childFile('Flutter.podspec')..createSync(recursive: true); + podspec.writeAsStringSync(podspecContents); + } finally { + status.stop(); + } + } + Future _produceFlutterFramework(Directory outputDirectory, BuildMode mode, Directory iPhoneBuildOutput, Directory simulatorBuildOutput, Directory modeDirectory) async { final Status status = logger.startProgress(' ├─Populating Flutter.framework...', timeout: timeoutConfiguration.slowOperation); try { @@ -278,7 +350,7 @@ class BuildIOSFrameworkCommand extends BuildSubCommand { destinationAppFrameworkDirectory.createSync(recursive: true); if (mode == BuildMode.debug) { - final Status status = logger.startProgress(' ├─Add placeholder App.framework for debug...', timeout: timeoutConfiguration.fastOperation); + final Status status = logger.startProgress(' ├─Adding placeholder App.framework for debug...', timeout: timeoutConfiguration.fastOperation); try { await _produceStubAppFrameworkIfNeeded(mode, iPhoneBuildOutput, simulatorBuildOutput, destinationAppFrameworkDirectory); } finally { diff --git a/packages/flutter_tools/lib/src/version.dart b/packages/flutter_tools/lib/src/version.dart index 938dfae382..738015fadc 100644 --- a/packages/flutter_tools/lib/src/version.dart +++ b/packages/flutter_tools/lib/src/version.dart @@ -20,7 +20,8 @@ import 'globals.dart'; class FlutterVersion { FlutterVersion([this._clock = const SystemClock()]) { _frameworkRevision = _runGit(gitLog(['-n', '1', '--pretty=format:%H']).join(' ')); - _frameworkVersion = GitTagVersion.determine().frameworkVersionFor(_frameworkRevision); + _gitTagVersion = GitTagVersion.determine(); + _frameworkVersion = gitTagVersion.frameworkVersionFor(_frameworkRevision); } final SystemClock _clock; @@ -75,6 +76,9 @@ class FlutterVersion { return _channel; } + GitTagVersion _gitTagVersion; + GitTagVersion get gitTagVersion => _gitTagVersion; + /// The name of the local branch. /// Use getBranchName() to read this. String _branch; diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ios_framework_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_framework_test.dart new file mode 100644 index 0000000000..3c9a5570d6 --- /dev/null +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_framework_test.dart @@ -0,0 +1,191 @@ +// 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'; + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/build_info.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/commands/build_ios_framework.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/version.dart'; +import 'package:mockito/mockito.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +void main() { + group('build ios-framework', () { + group('podspec', () { + MemoryFileSystem memoryFileSystem; + MockFlutterVersion mockFlutterVersion; + MockGitTagVersion mockGitTagVersion; + MockCache mockCache; + Directory outputDirectory; + const String storageBaseUrl = 'https://fake.googleapis.com'; + const String engineRevision = '0123456789abcdef'; + File licenseFile; + + setUp(() { + memoryFileSystem = MemoryFileSystem(); + mockFlutterVersion = MockFlutterVersion(); + mockGitTagVersion = MockGitTagVersion(); + mockCache = MockCache(); + + when(mockFlutterVersion.gitTagVersion).thenReturn(mockGitTagVersion); + outputDirectory = fs.systemTempDirectory + .createTempSync('flutter_build_ios_framework_test_output.') + .childDirectory('Debug') + ..createSync(); + + when(mockCache.storageBaseUrl).thenReturn(storageBaseUrl); + when(mockCache.engineRevision).thenReturn(engineRevision); + licenseFile = memoryFileSystem.file('LICENSE'); + when(mockCache.getLicenseFile()).thenReturn(licenseFile); + }); + + testUsingContext('version unknown', () async { + const String frameworkVersion = '0.0.0-unknown'; + when(mockFlutterVersion.frameworkVersion).thenReturn(frameworkVersion); + + final BuildIOSFrameworkCommand command = BuildIOSFrameworkCommand( + flutterVersion: mockFlutterVersion, + cache: mockCache + ); + + expect(() => command.produceFlutterPodspec(BuildMode.debug, outputDirectory), + throwsToolExit(message: 'Detected version is $frameworkVersion')); + }, overrides: { + FileSystem: () => memoryFileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('throws when not on a released version', () async { + const String frameworkVersion = 'v1.13.10+hotfix-pre.2'; + when(mockFlutterVersion.frameworkVersion).thenReturn(frameworkVersion); + + when(mockGitTagVersion.x).thenReturn(1); + when(mockGitTagVersion.y).thenReturn(13); + when(mockGitTagVersion.z).thenReturn(10); + when(mockGitTagVersion.hotfix).thenReturn(13); + when(mockGitTagVersion.commits).thenReturn(2); + + final BuildIOSFrameworkCommand command = BuildIOSFrameworkCommand( + flutterVersion: mockFlutterVersion, + cache: mockCache + ); + + expect(() => command.produceFlutterPodspec(BuildMode.debug, outputDirectory), + throwsToolExit(message: 'Detected version is $frameworkVersion')); + }, overrides: { + FileSystem: () => memoryFileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('throws when license not found', () async { + when(mockGitTagVersion.x).thenReturn(1); + when(mockGitTagVersion.y).thenReturn(13); + when(mockGitTagVersion.z).thenReturn(10); + when(mockGitTagVersion.hotfix).thenReturn(13); + when(mockGitTagVersion.commits).thenReturn(0); + + final BuildIOSFrameworkCommand command = BuildIOSFrameworkCommand( + flutterVersion: mockFlutterVersion, + cache: mockCache + ); + + expect(() => command.produceFlutterPodspec(BuildMode.debug, outputDirectory), + throwsToolExit(message: 'Could not find license')); + }, overrides: { + FileSystem: () => memoryFileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + group('is created', () { + const String frameworkVersion = 'v1.13.11+hotfix.13'; + const String licenseText = 'This is the license!'; + + setUp(() { + when(mockGitTagVersion.x).thenReturn(1); + when(mockGitTagVersion.y).thenReturn(13); + when(mockGitTagVersion.z).thenReturn(11); + when(mockGitTagVersion.hotfix).thenReturn(13); + when(mockGitTagVersion.commits).thenReturn(0); + + when(mockFlutterVersion.frameworkVersion).thenReturn(frameworkVersion); + + licenseFile + ..createSync(recursive: true) + ..writeAsStringSync(licenseText); + }); + + testUsingContext('contains license and version', () async { + final BuildIOSFrameworkCommand command = BuildIOSFrameworkCommand( + flutterVersion: mockFlutterVersion, + cache: mockCache + ); + command.produceFlutterPodspec(BuildMode.debug, outputDirectory); + + final File expectedPodspec = outputDirectory.childFile('Flutter.podspec'); + final String podspecContents = expectedPodspec.readAsStringSync(); + expect(podspecContents, contains('\'1.13.1113\'')); + expect(podspecContents, contains('# $frameworkVersion')); + expect(podspecContents, contains(licenseText)); + }, overrides: { + FileSystem: () => memoryFileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('debug URL', () async { + final BuildIOSFrameworkCommand command = BuildIOSFrameworkCommand( + flutterVersion: mockFlutterVersion, + cache: mockCache + ); + command.produceFlutterPodspec(BuildMode.debug, outputDirectory); + + final File expectedPodspec = outputDirectory.childFile('Flutter.podspec'); + final String podspecContents = expectedPodspec.readAsStringSync(); + expect(podspecContents, contains('\'$storageBaseUrl/flutter_infra/flutter/$engineRevision/ios/artifacts.zip\'')); + }, overrides: { + FileSystem: () => memoryFileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('profile URL', () async { + final BuildIOSFrameworkCommand command = BuildIOSFrameworkCommand( + flutterVersion: mockFlutterVersion, + cache: mockCache + ); + command.produceFlutterPodspec(BuildMode.profile, outputDirectory); + + final File expectedPodspec = outputDirectory.childFile('Flutter.podspec'); + final String podspecContents = expectedPodspec.readAsStringSync(); + expect(podspecContents, contains('\'$storageBaseUrl/flutter_infra/flutter/$engineRevision/ios-profile/artifacts.zip\'')); + }, overrides: { + FileSystem: () => memoryFileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('release URL', () async { + final BuildIOSFrameworkCommand command = BuildIOSFrameworkCommand( + flutterVersion: mockFlutterVersion, + cache: mockCache + ); + command.produceFlutterPodspec(BuildMode.release, outputDirectory); + + final File expectedPodspec = outputDirectory.childFile('Flutter.podspec'); + final String podspecContents = expectedPodspec.readAsStringSync(); + expect(podspecContents, contains('\'$storageBaseUrl/flutter_infra/flutter/$engineRevision/ios-release/artifacts.zip\'')); + }, overrides: { + FileSystem: () => memoryFileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + }); + }); + }); +} + +class MockFlutterVersion extends Mock implements FlutterVersion {} +class MockGitTagVersion extends Mock implements GitTagVersion {} +class MockCache extends Mock implements Cache {} diff --git a/packages/flutter_tools/test/general.shard/cache_test.dart b/packages/flutter_tools/test/general.shard/cache_test.dart index f686d34e40..f77cf52bbd 100644 --- a/packages/flutter_tools/test/general.shard/cache_test.dart +++ b/packages/flutter_tools/test/general.shard/cache_test.dart @@ -230,9 +230,7 @@ void main() { 'FLUTTER_STORAGE_BASE_URL': ' http://foo', }); final Cache cache = Cache(); - final CachedArtifact artifact = MaterialFonts(cache); - - expect(() => artifact.storageBaseUrl, throwsA(isInstanceOf())); + expect(() => cache.storageBaseUrl, throwsA(isInstanceOf())); }, overrides: { Platform: () => MockPlatform(), }); diff --git a/packages/flutter_tools/test/src/testbed.dart b/packages/flutter_tools/test/src/testbed.dart index 44c31cf538..476bb72d00 100644 --- a/packages/flutter_tools/test/src/testbed.dart +++ b/packages/flutter_tools/test/src/testbed.dart @@ -680,6 +680,9 @@ class FakeFlutterVersion implements FlutterVersion { @override String get frameworkVersion => null; + @override + GitTagVersion get gitTagVersion => null; + @override String getBranchName({bool redactUnknownBranches = false}) { return 'master'; @@ -829,6 +832,9 @@ class FakeCache implements Cache { @override String get dartSdkVersion => null; + @override + String get storageBaseUrl => null; + @override MapEntry get dyLdLibEntry => null; @@ -860,6 +866,11 @@ class FakeCache implements Cache { return fs.currentDirectory; } + @override + File getLicenseFile() { + return fs.currentDirectory.childFile('LICENSE'); + } + @override File getStampFileFor(String artifactName) { throw UnsupportedError('Not supported in the fake Cache');