Aller au contenu principal

Migrer un plugin Capacitor vers Swift Package Manager

Ajouter le support de Swift Package Manager à un plugin Capacitor : règles de nommage de Package.swift, CAPBridgedPlugin, structure de Sources, ressources, Obj-C code et cap2spm.

Illustration de la migration d’un plugin Capacitor de CocoaPods vers Swift Package Manager

Pour migrer un plugin Capacitor vers Swift Package Manager, ajoutez un fichier Package.swift à la racine du plugin, dont le nom du package et du produit correspond à ce que Capacitor CLI attend, déplacez le code iOS dans ios/Sources/<Target>, remplacez les fichiers Objective-C CAP_PLUGIN de bridge par CAPBridgedPlugin la conformance en Swift, et ajoutez-le Package.swift à la liste du npm files list. Keep the podspec so CocoaPods apps keep working.

Depuis Capacitor 8, cap add ios crée des projets SPM par défaut. Un plugin sans Package.swift est ignoré dans ces applications avec l’avertissement « Some installed Capacitor plugins are not compatible with SPM », et les utilisateurs voient ensuite « plugin is not implemented » à l’exécution. Ce guide couvre la migration manuelle, l’outil de conversion et les aspects souvent omis : règles de nommage, ressources, code Objective-C et dépendances tierces.

Les équipes qui migrent leur propre projet doivent lire Comment migrer votre application Capacitor vers SPM plutôt.

Comment Capacitor consomme votre package

Activé bunx cap sync ios, les réécritures CLI ios/App/CapApp-SPM/Package.swift dans l’application. Pour chaque plugin Capacitor qui possède un Package.swift, il ajoute deux lignes :

.package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/@capgo/capacitor-updater")
// ...
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater")

Deux conséquences :

  1. Votre package est consommé par chemin local depuis node_modules, et non depuis une URL git. Votre Package.swift doit se trouver à la racine du package npm, et tout ce qu’il référence doit être publié sur npm.
  2. Le nom est dérivé du nom npm, et non depuis votre podspec. CLI supprime @, transforme / et - en _, puis convertit chaque segment en camelCase et met la première lettre en majuscule :
package npm Nom du package et du produit requis
@capacitor/haptics CapacitorHaptics
@capgo/capacitor-updater CapgoCapacitorUpdater
capacitor-my-plugin CapacitorMyPlugin
@acme/capacitor-scanner AcmeCapacitorScanner

Si votre Package.swift utilise un autre nom ou un autre nom de produit, l’application échoue à résoudre les packages avec une erreur du type product 'X' required by package 'capapp-spm' target 'CapApp-SPM' not found.

Étape 1 : écrire Package.swift

C’est la structure utilisée par les plugins officiels. Voici le manifeste réel de @capacitor/haptics 8:

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "CapacitorHaptics",
    platforms: [.iOS(.v15)],
    products: [
        .library(
            name: "CapacitorHaptics",
            targets: ["HapticsPlugin"])
    ],
    dependencies: [
        .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
    ],
    targets: [
        .target(
            name: "HapticsPlugin",
            dependencies: [
                .product(name: "Capacitor", package: "capacitor-swift-pm"),
                .product(name: "Cordova", package: "capacitor-swift-pm")
            ],
            path: "ios/Sources/HapticsPlugin"),
        .testTarget(
            name: "HapticsPluginTests",
            dependencies: ["HapticsPlugin"],
            path: "ios/Tests/HapticsPluginTests")
    ]
)

Notes :

  • Conservez swift-tools-version: 5.9 sauf si vous avez besoin d'une fonctionnalité plus récente. Le package généré par l'application utilise également 5,9 par défaut, et Capacitor 8 ne prend pas encore officiellement en charge Swift 6.
  • Utiliser from: "8.0.0" pour capacitor-swift-pm. L'application fixe une version exacte correspondant à celle installée @capacitor/ios, et une plage permet à SPM de résoudre les deux. branch: ou exact: provoquera des conflits de résolution.
  • Le nom cible peut être n’importe quoi, mais il devient le nom du module Swift. Évitez les noms génériques comme Plugin qui entrent en collision avec d'autres plugins.

Étape 2 : déplacer les sources

SPM s'attend à un dossier par cible. Le convertisseur et le modèl’officiel utilisent :

my-plugin/
├── Package.swift
├── MyPlugin.podspec
├── ios/
│   ├── Sources/
│   │   └── MyPlugin/
│   │       ├── MyPlugin.swift
│   │       └── MyPluginImplementation.swift
│   └── Tests/
│       └── MyPluginTests/
│           └── MyPluginTests.swift
└── package.json

Supprimez ensuite ce dont SPM n’a pas besoin : ios/Plugin.xcodeproj, ios/Plugin.xcworkspace, ios/Podfile, ios/Plugin/Info.plist et ios/PluginTests/Info.plist.

