From 1a55b0f652931701c1c18f5593618bfeba9a2661 Mon Sep 17 00:00:00 2001 From: Kabir Oberai Date: Tue, 20 Oct 2026 21:36:23 -0400 Subject: [PATCH] xcode: tweaks to XcodeProjects API --- Sources/PackLib/XcodePacker.swift | 10 ++--- Sources/XcodeProjects/XCSchema+PBXProj.swift | 3 +- Sources/XcodeProjects/XcodeProject.swift | 43 +++++++++++++++---- .../XcodeProjects/XcodeWorkspace+XML.swift | 9 ++-- Sources/XcodeProjects/XcodeWorkspace.swift | 11 ++++- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/Sources/PackLib/XcodePacker.swift b/Sources/PackLib/XcodePacker.swift index d679ea78..ecac7d41 100644 --- a/Sources/PackLib/XcodePacker.swift +++ b/Sources/PackLib/XcodePacker.swift @@ -220,16 +220,16 @@ public struct XcodePacker { importedProducts: [], ) - let project = XcodeProject(project: schema) let projectURL = projectDir.appending(path: "\(plan.app.product).xcodeproj") - try project.write(to: projectURL) + let project = XcodeProject(project: schema, url: projectURL) + try project.write() + let workspaceURL = xtoolDir.appending(path: "\(plan.app.product).xcworkspace") let workspace = XcodeWorkspace(children: [ .file(XcodeWorkspace.Location(base: .container, path: "..")), .file(XcodeWorkspace.Location(base: .group, path: ".xtool-tmp/\(projectURL.lastPathComponent)")) - ]) - let workspaceURL = xtoolDir.appending(path: "\(plan.app.product).xcworkspace") - try workspace.write(to: workspaceURL) + ], url: workspaceURL) + try workspace.write() return workspaceURL } diff --git a/Sources/XcodeProjects/XCSchema+PBXProj.swift b/Sources/XcodeProjects/XCSchema+PBXProj.swift index 5849060d..42c971a6 100644 --- a/Sources/XcodeProjects/XCSchema+PBXProj.swift +++ b/Sources/XcodeProjects/XCSchema+PBXProj.swift @@ -2,9 +2,10 @@ import Foundation private import XcodeProj extension XCSchema.Project { - public func pbxprojRepresentation(name: String) throws -> Data { + public func pbxprojRepresentation(name: String, projectDirPath: String = "") throws -> Data { let xcprojData = try self.jsonRepresentation() let pbxproj = try PBXProj(xcprojData: xcprojData, projectName: name) + try pbxproj.rootProject()?.projectDirPath = projectDirPath // dataRepresentation should never return nil return try pbxproj.dataRepresentation() ?? Data() } diff --git a/Sources/XcodeProjects/XcodeProject.swift b/Sources/XcodeProjects/XcodeProject.swift index 99cb1df2..a733cb0f 100644 --- a/Sources/XcodeProjects/XcodeProject.swift +++ b/Sources/XcodeProjects/XcodeProject.swift @@ -1,4 +1,5 @@ import Foundation +private import XcodeProj @_exported import XcodeProjectFormat // This library is a lightweight layer on top of the XcodeProjectFormat @@ -16,13 +17,21 @@ public struct XcodeProject: Equatable, Sendable { public var project: XCSchema.Project public var format: Format + /// The .xcodeproj bundle's location, including for projects constructed in memory. + public var url: URL + /// An explicit PBX projectDirPath override. nil = use the .xcodeproj bundle's parent directory. + public var sourceRoot: URL? public init( project: XCSchema.Project, + url: URL, format: Format = .pbxproj, + sourceRoot: URL? = nil, ) { self.project = project self.format = format + self.url = url.standardizedFileURL.resolvingSymlinksInPath() + self.sourceRoot = sourceRoot?.standardizedFileURL } /// Reads the project from the given `.xcodeproj` path. @@ -30,6 +39,9 @@ public struct XcodeProject: Equatable, Sendable { guard url.pathExtension == "xcodeproj" else { throw Errors.notAnXcodeproj(url.path) } + let url = url.standardizedFileURL.resolvingSymlinksInPath() + self.url = url + self.sourceRoot = nil let xcprojURL = url.appending(path: "project.xcproj") lazy var pbxprojURL = url.appending(path: "project.pbxproj") if FileManager.default.fileExists(atPath: xcprojURL.path) { @@ -38,7 +50,14 @@ public struct XcodeProject: Equatable, Sendable { self.format = .xcproj } else if FileManager.default.fileExists(atPath: pbxprojURL.path) { let data = try Data(contentsOf: pbxprojURL) - self.project = try XCSchema.Project(pbxprojRepresentation: data) + let pbxproj = try PBXProj(data: data) + self.project = try XCSchema.Project(jsonRepresentation: pbxproj.xcprojData()) + let directory = try pbxproj.rootProject()?.projectDirPath ?? "" + if !directory.isEmpty { + self.sourceRoot = (directory as NSString).isAbsolutePath + ? URL(filePath: directory).standardizedFileURL + : url.deletingLastPathComponent().appending(path: directory).standardizedFileURL + } self.format = .pbxproj } else { throw Errors.missingProjectFile(url.path) @@ -52,23 +71,31 @@ public struct XcodeProject: Equatable, Sendable { return copy } - /// Writes the project to the given `.xcodeproj` path. - public func write(to url: URL) throws { + /// Returns a copy at a new bundle location, preserving any explicit source root. + public func withURL(_ url: URL) -> Self { + var copy = self + copy.url = url.standardizedFileURL.resolvingSymlinksInPath() + return copy + } + + /// Writes the project to its current `.xcodeproj` URL. + public func write() throws { try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) switch format { case .pbxproj: - try project - .pbxprojRepresentation(name: url.deletingPathExtension().lastPathComponent) - .write(to: url.appendingPathComponent("project.pbxproj")) + try project.pbxprojRepresentation( + name: url.deletingPathExtension().lastPathComponent, + projectDirPath: sourceRoot?.path ?? "" + ).write(to: url.appendingPathComponent("project.pbxproj")) case .xcproj: try project .jsonRepresentation() .write(to: url.appendingPathComponent("project.xcproj")) } - try XcodeWorkspace(children: [.file(.current)]) - .write(to: url.appendingPathComponent("project.xcworkspace")) + let workspaceURL = url.appendingPathComponent("project.xcworkspace") + try XcodeWorkspace(children: [.file(.current)], url: workspaceURL).write() } public enum Errors: Error { diff --git a/Sources/XcodeProjects/XcodeWorkspace+XML.swift b/Sources/XcodeProjects/XcodeWorkspace+XML.swift index b390a95b..c0e08a3b 100644 --- a/Sources/XcodeProjects/XcodeWorkspace+XML.swift +++ b/Sources/XcodeProjects/XcodeWorkspace+XML.swift @@ -4,14 +4,15 @@ private import XcodeProj extension XcodeWorkspace { public init(url: URL) throws { let workspace = try XCWorkspace(pathString: url.path) - self.init(workspace.data) + self.init(workspace.data, url: url) } public func dataRepresentation() throws -> Data { try xcWorkspaceData.dataRepresentation() ?? Data() } - public func write(to url: URL) throws { + /// Writes the workspace to its current URL. + public func write() throws { try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) try self.dataRepresentation() .write(to: url.appendingPathComponent("contents.xcworkspacedata")) @@ -21,8 +22,8 @@ extension XcodeWorkspace { // MARK: - Conversion extension XcodeWorkspace { - fileprivate init(_ xcWorkspaceData: XCWorkspaceData) { - self.init(children: xcWorkspaceData.children.map { .init(from: $0) }) + fileprivate init(_ xcWorkspaceData: XCWorkspaceData, url: URL) { + self.init(children: xcWorkspaceData.children.map { .init(from: $0) }, url: url) } fileprivate var xcWorkspaceData: XCWorkspaceData { diff --git a/Sources/XcodeProjects/XcodeWorkspace.swift b/Sources/XcodeProjects/XcodeWorkspace.swift index 13b6258d..e00fcf46 100644 --- a/Sources/XcodeProjects/XcodeWorkspace.swift +++ b/Sources/XcodeProjects/XcodeWorkspace.swift @@ -63,9 +63,18 @@ public struct XcodeWorkspace: Hashable, Sendable { } public var children: [Element] + public var url: URL - public init(children: [Element]) { + public init(children: [Element], url: URL) { self.children = children + self.url = url.standardizedFileURL.resolvingSymlinksInPath() + } + + /// Returns a copy at a new workspace location. + public func withURL(_ url: URL) -> Self { + var copy = self + copy.url = url.standardizedFileURL.resolvingSymlinksInPath() + return copy } }