Saltar al contenido principal
Capacitor iOS Migración

Migra un plugin de Capacitor a Swift Package Manager

Añade soporte de Swift Package Manager a un plugin de Capacitor: reglas de nombrado en Package.swift, CAPBridgedPlugin, estructura de Sources, recursos, code de Obj-C y cap2spm.

Ilustración para migrar un plugin de Capacitor de CocoaPods a Swift Package Manager

Para migrar un plugin de Capacitor a Swift Package Manager, añade un Package.swift en la raíz del plugin cuyo nombre de paquete y producto coincida con lo que espera Capacitor CLI, mueve el code de iOS a ios/Sources/<Target>, reemplaza los archivos CAP_PLUGIN bridge de Objective-C por CAPBridgedPlugin conformidad en Swift y añade Package.swift a la lista de npm files . Mantén el podspec para que las apps con CocoaPods sigan funcionando.

Desde Capacitor 8, cap add ios crea proyectos SPM por defecto. Un plugin sin Package.swift se omite en esas apps con el aviso “Some installed Capacitor plugins are not compatible with SPM”, y luego los usuarios ven “plugin is not implemented” en runtime. Esta guía cubre la migración manual, la herramienta de conversión y lo que la mayoría de guías omiten: reglas de nombres, recursos, code de Objective-C y dependencias de terceros.

Los equipos de apps que migran su propio proyecto deben leer Cómo migrar tu app Capacitor a SPM en su lugar.

Cómo Capacitor consume tu paquete

En bunx cap sync ios, las CLI reescriben ios/App/CapApp-SPM/Package.swift en la app. Para cada plugin de Capacitor que tenga un Package.swift, añade dos líneas:

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

Dos consecuencias:

  1. Tu paquete se consume por ruta local desde node_modules, no desde una URL de git. Tu Package.swift debe estar en la raíz del paquete npm, y todo lo que referencie debe estar publicado en npm.
  2. El nombre se deriva del nombre de npm, no desde tu podspec. El CLI elimina @, convierte / y - en _, luego aplica camelCase a cada segmento y capitaliza la primera letra:
paquete npm Nombre de paquete y producto requeridos
@capacitor/haptics CapacitorHaptics
@capgo/capacitor-updater CapgoCapacitorUpdater
capacitor-my-plugin CapacitorMyPlugin
@acme/capacitor-scanner AcmeCapacitorScanner

Si tu Package.swift usa otro nombre o nombre de producto, la app falla al resolver los paquetes con un error como product 'X' required by package 'capapp-spm' target 'CapApp-SPM' not found.

Paso 1: escribe Package.swift

Este es el formato que usan los plugins oficiales. Aquí tienes el manifiesto real 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")
    ]
)

Notas:

  • Mantén swift-tools-version: 5.9 a menos que necesites una funcionalidad más reciente. El paquete generado de la app también usa 5.9 por defecto, y Capacitor 8 aún no soporta Swift 6 oficialmente.
  • Usa from: "8.0.0" para capacitor-swift-pm. La app fija una versión exacta que coincide con la instalada @capacitor/ios, y un rango permite que SPM resuelva ambos. branch: o exact: provocará conflictos de resolución.
  • El nombre del target puede ser cualquiera, pero será el nombre del módulo Swift. Evita nombres genéricos como Plugin que colisionen con otros plugins.

Paso 2: mover las fuentes

SPM espera una carpeta por target. El conversor y la plantilla oficial usan:

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

Luego elimina lo que SPM no necesita: ios/Plugin.xcodeproj, ios/Plugin.xcworkspace, ios/Podfile, ios/Plugin/Info.plist y ios/PluginTests/Info.plist.

Actualiza el podspec para que CocoaPods compile los mismos archivos:

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'

Paso 3: reemplaza el bridge de Objective-C por CAPBridgedPlugin

Los plugins antiguos registran métodos en Plugin.m:

#import <Capacitor/Capacitor.h>

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

SwiftPM no puede mezclar Objective-C y Swift en un mismo target, así que esto se mueve a la clase 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
        // ...
    }
}

Reglas de mapeo:

  • identifier es el primer argumento de CAP_PLUGIN (el nombre de la clase).
  • jsName es el segundo argumento, el nombre usado en registerPlugin('MyPlugin') en tu TypeScript.
  • Cada CAP_PLUGIN_METHOD se convierte en uno CAPPluginMethod, manteniendo el mismo tipo de retorno (CAPPluginReturnPromise, CAPPluginReturnCallback o CAPPluginReturnNone).

Un método que falta en pluginMethods compila sin problemas y solo falla cuando JavaScript lo llama. Busca con Grep @objc func con un CAPPluginCall parámetro y compáralos con el array. Luego elimina Plugin.h y Plugin.m.

