From 5e0aa8b9fc423a2e05d47fba0cfe1ef3f47cb303 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 2 Sep 2020 17:57:43 -0700 Subject: [PATCH] Add observatory Bonjour service to built iOS Info.plist bundle (#65138) --- .../tasks/ios_content_validation_test.dart | 62 ++++-- dev/devicelab/lib/framework/ios.dart | 36 +++ packages/flutter_tools/bin/xcode_backend.sh | 81 +++++-- .../general.shard/ios/xcode_backend_test.dart | 64 ------ .../integration.shard/xcode_backend_test.dart | 206 ++++++++++++++++++ 5 files changed, 352 insertions(+), 97 deletions(-) delete mode 100644 packages/flutter_tools/test/general.shard/ios/xcode_backend_test.dart create mode 100644 packages/flutter_tools/test/integration.shard/xcode_backend_test.dart diff --git a/dev/devicelab/bin/tasks/ios_content_validation_test.dart b/dev/devicelab/bin/tasks/ios_content_validation_test.dart index 2f120975e50..50595f693b5 100644 --- a/dev/devicelab/bin/tasks/ios_content_validation_test.dart +++ b/dev/devicelab/bin/tasks/ios_content_validation_test.dart @@ -14,8 +14,6 @@ import 'package:path/path.dart' as path; Future main() async { await task(() async { try { - bool foundProjectName = false; - bool bitcode = false; await runProjectTest((FlutterProject flutterProject) async { section('Build app with with --obfuscate'); await inDirectory(flutterProject.rootPath, () async { @@ -52,6 +50,13 @@ Future main() async { fail('Failed to produce expected output at ${outputAppFrameworkBinary.path}'); } + if (await dartObservatoryBonjourServiceFound(outputAppPath)) { + throw TaskResult.failure('Release bundle has unexpected NSBonjourServices'); + } + if (await localNetworkUsageFound(outputAppPath)) { + throw TaskResult.failure('Release bundle has unexpected NSLocalNetworkUsageDescription'); + } + section('Validate obfuscation'); // Verify that an identifier from the Dart project code is not present @@ -63,11 +68,11 @@ Future main() async { canFail: true, ); if (response.trim().contains('matches')) { - foundProjectName = true; + throw TaskResult.failure('Found project name in obfuscated dart library'); } }); - section('Validate bitcode'); + section('Validate release contents'); final Directory outputFlutterFramework = Directory(path.join( flutterProject.rootPath, @@ -83,7 +88,13 @@ Future main() async { if (!outputFlutterFrameworkBinary.existsSync()) { fail('Failed to produce expected output at ${outputFlutterFrameworkBinary.path}'); } - bitcode = await containsBitcode(outputFlutterFrameworkBinary.path); + + // Archiving should contain a bitcode blob, but not building in release. + // This mimics Xcode behavior and present a developer from having to install a + // 300+MB app to test devices. + if (await containsBitcode(outputFlutterFrameworkBinary.path)) { + throw TaskResult.failure('Bitcode present in Flutter.framework'); + } section('Xcode backend script'); @@ -101,7 +112,7 @@ Future main() async { 'xcode_backend.sh' ); - // Simulate a commonly Xcode build setting misconfiguration + // Simulate a common Xcode build setting misconfiguration // where FLUTTER_APPLICATION_PATH is missing final int result = await exec( xcodeBackendPath, @@ -111,6 +122,7 @@ Future main() async { 'TARGET_BUILD_DIR': buildPath, 'FRAMEWORKS_FOLDER_PATH': 'Runner.app/Frameworks', 'VERBOSE_SCRIPT_LOGGING': '1', + 'FLUTTER_BUILD_MODE': 'release', 'ACTION': 'install', // Skip bitcode stripping since we just checked that above. }, ); @@ -126,17 +138,35 @@ Future main() async { if (!outputAppFrameworkBinary.existsSync()) { fail('Failed to re-embed ${outputAppFrameworkBinary.path}'); } - }); - if (foundProjectName) { - return TaskResult.failure('Found project name in obfuscated dart library'); - } - // Archiving should contain a bitcode blob, but not building in release. - // This mimics Xcode behavior and present a developer from having to install a - // 300+MB app to test devices. - if (bitcode) { - return TaskResult.failure('Bitcode present in Flutter.framework'); - } + section('Clean build'); + + await inDirectory(flutterProject.rootPath, () async { + await flutter('clean'); + }); + + section('Validate debug contents'); + + await inDirectory(flutterProject.rootPath, () async { + await flutter('build', options: [ + 'ios', + '--debug', + '--no-codesign', + ]); + }); + + // Debug should also not contain bitcode. + if (await containsBitcode(outputFlutterFrameworkBinary.path)) { + throw TaskResult.failure('Bitcode present in Flutter.framework'); + } + + if (!await dartObservatoryBonjourServiceFound(outputAppPath)) { + throw TaskResult.failure('Debug bundle is missing NSBonjourServices'); + } + if (!await localNetworkUsageFound(outputAppPath)) { + throw TaskResult.failure('Debug bundle is missing NSLocalNetworkUsageDescription'); + } + }); return TaskResult.success(null); } on TaskResult catch (taskResult) { diff --git a/dev/devicelab/lib/framework/ios.dart b/dev/devicelab/lib/framework/ios.dart index 5ea618b3344..38be6612caf 100644 --- a/dev/devicelab/lib/framework/ios.dart +++ b/dev/devicelab/lib/framework/ios.dart @@ -5,6 +5,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:path/path.dart' as path; + import 'utils.dart'; typedef SimulatorFunction = Future Function(String deviceId); @@ -102,6 +104,40 @@ Future containsBitcode(String pathToBinary) async { return !emptyBitcodeMarkerFound; } +Future dartObservatoryBonjourServiceFound(String appBundlePath) async => + (await eval( + 'plutil', + [ + '-extract', + 'NSBonjourServices', + 'xml1', + '-o', + '-', + path.join( + appBundlePath, + 'Info.plist', + ), + ], + canFail: true, + )).contains('_dartobservatory._tcp'); + +Future localNetworkUsageFound(String appBundlePath) async => + await exec( + 'plutil', + [ + '-extract', + 'NSLocalNetworkUsageDescription', + 'xml1', + '-o', + '-', + path.join( + appBundlePath, + 'Info.plist', + ), + ], + canFail: true, + ) == 0; + /// Creates and boots a new simulator, passes the new simulator's identifier to /// `testFunction`. /// diff --git a/packages/flutter_tools/bin/xcode_backend.sh b/packages/flutter_tools/bin/xcode_backend.sh index b1e971cfde3..4d70f55029e 100755 --- a/packages/flutter_tools/bin/xcode_backend.sh +++ b/packages/flutter_tools/bin/xcode_backend.sh @@ -38,6 +38,32 @@ AssertExists() { return 0 } +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. + local build_mode="$(echo "${FLUTTER_BUILD_MODE:-${CONFIGURATION}}" | tr "[:upper:]" "[:lower:]")" + + case "$build_mode" in + *release*) build_mode="release";; + *profile*) build_mode="profile";; + *debug*) build_mode="debug";; + *) + EchoError "========================================================================" + EchoError "ERROR: Unknown FLUTTER_BUILD_MODE: ${build_mode}." + 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 (${CONFIGURATION})." + EchoError "========================================================================" + exit -1;; + esac + echo "${build_mode}" +} + BuildApp() { local project_path="${SOURCE_ROOT}/.." if [[ -n "$FLUTTER_APPLICATION_PATH" ]]; then @@ -72,24 +98,12 @@ BuildApp() { # 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. - local build_mode="$(echo "${FLUTTER_BUILD_MODE:-${CONFIGURATION}}" | tr "[:upper:]" "[:lower:]")" + local build_mode="$(ParseFlutterBuildMode)" local artifact_variant="unknown" case "$build_mode" in - *release*) build_mode="release"; artifact_variant="ios-release";; - *profile*) build_mode="profile"; artifact_variant="ios-profile";; - *debug*) build_mode="debug"; artifact_variant="ios";; - *) - EchoError "========================================================================" - EchoError "ERROR: Unknown FLUTTER_BUILD_MODE: ${build_mode}." - 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 (${CONFIGURATION})." - EchoError "========================================================================" - exit -1;; + release ) artifact_variant="ios-release";; + profile ) artifact_variant="ios-profile";; + debug ) artifact_variant="ios";; esac # Warn the user if not archiving (ACTION=install) in release mode. @@ -127,7 +141,7 @@ is set to release or run \"flutter build ios --release\", then re-run Archive fr fi local bitcode_flag="" - if [[ $ENABLE_BITCODE == "YES" ]]; then + if [[ "$ENABLE_BITCODE" == "YES" ]]; then bitcode_flag="true" fi @@ -306,6 +320,36 @@ EmbedFlutterFrameworks() { RunCommand codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${xcode_frameworks_dir}/App.framework/App" RunCommand codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${xcode_frameworks_dir}/Flutter.framework/Flutter" fi + + AddObservatoryBonjourService +} + +# Add the observatory publisher Bonjour service to the produced app bundle Info.plist. +AddObservatoryBonjourService() { + local build_mode="$(ParseFlutterBuildMode)" + # Debug and profile only. + if [[ "${build_mode}" == "release" ]]; then + return + fi + local built_products_plist="${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}" + + if [[ ! -f "${built_products_plist}" ]]; then + EchoError "error: ${INFOPLIST_PATH} does not exist. The Flutter \"Thin Binary\" build phase must run after \"Copy Bundle Resources\"." + exit -1 + fi + # If there are already NSBonjourServices specified by the app (uncommon), insert the observatory service name to the existing list. + if plutil -extract NSBonjourServices xml1 -o - "${built_products_plist}"; then + RunCommand plutil -insert NSBonjourServices.0 -string "_dartobservatory._tcp" "${built_products_plist}" + else + # Otherwise, add the NSBonjourServices key and observatory service name. + RunCommand plutil -insert NSBonjourServices -json "[\"_dartobservatory._tcp\"]" "${built_products_plist}" + 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. + if ! plutil -extract NSLocalNetworkUsageDescription xml1 -o - "${built_products_plist}"; then + RunCommand plutil -insert NSLocalNetworkUsageDescription -string "Allow Flutter tools on your computer to connect and debug your application. This prompt will not appear on release builds." "${built_products_plist}" + fi } EmbedAndThinFrameworks() { @@ -328,5 +372,8 @@ else EmbedFlutterFrameworks ;; "embed_and_thin") EmbedAndThinFrameworks ;; + "test_observatory_bonjour_service") + # Exposed for integration testing only. + AddObservatoryBonjourService ;; esac fi diff --git a/packages/flutter_tools/test/general.shard/ios/xcode_backend_test.dart b/packages/flutter_tools/test/general.shard/ios/xcode_backend_test.dart deleted file mode 100644 index b350cde3844..00000000000 --- a/packages/flutter_tools/test/general.shard/ios/xcode_backend_test.dart +++ /dev/null @@ -1,64 +0,0 @@ -// 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:flutter_tools/src/base/io.dart'; - -import '../../src/common.dart'; - -const String xcodeBackendPath = 'bin/xcode_backend.sh'; -const String xcodeBackendErrorHeader = '========================================================================'; - -// Acceptable $CONFIGURATION/$FLUTTER_BUILD_MODE values should be debug, profile, or release -const Map unknownConfiguration = { - 'CONFIGURATION': 'Custom', -}; - -// $FLUTTER_BUILD_MODE will override $CONFIGURATION -const Map unknownFlutterBuildMode = { - 'FLUTTER_BUILD_MODE': 'Custom', - 'CONFIGURATION': 'Debug', -}; - -// Can't archive a non-release build. -const Map installWithoutRelease = { - 'CONFIGURATION': 'Debug', - 'ACTION': 'install', -}; - -// Can't use a debug engine build with a release build. -const Map localEngineDebugBuildModeRelease = { - 'SOURCE_ROOT': '../../../examples/hello_world', - 'FLUTTER_ROOT': '../../..', - 'LOCAL_ENGINE': '/engine/src/out/ios_debug_unopt', - 'CONFIGURATION': 'Release', -}; - -// Can't use a debug build with a profile engine. -const Map localEngineProfileBuildeModeRelease = { - 'SOURCE_ROOT': '../../../examples/hello_world', - 'FLUTTER_ROOT': '../../..', - 'LOCAL_ENGINE': '/engine/src/out/ios_profile', - 'CONFIGURATION': 'Debug', - 'FLUTTER_BUILD_MODE': 'Debug', -}; - -void main() { - Future expectXcodeBackendFails(Map environment) async { - final ProcessResult result = await Process.run( - xcodeBackendPath, - ['build'], - environment: environment, - ); - expect(result.stderr, startsWith(xcodeBackendErrorHeader)); - expect(result.exitCode, isNot(0)); - } - - test('Xcode backend fails for on unsupported configuration combinations', () async { - await expectXcodeBackendFails(unknownConfiguration); - await expectXcodeBackendFails(unknownFlutterBuildMode); - await expectXcodeBackendFails(installWithoutRelease); - await expectXcodeBackendFails(localEngineDebugBuildModeRelease); - await expectXcodeBackendFails(localEngineProfileBuildeModeRelease); - }, skip: true); // https://github.com/flutter/flutter/issues/35707 (non-hermetic test requires precache to have run) -} diff --git a/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart b/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart new file mode 100644 index 00000000000..11feaf6a5f1 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart @@ -0,0 +1,206 @@ +// 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' as io; + +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; + +import '../src/common.dart'; + +const String xcodeBackendPath = 'bin/xcode_backend.sh'; +const String xcodeBackendErrorHeader = '========================================================================'; + +// Acceptable $CONFIGURATION/$FLUTTER_BUILD_MODE values should be debug, profile, or release +const Map unknownConfiguration = { + 'CONFIGURATION': 'Custom', +}; + +// $FLUTTER_BUILD_MODE will override $CONFIGURATION +const Map unknownFlutterBuildMode = { + 'FLUTTER_BUILD_MODE': 'Custom', + 'CONFIGURATION': 'Debug', +}; + +// Can't use a debug engine build with a release build. +const Map localEngineDebugBuildModeRelease = { + 'SOURCE_ROOT': '../examples/hello_world', + 'FLUTTER_ROOT': '../..', + 'LOCAL_ENGINE': '/engine/src/out/ios_debug_unopt', + 'CONFIGURATION': 'Release', +}; + +// Can't use a debug build with a profile engine. +const Map localEngineProfileBuildeModeRelease = { + 'SOURCE_ROOT': '../examples/hello_world', + 'FLUTTER_ROOT': '../..', + 'LOCAL_ENGINE': '/engine/src/out/ios_profile', + 'CONFIGURATION': 'Debug', + 'FLUTTER_BUILD_MODE': 'Debug', +}; + +void main() { + Future expectXcodeBackendFails(Map environment) async { + final ProcessResult result = await Process.run( + xcodeBackendPath, + ['build'], + environment: environment, + ); + expect(result.stderr, startsWith(xcodeBackendErrorHeader)); + expect(result.exitCode, isNot(0)); + } + + test('Xcode backend fails with no arguments', () async { + final ProcessResult result = await Process.run( + xcodeBackendPath, + [], + environment: { + 'SOURCE_ROOT': '../examples/hello_world', + 'FLUTTER_ROOT': '../..', + }, + ); + expect(result.stderr, startsWith('error: Your Xcode project is incompatible with this version of Flutter.')); + expect(result.exitCode, isNot(0)); + }, skip: !io.Platform.isMacOS); + + test('Xcode backend fails for on unsupported configuration combinations', () async { + await expectXcodeBackendFails(unknownConfiguration); + await expectXcodeBackendFails(unknownFlutterBuildMode); + await expectXcodeBackendFails(localEngineDebugBuildModeRelease); + await expectXcodeBackendFails(localEngineProfileBuildeModeRelease); + }, skip: !io.Platform.isMacOS); + + test('Xcode backend warns archiving a non-release build.', () async { + final ProcessResult result = await Process.run( + xcodeBackendPath, + ['build'], + environment: { + 'CONFIGURATION': 'Debug', + 'ACTION': 'install', + }, + ); + expect(result.stdout, contains('warning: Flutter archive not built in Release mode.')); + expect(result.exitCode, isNot(0)); + }, skip: !io.Platform.isMacOS); + + group('observatory Bonjour service keys', () { + Directory buildDirectory; + File infoPlist; + + setUp(() { + buildDirectory = globals.fs.systemTempDirectory.createTempSync('flutter_tools_xcode_backend_test.'); + infoPlist = buildDirectory.childFile('Info.plist'); + }); + + test('fails when the Info.plist is missing', () async { + final ProcessResult result = await Process.run( + xcodeBackendPath, + ['test_observatory_bonjour_service'], + environment: { + 'CONFIGURATION': 'Debug', + 'BUILT_PRODUCTS_DIR': buildDirectory.path, + 'INFOPLIST_PATH': 'Info.plist', + }, + ); + expect(result.stderr, startsWith('error: Info.plist does not exist.')); + expect(result.exitCode, isNot(0)); + }); + + const String emptyPlist = ''' + + + + + +'''; + + test('does not add keys in Release', () async { + infoPlist.writeAsStringSync(emptyPlist); + + final ProcessResult result = await Process.run( + xcodeBackendPath, + ['test_observatory_bonjour_service'], + environment: { + 'CONFIGURATION': 'Release', + 'BUILT_PRODUCTS_DIR': buildDirectory.path, + 'INFOPLIST_PATH': 'Info.plist', + }, + ); + + final String actualInfoPlist = infoPlist.readAsStringSync(); + expect(actualInfoPlist, isNot(contains('NSBonjourServices'))); + expect(actualInfoPlist, isNot(contains('dartobservatory'))); + expect(actualInfoPlist, isNot(contains('NSLocalNetworkUsageDescription'))); + + expect(result.exitCode, 0); + }); + + for (final String buildConfiguration in ['Debug', 'Profile']) { + test('add keys in $buildConfiguration', () async { + infoPlist.writeAsStringSync(emptyPlist); + + final ProcessResult result = await Process.run( + xcodeBackendPath, + ['test_observatory_bonjour_service'], + environment: { + 'CONFIGURATION': buildConfiguration, + 'BUILT_PRODUCTS_DIR': buildDirectory.path, + 'INFOPLIST_PATH': 'Info.plist', + }, + ); + + final String actualInfoPlist = infoPlist.readAsStringSync(); + expect(actualInfoPlist, contains('NSBonjourServices')); + expect(actualInfoPlist, contains('dartobservatory')); + expect(actualInfoPlist, contains('NSLocalNetworkUsageDescription')); + + expect(result.exitCode, 0); + }); + } + + test('adds to existing Bonjour services, does not override network usage description', () async { + infoPlist.writeAsStringSync(''' + + + + + NSBonjourServices + + _bogus._tcp + + NSLocalNetworkUsageDescription + Don't override this + +'''); + + final ProcessResult result = await Process.run( + xcodeBackendPath, + ['test_observatory_bonjour_service'], + environment: { + 'CONFIGURATION': 'Debug', + 'BUILT_PRODUCTS_DIR': buildDirectory.path, + 'INFOPLIST_PATH': 'Info.plist', + }, + ); + + expect(infoPlist.readAsStringSync(), ''' + + + + + NSBonjourServices + + _dartobservatory._tcp + _bogus._tcp + + NSLocalNetworkUsageDescription + Don't override this + + +'''); + expect(result.exitCode, 0); + }); + }, skip: !io.Platform.isMacOS); +}