├── swift-export.pkg
├── export.yml
├── Sources
├── URL+filePath.swift
├── String+terminal.swift
├── Logger.swift
├── ExportError.swift
├── CommandRunner.swift
├── SwiftExport.swift
└── Config.swift
├── .gitignore
├── hardened.entitlements
├── Package.swift
├── LICENSE
└── README.md
/swift-export.pkg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/franklefebvre/swift-export/HEAD/swift-export.pkg
--------------------------------------------------------------------------------
/export.yml:
--------------------------------------------------------------------------------
1 | executable:
2 | identifier: com.franklefebvre.swift-export
3 | package:
4 | version: 1.0
5 |
--------------------------------------------------------------------------------
/Sources/URL+filePath.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension URL {
4 | var filePath: String {
5 | standardizedFileURL.path(percentEncoded: false)
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/Sources/String+terminal.swift:
--------------------------------------------------------------------------------
1 | extension String {
2 | var commandStyle: String {
3 | "\u{1B}[1m\(self)\u{1B}[0m"
4 | }
5 |
6 | var messageStyle: String {
7 | "\u{1B}[1;34m\(self)\u{1B}[0m"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/hardened.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sources/Logger.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | actor Logger {
4 | enum Level: Int, Comparable {
5 | case info
6 | case error
7 |
8 | static func < (lhs: Logger.Level, rhs: Logger.Level) -> Bool {
9 | lhs.rawValue < rhs.rawValue
10 | }
11 | }
12 |
13 | var level: Level
14 |
15 | init(level: Level) {
16 | self.level = level
17 | }
18 |
19 | func info(_ message: @autoclosure () -> String) {
20 | guard level <= .info else { return }
21 | print(message().messageStyle)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "swift-export",
8 | platforms: [
9 | .macOS(.v13)
10 | ],
11 | dependencies: [
12 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"),
13 | .package(url: "https://github.com/jpsim/Yams.git", from: "5.1.0")
14 | ],
15 | targets: [
16 | // Targets are the basic building blocks of a package, defining a module or a test suite.
17 | // Targets can depend on other targets in this package and products from dependencies.
18 | .executableTarget(
19 | name: "swift-export",
20 | dependencies: [
21 | .product(name: "ArgumentParser", package: "swift-argument-parser"),
22 | .product(name: "Yams", package: "yams")
23 | ]
24 | ),
25 | ]
26 | )
27 |
--------------------------------------------------------------------------------
/Sources/ExportError.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum ExportError: Error {
4 | case missingConfigFile(lookupDir: URL)
5 | case invalidConfigFile
6 | case invalidBundleIdentifier
7 | case missingConfigField(name: String)
8 | case missingExecutableName
9 | case cantDetectExecutablePath
10 | }
11 |
12 | extension ExportError: CustomStringConvertible {
13 | var description: String {
14 | switch self {
15 | case .missingConfigFile(lookupDir: let lookupDir):
16 | "Missing configuration file in \(lookupDir.filePath)."
17 | case .invalidConfigFile:
18 | "Invalid config file."
19 | case .invalidBundleIdentifier:
20 | "Invalid bundle identifier"
21 | case .missingConfigField(name: let name):
22 | "Missing config field: \(name)"
23 | case .missingExecutableName:
24 | "Missing executable name"
25 | case .cantDetectExecutablePath:
26 | "Can't detect executable path"
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Frank Lefebvre
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/CommandRunner.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct ShellCommand {
4 | let path: String
5 | var url: URL { .init(filePath: path, directoryHint: .notDirectory) }
6 | var name: String { url.lastPathComponent }
7 | }
8 |
9 | extension ShellCommand {
10 | static var swift: Self { .init(path: "/usr/bin/swift") }
11 | static var codesign: Self { .init(path: "/usr/bin/codesign") }
12 | static var pkgbuild: Self { .init(path: "/usr/bin/pkgbuild") }
13 | static var xcrun: Self { .init(path: "/usr/bin/xcrun") }
14 | }
15 |
16 | actor CommandRunner {
17 | enum OutputMode {
18 | case silent
19 | case verbose
20 | case recorded
21 | }
22 |
23 | private let outputMode: OutputMode
24 |
25 | init(outputMode: OutputMode) {
26 | self.outputMode = outputMode
27 | }
28 |
29 | func run(_ executable: URL, args: [String]) throws -> Data? {
30 | let process = Process()
31 | process.executableURL = executable
32 | process.arguments = args
33 | let stdoutPipe: Pipe?
34 | switch outputMode {
35 | case .silent:
36 | stdoutPipe = nil
37 | process.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
38 | process.standardError = FileHandle(forWritingAtPath: "/dev/null")
39 | case .verbose:
40 | stdoutPipe = nil
41 | case .recorded:
42 | stdoutPipe = Pipe()
43 | process.standardOutput = stdoutPipe
44 | process.standardError = FileHandle(forWritingAtPath: "/dev/null")
45 | }
46 | try process.run()
47 | process.waitUntilExit()
48 | if process.terminationStatus != 0 {
49 | throw NSError(domain: NSPOSIXErrorDomain, code: Int(process.terminationStatus))
50 | }
51 | return try stdoutPipe?.fileHandleForReading.readToEnd()
52 | }
53 |
54 | func data(_ command: ShellCommand, _ args: String...) throws -> Data? {
55 | try run(command.url, args: args)
56 | }
57 |
58 | func string(_ command: ShellCommand, args: [String]) throws -> String? {
59 | guard let data = try run(command.url, args: args) else { return nil }
60 | return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .newlines)
61 | }
62 |
63 | func string(_ command: ShellCommand, _ args: String...) throws -> String? {
64 | try string(command, args: args)
65 | }
66 | }
67 |
68 | protocol RunnerProtocol {
69 | func run(command: ShellCommand, args: [String]) async throws -> Data?
70 | }
71 |
72 | extension RunnerProtocol {
73 | @discardableResult
74 | func callAsFunction(_ command: ShellCommand, _ args: String...) async throws -> Data? {
75 | try await run(command: command, args: args)
76 | }
77 |
78 | @discardableResult
79 | func callAsFunction(_ command: ShellCommand, _ args: [String]) async throws -> Data? {
80 | try await run(command: command, args: args)
81 | }
82 | }
83 |
84 | extension CommandRunner: RunnerProtocol {
85 | func run(command: ShellCommand, args: [String]) throws -> Data? {
86 | print((command.name + " " + args.joined(separator: " ")).commandStyle)
87 | let executable = command.url
88 | return try run(executable, args: args)
89 | }
90 | }
91 |
92 | actor MockRunner: RunnerProtocol {
93 | func run(command: ShellCommand, args: [String]) -> Data? {
94 | print((command.name + " " + args.joined(separator: " ")).commandStyle)
95 | return nil
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Sources/SwiftExport.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ArgumentParser
3 |
4 | @main
5 | struct SwiftExport: AsyncParsableCommand {
6 | static var configuration = CommandConfiguration(
7 | commandName: "swift export",
8 | abstract: "A utility to create macOS installer packages for command-line tools."
9 | )
10 |
11 | @Option(help: .init(
12 | "The path to the directory containing the configuration and code-signing files",
13 | discussion: """
14 | The path can be either absolute or relative to the current directory.
15 | By default, if a directory named `export` is found in the current directory, it is used; otherwise the current directory is used.
16 | """,
17 | valueName: "path"))
18 | var configDir: String?
19 |
20 | @Option(help: .init(
21 | "The path to the `export.yaml`, `export.yml` or `export.plist` file containing the export configuration.",
22 | discussion: """
23 | The path can be either absolute or relative to the directory specified by `--config-dir`.
24 | By default, files `export.yaml`, `export.yml` and `export.plist` are searched, in this order.
25 | """,
26 | valueName: "path"))
27 | var exportConfig: String?
28 |
29 | @Option(help: .init(
30 | "The identifier used to sign the executable binary.",
31 | discussion: """
32 | Same format as a bundle identifier, e.g. "com.example.MyAwesomeTool".
33 | This option overrides the identifier specified in `executable.identifier` in the export configuration.
34 | """))
35 | var identifier: String?
36 |
37 | @Option(help: .init(
38 | "The \"Developer ID Application\" certificate used to sign the executable file.",
39 | discussion: """
40 | Either the common name or the SHA-1 hash of the certificate can be provided.
41 | This option overrides the SWIFT_EXPORT_EXECUTABLE_CERTIFICATE environment variable and the value specified in `executable.certificate` in the export configuration.
42 | """,
43 | valueName: "identity"))
44 | var executableCertificate: String?
45 |
46 | @Option(help: .init(
47 | "The \"Developer ID Installer\" certificate used to sign the installer package.",
48 | discussion: """
49 | Either the common name or the SHA-1 hash of the certificate can be provided.
50 | This option overrides the SWIFT_EXPORT_PACKAGE_CERTIFICATE environment variable and the value specified in `package.certificate` in the export configuration.
51 | """,
52 | valueName: "identity"))
53 | var packageCertificate: String?
54 |
55 | @Option(help: .init(
56 | "The path to the entitlements file used for code signing.",
57 | discussion: """
58 | The path can be either absolute or relative to the directory containing the export configuration file.
59 | This option overrides the path specified in `executable.entitlements` in the export configuration.
60 | Default value: `hardened.entitlements` if this file exists. Otherwise default entitlements will be provided, with hardened runtime enabled and sandbox disabled.
61 | """,
62 | valueName: "path"))
63 | var entitlements: String?
64 |
65 | @Option(help: .init(
66 | "The output path (either pkg file or parent directory).",
67 | discussion: """
68 | The path can be either absolute or relative to the current directory.
69 | If a directory is provided, the name of the package will be based on the project name.
70 | Default value: current directory.
71 | """,
72 | valueName: "path"))
73 | var output: String?
74 |
75 | @Option(help: .init(
76 | "The identifier used to sign the installer package.",
77 | discussion: """
78 | Same format as a bundle identifier, e.g. "com.example.MyAwesomeTool".
79 | This option overrides the identifier specified in `package.identifier` in the export configuration.
80 | Default value: same as executable identifier.
81 | """,
82 | valueName: "identifier"))
83 | var packageIdentifier: String?
84 |
85 | @Option(help: .init(
86 | "The version number of the installer package.",
87 | discussion: """
88 | This option overrides the identifier specified in `package.version` in the export configuration.
89 | """,
90 | valueName: "version"))
91 | var packageVersion: String?
92 |
93 | @Option(help: .init(
94 | "The keychain profile name used to identify the developer account when submitting the package for notarization.",
95 | discussion: """
96 | This option overrides the SWIFT_EXPORT_NOTARY_PROFILE environment variable and the name specified in `notary.profile` in the export configuration.
97 | """,
98 | valueName: "name"))
99 | var notaryProfile: String?
100 |
101 | @Flag(help: "Print debugging and progress messages.")
102 | var verbose = false
103 |
104 | @Flag(help: "Print the commands to be performed, without actually performing them.")
105 | var dryRun = false
106 |
107 | mutating func run() async throws {
108 | let currentDirectory = URL.currentDirectory()
109 | let config = try Config(currentDirectory: currentDirectory, environment: ProcessInfo.processInfo.environment, parsedCommand: self)
110 | let shell = config.shell
111 | let cmd = CommandRunner(outputMode: .recorded)
112 |
113 | let logger = Logger(level: .info)
114 |
115 | var architectures = config.exportConfig.executable.architectures
116 | if architectures.isEmpty {
117 | architectures = ["arm64", "x86_64"]
118 | }
119 | let archsOptions = architectures.flatMap { ["--arch", $0] }
120 |
121 | guard let packageDescriptionData = try await cmd.data(.swift, "package", "describe", "--type", "json"),
122 | let packageDescriptionDict = try JSONSerialization.jsonObject(with: packageDescriptionData) as? [String: Any],
123 | // let firstProduct = (packageDescriptionDict["package"] as? [[String: Any]])?.first,
124 | let builtExecutableName = packageDescriptionDict["name"] as? String
125 | else { throw ExportError.missingExecutableName }
126 | guard let executableDirPath = try await cmd.string(.swift, args: ["build", "--configuration", "release"] + archsOptions + ["--show-bin-path"]) else { throw ExportError.cantDetectExecutablePath }
127 |
128 | let builtExecutableURL = URL(filePath: executableDirPath).appending(components: builtExecutableName, directoryHint: .notDirectory)
129 |
130 | await logger.info("Building executable")
131 | try await shell(.swift, ["build", "--configuration", "release"] + archsOptions)
132 |
133 | let pkgRoot = URL.temporaryDirectory.appending(component: UUID().uuidString, directoryHint: .isDirectory)
134 | let installPath = config.exportConfig.package.executable?.destination ?? "/usr/local/bin"
135 | let installDirectory = pkgRoot.appending(path: installPath.trimmingPrefix("/"), directoryHint: .isDirectory)
136 | let installedExecutableName = config.executableName ?? builtExecutableName
137 | let pkgRootExecutableURL = installDirectory.appending(path: installedExecutableName, directoryHint: .notDirectory)
138 | let executablePath = pkgRootExecutableURL.filePath
139 |
140 | guard let codesignIdentifier = config.exportConfig.executable.identifier else {
141 | throw ExportError.missingConfigField(name: "executable.identifier")
142 | }
143 |
144 | if !dryRun {
145 | await logger.info("Creating root directory for installer package at \(pkgRoot.filePath)")
146 | try FileManager.default.createDirectory(at: installDirectory, withIntermediateDirectories: true)
147 | // Can't defer here: this is an asynchronous context.
148 | await logger.info("Copying executable to \(executablePath)")
149 | try FileManager.default.copyItem(at: builtExecutableURL, to: pkgRootExecutableURL)
150 |
151 | for resource in config.exportConfig.package.resources ?? [] {
152 | let installDirectory = pkgRoot.appending(path: resource.destination.trimmingPrefix("/"), directoryHint: .isDirectory)
153 | try FileManager.default.createDirectory(at: installDirectory, withIntermediateDirectories: true)
154 | await logger.info("Copying \(resource.source) to \(installDirectory.filePath)")
155 | let resourceURL = currentDirectory.appending(path: resource.source)
156 | try FileManager.default.copyItem(at: resourceURL, to: installDirectory.appending(path: resourceURL.lastPathComponent))
157 | }
158 | }
159 |
160 | let entitlementsFile: URL
161 | if let configEntitlementsFile = config.entitlementsFile {
162 | entitlementsFile = configEntitlementsFile
163 | } else {
164 | entitlementsFile = URL.temporaryDirectory.appending(component: "entitlements", directoryHint: .notDirectory)
165 | if !dryRun {
166 | await logger.info("Creating default entitlements")
167 | let defaultEntitlements = ["com.apple.security.app-sandbox": false]
168 | let entitlementsData = try PropertyListEncoder().encode(defaultEntitlements)
169 | try entitlementsData.write(to: entitlementsFile)
170 | }
171 | }
172 |
173 | await logger.info("Signing executable")
174 | // target = .build/release/target_name
175 | try await shell(.codesign, "--force", "--entitlements", entitlementsFile.filePath, "--options", "runtime", "--sign", config.exportConfig.executable.certificate, "--identifier=\"\(codesignIdentifier)\"", executablePath)
176 | try await shell(.codesign, "--verify", "--verbose", executablePath)
177 |
178 | if !dryRun {
179 | await logger.info("Creating output directory: \(config.outputDirectory.filePath)")
180 | try FileManager.default.createDirectory(at: config.outputDirectory, withIntermediateDirectories: true)
181 | }
182 | let pkgName = config.pkgName ?? installedExecutableName + ".pkg"
183 | let pkgFile = config.outputDirectory.appending(path: pkgName, directoryHint: .notDirectory)
184 |
185 | await logger.info("Creating signed installer package at \(pkgFile.filePath)")
186 | let pkgIdentifier = config.exportConfig.package.identifier ?? codesignIdentifier
187 | try await shell(.pkgbuild, "--identifier", pkgIdentifier, "--version", config.pkgVersion, "--root", pkgRoot.filePath, "--sign", config.exportConfig.package.certificate, pkgFile.filePath)
188 |
189 | await logger.info("Submitting installer package for notarization")
190 | try await shell(.xcrun, "notarytool", "submit", pkgFile.filePath, "--keychain-profile", config.exportConfig.notary.keychainProfile, "--wait")
191 | await logger.info("Stapling notarization receipt to installer package")
192 | try await shell(.xcrun, "stapler", "staple", "-v", pkgFile.filePath)
193 |
194 | if !dryRun {
195 | await logger.info("Deleting temporary files")
196 | if config.entitlementsFile == nil {
197 | try? FileManager.default.removeItem(at: entitlementsFile)
198 | }
199 | try? FileManager.default.removeItem(at: installDirectory)
200 | }
201 |
202 | await logger.info("Done")
203 | }
204 | }
205 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## What is swift-export?
2 |
3 | `swift-export` is a command-line tool used to generate signed and notarized installer packages for macOS. The generated installer packages can contain any executable file (built from a Swift Package Manager project) and optional payloads, such as LaunchDaemons plist files. It runs on macOS 13 and above.
4 |
5 | You can download the installer here: [swift-export.pkg](https://raw.githubusercontent.com/franklefebvre/swift-export/main/swift-export.pkg), or build the tool from source.
6 |
7 | ## Basic Usage
8 |
9 | - build a universal binary from a Swift package
10 | - sign it
11 | - generate a signed and notarized installer package
12 |
13 | ### Command-line options
14 |
15 | Assuming the working directory is the directory containing the `Package.swift` file:
16 |
17 | `swift export --identifier --executable-certificate --package-certificate --package-version --notary-profile `
18 |
19 | - `identifier`: a unique identifier for the executable, typically in the form `com.your-domain.executable-name`
20 | - `application cert` and `installer cert`: certificates to be used for code signing (see [Code signing identities](#code-signing-identities) below)
21 | - `version`: the version of the installer package
22 | - `notary profile name`: the name of the profile stored in the keychain for notarization (see [Notary identity](#notary-identity) below)
23 |
24 | ### Configuration file and environment
25 |
26 | If the project contains a file named "export.yml", either at its root or in a directory named "export", with the following contents:
27 |
28 | ```
29 | executable:
30 | identifier: com.your-domain.executable-name
31 | package:
32 | version: 1.0
33 | ```
34 |
35 | and these environment variables are defined:
36 |
37 | ```
38 | SWIFT_EXPORT_EXECUTABLE_CERTIFICATE=
39 | SWIFT_EXPORT_PACKAGE_CERTIFICATE=
40 | SWIFT_EXPORT_NOTARY_PROFILE=
41 | ```
42 |
43 | then the command can be reduced to `swift export`.
44 |
45 | ## Advanced Usage
46 |
47 | ### Sandboxing and entitlements
48 |
49 | By default the executable is built with the hardened runtime enabled, without sandboxing. For other situations (e.g. to enable sandboxing or to give additional entitlements), it is possible to provide an entitlements file. It should be named "hardened.entitlements" in the same directory as the `export.yml` file, or its path can be specified in the config file.
50 |
51 | ### Installation destination
52 |
53 | By default the executable is installed in `/usr/local/bin`. This can be changed by adding this entry to the `export.yml` file:
54 |
55 | ```
56 | package:
57 | executable:
58 | destination: /path/to/install/directory
59 | ```
60 |
61 | ### Additional installer payload
62 |
63 | It is possible to provide additional files to be part of the installer package. For instance if the executable is a daemon, a plist file (e.g. `com.your-domain.service-name.plist`) should be installed in `/Library/LaunchDaemons`:
64 |
65 | - add the `com.your-domain.service-name.plist` file to your project
66 |
67 | - add the following lines to `export.yml`:
68 |
69 | ```
70 | package:
71 | resources:
72 | - source: com.your-domain.service-name.plist
73 | destination: /Library/LaunchDaemons
74 | ```
75 |
76 | ## Option precedence
77 |
78 | Some settings can be given as command-line options and defined in the configuration file or as environment variables. In that case, the command-line option has priority over the configuration file setting, which has priority over the environment variable.
79 |
80 | The following elements are mandatory, whether they are provided as environment variables, in the config file, or on the command line:
81 |
82 | - executable identifier
83 | - executable certificate
84 | - package certificate
85 | - package version
86 | - notary profile
87 |
88 | Everything else is optional.
89 |
90 | ## Command-line options
91 |
92 | ### `--config-dir `
93 |
94 | Specifies the directory containing the configuration and code-signing files. The path can be either absolute or relative to the current directory.
95 |
96 | By default, if a directory named `export` is found in the current directory, it is used; otherwise the current directory is used.
97 |
98 | ### `--export-config `
99 |
100 | The path to the `export.yaml`, `export.yml` or `export.plist` file containing the export configuration. The path can be either absolute or relative to the directory specified by `--config-dir`.
101 |
102 | By default, files `export.yaml`, `export.yml` and `export.plist` are searched, in this order.
103 |
104 | ### `--identifier `
105 |
106 | The identifier used to sign the executable binary. Same format as a bundle identifier, e.g. "com.example.MyAwesomeTool".
107 |
108 | This option overrides the identifier specified in `executable.identifier` in the export configuration.
109 |
110 | ### `--executable-certificate `
111 |
112 | The "Developer ID Application" certificate used to sign the executable file. Either the common name or the SHA-1 hash of the certificate can be provided.
113 |
114 | This option overrides the SWIFT_EXPORT_EXECUTABLE_CERTIFICATE environment variable and the value specified in `executable.certificate` in the export configuration.
115 |
116 | ### `--package-certificate `
117 |
118 | The "Developer ID Installer" certificate used to sign the installer package. Either the common name or the SHA-1 hash of the certificate can be provided.
119 |
120 | This option overrides the SWIFT_EXPORT_PACKAGE_CERTIFICATE environment variable and the value specified in `package.certificate` in the export configuration.
121 |
122 | ### `--entitlements `
123 |
124 | The path to the entitlements file used for code signing. The path can be either absolute or relative to the directory containing the export configuration file.
125 |
126 | This option overrides the path specified in `executable.entitlements` in the export configuration.
127 |
128 | Default value: `hardened.entitlements` if this file exists. Otherwise default entitlements will be provided, with hardened runtime enabled and sandbox disabled.
129 |
130 | ### `--output `
131 |
132 | The output path (either pkg file or parent directory). The path can be either absolute or relative to the current directory.
133 |
134 | If a directory is provided, the name of the package will be based on the project name.
135 |
136 | Default value: current directory.
137 |
138 | ### `--package-identifier `
139 |
140 | The identifier used to sign the installer package. Same format as a bundle identifier, e.g. "com.example.MyAwesomeTool".
141 |
142 | This option overrides the identifier specified in `package.identifier` in the export configuration.
143 |
144 | Default value: same as executable identifier.
145 |
146 | ### `--package-version `
147 |
148 | The version number of the installer package.
149 |
150 | This option overrides the identifier specified in `package.version` in the export configuration.
151 |
152 | ### `--notary-profile `
153 |
154 | The keychain profile name used to identify the developer account when submitting the package for notarization.
155 |
156 | This option overrides the SWIFT_EXPORT_NOTARY_PROFILE environment variable and the name specified in `notary.profile` in the export configuration.
157 |
158 | ### `--verbose`
159 |
160 | Print debugging and progress messages.
161 |
162 | ### `--dry-run`
163 |
164 | Print the commands to be performed, without actually performing them.
165 |
166 | ### `--help`
167 |
168 | Show help information.
169 |
170 | ## Configuration file
171 |
172 | The configuration file can be in either YAML or plist format.
173 |
174 | If an explicit filename is specified with the `--export-config` option, it is used. Otherwise `swift export` searches for a file named "export.yaml", "export.yml" or "export.plist" in the directory specified by the `--config-dir` option.
175 |
176 | If neither the `--export-config` option no the `--config-dir` option is given, the configuration file is searched in an "export" directory if it exists, then in the current directory.
177 |
178 | The configuration file has the following structure (all fields are optional):
179 |
180 | - `executable`
181 | - `architectures`: list of target architectures as an array of strings, default: [arm64, x86_64]
182 | - `identifier`: unique identifier used for code signing
183 | - `certificate`: Developer ID Application certificate name or hash
184 | - `entitlements`: path to an entitlements file (defaults to "hardened.entitlements" if such a file exists in the configuration directory, otherwise the executable is built with hardened runtime enabled and sandboxing disabled)
185 | - `package`
186 | - `identifer`: unique identifier used for code signing (default: same as executable.identifier)
187 | - `version`: version of the .pkg file
188 | - `certificate`: Developer ID Installer certificate name or hash
189 | - `executable`
190 | - `source`: name of the executable to be built (default: determined by Package.swift)
191 | - `destination`: path where the executable should be installed (default: /usr/local/bin)
192 | - `resources`: array of additional files to be installed; for each file:
193 | - `source`: name or path of the resource to be copied to the installer package
194 | - `destination`: path where it should be installed
195 | - `notary`
196 | - `keychain-profile`: name of the saved credentials in the keychain (see [Notary identity](#notary-identity))
197 |
198 | ## Environment
199 |
200 | Some settings should not appear in a git repository, either for security reasons, or because they can differ across users. For this reason, these settings can be provided as environment variables:
201 |
202 | - `SWIFT_EXPORT_EXECUTABLE_CERTIFICATE`: the common name or SHA-1 hash of the "Developer ID Application" certificate
203 | - `SWIFT_EXPORT_PACKAGE_CERTIFICATE`: the common name or SHA-1 hash of the "Developer ID Installer" certificate
204 | - `SWIFT_EXPORT_NOTARY_PROFILE`: the name of the keychain profile used for notarization
205 |
206 | Since these settings are likely to be shared across projects for a given user, a recommendation is to declare them in a shell profile (`~/.profile`, `~/.zshrc`, etc).
207 |
208 | ```
209 | export SWIFT_EXPORT_EXECUTABLE_CERTIFICATE=...
210 | export SWIFT_EXPORT_PACKAGE_CERTIFICATE=...
211 | export SWIFT_EXPORT_NOTARY_PROFILE=...
212 | ```
213 |
214 | ## Code signing identities
215 |
216 | `swift export` needs two certificates: a "Developer ID Application" certificate to sign the executable, and a "Developer ID Installer" certificate to sign the installer package.
217 |
218 | These certificates can be created on your Apple Developer account: https://developer.apple.com/account/resources/certificates/add
219 |
220 | Later on these certificates can be referred to by either their common names (typically "Developer ID Application: your name (team id)" and "Developer ID Installer: your name (team id)") or by their SHA-1 hashes, visible at the bottom of the Details section of the certificates in the keychain application.
221 |
222 | Note:
223 |
224 | - Search by common name is case-sensitive
225 | - SHA-1 hashes must consist of exactly 40 hexadecimal digits (no spaces)
226 |
227 | ## Notary identity
228 |
229 | In order to submit your package for notarization, you need to provide the Apple ID of your developer account to the notary service. A secure way to achieve this is to create an app-specific password and to store it in the keychain.
230 |
231 | First generate an app-specific password on https://appleid.apple.com, by following these instructions: https://support.apple.com/en-us/102654
232 |
233 | Then run this command: `xcrun notarytool store-credentials`. The tool will interactively prompt you for a profile name, your developer Apple ID, your app-specific password, and your Team ID.
234 |
235 | Your credentials are stored in the keychain, and you can now provide `notarytool` with the profile name whenever you need to submit any application or package for notarization.
236 |
237 |
--------------------------------------------------------------------------------
/Sources/Config.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Yams
3 |
4 | struct OverridableExportConfig: Codable {
5 | struct Executable: Codable {
6 | var architectures: [String]?
7 | var identifier: String?
8 | var certificate: String?
9 | var entitlements: String?
10 | }
11 | struct InstallExecutable: Codable {
12 | var source: String?
13 | var destination: String?
14 | }
15 | struct InstallResource: Codable {
16 | var source: String
17 | var destination: String
18 | }
19 | struct Package: Codable {
20 | var identifier: String?
21 | var version: String?
22 | var certificate: String?
23 | var executable: InstallExecutable?
24 | var resources: [InstallResource]?
25 | // TODO: preinstall/postinstall scripts
26 | }
27 | struct Notary: Codable {
28 | var keychainProfile: String?
29 | private enum CodingKeys: String, CodingKey {
30 | case keychainProfile = "keychain-profile"
31 | }
32 | }
33 | var executable: Executable?
34 | var package: Package?
35 | var notary: Notary?
36 | }
37 |
38 | extension OverridableExportConfig.Executable {
39 | init(parsedCommand: SwiftExport) {
40 | identifier = parsedCommand.identifier
41 | certificate = parsedCommand.executableCertificate
42 | entitlements = parsedCommand.entitlements
43 | }
44 |
45 | init(environment: [String : String]) {
46 | certificate = environment["SWIFT_EXPORT_EXECUTABLE_CERTIFICATE"]
47 | }
48 |
49 | func overridden(with other: OverridableExportConfig.Executable?) -> Self {
50 | guard let other else { return self }
51 | var overridden = self
52 | overridden.architectures = other.architectures ?? architectures
53 | overridden.identifier = other.identifier ?? identifier
54 | overridden.certificate = other.certificate ?? certificate
55 | overridden.entitlements = other.entitlements ?? entitlements
56 | return overridden
57 | }
58 | }
59 |
60 | extension OverridableExportConfig.Package {
61 | init(parsedCommand: SwiftExport) {
62 | identifier = parsedCommand.packageIdentifier
63 | version = parsedCommand.packageVersion
64 | certificate = parsedCommand.packageCertificate
65 | }
66 |
67 | init(environment: [String : String]) {
68 | certificate = environment["SWIFT_EXPORT_PACKAGE_CERTIFICATE"]
69 | }
70 |
71 | func overridden(with other: OverridableExportConfig.Package?) -> Self {
72 | guard let other else { return self }
73 | var overridden = self
74 | overridden.identifier = other.identifier ?? identifier
75 | overridden.version = other.version ?? version
76 | overridden.certificate = other.certificate ?? certificate
77 | overridden.executable = other.executable ?? executable
78 | overridden.resources = other.resources ?? resources
79 | return overridden
80 | }
81 | }
82 |
83 | extension OverridableExportConfig.Notary {
84 | init(parsedCommand: SwiftExport) {
85 | keychainProfile = parsedCommand.notaryProfile
86 | }
87 |
88 | init(environment: [String : String]) {
89 | keychainProfile = environment["SWIFT_EXPORT_NOTARY_PROFILE"]
90 | }
91 |
92 | func overridden(with other: OverridableExportConfig.Notary?) -> Self {
93 | guard let other else { return self }
94 | var overridden = self
95 | overridden.keychainProfile = other.keychainProfile ?? keychainProfile
96 | return overridden
97 | }
98 | }
99 |
100 | extension OverridableExportConfig {
101 | init(parsedCommand: SwiftExport) {
102 | self.executable = Executable(parsedCommand: parsedCommand)
103 | self.package = Package(parsedCommand: parsedCommand)
104 | self.notary = Notary(parsedCommand: parsedCommand)
105 | }
106 |
107 | init(environment: [String : String]) {
108 | self.executable = Executable(environment: environment)
109 | self.package = Package(environment: environment)
110 | self.notary = Notary(environment: environment)
111 | }
112 |
113 | func overridden(with other: OverridableExportConfig) -> Self {
114 | var overridden = self
115 | overridden.executable = (overridden.executable ?? .init()).overridden(with: other.executable)
116 | overridden.package = (overridden.package ?? .init()).overridden(with: other.package)
117 | overridden.notary = (overridden.notary ?? .init()).overridden(with: other.notary)
118 | return overridden
119 | }
120 | }
121 |
122 | struct ExportConfig {
123 | struct Executable {
124 | var architectures: [String]
125 | var identifier: String?
126 | var certificate: String
127 | var entitlements: String?
128 | }
129 | struct InstallExecutable {
130 | var source: String?
131 | var destination: String?
132 | }
133 | struct InstallResource {
134 | var source: String
135 | var destination: String
136 | }
137 | struct Package {
138 | var identifier: String?
139 | var version: String?
140 | var certificate: String
141 | var executable: InstallExecutable?
142 | var resources: [InstallResource]?
143 | // TODO: preinstall/postinstall scripts
144 | }
145 | struct Notary {
146 | var keychainProfile: String
147 | }
148 | var executable: Executable
149 | var package: Package
150 | var notary: Notary
151 | }
152 |
153 | extension ExportConfig {
154 | init(_ overridable: OverridableExportConfig) throws {
155 | guard let overridableExecutable = overridable.executable else { throw ExportError.missingConfigField(name: "executable") }
156 | guard let overridablePackage = overridable.package else { throw ExportError.missingConfigField(name: "package") }
157 | guard let overridableNotary = overridable.notary else { throw ExportError.missingConfigField(name: "notary") }
158 | self.executable = try .init(overridableExecutable)
159 | self.package = try .init(overridablePackage)
160 | self.notary = try .init(overridableNotary)
161 | }
162 | }
163 |
164 | extension ExportConfig.Executable {
165 | init(_ overridable: OverridableExportConfig.Executable) throws {
166 | guard let overridableCertificate = overridable.certificate else { throw ExportError.missingConfigField(name: "executable.certificate") }
167 | certificate = overridableCertificate
168 | architectures = overridable.architectures ?? []
169 | identifier = overridable.identifier
170 | entitlements = overridable.entitlements
171 | }
172 | }
173 |
174 | extension ExportConfig.InstallExecutable {
175 | init(_ overridable: OverridableExportConfig.InstallExecutable) {
176 | source = overridable.source
177 | destination = overridable.destination
178 | }
179 | }
180 |
181 | extension ExportConfig.InstallResource {
182 | init(_ overridable: OverridableExportConfig.InstallResource) {
183 | source = overridable.source
184 | destination = overridable.destination
185 | }
186 | }
187 |
188 | extension ExportConfig.Package {
189 | init(_ overridable: OverridableExportConfig.Package) throws {
190 | guard let overridableCertificate = overridable.certificate else { throw ExportError.missingConfigField(name: "package.certificate") }
191 | identifier = overridable.identifier
192 | version = overridable.version
193 | certificate = overridableCertificate
194 | if let overridableExecutable = overridable.executable {
195 | executable = .init(overridableExecutable)
196 | } else {
197 | executable = nil
198 | }
199 | if let overridableResources = overridable.resources {
200 | resources = overridableResources.map(ExportConfig.InstallResource.init)
201 | } else {
202 | resources = nil
203 | }
204 | }
205 | }
206 |
207 | extension ExportConfig.Notary {
208 | init(_ overridable: OverridableExportConfig.Notary) throws {
209 | guard let overridableKeychainProfile = overridable.keychainProfile else { throw ExportError.missingConfigField(name: "notary.keychain-profile") }
210 | keychainProfile = overridableKeychainProfile
211 | }
212 | }
213 |
214 | struct Config {
215 | let currentDirectory: URL
216 | let configDirectory: URL
217 | let outputDirectory: URL
218 | let parsedCommand: SwiftExport
219 | let shell: RunnerProtocol
220 | let configFile: URL?
221 | let exportConfig: ExportConfig
222 | let entitlementsFile: URL?
223 | let executableName: String?
224 | let pkgName: String?
225 | let pkgVersion: String
226 |
227 | init(currentDirectory: URL, environment: [String: String], parsedCommand: SwiftExport) throws {
228 | self.currentDirectory = currentDirectory
229 | self.parsedCommand = parsedCommand
230 |
231 | if parsedCommand.dryRun {
232 | shell = MockRunner()
233 | } else {
234 | shell = CommandRunner(outputMode: parsedCommand.verbose ? .verbose : .silent)
235 | }
236 |
237 | var exportConfig = OverridableExportConfig(environment: environment)
238 |
239 | self.configFile = Self.configFile(currentDirectory: currentDirectory, parsedCommand: parsedCommand)
240 | if let configFile {
241 | let configData = try Data(contentsOf: configFile)
242 | switch configFile.pathExtension.lowercased() {
243 | case "yml", "yaml":
244 | exportConfig = exportConfig.overridden(with: try YAMLDecoder().decode(OverridableExportConfig.self, from: configData))
245 | case "plist":
246 | exportConfig = exportConfig.overridden(with: try PropertyListDecoder().decode(OverridableExportConfig.self, from: configData))
247 | default:
248 | throw ExportError.invalidConfigFile
249 | }
250 | self.configDirectory = configFile.deletingLastPathComponent()
251 | } else {
252 | self.configDirectory = currentDirectory
253 | }
254 |
255 | exportConfig = exportConfig.overridden(with: .init(parsedCommand: parsedCommand))
256 | self.exportConfig = try ExportConfig(exportConfig)
257 |
258 | if let output = parsedCommand.output {
259 | self.outputDirectory = URL(filePath: output, directoryHint: .inferFromPath, relativeTo: currentDirectory)
260 | } else {
261 | self.outputDirectory = currentDirectory
262 | }
263 |
264 | var entitlementsPath = parsedCommand.entitlements ?? exportConfig.executable?.entitlements
265 | if entitlementsPath == nil {
266 | let defaultEntitlementsPath = "hardened.entitlements"
267 | let defaultEntitlementsFile = URL(filePath: defaultEntitlementsPath, directoryHint: .notDirectory, relativeTo: configDirectory)
268 | if FileManager.default.fileExists(atPath: defaultEntitlementsFile.filePath) {
269 | entitlementsPath = defaultEntitlementsPath
270 | }
271 | }
272 |
273 | if let entitlementsPath {
274 | self.entitlementsFile = URL(filePath: entitlementsPath, directoryHint: .notDirectory, relativeTo: configDirectory)
275 | } else {
276 | self.entitlementsFile = nil
277 | }
278 |
279 | guard let packageVersion = exportConfig.package?.version else { throw ExportError.missingConfigField(name: "package.version") }
280 | self.executableName = exportConfig.package?.executable?.source
281 | self.pkgName = executableName?.appending(".pkg")
282 | self.pkgVersion = packageVersion
283 | }
284 |
285 | private static func configFile(currentDirectory: URL, parsedCommand: SwiftExport) -> URL? {
286 | let lookupDirectory: URL
287 | if let configDir = parsedCommand.configDir {
288 | lookupDirectory = URL(filePath: configDir, directoryHint: .isDirectory, relativeTo: currentDirectory)
289 | } else {
290 | let exportDirectory = URL(filePath: "export", directoryHint: .isDirectory, relativeTo: currentDirectory)
291 | var isDirectory: ObjCBool = false
292 | if FileManager.default.fileExists(atPath: exportDirectory.path, isDirectory: &isDirectory), isDirectory.boolValue {
293 | lookupDirectory = exportDirectory
294 | } else {
295 | lookupDirectory = currentDirectory
296 | }
297 | }
298 | if let configFile = parsedCommand.exportConfig {
299 | return URL(filePath: configFile, directoryHint: .notDirectory, relativeTo: lookupDirectory)
300 | }
301 | let yamlConfigFile = lookupDirectory.appending(path: "export.yaml", directoryHint: .notDirectory)
302 | let ymlConfigFile = lookupDirectory.appending(path: "export.yml", directoryHint: .notDirectory)
303 | let plistConfigFile = lookupDirectory.appending(path: "export.plist", directoryHint: .notDirectory)
304 | if (try? Data(contentsOf: yamlConfigFile)) != nil {
305 | return yamlConfigFile
306 | }
307 | if (try? Data(contentsOf: ymlConfigFile)) != nil {
308 | return ymlConfigFile
309 | }
310 | if (try? Data(contentsOf: plistConfigFile)) != nil {
311 | return plistConfigFile
312 | }
313 | return nil
314 | }
315 | }
316 |
--------------------------------------------------------------------------------