Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |

Expand Down
12 changes: 6 additions & 6 deletions cli/src/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 76 additions & 9 deletions cli/src/util/capacitor-package.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
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';
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';
Expand Down Expand Up @@ -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,
Expand All @@ -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') {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
Expand Down
114 changes: 97 additions & 17 deletions cli/test/capacitor-package.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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/);
});
});

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion ios-spm-template/App/CapApp-SPM/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading