Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions packages/flutter_tools/lib/src/ios/xcodeproj.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import '../base/terminal.dart';
import '../base/utils.dart';
import '../base/version.dart';
import '../build_info.dart';
import '../macos/swift_package_manager.dart';
import '../plugins.dart';
import '../xcode_project.dart';

final _settingExpr = RegExp(r'(\w+)\s*=\s*(.*)$');
Expand Down Expand Up @@ -440,7 +442,75 @@ class XcodeProjectInterpreter {
// User configuration error, tool exit instead of crashing.
throwToolExit('Unable to get Xcode project information:\n ${result.stderr}');
}
return XcodeProjectInfo.fromXcodeBuildOutput(result.toString(), _logger);
return XcodeProjectInfo.fromXcodeBuildOutput(
result.toString(),
_logger,
ignoredSchemes: await _ignoredSwiftPackageSchemes(xcodeProject, buildDirectory),
);
}

/// Returns scheme-name candidates for Swift packages that should be excluded from
/// [XcodeProjectInfo.schemes] to avoid expensive iterations through the scheme list, such as
/// during `flutter clean` or during [IosProject.containsWatchCompanion].
///
/// Local Swift packages are automatically included by Xcode in `xcodebuild -list` despite not
/// being declared in the host `.xcodeproj`. Remote Swift packages may also be included (see
/// [_swiftPackageCheckoutSchemes]).
///
/// Covers Flutter's generated SwiftPM packages, plugin names in snake_case
/// and dashed forms, and transitive SwiftPM checkout schemes.
Future<Set<String>> _ignoredSwiftPackageSchemes(
Comment thread
vashworth marked this conversation as resolved.
XcodeBasedProject xcodeProject,
Directory buildDirectory,
) async {
final ignoredSchemes = <String>{
kFlutterGeneratedPluginSwiftPackageName,
kFlutterGeneratedFrameworkSwiftPackageTargetName,
..._swiftPackageCheckoutSchemes(buildDirectory),
};
try {
for (final Plugin plugin in await xcodeProject.getPlugins()) {
ignoredSchemes.add(plugin.name);
ignoredSchemes.add(plugin.name.replaceAll('_', '-'));
}
} on Object catch (error) {
_logger.printTrace('Failed to get plugins while filtering Xcode schemes: $error');
}
return ignoredSchemes;
}

/// Returns scheme names contributed by direct and transitive Swift package checkouts.
///
/// When a Swift package ships its own `.swiftpm/xcode/xcshareddata/xcschemes/`
/// directory, Xcode auto-merges those schemes into the host project's scheme
/// list, so they appear in `xcodebuild -list` despite not being declared in
/// the host `.xcodeproj`. See
/// https://www.jessesquires.com/blog/2025/03/10/swiftpm-schemes-in-xcode/.
Set<String> _swiftPackageCheckoutSchemes(Directory buildDirectory) {
Comment thread
vashworth marked this conversation as resolved.
final Directory checkoutsDirectory = buildDirectory
.childDirectory(kSwiftPackageCacheDirectoryName)
.childDirectory('checkouts');
if (!checkoutsDirectory.existsSync()) {
return const <String>{};
}
final schemes = <String>{};
for (final Directory checkoutDirectory
in checkoutsDirectory.listSync().whereType<Directory>()) {
final Directory schemeDirectory = checkoutDirectory
.childDirectory('.swiftpm')
.childDirectory('xcode')
.childDirectory('xcshareddata')
.childDirectory('xcschemes');
if (!schemeDirectory.existsSync()) {
continue;
}
for (final File schemeFile in schemeDirectory.listSync().whereType<File>()) {
if (_fileSystem.path.extension(schemeFile.path) == '.xcscheme') {
schemes.add(_fileSystem.path.basenameWithoutExtension(schemeFile.path));
}
}
}
return schemes;
}
}

Expand Down Expand Up @@ -557,7 +627,17 @@ class XcodeProjectInfo {
const XcodeProjectInfo(this.targets, this.buildConfigurations, this.schemes, Logger logger)
: _logger = logger;

factory XcodeProjectInfo.fromXcodeBuildOutput(String output, Logger logger) {
/// Parses the output of `xcodebuild -list`.
///
/// [ignoredSchemes] is matched case-insensitively against parsed schemes.
factory XcodeProjectInfo.fromXcodeBuildOutput(
String output,
Logger logger, {
Set<String> ignoredSchemes = const <String>{},
}) {
final ignoredSchemeLookup = <String>{
for (final String scheme in ignoredSchemes) scheme.toLowerCase(),
};
final targets = <String>[];
final buildConfigurations = <String>[];
final schemes = <String>[];
Expand All @@ -578,6 +658,7 @@ class XcodeProjectInfo {
}
collector?.add(line.trim());
}
schemes.removeWhere((String scheme) => ignoredSchemeLookup.contains(scheme.toLowerCase()));
if (schemes.isEmpty) {
schemes.add('Runner');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/ios/xcode_build_settings.dart';
import 'package:flutter_tools/src/ios/xcodeproj.dart';
import 'package:flutter_tools/src/macos/xcode.dart';
import 'package:flutter_tools/src/plugins.dart';
import 'package:flutter_tools/src/project.dart';
import 'package:test/fake.dart';
import 'package:unified_analytics/unified_analytics.dart';
Expand Down Expand Up @@ -843,6 +844,78 @@ Information about project "Runner":
]);
});

testWithoutContext('getInfo filters Swift package schemes from project schemes', () async {
const workingDirectory = '/';
final Directory buildDirectory = fileSystem.directory('build/ios');
buildDirectory
.childDirectory(kSwiftPackageCacheDirectoryName)
.childDirectory('checkouts')
.childDirectory('purchases-ios')
.childDirectory('.swiftpm')
.childDirectory('xcode')
.childDirectory('xcshareddata')
.childDirectory('xcschemes')
.childFile('RevenueCatUI.xcscheme')
.createSync(recursive: true);
fakeProcessManager.addCommands(const <FakeCommand>[
kWhichSysctlCommand,
kx64CheckCommand,
kFindProcessResolvePackagesCommand,
kResolvePackagesCommand,
FakeCommand(
command: <String>[
'xcrun',
'xcodebuild',
'-clonedSourcePackagesDirPath',
'/build/ios/SourcePackages',
'-skipPackageUpdates',
'-skipPackagePluginValidation',
'-skipPackageSignatureValidation',
'-list',
],
stdout: '''
Information about project "Runner":
Targets:
Runner

Build Configurations:
Debug
Release

Schemes:
Runner
FlutterGeneratedPluginSwiftPackage
FlutterFramework
advertising-id
advertising_id
Patrol
RevenueCatUI
WatchScheme

''',
),
]);

final xcodeProjectInterpreter = XcodeProjectInterpreter(
logger: logger,
fileSystem: fileSystem,
platform: platform,
processManager: fakeProcessManager,
analytics: const NoOpAnalytics(),
);

final XcodeProjectInfo? info = await xcodeProjectInterpreter.getInfo(
FakeXcodeBasedProject(workingDirectory, fileSystem, <Plugin>[
FakePlugin('advertising_id'),
FakePlugin('patrol'),
]),
buildDirectory: buildDirectory,
);

expect(info!.schemes, <String>['Runner', 'WatchScheme']);
expect(fakeProcessManager, hasNoRemainingExpectations);
});

testWithoutContext('expected scheme for non-flavored build is Runner', () {
expect(XcodeProjectInfo.expectedSchemeFor(BuildInfo.debug), 'Runner');
expect(XcodeProjectInfo.expectedSchemeFor(BuildInfo.profile), 'Runner');
Expand Down Expand Up @@ -2516,12 +2589,13 @@ flutter:
class FakeFlutterProject extends Fake implements FlutterProject {}

class FakeXcodeBasedProject extends IosProject {
FakeXcodeBasedProject(this.path, [FileSystem? fileSystem])
FakeXcodeBasedProject(this.path, [FileSystem? fileSystem, this.plugins = const <Plugin>[]])
: fs = fileSystem ?? MemoryFileSystem.test(),
super.fromFlutter(FakeFlutterProject());

final String path;
final FileSystem fs;
final List<Plugin> plugins;

@override
Directory get hostAppRoot => fs.directory(path);
Expand All @@ -2534,4 +2608,14 @@ class FakeXcodeBasedProject extends IosProject {

@override
bool get flutterPluginSwiftPackageInProjectSettings => true;

@override
Future<List<Plugin>> getPlugins() async => plugins;
}

class FakePlugin extends Fake implements Plugin {
FakePlugin(this.name);

@override
final String name;
}
Loading