Skip to content
Merged
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
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,100 @@ const outputPlist = settings.build(config);
fs.writeFileSync("/path/to/WorkspaceSettings.xcsettings", outputPlist);
```

## Swift Package Manager Support

Add Swift Package Manager dependencies to your Xcode projects with full wiring handled automatically.

### Adding Remote Packages

```ts
import { XcodeProject } from "@bacons/xcode";

const project = XcodeProject.open("/path/to/project.pbxproj");
const rootProject = project.rootObject;

// Add a remote Swift package to the project
const packageRef = rootProject.addRemoteSwiftPackage({
repositoryURL: "https://github.com/apple/swift-collections",
requirement: {
kind: "upToNextMajorVersion",
minimumVersion: "1.0.0",
},
});

// Add the package product to a target
const target = rootProject.getMainAppTarget("ios");
const productDep = target.addSwiftPackageProduct({
productName: "Collections",
package: packageRef,
});

// Save the project
fs.writeFileSync("/path/to/project.pbxproj", build(project.toJSON()));
```

### Adding Local Packages

```ts
// Add a local Swift package (e.g., from a monorepo)
const localPackage = rootProject.addLocalSwiftPackage({
relativePath: "../Packages/MyFeature",
});

// Add the product to your target
target.addSwiftPackageProduct({
productName: "MyFeature",
package: localPackage,
});
```

### What Gets Wired Up

When you call `target.addSwiftPackageProduct()`, the following is handled automatically:

1. Creates `XCSwiftPackageProductDependency` and adds it to target's `packageProductDependencies`
2. Creates `PBXBuildFile` with `productRef` pointing to the dependency
3. Adds the build file to the target's Frameworks build phase

### Managing Package Dependencies

```ts
// Get all package product dependencies for a target
const deps = target.getSwiftPackageProductDependencies();

// Find an existing package by URL or path
const existing = rootProject.getPackageReference(
"https://github.com/apple/swift-collections"
);

// Remove a package product from a target (cleans up build file too)
target.removeSwiftPackageProduct(productDep);
```

### Version Requirements

Remote packages support various version requirement types:

```ts
// Up to next major version (e.g., 1.0.0 to 2.0.0)
{ kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }

// Up to next minor version (e.g., 1.2.0 to 1.3.0)
{ kind: "upToNextMinorVersion", minimumVersion: "1.2.0" }

// Exact version
{ kind: "exactVersion", version: "1.2.3" }

// Version range
{ kind: "versionRange", minimumVersion: "1.0.0", maximumVersion: "2.0.0" }

// Branch
{ kind: "branch", branch: "main" }