CAPBridgedPlugin funciona también con CocoaPods, por lo que el mismo archivo Swift sirve para ambos gestores de paquetes.

Paso 4: actualiza package.json y .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"
  }
}

El -scheme valor es el nombre de tu paquete. Olvidar Package.swift o ios/Sources en files es la razón más común por la que un plugin funciona desde un checkout de git y falla después npm publish. Ejecuta bun pm pack --dry-run y revisa la lista de archivos.

Añade el output de build de SPM a .gitignore:

.build/
Package.resolved
/Packages
.swiftpm/

Paso 5: gestiona lo que el conversor no cubre

Dependencias de terceros

Una línea de podspec como s.dependency 'Alamofire', '~> 5.9' se convierte en una dependencia de paquete y un producto en tu target:

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 el SDK solo distribuye un .xcframework, inclúyelo dentro del paquete npm y usa un binary target:

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

Si un proveedor no ofrece un paquete SPM ni un xcframework, aún no puedes añadir soporte para SPM. Mantén el plugin solo con CocoaPods y acláralo en el README.

Mantén los rangos de versión en Package.swift y el podspec alineados. Dos plugins en la misma app que requieran versiones incompatibles de un SDK fallarán al resolverse en cualquiera de los dos package managers.

Recursos y manifiestos de privacidad

Imágenes, JSON, storyboards y PrivacyInfo.xcprivacy deben declararse:

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

CocoaPods necesita que los mismos archivos estén declarados en el podspec. Ponlos en un bundle de recursos con nombre:

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

En code, los recursos de SPM están en Bundle.module, mientras que CocoaPods los pone en ese MyPluginResources.bundle, junto a la clase del plugin. Cambia con el SWIFT_PACKAGE flag, que SwiftPM define automáticamente:

#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

code Objective-C real

Si parte del plugin es Objective-C (no solo el bridge), ponlo en su propio target con headers públicos en una include carpeta, y haz que el target de Swift dependa de él:

.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")

El code de Swift entonces hace import MyPluginObjC.

La ruta automatizada: cap2spm

Del equipo de Ionic capacitor-plugin-converter construye un cap2spm binario que lee Plugin.m y Plugin.h, añade CAPBridgedPlugin conformidad a tu clase Swift, genera Package.swift, mueve los archivos a Sources y Tests, actualiza el podspec y package.json, y elimina los antiguos archivos del proyecto 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

Está pensado para plugins que son solo Swift salvo los archivos del bridge. Ejecútalo sobre un árbol de git limpio y luego haz el Paso 5 a mano.

Otras opciones:

  • Generar un nuevo plugin con bun create @capacitor/plugin y copiar tu implementación en él. La plantilla ya soporta SPM y CocoaPods. Esto suele ser más rápido para plugins pequeños.
  • Usa un agente. El capacitor-plugin-spm-support skill en Capgo Skills abiertas recorre Package.swift, la limpieza del bridge, recursos y package.json. Instala con bunx skills add Cap-go/capgo-skills.

Pruébalo en una app real con 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

Comprueba que ios/App/CapApp-SPM/Package.swift enumera tu paquete y que el sync imprimió “All Capacitor plugins have a Package.swift file”. Luego, compila en un dispositivo y llama a cada método. Repite en una app creada con bunx cap add ios --packagemanager CocoaPods para confirmar que el podspec sigue funcionando.

Solución de problemas

product 'X' required by package 'capapp-spm' ... not found. El nombre del Package o producto no coincide con el nombre derivado de tu npm. Consulta la tabla anterior.

"MyPlugin" plugin is not implemented on ios. O bien Package.swift no se publicó, jsName no coincide registerPlugin, o a la clase le falta CAPBridgedPlugin. Inspecciona la carpeta del plugin en node_modules.

Multiple targets named 'Plugin' o errores de módulos duplicados. Dos plugins usan el mismo nombre de target. Cambia el tuyo por uno único. Los equipos de apps también pueden sortearlo con experimental.ios.spm.packageOptions (moduleAliases o symlink) en la configuración de Capacitor, disponible desde CLI 8.4.

target 'X' contains mixed language source files. Los archivos Objective-C y Swift comparten una carpeta. Sepáralos en dos targets.

Xcode muestra errores de paquetes obsoletos tras las correcciones. Usa Archivo &gt; Paquetes &gt; Restablecer caché de paquetes, y luego vuelve a hacer build.

Si también necesitas activar funciones opcionales por app, consulta cómo usar los package traits de SPM en Capacitor. Para apps que deban seguir con CocoaPods por ahora, consulta cómo usar CocoaPods con Capacitor 8, y para ver el panorama general, SPM vs CocoaPods para Capacitor.

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.