Files
xtool-mirror/Sources/PackLib/XcodePacker.swift
Erik Bautista Santibanez edd6b90f53 Add support for App Extensions (#97)
This pull request adds support for Apple's [App
Extensions](https://developer.apple.com/app-extensions/).

Closes: #45

---------

Co-authored-by: ErrorErrorError <16653389+ErrorErrorError@users.noreply.github.com>
Co-authored-by: Kabir Oberai <oberai.kabir@gmail.com>
2025-07-13 23:47:47 -04:00

165 lines
5.8 KiB
Swift

#if os(macOS)
import Foundation
import PathKit
import Version
import ProjectSpec
import XcodeGenKit
import XcodeProj
public struct XcodePacker {
public var plan: Plan
public init(plan: Plan) {
self.plan = plan
}
// swiftlint:disable:next function_body_length
public func createProject() async throws -> URL {
let xtoolDir: Path = "xtool"
let projectDir: Path = xtoolDir + ".xtool-tmp"
try? xtoolDir.delete()
try projectDir.mkpath()
let fromProjectToRoot = try Path(".").relativePath(from: projectDir)
guard let deploymentTarget = Version(tolerant: plan.app.deploymentTarget) else {
throw StringError("Could not parse deployment target '\(plan.app.deploymentTarget)'")
}
let emptyText = Data("""
// leave this file empty
""".utf8)
let targets = try plan.allProducts.map { product in
let productDir = projectDir + product.product
try productDir.mkpath()
let emptyFile = productDir + "empty.c"
try emptyFile.write(emptyText)
let infoPath = productDir + "Info.plist"
var plist = product.infoPlist
let families = (plist.removeValue(forKey: "UIDeviceFamily") as? [Int]) ?? [1, 2]
plist["CFBundleExecutable"] = product.targetName
plist["CFBundleName"] = product.targetName
plist["CFBundleDisplayName"] = product.product
let encodedPlist = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try infoPath.write(encodedPlist)
var buildSettings: [String: Any] = [
"PRODUCT_BUNDLE_IDENTIFIER": product.bundleID,
"TARGETED_DEVICE_FAMILY": families.map { "\($0)" }.joined(separator: ","),
]
if product.type == .appExtension {
buildSettings["APPLICATION_EXTENSION_API_ONLY"] = true
}
if let entitlementsPath = product.entitlementsPath {
buildSettings["CODE_SIGN_ENTITLEMENTS"] = fromProjectToRoot + Path(entitlementsPath)
}
let additionalDependencies: [Dependency] = if product.type == .application {
plan.extensions.map {
Dependency(
type: .target,
reference: $0.targetName
)
}
} else {
[]
}
return Target(
name: product.targetName,
type: product.type == .application ? .application : .appExtension,
platform: .iOS,
deploymentTarget: deploymentTarget,
settings: Settings(buildSettings: buildSettings),
sources: [
TargetSource(
path: try emptyFile.relativePath(from: projectDir).string,
buildPhase: .sources
),
],
dependencies: [
Dependency(
type: .package(products: [product.product]),
reference: "RootPackage"
),
] + additionalDependencies,
info: Plist(
path: try infoPath.relativePath(from: projectDir).string,
attributes: [:]
)
)
}
let project = Project(
name: plan.app.targetName,
targets: targets,
packages: [
"RootPackage": .local(
path: fromProjectToRoot.string,
group: nil,
excludeFromProject: false
),
],
options: SpecOptions(
localPackagesGroup: ""
)
)
// TODO: Handle plan.resources of type .root
// TODO: Handle plan.iconPath
let generator = ProjectGenerator(project: project)
let xcodeproj = projectDir + "\(plan.app.product).xcodeproj"
let xcworkspace = xtoolDir + "\(plan.app.product).xcworkspace"
do {
let current = Path.current
Path.current = xcodeproj.parent()
defer { Path.current = current }
let xcodeProject = try generator.generateXcodeProject(userName: NSUserName())
if let packageRef = xcodeProject.pbxproj.fileReferences.first(where: { $0.name == ".." }) {
for group in xcodeProject.pbxproj.groups {
group.children.removeAll(where: { $0.uuid == packageRef.uuid })
}
xcodeProject.pbxproj.delete(object: packageRef)
}
try xcodeProject.write(path: Path(xcodeproj.lastComponent))
}
do {
let current = Path.current
Path.current = xcworkspace.parent()
defer { Path.current = current }
let xcworkspaceDirectory = xcworkspace.parent()
let fromWorkspaceToSelf = try Path(".").relativePath(from: xcworkspaceDirectory).withName()
let fromWorkspaceToProject = try xcodeproj.relativePath(from: xcworkspaceDirectory).withName()
let workspace = XCWorkspace(data: .init(children: [
.file(.init(location: .container(fromWorkspaceToSelf.string))),
.file(.init(location: .group(fromWorkspaceToProject.string))),
]))
try workspace.write(path: Path(xcworkspace.lastComponent))
}
return xcworkspace.url
}
}
extension Path {
fileprivate func withName() -> Path {
// eg if curr dir is Foo, this converts "." to "../Foo"
// which includes the name in the path, and therefore
// in the Xcode navigator
self.parent() + self.absolute().lastComponent
}
}
#endif