diff --git a/packages/flutterfire_cli/lib/src/commands/config.dart b/packages/flutterfire_cli/lib/src/commands/config.dart index 5b2b5d19..8979d12e 100644 --- a/packages/flutterfire_cli/lib/src/commands/config.dart +++ b/packages/flutterfire_cli/lib/src/commands/config.dart @@ -665,6 +665,7 @@ class ConfigCommand extends FlutterFireCommand { buildConfiguration: iosInputs?.buildConfiguration, target: iosInputs?.target, platform: kIos, + xcodeProjectPath: iosInputs!.xcodeProjectPath, projectConfiguration: iosInputs!.projectConfiguration, ); @@ -682,6 +683,7 @@ class ConfigCommand extends FlutterFireCommand { buildConfiguration: macosInputs?.buildConfiguration, target: macosInputs?.target, platform: kMacos, + xcodeProjectPath: macosInputs!.xcodeProjectPath, projectConfiguration: macosInputs!.projectConfiguration, ); diff --git a/packages/flutterfire_cli/lib/src/commands/reconfigure.dart b/packages/flutterfire_cli/lib/src/commands/reconfigure.dart index b86ad313..0e9c7e76 100644 --- a/packages/flutterfire_cli/lib/src/commands/reconfigure.dart +++ b/packages/flutterfire_cli/lib/src/commands/reconfigure.dart @@ -135,6 +135,10 @@ class Reconfigure extends FlutterFireCommand { // ios or macos String platform, ) async { + final xcodeProjectPath = getXcodeProjectPath( + Directory(flutterApp!.package.path), + platform, + ); final appleMapKeys = [ kFlutter, kPlatforms, @@ -155,6 +159,7 @@ class Reconfigure extends FlutterFireCommand { flutterAppPath: flutterApp!.package.path, platform: platform, logger: logger, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: ProjectConfiguration.buildConfiguration, isDevDependency: flutterApp!.dependsOnPackage('flutterfire_cli'), ); @@ -196,6 +201,7 @@ class Reconfigure extends FlutterFireCommand { flutterAppPath: flutterApp!.package.path, platform: platform, logger: logger, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: ProjectConfiguration.defaultConfig, isDevDependency: flutterApp!.dependsOnPackage('flutterfire_cli'), ); @@ -231,6 +237,7 @@ class Reconfigure extends FlutterFireCommand { flutterAppPath: flutterApp!.package.path, platform: platform, logger: logger, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: ProjectConfiguration.target, isDevDependency: flutterApp!.dependsOnPackage('flutterfire_cli'), ); diff --git a/packages/flutterfire_cli/lib/src/common/inputs.dart b/packages/flutterfire_cli/lib/src/common/inputs.dart index cb246da5..e49d9c15 100644 --- a/packages/flutterfire_cli/lib/src/common/inputs.dart +++ b/packages/flutterfire_cli/lib/src/common/inputs.dart @@ -22,11 +22,13 @@ class AppleInputs { AppleInputs({ this.buildConfiguration, this.target, + required this.xcodeProjectPath, required this.serviceFilePath, required this.projectConfiguration, }); final String? buildConfiguration; final String? target; + final String xcodeProjectPath; final String serviceFilePath; ProjectConfiguration projectConfiguration; } diff --git a/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart b/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart index 538f8c88..24b60b9f 100644 --- a/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart +++ b/packages/flutterfire_cli/lib/src/common/prompts/apple_prompts.dart @@ -43,10 +43,11 @@ String getAppleServiceFile( Future promptCheckBuildConfiguration( String buildConfiguration, String platform, + String xcodeProjectPath, ) async { final buildConfigurations = await findBuildConfigurationsAvailable( platform, - getXcodeProjectPath(platform), + xcodeProjectPath, ); if (!buildConfigurations.contains(buildConfiguration)) { @@ -66,10 +67,13 @@ Future promptCheckBuildConfiguration( return buildConfiguration; } -Future promptGetBuildConfiguration(String platform) async { +Future promptGetBuildConfiguration( + String platform, + String xcodeProjectPath, +) async { final buildConfigurations = await findBuildConfigurationsAvailable( platform, - getXcodeProjectPath(platform), + xcodeProjectPath, ); final response = promptSelect( @@ -80,9 +84,11 @@ Future promptGetBuildConfiguration(String platform) async { return buildConfigurations[response]; } -Future promptGetTarget(String platform) async { - final targets = - await findTargetsAvailable(platform, getXcodeProjectPath(platform)); +Future promptGetTarget( + String platform, + String xcodeProjectPath, +) async { + final targets = await findTargetsAvailable(platform, xcodeProjectPath); final response = promptSelect( 'Please choose one of the following targets', @@ -92,9 +98,12 @@ Future promptGetTarget(String platform) async { return targets[response]; } -Future promptCheckTarget(String target, String platform) async { - final targets = - await findTargetsAvailable(platform, getXcodeProjectPath(platform)); +Future promptCheckTarget( + String target, + String platform, + String xcodeProjectPath, +) async { + final targets = await findTargetsAvailable(platform, xcodeProjectPath); if (!targets.contains(target)) { if (isCI) { diff --git a/packages/flutterfire_cli/lib/src/common/utils.dart b/packages/flutterfire_cli/lib/src/common/utils.dart index b99caf47..93718db8 100644 --- a/packages/flutterfire_cli/lib/src/common/utils.dart +++ b/packages/flutterfire_cli/lib/src/common/utils.dart @@ -227,9 +227,7 @@ String androidAppBuildGradleKtsPathForAppDirectory(Directory directory) { File xcodeProjectFileInDirectory(Directory directory, String platform) { return File( - joinAll( - [directory.path, platform, 'Runner.xcodeproj', 'project.pbxproj'], - ), + join(getXcodeProjectPath(directory, platform), 'project.pbxproj'), ); } @@ -502,11 +500,44 @@ Future> findBuildConfigurationsAvailable( return buildConfigurations; } -String getXcodeProjectPath(String platform) { - return join( - Directory.current.path, +String getXcodeProjectPath(Directory directory, String platform) { + final platformDirectory = Directory(join(directory.path, platform)); + final defaultProjectDirectory = Directory( + join(platformDirectory.path, 'Runner.xcodeproj'), + ); + + if (defaultProjectDirectory.existsSync()) { + return defaultProjectDirectory.path; + } + + if (!platformDirectory.existsSync()) { + throw PlatformDirectoryDoesNotExistException(platformDirectory.path); + } + + final xcodeProjectDirectories = platformDirectory + .listSync() + .whereType() + .where((directory) => directory.path.endsWith('.xcodeproj')) + .toList(); + + if (xcodeProjectDirectories.length == 1) { + return xcodeProjectDirectories.single.path; + } + + if (xcodeProjectDirectories.isEmpty) { + throw XcodeProjectException( + platform, + 'Unable to find an Xcode project in ${platformDirectory.path}.', + ); + } + + final projectNames = xcodeProjectDirectories + .map((directory) => directory.path.split(Platform.pathSeparator).last) + .toList() + ..sort(); + throw XcodeProjectException( platform, - 'Runner.xcodeproj', + 'Found multiple Xcode projects in ${platformDirectory.path}: ${projectNames.join(', ')}. Please keep a single Xcode project in this directory.', ); } diff --git a/packages/flutterfire_cli/lib/src/common/validation.dart b/packages/flutterfire_cli/lib/src/common/validation.dart index ce5ab4f7..617e6672 100644 --- a/packages/flutterfire_cli/lib/src/common/validation.dart +++ b/packages/flutterfire_cli/lib/src/common/validation.dart @@ -19,14 +19,19 @@ Future appleValidation({ String? targetResponse; String? buildConfigurationResponse; var configurationResponse = ProjectConfiguration.defaultConfig; + final xcodeProjectPath = getXcodeProjectPath( + Directory(flutterAppPath), + platform, + ); if (target == null && buildConfiguration == null && serviceFilePath == null) { // Default configuration return AppleInputs( projectConfiguration: configurationResponse, target: 'Runner', + xcodeProjectPath: xcodeProjectPath, serviceFilePath: path.join( - Directory.current.path, + flutterAppPath, platform, 'Runner', appleServiceFileName, @@ -77,18 +82,22 @@ Future appleValidation({ if (configurationResponse == ProjectConfiguration.target) { // User chooses from list of targets - targetResponse = await promptGetTarget(platform); + targetResponse = await promptGetTarget(platform, xcodeProjectPath); } if (configurationResponse == ProjectConfiguration.buildConfiguration) { // User chooses from list of build configurations - buildConfigurationResponse = await promptGetBuildConfiguration(platform); + buildConfigurationResponse = await promptGetBuildConfiguration( + platform, + xcodeProjectPath, + ); } } if (serviceFilePath != null && target != null) { // Check if target exists - targetResponse = await promptCheckTarget(target, platform); + targetResponse = + await promptCheckTarget(target, platform, xcodeProjectPath); configurationResponse = ProjectConfiguration.target; } @@ -97,6 +106,7 @@ Future appleValidation({ buildConfigurationResponse = await promptCheckBuildConfiguration( buildConfiguration, platform, + xcodeProjectPath, ); configurationResponse = ProjectConfiguration.buildConfiguration; } @@ -105,6 +115,7 @@ Future appleValidation({ projectConfiguration: configurationResponse, buildConfiguration: buildConfigurationResponse, target: targetResponse, + xcodeProjectPath: xcodeProjectPath, serviceFilePath: getAppleServiceFile( serviceFilePath, platform, diff --git a/packages/flutterfire_cli/lib/src/firebase/firebase_apple_writes.dart b/packages/flutterfire_cli/lib/src/firebase/firebase_apple_writes.dart index 8cdb57a1..37fd01d7 100644 --- a/packages/flutterfire_cli/lib/src/firebase/firebase_apple_writes.dart +++ b/packages/flutterfire_cli/lib/src/firebase/firebase_apple_writes.dart @@ -17,6 +17,7 @@ const bundleServiceScriptName = Future appleWrites({ required String platform, required FlutterApp flutterApp, + required String xcodeProjectPath, required String serviceFilePath, required FirebaseOptions platformOptions, required Logger logger, @@ -32,6 +33,7 @@ Future appleWrites({ serviceFilePath: serviceFilePath, logger: logger, platform: platform, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: projectConfiguration, buildConfiguration: buildConfiguration!, ).apply(); @@ -43,6 +45,7 @@ Future appleWrites({ serviceFilePath: serviceFilePath, logger: logger, platform: platform, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: projectConfiguration, target: ProjectConfiguration.defaultConfig == projectConfiguration ? 'Runner' @@ -58,6 +61,7 @@ class FirebaseAppleTargetConfiguration extends FirebaseAppleConfiguration { required String serviceFilePath, required Logger logger, required String platform, + required String xcodeProjectPath, required ProjectConfiguration projectConfiguration, required this.target, }) : super( @@ -66,6 +70,7 @@ class FirebaseAppleTargetConfiguration extends FirebaseAppleConfiguration { serviceFilePath: serviceFilePath, logger: logger, platform: platform, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: projectConfiguration, ); @@ -89,7 +94,7 @@ class FirebaseAppleTargetConfiguration extends FirebaseAppleConfiguration { return ''' require 'xcodeproj' googleFile='$serviceFilePath' -xcodeFile='${getXcodeProjectPath(platform)}' +xcodeFile='$xcodeProjectPath' targetName='$target' project = Xcodeproj::Project.open(xcodeFile) @@ -127,6 +132,7 @@ end flutterAppPath: flutterApp.package.path, logger: logger, platform: platform, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: projectConfiguration, isDevDependency: flutterApp.dependsOnPackage('flutterfire_cli'), ); @@ -147,6 +153,7 @@ class FirebaseAppleBuildConfiguration extends FirebaseAppleConfiguration { required String serviceFilePath, required Logger logger, required String platform, + required String xcodeProjectPath, required ProjectConfiguration projectConfiguration, required this.buildConfiguration, }) : super( @@ -155,6 +162,7 @@ class FirebaseAppleBuildConfiguration extends FirebaseAppleConfiguration { serviceFilePath: serviceFilePath, logger: logger, platform: platform, + xcodeProjectPath: xcodeProjectPath, projectConfiguration: projectConfiguration, ); // e.g. Debug, Profile, Release, etc @@ -192,7 +200,7 @@ class FirebaseAppleBuildConfiguration extends FirebaseAppleConfiguration { return ''' require 'xcodeproj' -xcodeFile='${getXcodeProjectPath(platform)}' +xcodeFile='$xcodeProjectPath' runScriptName='$bundleServiceScriptName' project = Xcodeproj::Project.open(xcodeFile) @@ -232,6 +240,7 @@ end logger: logger, projectConfiguration: projectConfiguration, platform: platform, + xcodeProjectPath: xcodeProjectPath, isDevDependency: flutterApp.dependsOnPackage('flutterfire_cli'), ); @@ -255,10 +264,12 @@ abstract class FirebaseAppleConfiguration { required this.serviceFilePath, required this.logger, required this.platform, + required this.xcodeProjectPath, required this.projectConfiguration, }); // Either "ios" or "macos" final String platform; + final String xcodeProjectPath; final FlutterApp flutterApp; final FirebaseOptions platformOptions; final String serviceFilePath; @@ -308,6 +319,7 @@ Future addFlutterFireDebugSymbolsScript({ required String flutterAppPath, required Logger logger, required String platform, + required String xcodeProjectPath, required ProjectConfiguration projectConfiguration, required bool isDevDependency, }) async { @@ -356,6 +368,7 @@ Future addFlutterFireDebugSymbolsScript({ target, projectConfiguration, platform, + xcodeProjectPath, isDevDependency, ), ]); @@ -377,6 +390,7 @@ String _debugSymbolsScript( String target, ProjectConfiguration projectConfiguration, String platform, + String xcodeProjectPath, bool isDevDependency, ) { final projectType = switch (projectConfiguration) { @@ -388,7 +402,7 @@ String _debugSymbolsScript( return ''' require 'xcodeproj' -xcodeFile='${getXcodeProjectPath(platform)}' +xcodeFile='$xcodeProjectPath' runScriptName='$debugSymbolScriptName' bundleScriptName='$bundleServiceScriptName' project = Xcodeproj::Project.open(xcodeFile) diff --git a/packages/flutterfire_cli/lib/src/flutter_app.dart b/packages/flutterfire_cli/lib/src/flutter_app.dart index d228318a..5be2be87 100644 --- a/packages/flutterfire_cli/lib/src/flutter_app.dart +++ b/packages/flutterfire_cli/lib/src/flutter_app.dart @@ -71,8 +71,17 @@ class FlutterApp { } String? _readBundleIdForPlatform(String platform) { - final xcodeProjFile = - xcodeProjectFileInDirectory(Directory(package.path), platform); + // This is just a convenience lookup for a default bundle id, so if the + // Xcode project can't be unambiguously resolved (e.g. missing or + // multiple .xcodeproj directories) we fall through to manual entry + // rather than failing the whole command. + File? xcodeProjFile; + try { + xcodeProjFile = + xcodeProjectFileInDirectory(Directory(package.path), platform); + } on FlutterFireException { + xcodeProjFile = null; + } final xcodeAppInfoConfigFile = xcodeAppInfoConfigFileInDirectory(Directory(package.path), platform); final bundleIdRegex = RegExp( @@ -93,7 +102,7 @@ class FlutterApp { } } - if (xcodeProjFile.existsSync()) { + if (xcodeProjFile != null && xcodeProjFile.existsSync()) { final fileContents = xcodeProjFile.readAsStringSync(); // TODO there can be multiple matches, e.g. build variants, // perhaps we should build a set and prompt for a choice? diff --git a/packages/flutterfire_cli/test/configure_test.dart b/packages/flutterfire_cli/test/configure_test.dart index a06d3aaf..3907f240 100644 --- a/packages/flutterfire_cli/test/configure_test.dart +++ b/packages/flutterfire_cli/test/configure_test.dart @@ -171,6 +171,76 @@ void main() { ), ); + test( + 'flutterfire configure: renamed iOS Xcode project', + () async { + const defaultTarget = 'Runner'; + const renamedXcodeProjectName = 'RenamedRunner.xcodeproj'; + final renamedXcodeProjectPath = p.join( + projectPath!, + kIos, + renamedXcodeProjectName, + ); + Directory( + p.join(projectPath!, kIos, 'Runner.xcodeproj'), + ).renameSync(renamedXcodeProjectPath); + + final result = Process.runSync( + 'flutterfire', + [ + 'configure', + '--yes', + '--platforms=ios', + '--project=$firebaseProjectId', + '--ios-bundle-id=$appleBundleId', + ], + workingDirectory: projectPath, + runInShell: true, + ); + + if (result.exitCode != 0) { + fail(result.stderr as String); + } + + final iosPath = + p.join(projectPath!, kIos, defaultTarget, appleServiceFileName); + await testAppleServiceFileValues(iosPath); + + final firebaseJsonFile = p.join(projectPath!, 'firebase.json'); + final firebaseJsonFileContent = + await File(firebaseJsonFile).readAsString(); + final decodedFirebaseJson = + jsonDecode(firebaseJsonFileContent) as Map; + + checkAppleFirebaseJsonValues( + decodedFirebaseJson, + [kFlutter, kPlatforms, kIos, kDefaultConfig], + '$kIos/$defaultTarget/$appleServiceFileName', + ); + + final scriptToCheckIosPbxprojFile = + rubyScriptForTestingDefaultConfigure(renamedXcodeProjectPath); + final iosResult = Process.runSync( + 'ruby', + [ + '-e', + scriptToCheckIosPbxprojFile, + ], + runInShell: true, + ); + + if (iosResult.exitCode != 0) { + fail(iosResult.stderr as String); + } + + expect(iosResult.stdout, 'success'); + }, + skip: !Platform.isMacOS, + timeout: const Timeout( + Duration(minutes: 2), + ), + ); + test( 'flutterfire configure: android - "build configuration" Apple - "build configuration"', () async { diff --git a/packages/flutterfire_cli/test/unit_test.dart b/packages/flutterfire_cli/test/unit_test.dart index 292473d8..45dba669 100644 --- a/packages/flutterfire_cli/test/unit_test.dart +++ b/packages/flutterfire_cli/test/unit_test.dart @@ -1,7 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutterfire_cli/src/common/strings.dart'; import 'package:flutterfire_cli/src/common/utils.dart'; +import 'package:flutterfire_cli/src/common/validation.dart'; +import 'package:flutterfire_cli/src/flutter_app.dart'; +import 'package:path/path.dart' as path; import 'package:test/test.dart'; void main() { @@ -233,4 +237,162 @@ void main() { }); }, ); + + group('Xcode project path discovery', () { + late Directory flutterAppDirectory; + + setUp(() { + flutterAppDirectory = Directory.systemTemp.createTempSync(); + }); + + tearDown(() { + flutterAppDirectory.deleteSync(recursive: true); + }); + + test('prefers Runner.xcodeproj when present', () { + Directory( + path.join(flutterAppDirectory.path, kIos, 'Renamed.xcodeproj'), + ).createSync(recursive: true); + Directory( + path.join(flutterAppDirectory.path, kIos, 'Runner.xcodeproj'), + ).createSync(recursive: true); + + final xcodeProjectPath = getXcodeProjectPath( + flutterAppDirectory, + kIos, + ); + + expect( + xcodeProjectPath, + path.join(flutterAppDirectory.path, kIos, 'Runner.xcodeproj'), + ); + }); + + test('uses a single renamed xcode project', () { + Directory( + path.join(flutterAppDirectory.path, kIos, 'Renamed.xcodeproj'), + ).createSync(recursive: true); + + final xcodeProjectPath = getXcodeProjectPath( + flutterAppDirectory, + kIos, + ); + final xcodeProjectFile = xcodeProjectFileInDirectory( + flutterAppDirectory, + kIos, + ); + + expect( + xcodeProjectPath, + path.join(flutterAppDirectory.path, kIos, 'Renamed.xcodeproj'), + ); + expect( + xcodeProjectFile.path, + path.join( + flutterAppDirectory.path, + kIos, + 'Renamed.xcodeproj', + 'project.pbxproj', + ), + ); + }); + + test('throws when no xcode project exists', () { + Directory(path.join(flutterAppDirectory.path, kIos)).createSync(); + + expect( + () => getXcodeProjectPath(flutterAppDirectory, kIos), + throwsA(isA()), + ); + }); + + test('throws when multiple renamed xcode projects exist', () { + Directory( + path.join(flutterAppDirectory.path, kIos, 'One.xcodeproj'), + ).createSync(recursive: true); + Directory( + path.join(flutterAppDirectory.path, kIos, 'Two.xcodeproj'), + ).createSync(recursive: true); + + expect( + () => getXcodeProjectPath(flutterAppDirectory, kIos), + throwsA(isA()), + ); + }); + + test( + 'throws a PlatformDirectoryDoesNotExistException when the ' + 'platform directory is missing', () { + expect( + () => getXcodeProjectPath(flutterAppDirectory, kIos), + throwsA(isA()), + ); + }); + }); + + group('Xcode project resolution wired through app validation', () { + late Directory flutterAppDirectory; + + setUp(() { + flutterAppDirectory = Directory.systemTemp.createTempSync(); + File(path.join(flutterAppDirectory.path, 'pubspec.yaml')) + .writeAsStringSync(''' +name: test_app +dependencies: + flutter: + sdk: flutter +'''); + }); + + tearDown(() { + flutterAppDirectory.deleteSync(recursive: true); + }); + + test('appleValidation resolves a renamed Xcode project', () async { + Directory( + path.join(flutterAppDirectory.path, kIos, 'MyApp.xcodeproj'), + ).createSync(recursive: true); + + final appleInputs = await appleValidation( + platform: kIos, + flutterAppPath: flutterAppDirectory.path, + ); + + expect( + appleInputs.xcodeProjectPath, + path.join(flutterAppDirectory.path, kIos, 'MyApp.xcodeproj'), + ); + }); + + test( + 'appleValidation surfaces a clear error when the platform directory ' + 'is missing', + () { + expect( + () => appleValidation( + platform: kIos, + flutterAppPath: flutterAppDirectory.path, + ), + throwsA(isA()), + ); + }, + ); + + test( + 'bundle id detection does not throw when the Xcode project is ' + 'ambiguous', + () async { + Directory( + path.join(flutterAppDirectory.path, kIos, 'One.xcodeproj'), + ).createSync(recursive: true); + Directory( + path.join(flutterAppDirectory.path, kIos, 'Two.xcodeproj'), + ).createSync(recursive: true); + + final flutterApp = await FlutterApp.load(flutterAppDirectory); + + expect(flutterApp!.iosBundleId, isNull); + }, + ); + }); }