Mettez à jour le podspec pour que CocoaPods compile les mêmes fichiers :

s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'

Étape 3 : remplacer le pont Objective-C par CAPBridgedPlugin

Les anciens plugins enregistrent les méthodes dans Plugin.m:

#import <Capacitor/Capacitor.h>

CAP_PLUGIN(MyPlugin, "MyPlugin",
    CAP_PLUGIN_METHOD(echo, CAPPluginReturnPromise);
    CAP_PLUGIN_METHOD(startScan, CAPPluginReturnCallback);
)

SwiftPM ne peut pas mélanger Objective-C et Swift dans une seule cible, donc cela se déplace dans la classe Swift :

import Foundation
import Capacitor

@objc(MyPlugin)
public class MyPlugin: CAPPlugin, CAPBridgedPlugin {
    public let identifier = "MyPlugin"
    public let jsName = "MyPlugin"
    public let pluginMethods: [CAPPluginMethod] = [
        CAPPluginMethod(name: "echo", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "startScan", returnType: CAPPluginReturnCallback)
    ]

    @objc func echo(_ call: CAPPluginCall) {
        call.resolve(["value": call.getString("value") ?? ""])
    }

    @objc func startScan(_ call: CAPPluginCall) {
        call.keepAlive = true
        // ...
    }
}

Règles de correspondance :

  • identifier est le premier argument de CAP_PLUGIN (le nom de la classe).
  • jsName est le deuxième argument, le nom utilisé dans registerPlugin('MyPlugin') dans votre TypeScript.
  • Chaque CAP_PLUGIN_METHOD devient un CAPPluginMethod, en conservant le même type de retour (CAPPluginReturnPromise, CAPPluginReturnCallback ou CAPPluginReturnNone).

Une méthode absente de pluginMethods compile sans erreur et n'échoue que lorsque JavaScript l'appelle. Cherchez @objc func avec un CAPPluginCall le paramètre et comptez-les par rapport au tableau. Puis supprimez Plugin.h et Plugin.m.

CAPBridgedPlugin fonctionne aussi avec CocoaPods, donc le même fichier Swift sert aux deux gestionnaires de paquets.

Étape 4 : mettre à jour package.json et .gitignore

{
  "files": [
    "android/src/main/",
    "android/build.gradle",
    "dist/",
    "ios/Sources",
    "ios/Tests",
    "Package.swift",
    "MyPlugin.podspec"
  ],
  "scripts": {
    "verify:ios": "xcodebuild -scheme CapacitorMyPlugin -destination generic/platform=iOS"
  }
}

Le -scheme est le nom de votre paquet. Oublier Package.swift ou ios/Sources dans files est la raison la plus courante pour laquelle un plugin fonctionne via un git checkout mais casse après npm publish. Exécutez bun pm pack --dry-run et vérifier la liste des fichiers.

Ajouter la sortie de build SPM à .gitignore:

.build/
Package.resolved
/Packages
.swiftpm/

Étape 5 : gérer ce que le convertisseur ne fait pas

Dépendances tierces

Une ligne podspec comme s.dependency 'Alamofire', '~> 5.9' devient une dépendance de package et un produit sur votre cible :

dependencies: [
    .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
    .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.9.0")
],
targets: [
    .target(
        name: "MyPlugin",
        dependencies: [
            .product(name: "Capacitor", package: "capacitor-swift-pm"),
            .product(name: "Cordova", package: "capacitor-swift-pm"),
            .product(name: "Alamofire", package: "Alamofire")
        ],
        path: "ios/Sources/MyPlugin")
]

Si le SDK ne fournit qu'un .xcframework, livrez-le dans le package npm et utilisez une cible binaire :

.binaryTarget(name: "VendorSDK", path: "ios/Frameworks/VendorSDK.xcframework")

Si un fournisseur ne propose ni package SPM ni xcframework, vous ne pouvez pas encore ajouter le support SPM. Gardez le plugin en CocoaPods uniquement et précisez-le dans le README.

Gardez les plages de versions dans Package.swift et le podspec aligné. Deux plugins dans la même app qui exigent des versions incompatibles d’un même SDK échoueront à la résolution dans n’importe quel package manager.

Ressources et manifestes de confidentialité

Images, JSON, storyboards et PrivacyInfo.xcprivacy doivent être déclarés :

.target(
    name: "MyPlugin",
    dependencies: [/* ... */],
    path: "ios/Sources/MyPlugin",
    resources: [
        .process("Resources"),
        .copy("PrivacyInfo.xcprivacy")
    ])

CocoaPods exige que les mêmes fichiers soient déclarés dans le podspec. Placez-les dans un resource bundle nommé :

s.resource_bundles = {
  'MyPluginResources' => [
    'ios/Sources/MyPlugin/Resources/**/*',
    'ios/Sources/MyPlugin/PrivacyInfo.xcprivacy'
  ]
}

