Make a plugin installable in SPM-based Capacitor apps while keeping its podspec for CocoaPods apps.
When to Use
TRIGGER when:
- A plugin repo has
ios/Plugin/*.swift+*.m/*.hwithCAP_PLUGIN(...)and no rootPackage.swift. - An app's
npx cap sync iosprints<plugin id> does not have a Package.swiftfor a plugin the user maintains. - A plugin works with CocoaPods but JS gets
"X" plugin is not implemented on iosin SPM apps. - Preparing a plugin for Capacitor 9 / CocoaPods Trunk read-only.
Do not use:
- App-side CocoaPods removal ->
cocoapods-to-spm. - Capacitor major bumps of a plugin ->
capacitor-plugin-upgrades,capacitor-plugin-upgrade-v8-to-v9. - Android-only plugin work, or non-Capacitor (pure Cordova, React Native) plugins.
Rules the CLI Enforces (non-obvious)
Package.swiftmust be at the npm package root (next topackage.json), not underios/. The app CLI only checks<plugin root>/Package.swift.- The CLI references the plugin in the app's
CapApp-SPM/Package.swiftas.package(name: "<Name>", path: ...)and.product(name: "<Name>", package: "<Name>"), where<Name>is derived from the npm name: drop@, turn/and-into word breaks, PascalCase.@capgo/capacitor-updater->CapgoCapacitorUpdater,@acme/capacitor-foo-bar->AcmeCapacitorFooBar. ThePackage(name:)and the.library(name:)product must both equal<Name>, or app builds fail to resolve the product. - Runtime registration uses
ios/App/App/capacitor.config.json->packageClassList, filled by scanning the plugin's iOS sources (capacitor.ios.srcin package.json, defaultios) for@objc(ClassName). Keep@objc(<Class>Plugin)on the class; without it the plugin is never registered. - The
capacitor-swift-pmdependency must usefrom: "<major>.0.0". If the major differs from the app's Capacitor, app sync rewrites it innode_modulesand warns<id> is built for Capacitor <N>, it might cause issues. - Capacitor 9: depend only on
.product(name: "Capacitor", package: "capacitor-swift-pm"). Remove the unconditionalCordovaproduct; it is only present in apps that install a Cordova plugin. Keep it on Capacitor 8 only if your code imports Cordova.
Procedure
1. Inspect
Read package.json (name, files, capacitor.ios.src, scripts), the .podspec (s.name, s.ios.deployment_target, s.dependency, s.resources, s.vendored_frameworks), every file in ios/, and the CAP_PLUGIN(...) macro in the .m file.
Record: SPM <Name> (rule above), class name, jsName, every CAP_PLUGIN_METHOD(name, returnType), iOS deployment target, third-party pods, resources, vendored frameworks, ObjC/C sources other than the bridge.
Check each third-party pod has an official SPM package. Report any that do not before editing; ask whether to drop, vendor as .xcframework, or keep the plugin CocoaPods-only.
2. Fast path: converter
If the iOS code is Swift-only apart from the [Name]Plugin.m / .h bridge, the official converter does most of the work (adds CAPBridgedPlugin, creates Package.swift, moves code to ios/Sources and ios/Tests, removes Plugin.xcodeproj/xcworkspace/Podfile, updates podspec source_files, files, and verify:ios): https://github.com/ionic-team/capacitor-plugin-converter. Review its diff against the rules above, especially the package/product name.
3. Manual conversion
- Move sources:
ios/Plugin/->ios/Sources/<ClassName>/, tests ->ios/Tests/<ClassName>Tests/. - Convert the class (keep method names and return types exactly as in the
.mmacro):import Capacitor @objc(FooPlugin) public class FooPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "FooPlugin" // first CAP_PLUGIN argument public let jsName = "Foo" // second CAP_PLUGIN argument (registerPlugin name) public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "doThing", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "watch", returnType: CAPPluginReturnCallback), CAPPluginMethod(name: "fireAndForget", returnType: CAPPluginReturnNone) ] @objc func doThing(_ call: CAPPluginCall) { call.resolve() } } - Delete
FooPlugin.mandFooPlugin.h(theCAP_PLUGINbridge) and stalePlugin.xcodeproj,Plugin.xcworkspace,Podfile,Info.plistfiles underios/. - Add root
Package.swift. Template and variants (resources, binary targets, third-party packages, mixed ObjC) inreferences/package-swift-templates.md. - Update the podspec:
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}', keeps.dependency 'Capacitor', and mirror deployment target and resources. - Update
package.json:files: addios/Sources/,Package.swift; keep the.podspec; remove oldios/Plugin/.verify:ios:xcodebuild -scheme <Name> -destination generic/platform=iOS.
Verification
# Package resolves and builds standalone (from plugin root)
swift package describe >/dev/null && echo "manifest ok"
xcodebuild -scheme <Name> -destination generic/platform=iOS build
# Published tarball contains the manifest and sources
npm pack --dry-run 2>&1 | grep -E 'Package.swift|ios/Sources'
# No leftover ObjC bridge
grep -rn "CAP_PLUGIN(" ios/ && echo "LEFTOVER BRIDGE"
Then in an SPM example/test app (directory with capacitor.config.*): install the plugin (npm install ../path or a packed tarball), run npx cap sync ios, confirm no does not have a Package.swift warning, confirm <Name> appears in ios/App/CapApp-SPM/Package.swift and the class appears in ios/App/App/capacitor.config.json packageClassList, build, and call one method from JS. Also run pod lib lint (or a CocoaPods example app) if the podspec is still published.
Error Handling
| Error | Fix |
|---|---|
product '<Name>' required by package 'capapp-spm' target 'CapApp-SPM' not found in package '<Name>' |
Package(name:) / .library(name:) do not match the CLI-derived <Name>. |
"Foo" plugin is not implemented on ios |
Missing @objc(FooPlugin), wrong jsName, or class not in packageClassList; re-sync after fixing. |
"Foo.bar()" is not implemented on ios |
Method missing from pluginMethods or not marked @objc. |
No such module 'Capacitor' building the package |
Missing capacitor-swift-pm dependency or wrong product name. |
public headers ("include") directory path for 'X' is invalid or not contained in the target |
Mixed ObjC target without publicHeadersPath; split ObjC into its own target (see reference). |
<id> is built for Capacitor <N>, it might cause issues (in app) |
Bump capacitor-swift-pm from: to the app's major. |
Cordova product missing (Capacitor 9 app) |
Remove the Cordova product dependency. |
| Resource not found at runtime | Declare resources: and load via Bundle.module, not Bundle.main. |
References
Only load when the topic is in play:
references/package-swift-templates.md: base manifest, resources and privacy manifest, third-party SPM dependencies, binary.xcframework, mixed Swift/ObjC targets, Capacitor 8 vs 9 dependency blocks.