diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ff4ff8..678e6827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,10 @@ ### Added - Added `buildArchitectures` to scheme build options and target schemes, to control Xcode's "Override Architectures" scheme setting #1642 @arhxam - Added FAQ documentation on how to add an Xcode capability, such as In-App Purchase #1644 @Hokila +- Added experimental support for the JSON project format Xcode 27.2 writes by default. Set `options.projectFormat: json` to write `project.xcproj` instead of `project.pbxproj`. The generated project opens in Xcode 27 or later only ### Changed +- XcodeGen now requires macOS 14 and Swift 6.1, which the XcodeProj 9.17 upgrade behind the JSON project format brings with it - Generated schemes now default to `buildArchitectures: matchRunDestination` ("Match Run Destination"), matching Xcode's default for new schemes. Previously schemes used each target's architecture settings. Set `buildArchitectures: useTargetSettings` to keep the previous behaviour #1642 @yonaskolb ### Fixed diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index 9fc2f918..a08c9ff7 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -134,12 +134,15 @@ Note that target names can also be changed by adding a `name` property to a targ - [ ] **tabWidth**: **Int** - If this is specified, the Xcode project will override the user's setting for indent width in number of spaces. - [ ] **xcodeVersion**: **String** - The version of Xcode. This defaults to the latest version periodically. You can specify it in the format `0910` or `9.1` - [ ] **projectFormat**: **String** - The version of Xcode project. By default this is set to `xcode16_0` + - `json`: experimental, the JSON format Xcode 27.2 writes. Stored as `project.xcproj` and opens only in Xcode 27 and later - `xcode16_3`: Xcode 16.3 - `xcode16_0`: Xcode 16.0 - `xcode15_3`: Xcode 15.3 - `xcode15_0`: Xcode 15.0 - `xcode14_0`: Xcode 14.0 + `json` is built on [XcodeProj's experimental support](https://github.com/tuist/XcodeProj/blob/main/Documentation/json-project-format.md) for [Apple's format](https://developer.apple.com/documentation/xcode/updating-your-xcode-project-configuration-file-format). Two things still generate a project Xcode will not open, so open yours before you commit it: a `copyFiles` [target source](#target-source) ([#1181](https://github.com/tuist/XcodeProj/pull/1181)), and a dependency through a [project reference](#project-reference) ([#1182](https://github.com/tuist/XcodeProj/pull/1182)). Both are fixed upstream but not yet released. + - [ ] **deploymentTarget**: **[[Platform](#platform): String]** - A project wide deployment target can be specified for each platform otherwise the default SDK version in Xcode will be used. This will be overridden by any custom build settings that set the deployment target eg `IPHONEOS_DEPLOYMENT_TARGET`. Target specific deployment targets can also be set with [Target](#target).deploymentTarget. - [ ] **disabledValidations**: **[String]** - A list of validations that can be disabled if they're too strict for your use case. By default this is set to an empty array. Currently these are the available options: - `missingConfigs`: Disable errors for configurations in yaml files that don't exist in the project itself. This can be useful if you include the same yaml file in different projects diff --git a/Package.resolved b/Package.resolved index c8b92c4e..d7da30a3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -72,13 +72,22 @@ "version" : "2.0.0" } }, + { + "identity" : "xcode-project-format", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/xcode-project-format.git", + "state" : { + "revision" : "c132630aabf40f6d6c3f7bb004595d2e5a28ea42", + "version" : "0.1.0" + } + }, { "identity" : "xcodeproj", "kind" : "remoteSourceControl", "location" : "https://github.com/tuist/XcodeProj.git", "state" : { - "revision" : "eb001108f64467b0cc7e8cbf90b3b4aaa1822cbb", - "version" : "9.14.0" + "revision" : "7e6d696264ecc6e3698466bb08e23b781feba031", + "version" : "9.17.1" } }, { diff --git a/Package.swift b/Package.swift index a6217b4f..e2aceaf3 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "XcodeGen", - platforms: [.macOS(.v11)], + platforms: [.macOS(.v14)], products: [ .executable(name: "xcodegen", targets: ["XcodeGen"]), .library(name: "XcodeGenKit", targets: ["XcodeGenKit"]), @@ -16,7 +16,7 @@ let package = Package( .package(url: "https://github.com/yonaskolb/JSONUtilities.git", from: "4.2.0"), .package(url: "https://github.com/kylef/Spectre.git", from: "0.9.2"), .package(url: "https://github.com/onevcat/Rainbow.git", from: "4.0.0"), - .package(url: "https://github.com/tuist/XcodeProj.git", exact: "9.14.0"), + .package(url: "https://github.com/tuist/XcodeProj.git", exact: "9.17.1"), .package(url: "https://github.com/jakeheis/SwiftCLI.git", from: "6.0.3"), .package(url: "https://github.com/mxcl/Version", from: "2.0.0"), .package(url: "https://github.com/freddi-kit/ArtifactBundleGen", exact: "0.0.8") diff --git a/Sources/ProjectSpec/Linkage.swift b/Sources/ProjectSpec/Linkage.swift index e42cac5c..f46bb43d 100644 --- a/Sources/ProjectSpec/Linkage.swift +++ b/Sources/ProjectSpec/Linkage.swift @@ -16,6 +16,7 @@ extension Target { .application, .bundle, .commandLineTool, + .hostBuildTool, .instrumentsPackage, .intentsServiceExtension, .messagesApplication, diff --git a/Sources/XcodeGenKit/FileWriter.swift b/Sources/XcodeGenKit/FileWriter.swift index 17af96c5..46b48c6d 100644 --- a/Sources/XcodeGenKit/FileWriter.swift +++ b/Sources/XcodeGenKit/FileWriter.swift @@ -18,7 +18,15 @@ public class FileWriter { if projectPath.exists { try projectPath.copy(tempPath) } - try xcodeProject.write(path: tempPath, override: true) + let isJSON = project.projectFormat.isJSON + if isJSON { + // Discarded: encoding the property list is what settles XcodeProj's object identifiers, + // without which `project.xcproj` gets fresh UUIDs per run. Drop with XcodeProj#1180. + _ = try xcodeProject.pbxproj.dataRepresentation(outputSettings: PBXOutputSettings()) + } + try xcodeProject.write(path: tempPath, override: true, format: isJSON ? .xcproj : .pbxproj) + // The copy above may carry the previous format's file, which would win the next read. + try? (isJSON ? XcodeProj.pbxprojPath(tempPath) : XcodeProj.xcprojPath(tempPath)).delete() try? projectPath.delete() try tempPath.copy(projectPath) try? tempPath.delete() diff --git a/Sources/XcodeGenKit/ProjectFormat.swift b/Sources/XcodeGenKit/ProjectFormat.swift index 0405518f..e1d6ffed 100644 --- a/Sources/XcodeGenKit/ProjectFormat.swift +++ b/Sources/XcodeGenKit/ProjectFormat.swift @@ -3,14 +3,21 @@ public extension ProjectFormat { } public enum ProjectFormat: String { + case json case xcode16_3 case xcode16_0 case xcode15_3 case xcode15_0 case xcode14_0 + public var isJSON: Bool { + self == .json + } + public var objectVersion: UInt { switch self { + // `project.xcproj` stores no object version; 77 is what XcodeProj assigns reading one back. + case .json: 77 case .xcode16_3: 90 case .xcode16_0: 77 case .xcode15_3: 63 @@ -22,13 +29,13 @@ public enum ProjectFormat: String { public var preferredProjectObjectVersion: UInt? { switch self { case .xcode16_3, .xcode16_0: objectVersion - case .xcode15_3, .xcode15_0, .xcode14_0: nil + case .json, .xcode15_3, .xcode15_0, .xcode14_0: nil } } public var compatibilityVersion: String? { switch self { - case .xcode16_3, .xcode16_0: nil + case .json, .xcode16_3, .xcode16_0: nil case .xcode15_3: "Xcode 15.3" case .xcode15_0: "Xcode 15.0" case .xcode14_0: "Xcode 14.0" diff --git a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift index 600e4d61..74dd20d3 100644 --- a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift @@ -135,6 +135,70 @@ class ProjectGeneratorTests: XCTestCase { } } + func testProjectFormat() throws { + + func writeProject(format: String?, to basePath: Path) throws -> Path { + try basePath.mkpath() + let project = Project( + basePath: basePath, + name: "MyProject", + targets: [framework], + options: SpecOptions(projectFormat: format) + ) + let xcodeProject = try ProjectGenerator(project: project).generateXcodeProject(userName: "someUser") + try FileWriter(project: project).writeXcodeProject(xcodeProject) + return project.defaultProjectPath + } + + describe { + + $0.it("writes project.pbxproj by default") { + let basePath = Path.temporary + "projectFormat/default" + try? basePath.delete() + let projectPath = try writeProject(format: nil, to: basePath) + + try expect(XcodeProj.pbxprojPath(projectPath).exists).beTrue() + try expect(XcodeProj.xcprojPath(projectPath).exists).beFalse() + } + + $0.it("writes project.xcproj for the json format") { + let basePath = Path.temporary + "projectFormat/json" + try? basePath.delete() + let projectPath = try writeProject(format: "json", to: basePath) + + try expect(XcodeProj.xcprojPath(projectPath).exists).beTrue() + try expect(XcodeProj.pbxprojPath(projectPath).exists).beFalse() + + let written = try XcodeProj(path: projectPath) + try expect(written.projectFormat) == .xcproj + try expect(written.pbxproj.nativeTargets.map(\.name)) == [framework.name] + } + + $0.it("writes the same project.xcproj on every generation") { + let basePath = Path.temporary + "projectFormat/jsonRepeated" + try? basePath.delete() + let projectPath = try writeProject(format: "json", to: basePath) + let first: Data = try XcodeProj.xcprojPath(projectPath).read() + + _ = try writeProject(format: "json", to: basePath) + let second: Data = try XcodeProj.xcprojPath(projectPath).read() + + try expect(first) == second + } + + $0.it("removes the file of the format it replaces") { + let basePath = Path.temporary + "projectFormat/switching" + try? basePath.delete() + let projectPath = try writeProject(format: "json", to: basePath) + try expect(XcodeProj.xcprojPath(projectPath).exists).beTrue() + + _ = try writeProject(format: "xcode16_0", to: basePath) + try expect(XcodeProj.pbxprojPath(projectPath).exists).beTrue() + try expect(XcodeProj.xcprojPath(projectPath).exists).beFalse() + } + } + } + func testConfigGenerator() { describe {