diff --git a/AGENTS.md b/AGENTS.md index 0abe686d4..cd92f3822 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,10 +54,18 @@ discovering *third-party plugin* classes, but the runtime itself is Java only add Kotlin sources or the Kotlin Gradle plugin. **The iOS runtime has no Xcode project.** Since the source-first SwiftPM migration, -`Package.swift` at the repo root is the manifest and new files under `ios/Sources/` are -picked up automatically. The only `.pbxproj` files are the app scaffolds in +`Package.swift` is the manifest and new files under `ios/Sources/` are picked up +automatically. The only `.pbxproj` files are the app scaffolds in `ios-spm-template/App/` and `ios-pods-template/App/`. +**There are two copies of that manifest, and a change to one belongs in both.** +`Package.swift` at the repo root is what a git URL resolves and the only one that can +declare the test targets (`ios/Tests/**` use `@testable import Capacitor`). +`ios/Package.swift` is published inside `@capacitor/ios`, which is what apps depend on by +path — it drops the `ios/` prefix from every target path and omits the test targets, +because `Tests/` is not in the npm `files` list. `npm run lint:package-swift` +(part of `npm run lint`) fails if they disagree on products or target source directories. + --- ## Things that will surprise you @@ -325,6 +333,7 @@ Report actual results; never claim success unverified. | `core/native-bridge.ts` | `npm run build:nativebridge`, then `npm test -w core` | | `cli/src/**` | `npm run build -w cli` + `npm test -w cli` | | `ios/**` | `npm run verify -w ios` (macOS + Xcode required) | +| `Package.swift` or `ios/Package.swift` | `npm run lint:package-swift` (both copies) | | `android/**` | `npm run verify -w android` (JDK 21) | | anything | `npm run lint` | diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts index 180111ad4..b4dee0a63 100644 --- a/cli/src/declarations.ts +++ b/cli/src/declarations.ts @@ -579,14 +579,14 @@ export interface CapacitorConfig { /** * Override which Capacitor Swift package the generated `Package.swift` depends on. * - * By default the generated package pins the git tag matching the installed - * `@capacitor/ios` version, which is correct for both stable and pre-release versions - * and matches what CocoaPods resolves. + * By default the generated package depends on the `@capacitor/ios` npm already installed + * in `node_modules`, which ships the Swift package alongside the sources. The native + * runtime is then always the one npm resolved, with no version to keep in step. * - * Set this only when developing against an unreleased Capacitor, such as a local + * Set this only when developing against an unreleased Capacitor, such as a repository * checkout or a feature branch. Exactly one of `path`, `branch`, `revision`, `exact`, - * or `from` must be set. `path` points at the repository root (the directory containing - * `Package.swift`) and may be relative to the app. + * or `from` must be set. `path` points at a directory containing a `Package.swift` — a + * repository checkout's root, or another `@capacitor/ios` — and may be relative to the app. * * The `CAPACITOR_IOS_PACKAGE` environment variable takes precedence over this setting, * so a checkout can be redirected without editing committed configuration. diff --git a/cli/src/util/capacitor-package.ts b/cli/src/util/capacitor-package.ts index 1c0e27936..4c1da6631 100644 --- a/cli/src/util/capacitor-package.ts +++ b/cli/src/util/capacitor-package.ts @@ -1,5 +1,5 @@ -import { existsSync } from 'fs-extra'; -import { isAbsolute, relative, resolve } from 'path'; +import { existsSync, realpathSync } from 'fs-extra'; +import { dirname, isAbsolute, relative, resolve } from 'path'; import { major, valid } from 'semver'; import { getCapacitorPackageVersion } from '../common'; @@ -7,11 +7,16 @@ import type { Config } from '../definitions'; import { fatal } from '../errors'; import { convertToUnixPath } from './fs'; +import { resolveNode } from './node'; /** Capacitor 9 and up builds the iOS platform from source out of the main repo. */ const SOURCE_PACKAGE_MAJOR = 9; const SOURCE_PACKAGE_URL = 'https://github.com/ionic-team/capacitor'; const SOURCE_PACKAGE_IDENTITY = 'capacitor'; +const SOURCE_PACKAGE_NAME = new RegExp(`name:\\s*"${SOURCE_PACKAGE_IDENTITY}"`); + +/** The npm package the source-based Swift package ships inside. */ +const PLATFORM_PACKAGE = '@capacitor/ios'; /** Before Capacitor 9 the platform was consumed as a prebuilt xcframework from a separate repo. */ const BINARY_PACKAGE_URL = 'https://github.com/ionic-team/capacitor-swift-pm.git'; @@ -44,15 +49,14 @@ export interface CapacitorPackage { * Precedence, most specific first: * 1. the `CAPACITOR_IOS_PACKAGE` environment variable * 2. `experimental.ios.spm.capacitorPackage` in the Capacitor config - * 3. the git tag matching the installed `@capacitor/ios` version + * 3. the installed `@capacitor/ios` in `node_modules` * * The environment variable deliberately outranks the config file so a checkout can be pointed at a * local or branch build without editing (and risking committing) the app's configuration. * * `legacyRequirement` only applies to pre-9 projects, where it preserves each caller's existing - * requirement style. Source-based projects always pin `exact`, because the app package and any - * generated plugin packages must agree: a prerelease such as `9.0.0-alpha.6` does not satisfy a - * `from:` range, so mixing the two styles fails to resolve. + * requirement style. Source-based projects resolve a path, which carries no version to disagree + * about between the app package and any generated plugin packages. */ export async function resolveCapacitorPackage( config: Config, @@ -77,10 +81,67 @@ export async function resolveCapacitorPackage( cordovaProduct: 'CapacitorCordova', sourceBased: true, url: override?.url ?? SOURCE_PACKAGE_URL, - requirement: override?.requirement ?? { kind: 'exact', version }, + requirement: override?.requirement ?? installedPackageRequirement(config), }; } +/** + * Points at the `@capacitor/ios` npm already installed in the app, which ships the Swift package + * manifest alongside the sources. Resolving from `node_modules` keeps the native runtime and the + * `@capacitor/core` bridge it is versioned against on the same release, with no git tag to line up + * against the npm version on each release. + */ +function installedPackageRequirement(config: Config): CapacitorRequirement { + const packageJsonPath = resolveNode(config.app.rootDir, PLATFORM_PACKAGE, 'package.json'); + + if (!packageJsonPath) { + fatal(`Unable to find node_modules/${PLATFORM_PACKAGE}.\nAre you sure ${PLATFORM_PACKAGE} is installed?`); + } + + const absolutePath = installDirectory(dirname(packageJsonPath), config.app.rootDir); + + if (!existsSync(resolve(absolutePath, 'Package.swift'))) { + fatal( + `The installed ${PLATFORM_PACKAGE} has no Package.swift (looked in ${absolutePath}).\n` + + `Early 9.0.0 alphas did not ship one; update ${PLATFORM_PACKAGE} to build with SPM.`, + ); + } + + return { kind: 'path', absolutePath }; +} + +/** + * Trades the resolved location for the `node_modules` entry that leads to it, when there is one. + * + * Node resolves symlinks away, so a linked or `file:`-installed package resolves to wherever it + * actually lives — an absolute path outside the app, which the generated `Package.swift` is + * committed with. The entry inside the project stays valid on every machine. + */ +function installDirectory(resolved: string, rootDir: string): string { + const real = realPath(resolved); + if (!real) { + return resolved; + } + + for (let dir = resolve(rootDir); ; dir = dirname(dir)) { + const candidate = resolve(dir, 'node_modules', PLATFORM_PACKAGE); + if (realPath(candidate) === real) { + return candidate; + } + if (dirname(dir) === dir) { + return resolved; + } + } +} + +function realPath(path: string): string | undefined { + try { + return realpathSync(path); + } catch { + return undefined; + } +} + /** Renders the `.package(...)` entry, with any path made relative to the file being written. */ export function renderCapacitorPackage(pkg: CapacitorPackage, fromDir: string): string { if (pkg.requirement.kind === 'path') { @@ -166,8 +227,13 @@ export function patchPackageSwiftDeploymentTarget( return { content: patchedContent, changed: true, previousVersion }; } +/** + * A path dependency carries no URL to match on, so it is matched on its pinned identity — without + * that, a manifest rewritten by an earlier sync keeps a relative path that no longer resolves once + * the app, or the package it points at, has moved. + */ function referencesCapacitor(call: string): boolean { - return call.includes(BINARY_PACKAGE_IDENTITY) || call.includes(`${SOURCE_PACKAGE_URL}`); + return call.includes(BINARY_PACKAGE_IDENTITY) || call.includes(SOURCE_PACKAGE_URL) || SOURCE_PACKAGE_NAME.test(call); } interface PackageCall { @@ -407,7 +473,8 @@ function pathRequirement(value: string, baseDir: string): CapacitorRequirement { if (!existsSync(resolve(absolutePath, 'Package.swift'))) { fatal( `No Package.swift found at ${absolutePath}.\n` + - `A local Capacitor package must point at the repository root, not the ios directory.`, + `A local Capacitor package must point at a directory containing one: a repository checkout's ` + + `root, or an installed ${PLATFORM_PACKAGE}.`, ); } return { kind: 'path', absolutePath }; diff --git a/cli/test/capacitor-package.spec.ts b/cli/test/capacitor-package.spec.ts index 19c78dbed..b79bb9f99 100644 --- a/cli/test/capacitor-package.spec.ts +++ b/cli/test/capacitor-package.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; @@ -38,34 +38,68 @@ describe('capacitor package resolution', () => { }); describe('default (no override)', () => { - it('pins the tag matching a stable installed version', async () => { - getCapacitorPackageVersion.mockResolvedValue('9.1.0'); - const pkg = await resolveCapacitorPackage(makeConfig()); + let appRoot: string; + let installed: string; + + beforeEach(() => { + getCapacitorPackageVersion.mockResolvedValue('9.0.0-alpha.8'); + appRoot = realpathSync(mkdtempSync(join(tmpdir(), 'cap-app-'))); + installed = join(appRoot, 'node_modules', '@capacitor', 'ios'); + mkdirSync(installed, { recursive: true }); + writeFileSync(join(installed, 'package.json'), '{ "name": "@capacitor/ios" }'); + writeFileSync(join(installed, 'Package.swift'), '// ios package'); + }); + + it('depends on the installed @capacitor/ios by path', async () => { + const pkg = await resolveCapacitorPackage(makeConfig({}, appRoot)); expect(pkg.sourceBased).toBe(true); expect(pkg.identity).toBe('capacitor'); expect(pkg.cordovaProduct).toBe('CapacitorCordova'); - expect(renderCapacitorPackage(pkg, SPM_DIR)).toBe( - '.package(url: "https://github.com/ionic-team/capacitor", exact: "9.1.0")', + expect(pkg.requirement).toEqual({ kind: 'path', absolutePath: installed }); + expect(renderCapacitorPackage(pkg, join(appRoot, 'ios', 'App', 'CapApp-SPM'))).toBe( + '.package(name: "capacitor", path: "../../../node_modules/@capacitor/ios")', ); }); - it('pins the tag for a prerelease too, rather than tracking a branch', async () => { - getCapacitorPackageVersion.mockResolvedValue('9.0.0-alpha.6'); - const pkg = await resolveCapacitorPackage(makeConfig()); + it('renders relative to a plugin being rewritten, not just the app package', async () => { + const pkg = await resolveCapacitorPackage(makeConfig({}, appRoot)); - expect(renderCapacitorPackage(pkg, SPM_DIR)).toBe( - '.package(url: "https://github.com/ionic-team/capacitor", exact: "9.0.0-alpha.6")', + expect(renderCapacitorPackage(pkg, join(appRoot, 'node_modules', '@capacitor', 'haptics'))).toBe( + '.package(name: "capacitor", path: "../ios")', ); }); - it('uses exact for source-based projects even when the caller asks for from', async () => { - // The app package and generated plugin packages must agree; a prerelease does not satisfy - // a `from:` range, so mixing the two styles would fail to resolve. - getCapacitorPackageVersion.mockResolvedValue('9.0.0-alpha.6'); - const pkg = await resolveCapacitorPackage(makeConfig(), 'from'); + it('resolves a path whatever requirement style the caller asks for', async () => { + const pkg = await resolveCapacitorPackage(makeConfig({}, appRoot), 'from'); + + expect(pkg.requirement).toEqual({ kind: 'path', absolutePath: installed }); + }); + + it('keeps the node_modules entry when the install is a symlink', async () => { + // npm link and file: installs resolve away to wherever the package really lives; the + // generated Package.swift is committed, so it must not carry that machine-specific path. + const checkout = realpathSync(mkdtempSync(join(tmpdir(), 'cap-checkout-'))); + writeFileSync(join(checkout, 'package.json'), '{ "name": "@capacitor/ios" }'); + writeFileSync(join(checkout, 'Package.swift'), '// ios package'); + rmSync(installed, { recursive: true }); + symlinkSync(checkout, installed); + + const pkg = await resolveCapacitorPackage(makeConfig({}, appRoot)); + + expect(pkg.requirement).toEqual({ kind: 'path', absolutePath: installed }); + }); + + it('reports an @capacitor/ios too old to ship a Package.swift', async () => { + rmSync(join(installed, 'Package.swift')); + + await expect(resolveCapacitorPackage(makeConfig({}, appRoot))).rejects.toThrow(/has no Package.swift/); + }); + + it('reports a missing @capacitor/ios', async () => { + rmSync(join(appRoot, 'node_modules'), { recursive: true }); - expect(pkg.requirement).toEqual({ kind: 'exact', version: '9.0.0-alpha.6' }); + await expect(resolveCapacitorPackage(makeConfig({}, appRoot))).rejects.toThrow(/Unable to find node_modules/); }); }); @@ -351,6 +385,52 @@ describe('rewriting an existing Package.swift', () => { expect(rewriteCapacitorDependency(once, pkg, '/plugin')).toBe(once); }); + describe('onto a path dependency', () => { + const pathPkg = { + ...pkg, + requirement: { kind: 'path' as const, absolutePath: '/app/node_modules/@capacitor/ios' }, + }; + const pluginDir = '/app/node_modules/@capacitor/haptics'; + + it('repoints a plugin at the installed package', () => { + const before = `dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor", from: "9.0.0") + ], + targets: [ + .target(name: "Plugin", dependencies: [.product(name: "Capacitor", package: "capacitor")]) + ]`; + + const after = rewriteCapacitorDependency(before, pathPkg, pluginDir); + + expect(after).toContain('.package(name: "capacitor", path: "../ios")'); + expect(after).toContain('.product(name: "Capacitor", package: "capacitor")'); + }); + + it('repoints a path left behind by an earlier sync', () => { + const before = '.package(name: "capacitor", path: "../../../somewhere/else")'; + + expect(rewriteCapacitorDependency(before, pathPkg, pluginDir)).toBe( + '.package(name: "capacitor", path: "../ios")', + ); + }); + + it('is idempotent', () => { + const once = rewriteCapacitorDependency( + '.package(url: "https://github.com/ionic-team/capacitor", from: "9.0.0")', + pathPkg, + pluginDir, + ); + + expect(rewriteCapacitorDependency(once, pathPkg, pluginDir)).toBe(once); + }); + + it('leaves a similarly named plugin package alone', () => { + const before = '.package(name: "capacitor-plugin-foo", path: "../foo")'; + + expect(rewriteCapacitorDependency(before, pathPkg, pluginDir)).toBe(before); + }); + }); + it('reads the pinned version from either requirement style', () => { expect( findCapacitorDependencyVersion( diff --git a/ios-spm-template/App/CapApp-SPM/Package.swift b/ios-spm-template/App/CapApp-SPM/Package.swift index 717924de4..6b96e14ed 100644 --- a/ios-spm-template/App/CapApp-SPM/Package.swift +++ b/ios-spm-template/App/CapApp-SPM/Package.swift @@ -11,7 +11,7 @@ let package = Package( targets: ["CapApp-SPM"]) ], dependencies: [ - .package(url: "https://github.com/ionic-team/capacitor", from: "9.0.0") + .package(name: "capacitor", path: "../../../node_modules/@capacitor/ios") ], targets: [ .target( diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 000000000..9492bcaa3 --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,79 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// Ships inside the @capacitor/ios npm package so apps can resolve Capacitor from node_modules. +// Mirrors the manifest at the repository root, minus the test targets: Tests/ is not published. +let package = Package( + name: "Capacitor", + platforms: [.iOS(.v16)], + products: [ + .library( + name: "Capacitor", + targets: ["Capacitor", "CapacitorObjC", "CapacitorObjCShims"] + ), + .library( + name: "Cordova", + targets: ["Cordova"] + ), + .library( + name: "CapacitorCordova", + targets: ["Cordova", "CapacitorCordova"] + ) + ], + targets: [ + .target( + name: "CapacitorObjC", + path: "Sources/CapacitorObjC", + publicHeadersPath: "include", + cSettings: [ + .headerSearchPath("include"), + .headerSearchPath("include/Capacitor") + ] + ), + .target( + name: "Capacitor", + dependencies: ["CapacitorObjC"], + path: "Sources/Capacitor", + resources: [ + .copy("assets"), + .copy("PrivacyInfo.xcprivacy") + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .target( + name: "CapacitorObjCShims", + dependencies: ["Capacitor"], + path: "Sources/CapacitorObjCShims", + exclude: ["Capacitor.modulemap"], + publicHeadersPath: "include", + cSettings: [ + .headerSearchPath("include"), + .headerSearchPath("include/Capacitor") + ] + ), + .target( + name: "Cordova", + path: "Sources/Cordova", + exclude: ["CapacitorCordova.modulemap"], + resources: [.copy("PrivacyInfo.xcprivacy")], + publicHeadersPath: "include", + cSettings: [ + .headerSearchPath("include"), + .headerSearchPath("include/Cordova") + ], + linkerSettings: [ + .linkedFramework("UIKit"), + .linkedFramework("WebKit"), + .linkedFramework("MobileCoreServices"), + .linkedFramework("CFNetwork") + ] + ), + .target( + name: "CapacitorCordova", + dependencies: ["Capacitor", "Cordova"], + path: "Sources/CapacitorCordova", + swiftSettings: [.swiftLanguageMode(.v5)] + ) + ], + swiftLanguageModes: [.v5] +) diff --git a/ios/package.json b/ios/package.json index 64efba325..c6b1be269 100644 --- a/ios/package.json +++ b/ios/package.json @@ -13,6 +13,7 @@ "url": "https://github.com/ionic-team/capacitor/issues" }, "files": [ + "Package.swift", "Sources/", "Capacitor.podspec", "CapacitorCordova.podspec", diff --git a/package.json b/package.json index acb713c7a..c795c6578 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "ci:publish:dev": "lerna version prerelease --conventional-commits --conventional-prerelease --force-publish --preid dev-$(date +\"%Y%m%dT%H%M%S\") --no-changelog --no-git-tag-version --no-push --yes && lerna exec -- npm publish --tag dev --provenance", "build:nativebridge": "lerna run build:nativebridge", "sync-peer-dependencies": "node scripts/sync-peer-dependencies.mjs", - "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", + "lint:package-swift": "node scripts/check-ios-package-manifests.mjs", + "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint && npm run lint:package-swift", "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format", "prettier": "prettier \"**/*.{css,html,java,js,mjs,ts}\" --plugin=prettier-plugin-java", "eslint": "eslint .", diff --git a/scripts/check-ios-package-manifests.mjs b/scripts/check-ios-package-manifests.mjs new file mode 100644 index 000000000..f2d3cf83b --- /dev/null +++ b/scripts/check-ios-package-manifests.mjs @@ -0,0 +1,90 @@ +import { readFile } from 'fs/promises'; +import { resolve } from 'path'; + +import { execute } from './lib/cli.mjs'; +import { root } from './lib/repo.mjs'; + +/** + * The repository root manifest is what a git-URL consumer resolves and the only one that can + * declare the test targets; `ios/Package.swift` is the copy published inside @capacitor/ios, which + * apps depend on by path. Nothing but the `ios/` path prefix and the test targets may differ. + */ +execute(async () => { + const rootManifest = parseManifest(await readFile(resolve(root, 'Package.swift'), 'utf-8'), 'ios/'); + const iosManifest = parseManifest(await readFile(resolve(root, 'ios', 'Package.swift'), 'utf-8')); + + const problems = [ + ...compare('product', rootManifest.products, iosManifest.products), + ...compare('target', rootManifest.targets, iosManifest.targets), + ]; + + if (problems.length > 0) { + throw new Error( + `Package.swift and ios/Package.swift have drifted:\n${problems.map((p) => ` - ${p}`).join('\n')}\n` + + `Apply the change to both; ios/Package.swift omits the test targets and drops the "ios/" path prefix.`, + ); + } + + console.log('Package.swift and ios/Package.swift agree.'); +}); + +function compare(kind, expected, actual) { + const problems = []; + + for (const [name, declaration] of expected) { + if (!actual.has(name)) { + problems.push(`${kind} "${name}" is missing from ios/Package.swift`); + } else if (actual.get(name) !== declaration) { + problems.push(`${kind} "${name}" declares ${actual.get(name)}, expected ${declaration}`); + } + } + + for (const name of actual.keys()) { + if (!expected.has(name)) { + problems.push(`${kind} "${name}" is only declared in ios/Package.swift`); + } + } + + return problems; +} + +/** Reads the products and non-test targets out of a manifest, stripping `pathPrefix` from paths. */ +function parseManifest(source, pathPrefix = '') { + const products = new Map(); + for (const [, name, targets] of source.matchAll(/\.library\(\s*name:\s*"([^"]+)",\s*targets:\s*\[([^\]]*)\]/g)) { + products.set(name, `targets [${quotedStrings(targets).sort().join(', ')}]`); + } + + const targets = new Map(); + for (const { isTest, body } of targetDeclarations(source)) { + if (isTest) { + continue; + } + const name = body.match(/name:\s*"([^"]+)"/)?.[1]; + const path = body.match(/\bpath:\s*"([^"]+)"/)?.[1]; + if (!name || !path) { + throw new Error(`Could not read a name and path out of a target declaration:\n${body}`); + } + targets.set(name, `path "${stripPrefix(path, pathPrefix)}"`); + } + + return { products, targets }; +} + +/** Splits a manifest into one chunk per `.target(`/`.testTarget(` call. */ +function targetDeclarations(source) { + const matches = [...source.matchAll(/\.(testTarget|target)\(/g)]; + + return matches.map((match, index) => ({ + isTest: match[1] === 'testTarget', + body: source.slice(match.index, matches[index + 1]?.index ?? source.length), + })); +} + +function quotedStrings(source) { + return [...source.matchAll(/"([^"]+)"/g)].map(([, value]) => value); +} + +function stripPrefix(value, prefix) { + return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value; +}