// Revision (commit hash)
{ kind: "revision", revision: "abc123def456" }
```

## Solution

- Uses a hand-optimized single-pass parser that is 11x faster than the legacy `xcode` package (which uses PEG.js).
Expand Down Expand Up @@ -521,6 +615,7 @@ We support the following types: `Object`, `Array`, `Data`, `String`. Notably, we
- [ ] Import from other tools.
- [ ] **XCUserData**: (`xcuserdata/<user>.xcuserdatad/`) Per-user schemes, breakpoints, UI state.
- [x] **IDEWorkspaceChecks**: (`xcshareddata/IDEWorkspaceChecks.plist`) Workspace check state storage (e.g., 32-bit deprecation warning).
- [x] **Swift Package Manager**: Add remote and local SPM dependencies with automatic wiring.

# Docs

Expand Down
17 changes: 17 additions & 0 deletions src/api/PBXBuildFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export class PBXBuildFile extends AbstractObject<PBXBuildFileModel> {
return object.isa === PBXBuildFile.isa;
}

/**
* Creates a PBXBuildFile with a fileRef for source files, resources, etc.
*/
static create(
project: XcodeProject,
opts: PickRequired<SansIsa<PBXBuildFileModel>, "fileRef">
Expand All @@ -36,6 +39,20 @@ export class PBXBuildFile extends AbstractObject<PBXBuildFileModel> {
}) as PBXBuildFile;
}

/**
* Creates a PBXBuildFile with a productRef for Swift Package dependencies.
* Use this instead of `create()` when linking a Swift Package product.
*/
static createFromProductRef(
project: XcodeProject,
opts: PickRequired<SansIsa<PBXBuildFileModel>, "productRef">
) {
return project.createModel<PBXBuildFileModel>({
isa: PBXBuildFile.isa,
...opts,
} as PBXBuildFileModel) as PBXBuildFile;
}

protected getObjectProps() {
return {
fileRef: String,
Expand Down
106 changes: 105 additions & 1 deletion src/api/PBXNativeTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ import {
type AnyBuildPhase,
} from "./PBXSourcesBuildPhase";
import { PBXFileReference } from "./PBXFileReference";
import { PBXBuildFile } from "./PBXBuildFile";
import { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependency";
import type { PickRequired, SansIsa } from "./utils/util.types";
import type { XcodeProject } from "./XcodeProject";
import type { PBXBuildRule } from "./PBXBuildRule";
import { PBXTargetDependency } from "./PBXTargetDependency";
import type { XCConfigurationList } from "./XCConfigurationList";
import type { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependency";
import type { PBXFileSystemSynchronizedRootGroup } from "./PBXFileSystemSynchronizedRootGroup";
import { PBXContainerItemProxy } from "./PBXContainerItemProxy";
import type { XCRemoteSwiftPackageReference } from "./XCRemoteSwiftPackageReference";
import type { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference";

export type PBXNativeTargetModel = json.PBXNativeTarget<
XCConfigurationList,
Expand Down Expand Up @@ -380,4 +383,105 @@ export class PBXNativeTarget extends AbstractTarget<PBXNativeTargetModel> {
// Call parent which handles removing from PBXProject.targets array
return super.removeFromProject();
}

/**
* Adds a Swift package product dependency to this target.
* This handles the full wiring:
* 1. Creates the XCSwiftPackageProductDependency
* 2. Adds it to target's packageProductDependencies
* 3. Creates a PBXBuildFile with productRef
* 4. Adds the build file to the frameworks build phase
*
* Note: The package reference must already be added to the project via
* `project.addPackageReference()`, `project.addRemoteSwiftPackage()`, or
* `project.addLocalSwiftPackage()`.
*
* @param opts.productName Name of the product from the Swift package
* @param opts.package The package reference (XCRemoteSwiftPackageReference or XCLocalSwiftPackageReference)
* @returns The created XCSwiftPackageProductDependency
*/
addSwiftPackageProduct(opts: {
productName: string;
package?: XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference;
}): XCSwiftPackageProductDependency {
const xcproj = this.getXcodeProject();

// Initialize packageProductDependencies if needed
if (!this.props.packageProductDependencies) {
this.props.packageProductDependencies = [];
}

// Check if this product dependency already exists for this target
const existing = this.props.packageProductDependencies.find(
(dep) =>
dep.props.productName === opts.productName &&
dep.props.package?.uuid === opts.package?.uuid
);
if (existing) {
return existing;
}

// Create the product dependency
const productDep = XCSwiftPackageProductDependency.create(xcproj, {
productName: opts.productName,
package: opts.package,
});

// Add to target's packageProductDependencies
this.props.packageProductDependencies.push(productDep);

// Create a build file with productRef pointing to the dependency
const buildFile = PBXBuildFile.createFromProductRef(xcproj, {
productRef: productDep,
});

// Add the build file to the frameworks build phase
this.getFrameworksBuildPhase().props.files.push(buildFile);

return productDep;
}

/**
* Gets all Swift package product dependencies for this target.
*
* @returns Array of XCSwiftPackageProductDependency objects
*/
getSwiftPackageProductDependencies(): XCSwiftPackageProductDependency[] {
return this.props.packageProductDependencies ?? [];
}

/**
* Removes a Swift package product dependency from this target.
* This handles removing from packageProductDependencies and the build file from the frameworks phase.
*
* @param productDep The product dependency to remove
*/
removeSwiftPackageProduct(productDep: XCSwiftPackageProductDependency): void {
// Remove from packageProductDependencies
if (this.props.packageProductDependencies) {
const index = this.props.packageProductDependencies.findIndex(
(dep) => dep.uuid === productDep.uuid
);
if (index !== -1) {
this.props.packageProductDependencies.splice(index, 1);
}
}

// Find and remove the build file that references this product dependency
const frameworksPhase = this.getBuildPhase(PBXFrameworksBuildPhase);
if (frameworksPhase) {
const buildFileIndex = frameworksPhase.props.files.findIndex(
(file) => file.props.productRef?.uuid === productDep.uuid
);
if (buildFileIndex !== -1) {
const buildFile = frameworksPhase.props.files[buildFileIndex];
frameworksPhase.props.files.splice(buildFileIndex, 1);
// Remove the build file from the project
buildFile.removeFromProject();
}
}

// Remove the product dependency from the project
productDep.removeFromProject();
}
}
108 changes: 106 additions & 2 deletions src/api/PBXProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import * as json from "../json/types";
import { AbstractObject } from "./AbstractObject";
import { PBXNativeTarget, PBXNativeTargetModel } from "./PBXNativeTarget";
import { XCBuildConfiguration } from "./XCBuildConfiguration";
import { XCRemoteSwiftPackageReference } from "./XCRemoteSwiftPackageReference";
import { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference";

import type { PickRequired, SansIsa } from "./utils/util.types";
import type { PBXGroup } from "./AbstractGroup";
import type { XcodeProject } from "./XcodeProject";
import type { PBXAggregateTarget } from "./PBXAggregateTarget";
import type { PBXLegacyTarget } from "./PBXLegacyTarget";
import type { XCConfigurationList } from "./XCConfigurationList";
import type { XCRemoteSwiftPackageReference } from "./XCRemoteSwiftPackageReference";
import type { XCLocalSwiftPackageReference } from "./XCLocalSwiftPackageReference";

export type PBXProjectModel = json.PBXProject<
XCConfigurationList,
Expand Down Expand Up @@ -234,4 +234,108 @@ export class PBXProject extends AbstractObject<PBXProjectModel> {
delete this.props.attributes.TargetAttributes[uuid];
}
}

/**
* Adds a Swift package reference to the project if not already present.
*
* @param packageRef The package reference to add (XCRemoteSwiftPackageReference or XCLocalSwiftPackageReference)
* @returns The package reference
*/
addPackageReference(
packageRef: XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference
): XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference {
if (!this.props.packageReferences) {
this.props.packageReferences = [];
}

// Check if already added
const existing = this.props.packageReferences.find(
(ref) => ref.uuid === packageRef.uuid
);
if (existing) {
return existing;
}

this.props.packageReferences.push(packageRef);
return packageRef;
}

/**
* Gets an existing package reference by repository URL (for remote) or relative path (for local).
*
* @param identifier The repository URL or relative path to search for
* @returns The package reference if found, null otherwise
*/
getPackageReference(
identifier: string
): XCRemoteSwiftPackageReference | XCLocalSwiftPackageReference | null {
if (!this.props.packageReferences) {
return null;
}

for (const ref of this.props.packageReferences) {
if (
XCRemoteSwiftPackageReference.is(ref) &&
ref.props.repositoryURL === identifier
) {
return ref;
}
if (
XCLocalSwiftPackageReference.is(ref) &&
ref.props.relativePath === identifier
) {
return ref;
}
}

return null;
}

/**
* Creates and adds a remote Swift package reference to the project.
*
* @param opts Options for creating the remote package reference
* @returns The created or existing package reference
*/
addRemoteSwiftPackage(
opts: SansIsa<json.XCRemoteSwiftPackageReference>
): XCRemoteSwiftPackageReference {
// Check if package already exists
if (opts.repositoryURL) {
const existing = this.getPackageReference(opts.repositoryURL);
if (existing && XCRemoteSwiftPackageReference.is(existing)) {
return existing;
}
}

const packageRef = XCRemoteSwiftPackageReference.create(
this.getXcodeProject(),
opts
);
this.addPackageReference(packageRef);
return packageRef;
}

/**
* Creates and adds a local Swift package reference to the project.
*
* @param opts Options for creating the local package reference
* @returns The created or existing package reference
*/
addLocalSwiftPackage(
opts: SansIsa<json.XCLocalSwiftPackageReference>
): XCLocalSwiftPackageReference {
// Check if package already exists
const existing = this.getPackageReference(opts.relativePath);
if (existing && XCLocalSwiftPackageReference.is(existing)) {
return existing;
}

const packageRef = XCLocalSwiftPackageReference.create(
this.getXcodeProject(),
opts
);
this.addPackageReference(packageRef);
return packageRef;
}
}
Loading