flutter/flutter

Remove use of modulemaps in flutter/packages when using SwiftPM

vashworth opened this issue · 3 comments

Use case

To support both CocoaPods and Swift Package Manager, dynamic and static linking, two different modulemaps are needed.

CocoaPods modulemap

CocoaPods seems to require the modulemap be a framework module and doesn't use relative paths.

For example,

framework module plugin_name_ios {
  umbrella header "plugin_name_ios-umbrella.h"

  export *
  module * { export * }

  explicit module Test {
    header "PublicHeader_Test.h"
    header "OtherPublicHeader_Test.h"
  }
}

SwiftPM modulemap

SwiftPM requires the modulemap to be named module.modulemap and be located within the include directory. It also doesn't seem to work if defined as a framework module and uses relative paths.

For example,

- framework module plugin_name_ios {
+ module plugin_name_ios {

explicit module Test {
-    header "PublicHeader_Test.h"
+    header "plugin_name_ios/OtherPublicHeader_Test.h"

The problem

The problem is that when you have two modulemaps (most likely also because one is named module.modulemap, which is the implicit modulemap file), this can cause issues:

Proposal

It's been pretty difficult to figure out a way to support both CocoaPods and SwiftPM modulemaps. As a result, we're leaning towards eliminating modulemaps in SwiftPM.


Instead of using a modulemap for the SwiftPM integration of plugins, we create a new target in the Package.swift that must be imported directly into the app (for the RunnerTests target).

Using camera_avfoundation plugin as an example:

Package.swift
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

// Copyright 2013 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 PackageDescription

let package = Package(
name: "camera_avfoundation",
platforms: [
  .iOS("12.0")
],
products: [
  .library(name: "camera-avfoundation", targets: ["camera_avfoundation"]),
  .library(name: "camera-avfoundation-test", targets: ["camera_avfoundation_test"])
],
dependencies: [],
targets: [
  .target(
    name: "camera_avfoundation",
    dependencies: [],
    exclude: ["CameraPlugin.modulemap", "camera_avfoundation-umbrella.h"],
    resources: [
      .process("PrivacyInfo.xcprivacy")
    ],
    cSettings: [
        .headerSearchPath("include/camera_avfoundation")
    ]
  ),
  .target(
    name: "camera_avfoundation_test",
    dependencies: ["camera_avfoundation"],
    cSettings: [
      .headerSearchPath("include/camera_avfoundation_test")
    ]
  )
]
)

Then create the following files:

camera_avfoundation/Sources/camera_avfoundation_test/include/camera_avfoundation_test/CameraAVFoundation_Test.h
#import <camera_avfoundation/CameraPlugin_Test.h>
#import <camera_avfoundation/CameraPermissionUtils.h>
#import <camera_avfoundation/CameraProperties.h>
#import <camera_avfoundation/FLTCam.h>
#import <camera_avfoundation/FLTCam_Test.h>
#import <camera_avfoundation/FLTSavePhotoDelegate_Test.h>
#import <camera_avfoundation/FLTThreadSafeEventChannel.h>
#import <camera_avfoundation/QueueUtils.h>

These are all the headers that would have been declared in the camera_avfoundation.Test submodule.

camera_avfoundation/Sources/camera_avfoundation_test/CameraAVFoundation_Test.m
#import "CameraAVFoundation_Test.h"

camera_avfoundation_test will need to added as a dependency to RunnerTests via Xcode

Screenshot 2024-05-17 at 3 04 33 PM

Screenshot 2024-05-17 at 3 04 45 PM

Then in test files, replace camera_avfoundation.Test with camera_avfoundation_test

- @import camera_avfoundation.Test;
+ @import camera_avfoundation_test;

This eliminates the modulemap. It unfortunately does not resolve having more headers public than we'd like, but they are already public so it's not a regression.

Prototype found here: flutter/packages@main...vashworth:packages:camera_avfoundation_remove_swiftpm_modulemap

fyi @jmagman @stuartmorgan

@loic-sharma and I like this approach and are planning to move forward with it. If users want to use modulemaps with SwiftPM, we plan to point them to SwiftPM's documentation about it: https://github.com/apple/swift-package-manager/blob/main/Documentation/Usage.md#creating-c-language-targets

If users want to use modulemaps with SwiftPM, we plan to point them to SwiftPM's documentation about it: https://github.com/apple/swift-package-manager/blob/main/Documentation/Usage.md#creating-c-language-targets

I created an experiment that allows for custom module maps with SwiftPM: flutter/packages@main...loic-sharma:flutter-packages:spm_camera_separate_public_file_symlinks

This works by creating separate header directories for CocoaPods (/cocoapods_headers) and Swift Package Manager (/include). To minimize duplication, the CocoaPods header files are symlinks back to the header files in the Swift Package Manager header directory. I verified this works on Xcode 14.2+. Some drawbacks to this approach:

  1. Pigeon generates implementation files that use relative #import "..." paths. This is incompatible with this experiment as the relative path to the header files changes depending on whether you're using CocoaPods or Swift Package Manager. Instead, the Pigeon implementation file should use non-relative imports like #import <my_plugin/MyHeader.h>. Today, this requires manually editing the code generated by Pigeon.
  2. You need to remember to update symlinks if you add/move/delete header files. If we were to use this approach in the flutter/packages repo, we'd likely want tooling/tests to verify CocoaPods/SwiftPM header files are in sync.

Due to these drawbacks we prefer to avoid module maps in SwiftPM if possible.

In the tests:

@import camera_avfoundation;
@import camera_avfoundation_test;

If we were only in SwiftPM world, could we normally #import the test headers directly? Or was there some problem with the hmap?