From b232a84b0d122418652610d014268564301a37bd Mon Sep 17 00:00:00 2001 From: xster Date: Mon, 15 May 2017 12:54:32 -0700 Subject: [PATCH] Auto provision iOS deploy 1/3 - find and use the first valid code signing certs (#9946) * blind wrote everything except the user prompt * works * Add some logical refinements * Make certificates unique and add more instructinos * print more info * Add test * use string is empty * review notes * some formatting around commands * add a newline --- .../lib/src/application_package.dart | 10 +- packages/flutter_tools/lib/src/ios/mac.dart | 124 +++++++++++++- .../flutter_tools/lib/src/ios/xcodeproj.dart | 5 +- .../flutter_tools/test/src/ios/mac_test.dart | 158 ++++++++++++++++++ 4 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 packages/flutter_tools/test/src/ios/mac_test.dart diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart index fbdc2b68e94..09b87375c79 100644 --- a/packages/flutter_tools/lib/src/application_package.dart +++ b/packages/flutter_tools/lib/src/application_package.dart @@ -181,11 +181,13 @@ abstract class IOSApp extends ApplicationPackage { if (id == null) return null; final String projectPath = fs.path.join('ios', 'Runner.xcodeproj'); - id = substituteXcodeVariables(id, projectPath, 'Runner'); + final Map buildSettings = getXcodeBuildSettings(projectPath, 'Runner'); + id = substituteXcodeVariables(id, buildSettings); return new BuildableIOSApp( appDirectory: fs.path.join('ios'), - projectBundleId: id + projectBundleId: id, + buildSettings: buildSettings, ); } @@ -203,10 +205,14 @@ class BuildableIOSApp extends IOSApp { BuildableIOSApp({ this.appDirectory, String projectBundleId, + this.buildSettings, }) : super(projectBundleId: projectBundleId); final String appDirectory; + /// Build settings of the app's XCode project. + final Map buildSettings; + @override String get name => kBundleName; diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 413b33ac929..00e4e2eb6d6 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -3,9 +3,10 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:convert' show JSON; +import 'dart:convert' show JSON, UTF8; import 'package:meta/meta.dart'; +import 'package:quiver/strings.dart'; import '../application_package.dart'; import '../base/common.dart'; @@ -145,6 +146,10 @@ Future buildXcodeProject({ return new XcodeBuildResult(success: false); } + String developmentTeam; + if (codesign && mode != BuildMode.release && buildForDevice) + developmentTeam = await getCodeSigningIdentityDevelopmentTeam(app); + // Before the build, all service definitions must be updated and the dylibs // copied over to a location that is suitable for Xcodebuild to find them. final Directory appDirectory = fs.directory(app.appDirectory); @@ -171,6 +176,9 @@ Future buildXcodeProject({ 'ONLY_ACTIVE_ARCH=YES', ]; + if (developmentTeam != null) + commands.add('DEVELOPMENT_TEAM=$developmentTeam'); + final List contents = fs.directory(app.appDirectory).listSync(); for (FileSystemEntity entity in contents) { if (fs.path.extension(entity.path) == '.xcworkspace') { @@ -281,7 +289,7 @@ Future diagnoseXcodeBuildFailure(XcodeBuildResult result) async { !checkBuildSettings.stdout?.contains(new RegExp(r'\bPROVISIONING_PROFILE\b')) == true) { printError(''' ═══════════════════════════════════════════════════════════════════════════════════ -Building an iOS app requires a selected Development Team with a Provisioning Profile +Building a deployable iOS app requires a selected Development Team with a Provisioning Profile Please ensure that a Development Team is selected by: 1- Opening the Flutter project's Xcode target with open ios/Runner.xcworkspace @@ -354,6 +362,118 @@ bool _checkXcodeVersion() { return true; } +final RegExp _securityFindIdentityDeveloperIdentityExtractionPattern = + new RegExp(r'^\s*\d+\).+"(.+Developer.+)"$'); +final RegExp _securityFindIdentityCertificateCnExtractionPattern = new RegExp(r'.*\(([a-zA-Z0-9]+)\)'); +final RegExp _certificateOrganizationalUnitExtractionPattern = new RegExp(r'OU=([a-zA-Z0-9]+)'); + +/// Given a [BuildableIOSApp], this will try to find valid development code +/// signing identities in the user's keychain prompting a choice if multiple +/// are found. +/// +/// Will return null if none are found, if the user cancels or if the Xcode +/// project has a development team set in the project's build settings. +Future getCodeSigningIdentityDevelopmentTeam(BuildableIOSApp iosApp) async{ + if (iosApp.buildSettings == null) + return null; + + // If the user already has it set in the project build settings itself, + // continue with that. + if (isNotEmpty(iosApp.buildSettings['DEVELOPMENT_TEAM'])) { + printStatus( + 'Automatically signing iOS for device deployment using specified development ' + 'team in Xcode project: ${iosApp.buildSettings['DEVELOPMENT_TEAM']}' + ); + return null; + } + + if (isNotEmpty(iosApp.buildSettings['PROVISIONING_PROFILE'])) + return null; + + // If the user's environment is missing the tools needed to find and read + // certificates, abandon. Tools should be pre-equipped on macOS. + if (!exitsHappy(['which', 'security']) + || !exitsHappy(['which', 'openssl'])) + return null; + + final List findIdentityCommand = + ['security', 'find-identity', '-p', 'codesigning', '-v']; + final List validCodeSigningIdentities = runCheckedSync(findIdentityCommand) + .split('\n') + .map((String outputLine) { + return _securityFindIdentityDeveloperIdentityExtractionPattern.firstMatch(outputLine)?.group(1); + }) + .where((String identityCN) => isNotEmpty(identityCN)) + .toSet() // Unique. + .toList(); + + final String signingIdentity = _chooseSigningIdentity(validCodeSigningIdentities); + + // If none are chosen. + if (signingIdentity == null) + return null; + + printStatus('Signing iOS app for device deployment using developer identity: "$signingIdentity"'); + + final String signingCertificateId = + _securityFindIdentityCertificateCnExtractionPattern.firstMatch(signingIdentity)?.group(1); + + // If `security`'s output format changes, we'd have to update this + if (signingCertificateId == null) + return null; + + final String signingCertificate = runCheckedSync( + ['security', 'find-certificate', '-c', signingCertificateId, '-p'] + ); + + final Process opensslProcess = await runCommand( + ['openssl', 'x509', '-subject'] + ); + opensslProcess.stdin + ..write(signingCertificate) + ..close(); + + final String opensslOutput = await UTF8.decodeStream(opensslProcess.stdout); + opensslProcess.stderr.drain(); + + if (await opensslProcess.exitCode != 0) { + return null; + } + + return _certificateOrganizationalUnitExtractionPattern.firstMatch(opensslOutput)?.group(1); +} + +String _chooseSigningIdentity(List validCodeSigningIdentities) { + // The user has no valid code signing identities. + if (validCodeSigningIdentities.isEmpty) { + printError( + ''' +═══════════════════════════════════════════════════════════════════════════════════ +No valid code signing certificates were found +Please ensure that you have a valid Development Team with valid iOS Development Certificates +associated with your Apple ID by: + 1- Opening the Xcode application + 2- Go to Xcode->Preferences->Accounts + 3- Make sure that you're signed in with your Apple ID via the '+' button on the bottom left + 4- Make sure that you have development certificates available by signing up to Apple + Developer Program and/or downloading available profiles as needed. +For more information, please visit: + https://developer.apple.com/library/content/documentation/IDEs/Conceptual/AppDistributionGuide/MaintainingCertificates/MaintainingCertificates.html + +Or run on an iOS simulator without code signing +═══════════════════════════════════════════════════════════════════════════════════''', + emphasis: true + ); + throwToolExit('No development certificates available to code sign app for device deployment'); + } + + // TODO(xster): let the user choose one. + if (validCodeSigningIdentities.isNotEmpty) + return validCodeSigningIdentities.first; + + return null; +} + final String noCocoaPodsConsequence = ''' CocoaPods is used to retrieve the iOS platform side's plugin code that responds to your plugin usage on the Dart side. Without resolving iOS dependencies with CocoaPods, plugins will not work on iOS. diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart index aa77254abbb..6b660671fde 100644 --- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart +++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart @@ -77,11 +77,10 @@ Map getXcodeBuildSettings(String xcodeProjPath, String target) { /// Substitutes variables in [str] with their values from the specified Xcode /// project and target. -String substituteXcodeVariables(String str, String xcodeProjPath, String target) { +String substituteXcodeVariables(String str, Map xcodeBuildSettings) { final Iterable matches = _varExpr.allMatches(str); if (matches.isEmpty) return str; - final Map settings = getXcodeBuildSettings(xcodeProjPath, target); - return str.replaceAllMapped(_varExpr, (Match m) => settings[m[1]] ?? m[0]); + return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]] ?? m[0]); } diff --git a/packages/flutter_tools/test/src/ios/mac_test.dart b/packages/flutter_tools/test/src/ios/mac_test.dart new file mode 100644 index 00000000000..5c801ad146a --- /dev/null +++ b/packages/flutter_tools/test/src/ios/mac_test.dart @@ -0,0 +1,158 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:convert'; + +import 'package:mockito/mockito.dart'; +import 'package:flutter_tools/src/application_package.dart'; +import 'package:flutter_tools/src/base/common.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:process/process.dart'; +import 'package:test/test.dart'; + +import '../context.dart'; + +void main() { + group('Auto signing', () { + ProcessManager mockProcessManager; + BuildableIOSApp app; + + setUp(() { + mockProcessManager = new MockProcessManager(); + app = new BuildableIOSApp( + projectBundleId: 'test.app', + buildSettings: { + 'For our purposes': 'a non-empty build settings map is valid', + }, + ); + }); + + testUsingContext('No auto-sign if Xcode project settings are not available', () async { + app = new BuildableIOSApp(projectBundleId: 'test.app'); + final String developmentTeam = await getCodeSigningIdentityDevelopmentTeam(app); + expect(developmentTeam, isNull); + }); + + testUsingContext('No discovery if development team specified in Xcode project', () async { + app = new BuildableIOSApp( + projectBundleId: 'test.app', + buildSettings: { + 'DEVELOPMENT_TEAM': 'abc', + }, + ); + final String developmentTeam = await getCodeSigningIdentityDevelopmentTeam(app); + expect(developmentTeam, isNull); + expect(testLogger.statusText, equals( + 'Automatically signing iOS for device deployment using specified development team in Xcode project: abc\n' + )); + }); + + testUsingContext('No auto-sign if security or openssl not available', () async { + when(mockProcessManager.runSync(['which', 'security'])) + .thenReturn(exitsFail); + final String developmentTeam = await getCodeSigningIdentityDevelopmentTeam(app); + expect(developmentTeam, isNull); + }, + overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('No valid code signing certificates shows instructions', () async { + when(mockProcessManager.runSync(['which', 'security'])) + .thenReturn(exitsHappy); + when(mockProcessManager.runSync(['which', 'openssl'])) + .thenReturn(exitsHappy); + when(mockProcessManager.runSync( + argThat(contains('find-identity')), environment: any, workingDirectory: any) + ).thenReturn(exitsHappy); + + String developmentTeam; + try { + developmentTeam = await getCodeSigningIdentityDevelopmentTeam(app); + fail('No identity should throw tool error'); + } on ToolExit { + expect(developmentTeam, isNull); + expect(testLogger.errorText, contains('No valid code signing certificates were found')); + } + }, + overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('Test extract identity and certificate organization works', () async { + when(mockProcessManager.runSync(['which', 'security'])) + .thenReturn(exitsHappy); + when(mockProcessManager.runSync(['which', 'openssl'])) + .thenReturn(exitsHappy); + when(mockProcessManager.runSync( + argThat(contains('find-identity')), environment: any, workingDirectory: any, + )).thenReturn(new ProcessResult( + 1, // pid + 0, // exitCode + ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" +2) da4b9237bacccdf19c0760cab7aec4a8359010b0 "iPhone Developer: Profile 2 (2222BBBB22)" + 2 valid identities found''', + '' + )); + when(mockProcessManager.runSync( + ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], + environment: any, + workingDirectory: any, + )).thenReturn(new ProcessResult( + 1, // pid + 0, // exitCode + 'This is a mock certificate', + '', + )); + + final MockProcess mockProcess = new MockProcess(); + final MockStdIn mockStdIn = new MockStdIn(); + final MockStream mockStdErr = new MockStream(); + + when(mockProcessManager.start( + argThat(contains('openssl')), environment: any, workingDirectory: any, + )).thenReturn(new Future.value(mockProcess)); + + when(mockProcess.stdin).thenReturn(mockStdIn); + when(mockProcess.stdout).thenReturn(new Stream>.fromFuture( + new Future>.value(UTF8.encode( + 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US' + )) + )); + when(mockProcess.stderr).thenReturn(mockStdErr); + when(mockProcess.exitCode).thenReturn(0); + + final String developmentTeam = await getCodeSigningIdentityDevelopmentTeam(app); + + expect(testLogger.statusText, contains('iPhone Developer: Profile 1 (1111AAAA11)')); + expect(testLogger.errorText, isEmpty); + verify(mockStdIn.write('This is a mock certificate')); + expect(developmentTeam, '3333CCCC33'); + }, + overrides: { + ProcessManager: () => mockProcessManager, + }); + }); +} + +final ProcessResult exitsHappy = new ProcessResult( + 1, // pid + 0, // exitCode + '', // stdout + '', // stderr +); + +final ProcessResult exitsFail = new ProcessResult( + 2, // pid + 1, // exitCode + '', // stdout + '', // stderr +); + +class MockProcessManager extends Mock implements ProcessManager {} +class MockProcess extends Mock implements Process {} +class MockStream extends Mock implements Stream> {} +class MockStdIn extends Mock implements IOSink {}