Dans code, les ressources SPM se trouvent dans Bundle.module, alors que CocoaPods les place dans ce MyPluginResources.bundle, à côté de la classe du plugin. Basculez avec le SWIFT_PACKAGE flag, que SwiftPM définit automatiquement :

#if SWIFT_PACKAGE
let resourceBundle = Bundle.module
#else
let resourceBundle: Bundle = {
    let classBundle = Bundle(for: MyPlugin.self)
    guard let url = classBundle.url(forResource: "MyPluginResources", withExtension: "bundle"),
          let bundle = Bundle(url: url) else {
        return classBundle
    }
    return bundle
}()
#endif

Du vrai Objective-C code

Si une partie du plugin est en Objective-C (pas seulement le bridge), placez-la dans sa propre cible avec des headers publics dans un include dossier, et faites dépendre la cible Swift de celle-ci :

.target(
    name: "MyPluginObjC",
    path: "ios/Sources/MyPluginObjC",
    publicHeadersPath: "include"),
.target(
    name: "MyPlugin",
    dependencies: [
        "MyPluginObjC",
        .product(name: "Capacitor", package: "capacitor-swift-pm")
    ],
    path: "ios/Sources/MyPlugin")

Le code Swift effectue ensuite import MyPluginObjC.

La méthode automatisée : cap2spm

de l’équipe Ionic capacitor-plugin-converter génère un cap2spm binaire qui lit Plugin.m et Plugin.h, ajoute la CAPBridgedPlugin conformité à votre classe Swift, génère Package.swift, déplace les fichiers vers Sources et Tests, met à jour le podspec et package.json, et supprime les anciens fichiers de projet Xcode.

curl -OL https://github.com/ionic-team/capacitor-plugin-converter/releases/latest/download/cap2spm.zip
unzip cap2spm.zip
xattr -d com.apple.quarantine ./cap2spm   # binary is unsigned
./cap2spm --backup /path/to/my-plugin

Il est conçu pour les plugins exclusivement en Swift, à l’exception des fichiers de bridge. Exécutez-le sur un arbre git propre, puis effectuez l’étape 5 manuellement.

Autres options :

  • Générer un nouveau plugin par bun create @capacitor/plugin et y copier votre implémentation. Le template prend déjà en charge SPM et CocoaPods. C’est souvent plus rapide pour les petits plugins.
  • Utiliser un agent. Le capacitor-plugin-spm-support skill dans skills Capgo open source parcourt Package.swift, le nettoyage du bridge, les ressources et package.json. Installez avec bunx skills add Cap-go/capgo-skills.

Tester dans une vraie app SPM

bun create @capacitor/app spm-test
cd spm-test
bun add @capacitor/ios
bun add ../my-plugin
bun run build
bunx cap add ios          # SPM is the default in Capacitor 8
bunx cap sync ios

Vérifiez que ios/App/CapApp-SPM/Package.swift liste votre package, et que la synchro a affiché « All Capacitor plugins have a Package.swift file ». Compilez ensuite sur un appareil et appelez chaque méthode. Répétez dans une app créée avec bunx cap add ios --packagemanager CocoaPods pour confirmer que le podspec fonctionne toujours.

Troubleshooting

product 'X' required by package 'capapp-spm' ... not found. Le nom du package ou du produit ne correspond pas au nom dérivé de votre nom npm. Consultez le tableau ci-dessus.

"MyPlugin" plugin is not implemented on ios. Soit Package.swift n’a pas été publié, jsName ne correspond pas registerPlugin, ou la classe ne possède pas CAPBridgedPlugin. Inspectez le dossier du plugin dans node_modules.

Multiple targets named 'Plugin' ou des erreurs de duplication de module. Deux plugins utilisent le même nom de target. Renommez le vôtre avec un nom unique. Les équipes peuvent aussi contourner le problème avec experimental.ios.spm.packageOptions (moduleAliases ou symlink) dans la config Capacitor, disponible depuis CLI 8.4.

target 'X' contains mixed language source files. Les fichiers Objective-C et Swift partagent un même dossier. Séparez-les en deux targets.

Xcode shows stale package errors after fixes. Utiliser File > Packages > Reset Package Caches, puis relancez le build.

Si vous avez aussi besoin de fonctionnalités optionnelles activables par app, lisez comment utiliser les traits de package SPM dans Capacitor. Pour les applications qui doivent encore utiliser CocoaPods, consultez comment utiliser CocoaPods avec Capacitor 8, et pour une vue d’ensemble, SPM vs CocoaPods pour Capacitor.

Live updates pour les apps Capacitor

Lorsqu'un bug de la couche web est en ligne, déployez le correctif via Capgo au lieu d'attendre des jours l'approbation des stores. Les utilisateurs reçoivent la mise à jour en arrière-plan, tandis que les changements natifs suivent le circuit de validation habituel.

Les derniers articles du blog

Capgo vous offre les meilleures informations pour créer une application mobile vraiment professionnelle.