Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Next Version

### Added
- Add `registries` to generate a Swift Package Registry (SE-0292) `registries.json` into the project's SwiftPM configuration, so generated projects can resolve `.package(id:)` dependencies from a registry @bdolewski

## 2.45.4

### Fixed
Expand Down
27 changes: 26 additions & 1 deletion Docs/ProjectSpec.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ You can also use environment variables in your configuration file, by using `${S
- [Test Plan](#test-plan)
- [Scheme Template](#scheme-template)
- [Swift Package](#swift-package)
- [Swift Package Registries](#swift-package-registries)
- [Remote Package](#remote-package)
- [Local Package](#local-package)
- [Project Reference](#project-reference)
Expand All @@ -72,6 +73,7 @@ You can also use environment variables in your configuration file, by using `${S
- [ ] **schemeTemplates**: **[String: [Scheme Template](#scheme-template)]** - a list of schemes that can be used as templates for actual schemes which reference them via a `template` property. They can be used to extract common scheme settings. Works great in combination with `include`.
- [ ] **targetTemplates**: **[String: [Target Template](#target-template)]** - a list of targets that can be used as templates for actual targets which reference them via a `template` property. They can be used to extract common target settings. Works great in combination with `include`.
- [ ] **packages**: **[String: [Swift Package](#swift-package)]** - a map of Swift packages by name.
- [ ] **registries**: **[Swift Package Registries](#swift-package-registries)** - Swift Package Registry (SE-0292) configuration, written to the generated project's SwiftPM `registries.json`.
- [ ] **projectReferences**: **[String: [Project Reference](#project-reference)]** - a map of project references by name

### Include
Expand Down Expand Up @@ -394,7 +396,7 @@ Settings are merged in the following order: `groups`, `base`, `configs` (simple
- [ ] **transitivelyLinkDependencies**: **Bool** - If this is not specified the value from the project set in [Options](#options)`.transitivelyLinkDependencies` will be used.
- [ ] **directlyEmbedCarthageDependencies**: **Bool** - If this is `true` Carthage framework dependencies will be embedded using an `Embed Frameworks` build phase instead of the `copy-frameworks` script. Defaults to `true` for all targets except iOS/tvOS/watchOS Applications.
- [ ] **requiresObjCLinking**: **Bool** - If this is `true` any targets that link to this target will have `-ObjC` added to their `OTHER_LDFLAGS`. This is required if a static library has any categories or extensions on Objective-C code. See [this guide](https://pewpewthespells.com/blog/objc_linker_flags.html#objc) for more details. Defaults to `true` if `type` is `library.static`. If you are 100% sure you don't have categories or extensions on Objective-C code (pure Swift with no use of Foundation/UIKit) you can set this to `false`, otherwise it's best to leave it alone.
- [ ] **onlyCopyFilesOnInstall**: **Bool**  If this is `true`, the `Embed Frameworks` and `Embed App Extensions` (if available) build phases will have the "Copy only when installing" chekbox checked. Defaults to `false`.
- [ ] **onlyCopyFilesOnInstall**: **Bool** - If this is `true`, the `Embed Frameworks` and `Embed App Extensions` (if available) build phases will have the "Copy only when installing" chekbox checked. Defaults to `false`.
- [ ] **buildToolPlugins**: **[[Build Tool Plug-ins](#build-tool-plug-ins)]** - Commands for the build system that run automatically *during* the build.
- [ ] **preBuildScripts**: **[[Build Script](#build-script)]** - Build scripts that run *before* any other build phases
- [ ] **postCompileScripts**: **[[Build Script](#build-script)]** - Build scripts that run after the Compile Sources phase
Expand Down Expand Up @@ -1279,6 +1281,29 @@ packages:
excludeFromProject: false
```

## Swift Package Registries

Configures one or more Swift Package Registries (SE-0292) for the generated project. XcodeGen serializes this into `<project>.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/configuration/registries.json`, the file SwiftPM and Xcode read to resolve registry (`.package(id:)`) dependencies. There is no project-level (pbxproj) representation for registry packages, so this configuration file is the supported way to point a generated project at a registry.

- [ ] **default**: **String** or **{ url, supportsAvailability }** - The default registry, written under SwiftPM's `[default]` key. A bare string is treated as the registry `url`, with `supportsAvailability` defaulting to `false`.
- [ ] **scopes**: **[String: String or { url, supportsAvailability }]** - Scoped registries keyed by package scope. A package whose identity is `<scope>.<name>` resolves from the matching scope's registry.

Each registry value is either a URL string or a mapping:

- [x] **url**: **String** - The registry base URL.
- [ ] **supportsAvailability**: **Bool** - Whether the registry implements the SE-0292 availability API. Defaults to `false`.

The dependencies themselves are still declared the supported way: by referencing a local Swift package whose `Package.swift` uses `.package(id:)` (see [Swift Package](#swift-package) `path`). XcodeGen does not write registry references into the `.xcodeproj` itself, because Xcode has no pbxproj representation for them. Authentication credentials are never written here; configure them via `.netrc` or the keychain as usual.

```yaml
registries:
default: https://tuist.dev/api/registry/swift
scopes:
acme:
url: https://packages.example.com/artifactory/api/swift/swift-registry
supportsAvailability: false
```

## Project Reference

Project References are defined at a project level, and then you can use the project name to refer its target via a [Scheme](#scheme)
Expand Down
13 changes: 13 additions & 0 deletions Sources/ProjectSpec/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public struct Project: BuildSettingsContainer {

public var packages: [String: SwiftPackage]

public var registries: SwiftPackageRegistries

public var settings: Settings
public var settingGroups: [String: Settings]
public var configs: [Config]
Expand Down Expand Up @@ -52,6 +54,7 @@ public struct Project: BuildSettingsContainer {
schemes: [Scheme] = [],
breakpoints: [Breakpoint] = [],
packages: [String: SwiftPackage] = [:],
registries: SwiftPackageRegistries = SwiftPackageRegistries(),
options: SpecOptions = SpecOptions(),
fileGroups: [String] = [],
configFiles: [String: String] = [:],
Expand All @@ -70,6 +73,7 @@ public struct Project: BuildSettingsContainer {
self.schemes = schemes
self.breakpoints = breakpoints
self.packages = packages
self.registries = registries
self.options = options
self.fileGroups = fileGroups
self.configFiles = configFiles
Expand Down Expand Up @@ -152,6 +156,7 @@ extension Project: Equatable {
lhs.configFiles == rhs.configFiles &&
lhs.options == rhs.options &&
lhs.packages == rhs.packages &&
lhs.registries == rhs.registries &&
NSDictionary(dictionary: lhs.attributes).isEqual(to: rhs.attributes)
}
}
Expand Down Expand Up @@ -208,6 +213,11 @@ extension Project {
}
)
}
if jsonDictionary["registries"] != nil {
registries = try jsonDictionary.json(atKeyPath: "registries")
} else {
registries = SwiftPackageRegistries()
}
if jsonDictionary["options"] != nil {
options = try jsonDictionary.json(atKeyPath: "options")
} else {
Expand Down Expand Up @@ -323,6 +333,9 @@ extension Project: JSONEncodable {
dictionary["include"] = include
dictionary["attributes"] = attributes
dictionary["packages"] = packages.mapValues { $0.toJSONValue() }
if !registries.isEmpty {
dictionary["registries"] = registries.toJSONValue()
}
dictionary["targets"] = Dictionary(uniqueKeysWithValues: targetPairs)
dictionary["configs"] = Dictionary(uniqueKeysWithValues: configsPairs)
dictionary["aggregateTargets"] = Dictionary(uniqueKeysWithValues: aggregateTargetsPairs)
Expand Down
159 changes: 159 additions & 0 deletions Sources/ProjectSpec/SwiftPackageRegistries.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import Foundation
import JSONUtilities

/// Swift Package Registry (SE-0292) configuration.
///
/// XcodeGen serializes this into the generated project's
/// `project.xcworkspace/xcshareddata/swiftpm/configuration/registries.json`, which is the
/// file SwiftPM and Xcode read to resolve registry (`.package(id:)`) dependencies. Xcode
/// has no project-level (pbxproj) representation for registry packages, so this configuration
/// file is the supported way to point a generated project at a registry.
public struct SwiftPackageRegistries: Equatable {

/// A single registry endpoint.
public struct Registry: Equatable {

/// The registry base URL, e.g. `https://tuist.dev/api/registry/swift`.
public var url: String

/// Whether the registry implements the SE-0292 availability API. Defaults to `false`,
/// matching what `swift package-registry set` writes.
public var supportsAvailability: Bool

public init(url: String, supportsAvailability: Bool = false) {
self.url = url
self.supportsAvailability = supportsAvailability
}
}

/// The default registry, written under SwiftPM's `[default]` key.
public var defaultRegistry: Registry?

/// Scoped registries keyed by package scope. A package whose identity is `<scope>.<name>`
/// resolves from the matching scope's registry.
public var scopes: [String: Registry]

public init(defaultRegistry: Registry? = nil, scopes: [String: Registry] = [:]) {
self.defaultRegistry = defaultRegistry
self.scopes = scopes
}

/// Whether there is anything to write. When empty, XcodeGen writes no registries.json.
public var isEmpty: Bool {
defaultRegistry == nil && scopes.isEmpty
}
}

extension SwiftPackageRegistries {

/// Errors raised while parsing a `registries` block.
public enum ParsingError: Error, CustomStringConvertible {
case invalidRegistry(Any)
case missingURL(JSONDictionary)

public var description: String {
switch self {
case let .invalidRegistry(value):
return "Invalid registry \"\(value)\". Expected a URL string or a mapping with a \"url\" key."
case let .missingURL(dictionary):
return "Registry \"\(dictionary)\" is missing a \"url\"."
}
}
}
}

extension SwiftPackageRegistries.Registry {

/// Decodes a registry from either a bare URL string or a `{ url, supportsAvailability }` mapping.
init(jsonValue: Any) throws {
if let url = jsonValue as? String {
self.init(url: url)
} else if let dictionary = jsonValue as? JSONDictionary {
guard let url = dictionary["url"] as? String else {
throw SwiftPackageRegistries.ParsingError.missingURL(dictionary)
}
let supportsAvailability = (dictionary["supportsAvailability"] as? Bool) ?? false
self.init(url: url, supportsAvailability: supportsAvailability)
} else {
throw SwiftPackageRegistries.ParsingError.invalidRegistry(jsonValue)
}
}
}

extension SwiftPackageRegistries: JSONObjectConvertible {

public init(jsonDictionary: JSONDictionary) throws {
if let defaultValue = jsonDictionary["default"] {
defaultRegistry = try Registry(jsonValue: defaultValue)
} else {
defaultRegistry = nil
}
if let scopesDictionary = jsonDictionary["scopes"] as? JSONDictionary {
scopes = try scopesDictionary.mapValues { try Registry(jsonValue: $0) }
} else {
scopes = [:]
}
}
}

extension SwiftPackageRegistries.Registry: JSONEncodable {

public func toJSONValue() -> Any {
// Use the compact URL-string form unless a non-default flag needs to be preserved.
if supportsAvailability {
return [
"url": url,
"supportsAvailability": supportsAvailability,
] as JSONDictionary
}
return url
}
}

extension SwiftPackageRegistries: JSONEncodable {

public func toJSONValue() -> Any {
var dictionary: JSONDictionary = [:]
if let defaultRegistry = defaultRegistry {
dictionary["default"] = defaultRegistry.toJSONValue()
}
if !scopes.isEmpty {
dictionary["scopes"] = scopes.mapValues { $0.toJSONValue() }
}
return dictionary
}
}

extension SwiftPackageRegistries {

/// The on-disk `registries.json` representation that SwiftPM and Xcode read.
struct File: Encodable {
struct Entry: Encodable {
let supportsAvailability: Bool
let url: String
}

let authentication: [String: String]
let registries: [String: Entry]
let version: Int
}

func makeFile() -> File {
var entries: [String: File.Entry] = [:]
if let defaultRegistry = defaultRegistry {
entries["[default]"] = File.Entry(supportsAvailability: defaultRegistry.supportsAvailability, url: defaultRegistry.url)
}
for (scope, registry) in scopes {
entries[scope] = File.Entry(supportsAvailability: registry.supportsAvailability, url: registry.url)
}
return File(authentication: [:], registries: entries, version: 1)
}

/// Serializes the configuration to `registries.json` bytes: sorted keys, pretty printed,
/// and slashes left unescaped to match the output of `swift package-registry set`.
public func registriesJSONData() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(makeFile())
}
}
1 change: 1 addition & 0 deletions Sources/XcodeGenCLI/Commands/GenerateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class GenerateCommand: ProjectCommand {
info("⚙️ Writing project...")
do {
try fileWriter.writeXcodeProject(xcodeProject, to: projectPath)
try fileWriter.writeSwiftPackageRegistries(to: projectPath)

success("Created project at \(projectPath)")
} catch {
Expand Down
16 changes: 16 additions & 0 deletions Sources/XcodeGenKit/FileWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ public class FileWriter {
try? tempPath.delete()
}

/// Writes the Swift Package Registry configuration (registries.json) into the generated
/// project's workspace, so SwiftPM/Xcode can resolve `.package(id:)` dependencies from a
/// registry. No-op when no registries are configured or when the file is unchanged.
public func writeSwiftPackageRegistries(to projectPath: Path? = nil) throws {
guard !project.registries.isEmpty else { return }
let projectPath = projectPath ?? project.defaultProjectPath
let configurationPath = projectPath + "project.xcworkspace/xcshareddata/swiftpm/configuration/registries.json"
let data = try project.registries.registriesJSONData()
if configurationPath.exists, let existing: Data = try? configurationPath.read(), existing == data {
// file is the same
return
}
try configurationPath.parent().mkpath()
try configurationPath.write(data)
}

public func writePlists() throws {

let infoPlistGenerator = InfoPlistGenerator()
Expand Down
43 changes: 43 additions & 0 deletions Tests/ProjectSpecTests/SpecLoadingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,49 @@ class SpecLoadingTests: XCTestCase {
try expect(parsedSpec) == project
}

$0.it("parses swift package registries") {
let project = Project(name: "test", registries: SwiftPackageRegistries(
defaultRegistry: SwiftPackageRegistries.Registry(url: "https://tuist.dev/api/registry/swift"),
scopes: [
"acme": SwiftPackageRegistries.Registry(url: "https://packages.example.com/artifactory/api/swift/swift-registry"),
"example": SwiftPackageRegistries.Registry(url: "https://example.com/registry", supportsAvailability: true),
]
))

let dictionary: [String: Any] = [
"name": "test",
"registries": [
"default": "https://tuist.dev/api/registry/swift",
"scopes": [
"acme": "https://packages.example.com/artifactory/api/swift/swift-registry",
"example": ["url": "https://example.com/registry", "supportsAvailability": true],
],
],
]
let parsedSpec = try getProjectSpec(dictionary)
try expect(parsedSpec) == project
}

$0.it("serializes swift package registries to registries.json") {
let registries = SwiftPackageRegistries(
defaultRegistry: SwiftPackageRegistries.Registry(url: "https://tuist.dev/api/registry/swift"),
scopes: ["acme": SwiftPackageRegistries.Registry(url: "https://packages.example.com/artifactory/api/swift/swift-registry")]
)
let data = try registries.registriesJSONData()
let string = String(data: data, encoding: .utf8) ?? ""
// Slashes must not be escaped, matching `swift package-registry set` output.
try expect(string.contains("https://tuist.dev/api/registry/swift")) == true
try expect(string.contains("\\/")) == false

let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
try expect(json["version"] as? Int) == 1
try expect(json["authentication"] is [String: Any]) == true
let parsedRegistries = json["registries"] as! [String: [String: Any]]
try expect(parsedRegistries["[default]"]?["url"] as? String) == "https://tuist.dev/api/registry/swift"
try expect(parsedRegistries["[default]"]?["supportsAvailability"] as? Bool) == false
try expect(parsedRegistries["acme"]?["url"] as? String) == "https://packages.example.com/artifactory/api/swift/swift-registry"
}

$0.it("parses old local package format") {
let project = Project(name: "spm", packages: [
"XcodeGen": .local(path: "../XcodeGen", group: nil, excludeFromProject: false),
Expand Down