rork-xcode 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -2
- package/dist/index.d.ts +1121 -1
- package/dist/index.js +2047 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/rorkai/rork-xcode/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/rork-xcode)
|
|
5
5
|
|
|
6
|
-
The [fastest](#performance) zero-dependency Xcode project (`project.pbxproj`) parser and
|
|
6
|
+
The [fastest](#performance) zero-dependency Xcode project (`project.pbxproj`) parser, builder, and object model for any JavaScript runtime: browsers, Node.js, Bun, Electron, Cloudflare Workers, and React Native.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
9
|
import { parsePbxproj, buildPbxproj } from "rork-xcode";
|
|
@@ -85,6 +85,150 @@ try {
|
|
|
85
85
|
|
|
86
86
|
Booleans are rejected on purpose. The format has no boolean notation (Xcode models flags as the strings `"YES"` and `"NO"`), so writing one would produce a value Xcode misreads.
|
|
87
87
|
|
|
88
|
+
## Object model
|
|
89
|
+
|
|
90
|
+
`XcodeProject` gives typed, mutable access to a parsed project. It is a set of lightweight views over the plain parsed document: all state lives in the document itself, a view holds only an object id, and `build()` serializes whatever the document currently says in Xcode's canonical layout. Model calls and direct dictionary access compose freely, and the model adds no measurable overhead over the raw functions (`XcodeProject.parse` and `project.build()` benchmark identically to `parsePbxproj` and `buildPbxproj`).
|
|
91
|
+
|
|
92
|
+
This document-first design is deliberate, and the library's guarantees fall out of it. There is no inflate step on parse and no deflate step on build, so an untouched project rebuilds byte-identically and an edit changes only the entries it touches. Combined with deterministic identifiers, the same edit sequence produces the same bytes on every run, on every runtime.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { XcodeProject } from "rork-xcode";
|
|
96
|
+
|
|
97
|
+
const project = XcodeProject.parse(pbxprojText);
|
|
98
|
+
const text = project.build();
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Targets and build settings
|
|
102
|
+
|
|
103
|
+
`getBuildSetting` resolves hierarchically the way Xcode does: the target's default configuration first, then the project-level configuration. Writes go to every configuration of the target, so Debug and Release stay consistent.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const app = project.findMainAppTarget("ios"); // "macos" | "tvos" | "watchos" | "visionos"
|
|
107
|
+
app?.getBuildSetting("PRODUCT_BUNDLE_IDENTIFIER"); // "com.example.app"
|
|
108
|
+
app?.getBuildSetting("SDKROOT"); // inherited from the project configuration
|
|
109
|
+
app?.setBuildSetting("MARKETING_VERSION", "1.2.0");
|
|
110
|
+
app?.removeBuildSetting("CODE_SIGN_IDENTITY");
|
|
111
|
+
|
|
112
|
+
for (const target of project.nativeTargets()) {
|
|
113
|
+
console.log(target.name, target.productType);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`project.targets()` returns every target kind. Aggregate targets (`PBXAggregateTarget`) and legacy external-build-tool targets (`PBXLegacyTarget`) share the full target surface, from configurations and build settings to phases and dependency wiring.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
for (const target of project.targets()) {
|
|
121
|
+
if (target instanceof LegacyTarget) {
|
|
122
|
+
console.log(target.name, target.buildToolPath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Scaffolding a target
|
|
128
|
+
|
|
129
|
+
`addNativeTarget` creates the configurations, the product reference in the Products group, and the standard Sources, Frameworks, and Resources phases. Dependencies wire the container proxy pair Xcode uses; `embed` picks the right copy-files phase and destination for the product type (foundation extensions, ExtensionKit extensions, App Clips, watch content).
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { ProductType } from "rork-xcode";
|
|
133
|
+
|
|
134
|
+
const widget = project.addNativeTarget({
|
|
135
|
+
name: "DemoWidget",
|
|
136
|
+
productType: ProductType.appExtension,
|
|
137
|
+
buildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "com.example.app.widget" },
|
|
138
|
+
});
|
|
139
|
+
widget.setBuildSetting("IPHONEOS_DEPLOYMENT_TARGET", "18.0");
|
|
140
|
+
|
|
141
|
+
app.addDependency(widget);
|
|
142
|
+
app.embed(widget); // "Embed Foundation Extensions", dstSubfolderSpec 13
|
|
143
|
+
|
|
144
|
+
// Xcode 16 synchronized folder, with the scaffolded Info.plist excluded
|
|
145
|
+
// so the build does not copy it twice.
|
|
146
|
+
const folder = widget.addSyncGroup("DemoWidget");
|
|
147
|
+
folder.addMembershipExceptions(widget, ["Info.plist"]);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Swift packages
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const pkg = project.addSwiftPackage({
|
|
154
|
+
repositoryURL: "https://github.com/example/example-kit",
|
|
155
|
+
requirement: { kind: "upToNextMajorVersion", minimumVersion: "2.0.0" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Wires the product dependency and its Frameworks-phase build file.
|
|
159
|
+
app.addSwiftPackageProduct({ productName: "ExampleKit", packageReference: pkg });
|
|
160
|
+
|
|
161
|
+
// Local (path-based) packages and system frameworks work the same way.
|
|
162
|
+
const local = project.addLocalSwiftPackage("Packages/DesignSystem");
|
|
163
|
+
app.addSwiftPackageProduct({ productName: "DesignSystem", packageReference: local });
|
|
164
|
+
app.addSystemFramework("Messages");
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Files, groups, and phases
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { Isa } from "rork-xcode";
|
|
171
|
+
|
|
172
|
+
// Classic (non-synchronized) file management, with nested group creation.
|
|
173
|
+
const mainGroup = project.rootProject.mainGroup();
|
|
174
|
+
const generated = mainGroup?.ensureGroup("Sources/Generated");
|
|
175
|
+
const file = generated?.createFile("Config.swift");
|
|
176
|
+
if (file) app.ensureSourcesPhase().ensureBuildFile(file);
|
|
177
|
+
|
|
178
|
+
project.findFileReference("Sources/Generated/Config.swift"); // resolves through the group tree
|
|
179
|
+
|
|
180
|
+
// Phases expose their build files for reorganization, and script phases
|
|
181
|
+
// create with the usual defaults.
|
|
182
|
+
const embedPhase = app.findBuildPhase(Isa.copyFilesBuildPhase, "Embed Foundation Extensions");
|
|
183
|
+
embedPhase?.buildFileIds;
|
|
184
|
+
app.ensureShellScriptPhase("Lint", { shellScript: "lint\n" });
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Validation
|
|
188
|
+
|
|
189
|
+
Projects rot over time. References outlive the objects they pointed at, orphans pile up, an entry loses its `isa`. `validate()` finds these problems and returns them as data. `pruneOrphans()` deletes everything the root object cannot reach, and it errs on the safe side. If anything still references an object, it stays.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
for (const issue of project.validate()) {
|
|
193
|
+
console.warn(issue.kind, issue.message);
|
|
194
|
+
}
|
|
195
|
+
project.pruneOrphans(); // returns the removed ids
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Removal
|
|
199
|
+
|
|
200
|
+
`removeObject` deletes one object and strips every reference to it from the rest of the document. `removeTarget` composes it into a full teardown: the target's phases and build files, configurations, product reference and its embeddings, dependency objects other targets hold on it, membership exceptions, and synchronized folders no remaining target links. On-disk sources are untouched.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const widget = project.findTarget("DemoWidget");
|
|
204
|
+
if (widget) project.removeTarget(widget);
|
|
205
|
+
|
|
206
|
+
project.referrersOf(app.id); // every object referencing an id, for custom teardowns
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Escape hatch
|
|
210
|
+
|
|
211
|
+
Every view exposes its raw dictionary, so anything the typed surface does not cover stays one property away, and `project.objects()` iterates every object with its typed view:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
app.properties["productName"] = "RenamedApp";
|
|
215
|
+
|
|
216
|
+
for (const [id, object] of project.objects()) {
|
|
217
|
+
if (object.isa === "PBXShellScriptBuildPhase") {
|
|
218
|
+
console.log(id, object.getString("name"));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Semantics
|
|
224
|
+
|
|
225
|
+
- **Typed vocabulary, generic fallback.** The typed views cover targets of every kind, groups and variant groups, Xcode 16 synchronized folders and their exception sets, build phases and build rules, Core Data version groups, and cross-project reference proxies. Every other kind is a generic `XcodeObject` with the same read and write access, so nothing in a document is out of reach.
|
|
226
|
+
- **Typed, open property shapes.** Known keys autocomplete (`target.properties.productType`) and the shape stays open, so keys like `INFOPLIST_KEY_*` settings remain first-class. The shapes describe well-formed documents. When reading untrusted input, use the narrowing accessors, which never trust them.
|
|
227
|
+
- **Two verb families.** `add*` wires something to its owner (a dependency, a package, a framework, a synchronized folder) and is idempotent: re-adding returns the existing wiring. `ensure*` returns a structural container, creating it when missing (a build phase, a group chain, the Products group). Both families can therefore run unconditionally in scaffold and repair flows.
|
|
228
|
+
- **Deterministic identifiers.** New objects get ids derived from what they are (`XX` + 20 digest characters + `XX`, from an embedded hash), so programmatic edits are reproducible run to run and diffs stay minimal. Collisions within a document resolve deterministically, and identical edit sequences produce byte-identical documents.
|
|
229
|
+
- **Soft reads, loud writes.** Real-world projects can be malformed, so lookups return `undefined` where a document could omit something. Operations that cannot proceed without structure (no root project object, an unknown product type, a view whose object was deleted) throw `XcodeModelError`.
|
|
230
|
+
- **Identity-mapped views.** Two lookups of the same id return the same instance, so views compare with `===`.
|
|
231
|
+
|
|
88
232
|
## Performance
|
|
89
233
|
|
|
90
234
|
`rork-xcode` is measured against the pbxproj parsers on npm, [`@bacons/xcode`](https://www.npmjs.com/package/@bacons/xcode) (its `/json` parse/build entry point) and [`xcode`](https://www.npmjs.com/package/xcode) (the long-standing package used by native build tooling), on three documents: two real Xcode-written projects from the test suite and a deterministically generated five-target app with 800 source files. It is the fastest at both operations on every document, with zero dependencies.
|
|
@@ -116,9 +260,18 @@ Measured on an Apple M5 Max, Node.js 24, single thread, with `@bacons/xcode` 1.0
|
|
|
116
260
|
- The committed fixture corpus spans project generations from Xcode 3 to Xcode 16, captured from real projects with identifiers neutralized: synchronized folders with both exception-set kinds, classic groups, variant groups, aggregate and legacy targets, reference proxies, build rules, Swift packages, and a ~100 KiB multiplatform framework project.
|
|
117
261
|
- Documents already in current Xcode's layout must round-trip byte for byte; documents from other tool generations must normalize to a byte-stable fixed point with unchanged values.
|
|
118
262
|
- On macOS, the suite cross-validates every fixture and its rebuilt form with `plutil`, Apple's own property list parser and the empirical ground truth for what Apple tooling accepts.
|
|
119
|
-
- A corpus sweep (`pnpm corpus`) walks every Xcode project on the machine, verifies each one parses and reaches a byte-stable fixed point, and cross-validates a sample
|
|
263
|
+
- A corpus sweep (`pnpm corpus`) walks every Xcode project on the machine, verifies each one parses and reaches a byte-stable fixed point, exercises the object model against it, and cross-validates a sample against plutil's own reading.
|
|
120
264
|
- CI runs the full gate on Linux and macOS, and executes the built artifact on the oldest supported Node to enforce the `engines` floor.
|
|
121
265
|
|
|
266
|
+
## Releasing
|
|
267
|
+
|
|
268
|
+
Releases publish to npm from CI with [provenance](https://docs.npmjs.com/generating-provenance-statements) via [trusted publishing](https://docs.npmjs.com/trusted-publishers); no long-lived tokens are stored in the repository.
|
|
269
|
+
|
|
270
|
+
1. Bump `version` in `package.json` and merge to `main`.
|
|
271
|
+
2. Create a GitHub release with an `X.Y.Z` tag matching the new version.
|
|
272
|
+
3. The release workflow verifies the tag, runs the full gate (including
|
|
273
|
+
plutil cross-validation on the macOS runner), and publishes.
|
|
274
|
+
|
|
122
275
|
## License
|
|
123
276
|
|
|
124
277
|
Apache-2.0
|