From adda3003dd4ead90c44d9a5e88a3cdd83561574a Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Sat, 11 Apr 2026 16:32:06 -0700 Subject: [PATCH 1/3] Refactor Xcode generator; add compare script Add a compare-output script and ignore its output, and refactor the Xcode project generator to use @bacons/xcode's Object API. - Add scripts/compare-output.ts to generate projects for multiple platforms, validate pbxproj structure, and save .pbxproj outputs under .compare-output/ for manual inspection; add .compare-output to .gitignore. - Rewrite src/generator.ts to build an XcodeProject via @bacons/xcode classes (XcodeProject, PBXGroup, PBXFileReference, PBXNativeTarget, PBXSourcesBuildPhase, PBXFrameworksBuildPhase, PBXResourcesBuildPhase, XCBuildConfiguration, XCConfigurationList) instead of manual UUID/object map construction. - Simplify file-type handling with helper predicates (isSourceFileType, isResourceFileType) and consolidate build/target/project settings into shared settings objects. These changes remove ad-hoc UUID generation and manual object assembly, leveraging the library's Object API for more robust pbxproj generation and easier validation. --- .gitignore | 3 + scripts/compare-output.ts | 94 +++++++++ src/generator.ts | 412 +++++++++++++++----------------------- 3 files changed, 255 insertions(+), 254 deletions(-) create mode 100644 scripts/compare-output.ts diff --git a/.gitignore b/.gitignore index cab80ae..bb05c2b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +# comparison output from scripts/compare-output.ts +.compare-output diff --git a/scripts/compare-output.ts b/scripts/compare-output.ts new file mode 100644 index 0000000..0d88f78 --- /dev/null +++ b/scripts/compare-output.ts @@ -0,0 +1,94 @@ +/** + * Compare script: generates projects for each platform and validates the + * pbxproj output from the Object API migration. + * + * Usage: bun scripts/compare-output.ts + */ + +import { join } from "path"; +import { rmSync, readdirSync, statSync } from "fs"; + +const ROOT = join(import.meta.dir, ".."); +const OUT_DIR = join(ROOT, ".compare-output"); + +async function runCLI(platform: string, name: string): Promise { + const outDir = join(OUT_DIR, platform); + const proc = Bun.spawnSync( + ["bun", "src/cli.ts", name, "--platform", platform, "--org", "com.example", "--org-name", "Example Inc", "-y", "--output", outDir], + { cwd: ROOT, stderr: "pipe", stdout: "pipe" } + ); + const stdout = new TextDecoder().decode(proc.stdout as unknown as ArrayBuffer); + const stderr = new TextDecoder().decode(proc.stderr as unknown as ArrayBuffer); + if (proc.exitCode !== 0) { + throw new Error(`CLI failed for ${platform}:\n${stderr}\n${stdout}`); + } + return join(outDir, name); +} + +async function main() { + rmSync(OUT_DIR, { recursive: true, force: true }); + + const platforms = ["ios", "macos", "tvos", "watchos", "visionos", "multiplatform"]; + let allPass = true; + + for (const platform of platforms) { + console.log(`\n--- ${platform} ---`); + try { + const projectDir = await runCLI(platform, "TestApp"); + const pbxprojPath = join(projectDir, "TestApp.xcodeproj", "project.pbxproj"); + const pbxproj = await Bun.file(pbxprojPath).text(); + + // Structural checks + const checks: Record = { + hasPBXProject: pbxproj.includes("isa = PBXProject"), + hasPBXNativeTarget: pbxproj.includes("isa = PBXNativeTarget"), + hasSourcesBuildPhase: pbxproj.includes("isa = PBXSourcesBuildPhase"), + hasFrameworksBuildPhase: pbxproj.includes("isa = PBXFrameworksBuildPhase"), + hasResourcesBuildPhase: pbxproj.includes("isa = PBXResourcesBuildPhase"), + hasXCBuildConfiguration: pbxproj.includes("isa = XCBuildConfiguration"), + hasXCConfigurationList: pbxproj.includes("isa = XCConfigurationList"), + hasPBXGroup: pbxproj.includes("isa = PBXGroup"), + hasPBXFileReference: pbxproj.includes("isa = PBXFileReference"), + hasPBXBuildFile: pbxproj.includes("isa = PBXBuildFile"), + hasProductApp: pbxproj.includes("TestApp.app"), + hasBundleId: pbxproj.includes("com.example."), + hasSwiftVersion: pbxproj.includes("SWIFT_VERSION"), + hasDebugConfig: pbxproj.includes("name = Debug"), + hasReleaseConfig: pbxproj.includes("name = Release"), + hasArchiveVersion: pbxproj.includes("archiveVersion = 1"), + hasObjectVersion: pbxproj.includes("objectVersion = 56"), + hasCompatVersion: pbxproj.includes("Xcode 14.0"), + hasUpgradeCheck: pbxproj.includes("1620"), + }; + + const platformPass = Object.values(checks).every(Boolean); + if (!platformPass) allPass = false; + console.log(` Checks: ${platformPass ? "ALL PASS" : "FAILURES"}`); + for (const [key, value] of Object.entries(checks)) { + if (!value) console.log(` FAIL: ${key}`); + } + + // Count objects + const isaCounts: Record = {}; + const isaRegex = /isa = (\w+)/g; + let match; + while ((match = isaRegex.exec(pbxproj)) !== null) { + const isa = match[1]!; + isaCounts[isa] = (isaCounts[isa] || 0) + 1; + } + console.log(" Objects:", JSON.stringify(isaCounts)); + + // Save for manual diff + await Bun.write(join(OUT_DIR, `${platform}.pbxproj`), pbxproj); + } catch (err: any) { + allPass = false; + console.log(` ERROR: ${err.message}`); + } + } + + console.log(`\n${"=".repeat(50)}`); + console.log(allPass ? "ALL PLATFORMS PASS" : "SOME PLATFORMS FAILED"); + console.log(`pbxproj files saved in ${OUT_DIR}/ for manual inspection`); +} + +main().catch(console.error); diff --git a/src/generator.ts b/src/generator.ts index 74709cf..61e3187 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -10,6 +10,17 @@ import { join, extname, basename } from "path"; import { build as buildPbxproj } from "@bacons/xcode/json"; import { build as buildWorkspace } from "@bacons/xcode/workspace"; import { build as buildScheme } from "@bacons/xcode/scheme"; +import { + XcodeProject, + PBXGroup, + PBXFileReference, + PBXNativeTarget, + PBXSourcesBuildPhase, + PBXFrameworksBuildPhase, + PBXResourcesBuildPhase, + XCBuildConfiguration, + XCConfigurationList, +} from "@bacons/xcode"; import type { ResolvedConfig, TemplateVariables, @@ -32,74 +43,27 @@ export interface GenerateOptions { templateFilesBase: string; } -const FILE_TYPES: Record = { - swift: "sourcecode.swift", - m: "sourcecode.c.objc", - mm: "sourcecode.cpp.objcpp", - c: "sourcecode.c.c", - cpp: "sourcecode.cpp.cpp", - h: "sourcecode.c.h", - hpp: "sourcecode.cpp.h", - metal: "sourcecode.metal", - storyboard: "file.storyboard", - xib: "file.xib", - plist: "text.plist.xml", - entitlements: "text.plist.entitlements", - xcassets: "folder.assetcatalog", - xcdatamodeld: "wrapper.xcdatamodeld", - xcdatamodel: "wrapper.xcdatamodel", - strings: "text.plist.strings", - stringsdict: "text.plist.stringsdict", - json: "text.json", - js: "sourcecode.javascript", - png: "image.png", - jpg: "image.jpeg", - gif: "image.gif", - pdf: "image.pdf", - ttf: "file", - otf: "file", -}; - -function getFileType(filename: string): string | undefined { - const ext = extname(filename).slice(1).toLowerCase(); - return FILE_TYPES[ext]; +/** Check if a file type string represents a source file that belongs in the Sources build phase. */ +function isSourceFileType(fileType: string | undefined): boolean { + return fileType?.startsWith("sourcecode.") ?? false; } -function isSourceFile(filename: string): boolean { - const ext = extname(filename).slice(1).toLowerCase(); - return ["swift", "m", "mm", "c", "cpp", "metal"].includes(ext); -} - -function isResourceFile(filename: string): boolean { - const ext = extname(filename).slice(1).toLowerCase(); - return [ - "xcassets", - "storyboard", - "xib", - "xcdatamodeld", - "strings", - "stringsdict", - "json", - "png", - "jpg", - "gif", - "pdf", - "ttf", - "otf", - "sks", - "scnassets", - "usdz", - ].includes(ext); -} - -function generateUUID(seed: string): string { - const hash = new Bun.CryptoHasher("md5").update(seed).digest("hex"); - return hash.slice(0, 24).toUpperCase(); -} - -let uuidCounter = 0; -function nextUUID(prefix: string): string { - return generateUUID(`${prefix}_${uuidCounter++}_${Date.now()}`); +/** Check if a file type string represents a resource that belongs in the Resources build phase. */ +function isResourceFileType(fileType: string | undefined): boolean { + if (!fileType) return false; + return ( + fileType.startsWith("image.") || + fileType.startsWith("audio.") || + fileType.startsWith("video.") || + fileType.startsWith("text.plist.strings") || + fileType.startsWith("text.plist.stringsdict") || + fileType.startsWith("text.json") || + fileType === "folder.assetcatalog" || + fileType === "file.storyboard" || + fileType === "file.xib" || + fileType === "file" || + fileType.startsWith("wrapper.xcdatamodel") + ); } function getUserName(): string { @@ -112,7 +76,6 @@ function getUserName(): string { } export async function generateProject(opts: GenerateOptions): Promise { - uuidCounter = 0; const { name, outputDir, @@ -350,49 +313,12 @@ function buildProjectFile( vars: TemplateVariables, writtenFiles: WrittenFile[] ): { pbxproj: string; targetUUID: string } { - const identifier = toIdentifier(name); const bundleId = `${toRFC1034(vars.bundleIdentifierPrefix)}.${toRFC1034(name)}`; - // Generate UUIDs for all objects - const rootProjectUUID = nextUUID("rootProject"); - const mainGroupUUID = nextUUID("mainGroup"); - const sourcesGroupUUID = nextUUID("sourcesGroup"); - const productsGroupUUID = nextUUID("productsGroup"); - const productRefUUID = nextUUID("productRef"); - const targetUUID = nextUUID("target"); - const projectConfigListUUID = nextUUID("projectConfigList"); - const targetConfigListUUID = nextUUID("targetConfigList"); - const projectDebugConfigUUID = nextUUID("projectDebugConfig"); - const projectReleaseConfigUUID = nextUUID("projectReleaseConfig"); - const targetDebugConfigUUID = nextUUID("targetDebugConfig"); - const targetReleaseConfigUUID = nextUUID("targetReleaseConfig"); - const sourcesBuildPhaseUUID = nextUUID("sourcesBuildPhase"); - const frameworksBuildPhaseUUID = nextUUID("frameworksBuildPhase"); - const resourcesBuildPhaseUUID = nextUUID("resourcesBuildPhase"); - // Determine deployment target settings based on platform const platformSettings = getPlatformSettings(config.platform); - // Build file references and build files - const fileRefUUIDs: Record = {}; - const buildFileUUIDs: Record = {}; - const sourcesBuildFiles: string[] = []; - const resourcesBuildFiles: string[] = []; - - for (const file of writtenFiles) { - const refUUID = nextUUID(`ref_${file.filename}`); - const buildUUID = nextUUID(`build_${file.filename}`); - fileRefUUIDs[file.filename] = refUUID; - buildFileUUIDs[file.filename] = buildUUID; - - if (file.isDirectory || isResourceFile(file.filename)) { - resourcesBuildFiles.push(file.filename); - } else if (isSourceFile(file.filename)) { - sourcesBuildFiles.push(file.filename); - } - } - - // Merge project build settings from template + // Merge build settings const projectShared: Record = { ...config.projectSharedSettings, LOCALIZATION_PREFERS_STRING_CATALOGS: "YES", @@ -440,7 +366,8 @@ function buildProjectFile( ALWAYS_SEARCH_USER_PATHS: "NO", }; - const projectDebug: Record = { + const projectDebugSettings: Record = { + ...projectShared, ...config.projectDebugSettings, DEBUG_INFORMATION_FORMAT: "dwarf", ENABLE_TESTABILITY: "YES", @@ -453,7 +380,8 @@ function buildProjectFile( SWIFT_OPTIMIZATION_LEVEL: "-Onone", }; - const projectRelease: Record = { + const projectReleaseSettings: Record = { + ...projectShared, ...config.projectReleaseSettings, DEBUG_INFORMATION_FORMAT: "dwarf-with-dsym", ENABLE_NS_ASSERTIONS: "NO", @@ -478,192 +406,168 @@ function buildProjectFile( SWIFT_APPROACHABLE_CONCURRENCY: "YES", SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY: "YES", SWIFT_DEFAULT_ACTOR_ISOLATION: "MainActor", + ...platformSettings.deploymentTarget, }; - // Add platform-specific deployment target & SDKROOT - Object.assign(targetShared, platformSettings.deploymentTarget); - - const targetDebug: Record = { + const targetDebugSettings: Record = { + ...targetShared, ...config.targetDebugSettings, }; - const targetRelease: Record = { + const targetReleaseSettings: Record = { + ...targetShared, ...config.targetReleaseSettings, VALIDATE_PRODUCT: "YES", }; - // Build the pbxproj using @bacons/xcode/json's build() - const objects: Record = {}; + // Create XcodeProject with a minimal bootstrap (rootObject must exist in objects) + const bootstrapRootUUID = "00000000000000000000ROOT"; + const xcproj = new XcodeProject("project.pbxproj", { + archiveVersion: 1, + objectVersion: 56, + classes: {}, + objects: { + [bootstrapRootUUID]: { + isa: "PBXProject", + buildConfigurationList: "PLACEHOLDER", + mainGroup: "PLACEHOLDER", + targets: [], + // These will be overridden by setupDefaults + our explicit values + compatibilityVersion: "Xcode 14.0", + attributes: { + BuildIndependentTargetsInParallel: "YES", + LastSwiftUpdateCheck: "1620", + LastUpgradeCheck: "1620", + TargetAttributes: {}, + }, + } as any, + }, + rootObject: bootstrapRootUUID, + }); - // Project configuration list - objects[projectDebugConfigUUID] = { - isa: "XCBuildConfiguration", - buildSettings: { ...projectShared, ...projectDebug }, + // Now build the real structure using the Object API + + // Project build configurations + const projDebugConfig = XCBuildConfiguration.create(xcproj, { name: "Debug", - }; - objects[projectReleaseConfigUUID] = { - isa: "XCBuildConfiguration", - buildSettings: { ...projectShared, ...projectRelease }, + buildSettings: projectDebugSettings, + }); + const projReleaseConfig = XCBuildConfiguration.create(xcproj, { name: "Release", - }; - objects[projectConfigListUUID] = { - isa: "XCConfigurationList", - buildConfigurations: [projectDebugConfigUUID, projectReleaseConfigUUID], - defaultConfigurationIsVisible: 0, + buildSettings: projectReleaseSettings, + }); + const projConfigList = XCConfigurationList.create(xcproj, { + buildConfigurations: [projDebugConfig, projReleaseConfig], defaultConfigurationName: "Release", - }; + }); - // Target configuration list - objects[targetDebugConfigUUID] = { - isa: "XCBuildConfiguration", - buildSettings: { ...targetShared, ...targetDebug }, + // Target build configurations + const tgtDebugConfig = XCBuildConfiguration.create(xcproj, { name: "Debug", - }; - objects[targetReleaseConfigUUID] = { - isa: "XCBuildConfiguration", - buildSettings: { ...targetShared, ...targetRelease }, + buildSettings: targetDebugSettings, + }); + const tgtReleaseConfig = XCBuildConfiguration.create(xcproj, { name: "Release", - }; - objects[targetConfigListUUID] = { - isa: "XCConfigurationList", - buildConfigurations: [targetDebugConfigUUID, targetReleaseConfigUUID], - defaultConfigurationIsVisible: 0, + buildSettings: targetReleaseSettings, + }); + const tgtConfigList = XCConfigurationList.create(xcproj, { + buildConfigurations: [tgtDebugConfig, tgtReleaseConfig], defaultConfigurationName: "Release", - }; + }); - // File references - for (const file of writtenFiles) { - const refUUID = fileRefUUIDs[file.filename]!; - const fileType = getFileType(file.filename); - objects[refUUID] = { - isa: "PBXFileReference", - lastKnownFileType: fileType, - path: file.filename, - sourceTree: "", - }; - if (file.filename.endsWith(".swift")) { - objects[refUUID].lastKnownFileType = "sourcecode.swift"; - } - } + // Main group (root of the file tree) + const mainGroup = PBXGroup.create(xcproj, { + sourceTree: "", + }); - // Product reference - objects[productRefUUID] = { - isa: "PBXFileReference", + // Sources group (contains project files) + const sourcesGroup = PBXGroup.create(xcproj, { + path: name, + sourceTree: "", + }); + mainGroup.props.children.push(sourcesGroup); + + // Product reference (.app bundle) + const productRef = PBXFileReference.create(xcproj, { explicitFileType: "wrapper.application", includeInIndex: 0, path: `${name}.app`, sourceTree: "BUILT_PRODUCTS_DIR", - }; + }); - // Build files (for build phases) - for (const filename of sourcesBuildFiles) { - objects[buildFileUUIDs[filename]!] = { - isa: "PBXBuildFile", - fileRef: fileRefUUIDs[filename]!, - }; - } - for (const filename of resourcesBuildFiles) { - objects[buildFileUUIDs[filename]!] = { - isa: "PBXBuildFile", - fileRef: fileRefUUIDs[filename]!, - }; - } + // Products group + const productsGroup = PBXGroup.create(xcproj, { + name: "Products", + sourceTree: "", + children: [productRef as any], + }); + mainGroup.props.children.push(productsGroup); - // Build phases - objects[sourcesBuildPhaseUUID] = { - isa: "PBXSourcesBuildPhase", - buildActionMask: 2147483647, - files: sourcesBuildFiles.map((f) => buildFileUUIDs[f]), - runOnlyForDeploymentPostprocessing: 0, - }; - objects[frameworksBuildPhaseUUID] = { - isa: "PBXFrameworksBuildPhase", - buildActionMask: 2147483647, + // Build phases — created empty, Object API sets buildActionMask and + // runOnlyForDeploymentPostprocessing via setupDefaults + const sourcesBuildPhase = xcproj.createModel({ + isa: "PBXSourcesBuildPhase" as const, files: [], - runOnlyForDeploymentPostprocessing: 0, - }; - objects[resourcesBuildPhaseUUID] = { - isa: "PBXResourcesBuildPhase", - buildActionMask: 2147483647, - files: resourcesBuildFiles.map((f) => buildFileUUIDs[f]), - runOnlyForDeploymentPostprocessing: 0, - }; + }) as PBXSourcesBuildPhase; - // Sources group (contains all project files) - objects[sourcesGroupUUID] = { - isa: "PBXGroup", - children: writtenFiles.map((f) => fileRefUUIDs[f.filename]), - path: name, - sourceTree: "", - }; + const frameworksBuildPhase = xcproj.createModel({ + isa: "PBXFrameworksBuildPhase" as const, + files: [], + }) as PBXFrameworksBuildPhase; - // Products group - objects[productsGroupUUID] = { - isa: "PBXGroup", - children: [productRefUUID], - name: "Products", - sourceTree: "", - }; + const resourcesBuildPhase = xcproj.createModel({ + isa: "PBXResourcesBuildPhase" as const, + files: [], + }) as PBXResourcesBuildPhase; - // Main group - objects[mainGroupUUID] = { - isa: "PBXGroup", - children: [sourcesGroupUUID, productsGroupUUID], - sourceTree: "", - }; + // Create file references and add to appropriate build phases + for (const file of writtenFiles) { + // createFile infers lastKnownFileType from extension and adds to group + const fileRef = sourcesGroup.createFile({ + path: file.filename, + sourceTree: "", + }); + + const fileType = fileRef.props.lastKnownFileType; + + if (file.isDirectory || isResourceFileType(fileType)) { + resourcesBuildPhase.createFile({ fileRef }); + } else if (isSourceFileType(fileType)) { + sourcesBuildPhase.createFile({ fileRef }); + } + } // Native target - objects[targetUUID] = { - isa: "PBXNativeTarget", - buildConfigurationList: targetConfigListUUID, - buildPhases: [ - sourcesBuildPhaseUUID, - frameworksBuildPhaseUUID, - resourcesBuildPhaseUUID, - ], - buildRules: [], - dependencies: [], + const target = PBXNativeTarget.create(xcproj, { name, productName: name, - productReference: productRefUUID, - productType: config.productType, - }; + productType: config.productType as any, + buildConfigurationList: tgtConfigList, + productReference: productRef, + buildPhases: [sourcesBuildPhase, frameworksBuildPhase, resourcesBuildPhase], + }); - // Root project - objects[rootProjectUUID] = { - isa: "PBXProject", - attributes: { - BuildIndependentTargetsInParallel: 1, - LastSwiftUpdateCheck: "1620", - LastUpgradeCheck: "1620", - TargetAttributes: { - [targetUUID]: { - CreatedOnToolsVersion: "16.2", - }, + // Update the root project object with real references + const rootProject = xcproj.rootObject; + rootProject.props.buildConfigurationList = projConfigList; + rootProject.props.mainGroup = mainGroup; + rootProject.props.productRefGroup = productsGroup; + rootProject.props.targets = [target]; + rootProject.props.attributes = { + BuildIndependentTargetsInParallel: "YES", + LastSwiftUpdateCheck: "1620", + LastUpgradeCheck: "1620", + TargetAttributes: { + [target.uuid]: { + CreatedOnToolsVersion: "16.2", }, }, - buildConfigurationList: projectConfigListUUID, - compatibilityVersion: "Xcode 14.0", - developmentRegion: "en", - hasScannedForEncodings: 0, - knownRegions: ["en", "Base"], - mainGroup: mainGroupUUID, - productRefGroup: productsGroupUUID, - projectDirPath: "", - projectRoot: "", - targets: [targetUUID], }; - // Use @bacons/xcode to serialize + // Serialize via the Object API → JSON → pbxproj string return { - pbxproj: buildPbxproj({ - archiveVersion: 1, - objectVersion: 56, - classes: {}, - objects, - rootObject: rootProjectUUID, - }), - targetUUID, + pbxproj: buildPbxproj(xcproj.toJSON()), + targetUUID: target.uuid, }; } From ed3685009e6371c4eb9a0802e0f330347640c90b Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Sat, 11 Apr 2026 16:50:25 -0700 Subject: [PATCH 2/3] Migrate to Object API, use target.get*BuildPhase() helpers Replace raw createModel() calls for build phases with target.getSourcesBuildPhase() / getFrameworksBuildPhase() / getResourcesBuildPhase(). Use createGroup/createFile on groups instead of manually wiring children. Inline config list creation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/generator.ts | 183 +++++++++++++++++++++-------------------------- 1 file changed, 83 insertions(+), 100 deletions(-) diff --git a/src/generator.ts b/src/generator.ts index 61e3187..f784413 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -13,18 +13,11 @@ import { build as buildScheme } from "@bacons/xcode/scheme"; import { XcodeProject, PBXGroup, - PBXFileReference, PBXNativeTarget, - PBXSourcesBuildPhase, - PBXFrameworksBuildPhase, - PBXResourcesBuildPhase, XCBuildConfiguration, XCConfigurationList, } from "@bacons/xcode"; -import type { - ResolvedConfig, - TemplateVariables, -} from "./resolver"; +import type { ResolvedConfig, TemplateVariables } from "./resolver"; import { substituteVariables, toIdentifier, toRFC1034 } from "./resolver"; /** Options passed to the generator */ @@ -69,7 +62,10 @@ function isResourceFileType(fileType: string | undefined): boolean { function getUserName(): string { try { const proc = Bun.spawnSync(["id", "-F"]); - return new TextDecoder().decode(proc.stdout as unknown as ArrayBuffer).trim() || "Unknown"; + return ( + new TextDecoder().decode(proc.stdout as unknown as ArrayBuffer).trim() || + "Unknown" + ); } catch { return "Unknown"; } @@ -114,11 +110,16 @@ export async function generateProject(opts: GenerateOptions): Promise { config, vars, sourcesDir, - templateFilesBase + templateFilesBase, ); // Generate project.pbxproj - const { pbxproj, targetUUID } = buildProjectFile(name, config, vars, writtenFiles); + const { pbxproj, targetUUID } = buildProjectFile( + name, + config, + vars, + writtenFiles, + ); await Bun.write(join(xcodeprojDir, "project.pbxproj"), pbxproj); // Generate workspace @@ -126,7 +127,10 @@ export async function generateProject(opts: GenerateOptions): Promise { version: "1.0", children: [{ type: "FileRef", location: `self:` }], } as any); - await Bun.write(join(xcworkspaceDir, "contents.xcworkspacedata"), workspaceData); + await Bun.write( + join(xcworkspaceDir, "contents.xcworkspacedata"), + workspaceData, + ); // Generate shared scheme const schemeDir = join(xcodeprojDir, "xcshareddata", "xcschemes"); @@ -151,7 +155,7 @@ async function copyTemplateFiles( config: ResolvedConfig, vars: TemplateVariables, sourcesDir: string, - templateFilesBase: string + templateFilesBase: string, ): Promise { const writtenFiles: WrittenFile[] = []; @@ -186,11 +190,11 @@ async function copyTemplateFiles( processed = processed.replace(/___FILENAME___/g, destName); processed = processed.replace( /___FILEBASENAME___/g, - basename(destName, extname(destName)) + basename(destName, extname(destName)), ); processed = processed.replace( /___FILEBASENAMEASIDENTIFIER___/g, - toIdentifier(basename(destName, extname(destName))) + toIdentifier(basename(destName, extname(destName))), ); await Bun.write(destPath, processed); @@ -208,11 +212,7 @@ async function copyTemplateFiles( mkdirSync(assetsPath, { recursive: true }); await Bun.write( join(assetsPath, "Contents.json"), - JSON.stringify( - { info: { author: "xcode", version: 1 } }, - null, - 2 - ) + JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2), ); // AppIcon - always generate (templates don't include it, Xcode generates dynamically) @@ -220,7 +220,7 @@ async function copyTemplateFiles( mkdirSync(appIconDir, { recursive: true }); await Bun.write( join(appIconDir, "Contents.json"), - JSON.stringify(buildAppIconContents(config.platform), null, 2) + JSON.stringify(buildAppIconContents(config.platform), null, 2), ); // AccentColor @@ -234,8 +234,8 @@ async function copyTemplateFiles( info: { author: "xcode", version: 1 }, }, null, - 2 - ) + 2, + ), ); if (!writtenFiles.some((f) => f.filename === "Assets.xcassets")) { @@ -255,9 +255,7 @@ function buildAppIconContents(platform: string): any { switch (platform) { case "ios": return { - images: [ - { idiom: "universal", platform: "ios", size: "1024x1024" }, - ], + images: [{ idiom: "universal", platform: "ios", size: "1024x1024" }], info, }; case "macos": @@ -311,7 +309,7 @@ function buildProjectFile( name: string, config: ResolvedConfig, vars: TemplateVariables, - writtenFiles: WrittenFile[] + writtenFiles: WrittenFile[], ): { pbxproj: string; targetUUID: string } { const bundleId = `${toRFC1034(vars.bundleIdentifierPrefix)}.${toRFC1034(name)}`; @@ -447,78 +445,56 @@ function buildProjectFile( // Now build the real structure using the Object API - // Project build configurations - const projDebugConfig = XCBuildConfiguration.create(xcproj, { - name: "Debug", - buildSettings: projectDebugSettings, - }); - const projReleaseConfig = XCBuildConfiguration.create(xcproj, { - name: "Release", - buildSettings: projectReleaseSettings, - }); - const projConfigList = XCConfigurationList.create(xcproj, { - buildConfigurations: [projDebugConfig, projReleaseConfig], - defaultConfigurationName: "Release", - }); - - // Target build configurations - const tgtDebugConfig = XCBuildConfiguration.create(xcproj, { - name: "Debug", - buildSettings: targetDebugSettings, - }); - const tgtReleaseConfig = XCBuildConfiguration.create(xcproj, { - name: "Release", - buildSettings: targetReleaseSettings, - }); - const tgtConfigList = XCConfigurationList.create(xcproj, { - buildConfigurations: [tgtDebugConfig, tgtReleaseConfig], - defaultConfigurationName: "Release", - }); - // Main group (root of the file tree) const mainGroup = PBXGroup.create(xcproj, { sourceTree: "", }); // Sources group (contains project files) - const sourcesGroup = PBXGroup.create(xcproj, { + const sourcesGroup = mainGroup.createGroup({ path: name, sourceTree: "", }); - mainGroup.props.children.push(sourcesGroup); + + // Products group + const productsGroup = mainGroup.createGroup({ + name: "Products", + sourceTree: "", + }); // Product reference (.app bundle) - const productRef = PBXFileReference.create(xcproj, { + const productRef = productsGroup.createFile({ explicitFileType: "wrapper.application", includeInIndex: 0, path: `${name}.app`, sourceTree: "BUILT_PRODUCTS_DIR", }); - // Products group - const productsGroup = PBXGroup.create(xcproj, { - name: "Products", - sourceTree: "", - children: [productRef as any], + // Native target + const target = PBXNativeTarget.create(xcproj, { + name, + productName: name, + productType: config.productType as any, + buildConfigurationList: XCConfigurationList.create(xcproj, { + buildConfigurations: [ + XCBuildConfiguration.create(xcproj, { + name: "Debug", + buildSettings: targetDebugSettings, + }), + XCBuildConfiguration.create(xcproj, { + name: "Release", + buildSettings: targetReleaseSettings, + }), + ], + defaultConfigurationName: "Release", + }), + productReference: productRef, }); - mainGroup.props.children.push(productsGroup); - // Build phases — created empty, Object API sets buildActionMask and - // runOnlyForDeploymentPostprocessing via setupDefaults - const sourcesBuildPhase = xcproj.createModel({ - isa: "PBXSourcesBuildPhase" as const, - files: [], - }) as PBXSourcesBuildPhase; - - const frameworksBuildPhase = xcproj.createModel({ - isa: "PBXFrameworksBuildPhase" as const, - files: [], - }) as PBXFrameworksBuildPhase; - - const resourcesBuildPhase = xcproj.createModel({ - isa: "PBXResourcesBuildPhase" as const, - files: [], - }) as PBXResourcesBuildPhase; + // Build phases — get-or-create via the target (sets defaults automatically) + const sourcesBuildPhase = target.getSourcesBuildPhase(); + target.getFrameworksBuildPhase(); + const resourcesBuildPhase = target.getResourcesBuildPhase(); // Create file references and add to appropriate build phases for (const file of writtenFiles) { @@ -537,19 +513,26 @@ function buildProjectFile( } } - // Native target - const target = PBXNativeTarget.create(xcproj, { - name, - productName: name, - productType: config.productType as any, - buildConfigurationList: tgtConfigList, - productReference: productRef, - buildPhases: [sourcesBuildPhase, frameworksBuildPhase, resourcesBuildPhase], - }); - + // Update the root project object with real references const rootProject = xcproj.rootObject; - rootProject.props.buildConfigurationList = projConfigList; + + rootProject.props.buildConfigurationList = XCConfigurationList.create( + xcproj, + { + buildConfigurations: [ + XCBuildConfiguration.create(xcproj, { + name: "Debug", + buildSettings: projectDebugSettings, + }), + XCBuildConfiguration.create(xcproj, { + name: "Release", + buildSettings: projectReleaseSettings, + }), + ], + defaultConfigurationName: "Release", + }, + ); rootProject.props.mainGroup = mainGroup; rootProject.props.productRefGroup = productsGroup; rootProject.props.targets = [target]; @@ -590,8 +573,7 @@ function getPlatformSettings(platform: string): PlatformConfig { "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight", INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight", - LD_RUNPATH_SEARCH_PATHS: - "$(inherited) @executable_path/Frameworks", + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks", ENABLE_PREVIEWS: "YES", }, deploymentTarget: { @@ -617,8 +599,7 @@ function getPlatformSettings(platform: string): PlatformConfig { targetSettings: { SDKROOT: "appletvos", TARGETED_DEVICE_FAMILY: "3", - LD_RUNPATH_SEARCH_PATHS: - "$(inherited) @executable_path/Frameworks", + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks", ENABLE_PREVIEWS: "YES", }, deploymentTarget: { @@ -630,8 +611,7 @@ function getPlatformSettings(platform: string): PlatformConfig { targetSettings: { SDKROOT: "watchos", TARGETED_DEVICE_FAMILY: "4", - LD_RUNPATH_SEARCH_PATHS: - "$(inherited) @executable_path/Frameworks", + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks", ENABLE_PREVIEWS: "YES", SKIP_INSTALL: "YES", }, @@ -644,8 +624,7 @@ function getPlatformSettings(platform: string): PlatformConfig { targetSettings: { SDKROOT: "xros", TARGETED_DEVICE_FAMILY: "7", - LD_RUNPATH_SEARCH_PATHS: - "$(inherited) @executable_path/Frameworks", + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks", ENABLE_PREVIEWS: "YES", }, deploymentTarget: { @@ -677,7 +656,11 @@ function getPlatformSettings(platform: string): PlatformConfig { } } -function buildSchemeXml(name: string, config: ResolvedConfig, targetUUID: string): string { +function buildSchemeXml( + name: string, + config: ResolvedConfig, + targetUUID: string, +): string { const ref = { buildableIdentifier: "primary", blueprintIdentifier: targetUUID, From bc14c7b131226c6bbc6e725b9e148de9a0d6ead4 Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Sat, 11 Apr 2026 16:52:26 -0700 Subject: [PATCH 3/3] Fix type errors: cast buildSettings to any for BuildSettings compat Co-Authored-By: Claude Opus 4.6 (1M context) --- src/generator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/generator.ts b/src/generator.ts index f784413..8e09c76 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -479,11 +479,11 @@ function buildProjectFile( buildConfigurations: [ XCBuildConfiguration.create(xcproj, { name: "Debug", - buildSettings: targetDebugSettings, + buildSettings: targetDebugSettings as any, }), XCBuildConfiguration.create(xcproj, { name: "Release", - buildSettings: targetReleaseSettings, + buildSettings: targetReleaseSettings as any, }), ], defaultConfigurationName: "Release", @@ -523,11 +523,11 @@ function buildProjectFile( buildConfigurations: [ XCBuildConfiguration.create(xcproj, { name: "Debug", - buildSettings: projectDebugSettings, + buildSettings: projectDebugSettings as any, }), XCBuildConfiguration.create(xcproj, { name: "Release", - buildSettings: projectReleaseSettings, + buildSettings: projectReleaseSettings as any, }), ], defaultConfigurationName: "Release",