Skip to content

Commit c612354

Browse files
committed
macOS support for swiftly
It works much like it does already for Linux with some notable differences: * The toolchains are installed using the pkg files and macOS installer * The toolchain directory is ~/Library/Developer/Toolchains instead of ~/.local/share/swiftly/toolchains * The swiftly shared directory is ~/Library/Application Support/swiftly as it this is a more typical place for macOS applications to store their supporting files Create a MacOS struct that implements the existing Platform protocol. Make a platform-specific target for this module. Bump the required swift toolchain version to resolve compiler errors and set the minimum macOS version to 13. Update the README.md with some macOS details and fix some of the details that were outdated, both there and in DESIGN.md. Since macOS configuration is very simple compared to Linux, the configuration can be auto-created from Swift directly if it is missing. Add some helpful notes regarding the need to rehash the zsh on macOS since even when the swiftly bin directory has higher precedence in the PATH it sometimes gets snagged on the /usr/bin/swift, which doesn't detect the user installed toolchains and sometimes tries to get the user to install Xcode. Make the shell script swiftly installer capable of operating in a standard macOS environment. First, detect that the environment is macOS, and then adjust the getopts for macOS's more limited implementation with the short opts. Also, remove any of the Linux specific steps to detect the distribution, check for gpg, and attempt to install Linux system packages.
1 parent 245d869 commit c612354

File tree

13 files changed

+358
-105
lines changed

13 files changed

+358
-105
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ xcuserdata/
66
DerivedData/
77
.swiftpm/
88
.vscode/
9+
**/*.swp

.swift-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
5.7
1+
5.10

DESIGN.md

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,30 +54,25 @@ This is all very similar to how rustup does things, but I figure there's no need
5454
## macOS
5555
### Installation of swiftly
5656

57-
Similar to Linux, the bootstrapping script for macOS will create a `~/.swiftly/bin` directory and drop the swiftly executable in it. A similar `~/.swiftly/env` file will be created and a message will be printed suggesting users add source `~/.swiftly/env` to their `.bash_profile` or `.zshrc`.
57+
Similar to Linux, the bootstrapping script for macOS will create a `~/.local/bin` directory and drop the swiftly executable in it. A `~/.local/share/swiftly/env` file will be created and a message will be printed suggesting users add source `~/.local/share/swiftly/env` to their `.bash_profile` or `.zshrc`.
5858

5959
The bootstrapping script will detect if xcode is installed and prompt the user to install it if it isn’t. We could also ask the user if they’d like us to install the xcode command line tools for them via `xcode-select --install`.
6060

6161
### Installation of a Swift toolchain
6262

63-
The contents of `~/.swiftly` would look like this:
63+
The contents of `~/Library/Application Support/swiftly` would look like this:
6464

6565
```
66-
~/.swiftly
67-
68-
|
69-
-- bin/
70-
|
71-
-- active-toolchain/
66+
~/Library/Application Support/swiftly
7267
|
7368
-- config.json
7469
|
7570
– env
7671
```
7772

78-
Instead of downloading tarballs containing the toolchains and storing them directly in `~/.swiftly/toolchains`, we instead install Swift toolchains to `~/Library/Developer/Toolchains` via the `.pkg` files provided for download at swift.org. (Side note: we’ll need to request that other versions than the latest be made available). To select a toolchain for use, we update the symlink at `~/.swiftly/active-toolchain` to point to the desired toolchain in `~/Library/Developer/Toolchains`. In the env file, we’ll contain a line that looks like `export PATH=$HOME/.swiftly/active-toolchain/usr/bin:$PATH`, so the version of swift being used will automatically always be from the active toolchain. `config.json` will contain version information about the selected toolchain as well as its actual location on disk.
73+
Instead of downloading tarballs containing the toolchains and storing them directly in `~/.local/share/swiftly/toolchains`, we instead install Swift toolchains to `~/Library/Developer/Toolchains` via the `.pkg` files provided for download at swift.org. To select a toolchain for use, we update the symlinks at `~/Library/Application Support/swiftly/bin` to point to the desired toolchain in `~/Library/Developer/Toolchains`. In the env file, we’ll contain a line that looks like `export PATH="$HOME/Library/Application Support/swiftly:$PATH"`, so the version of swift being used will automatically always be from the active toolchain. `config.json` will contain version information about the selected toolchain as well as its actual location on disk.
7974

80-
This scheme works for ensuring the version of Swift used on the command line can be controlled, but it doesn’t affect the active toolchain used by Xcode. From what I can tell, there doesn’t seem to be a way to tell Xcode which toolchain to use except through the GUI, which won’t work for us. A possible solution would be to have the active-toolchain symlink live with the rest of the toolchains, and then the user could select it from the GUI (we could name it something like “swiftly Active Toolchain” or something to indicate that it’s managed by swiftly). Alternatively, we could figure out how Xcode selects toolchains and do what it does in swiftly manually.
75+
This scheme works for ensuring the version of Swift used on the command line can be controlled, but it doesn’t affect the active toolchain used by Xcode, which uses its own mechanisms for that. Xcode, if it is installed, can find the toolchains installed by swiftly.
8176

8277
## Interface
8378

Package.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
// swift-tools-version:5.7
1+
// swift-tools-version:5.10
22

33
import PackageDescription
44

55
let package = Package(
66
name: "swiftly",
7-
platforms: [.macOS(.v13)],
7+
platforms: [
8+
.macOS(.v13)
9+
],
810
products: [
911
.executable(
1012
name: "swiftly",
@@ -24,6 +26,7 @@ let package = Package(
2426
.product(name: "ArgumentParser", package: "swift-argument-parser"),
2527
.target(name: "SwiftlyCore"),
2628
.target(name: "LinuxPlatform", condition: .when(platforms: [.linux])),
29+
.target(name: "MacOSPlatform", condition: .when(platforms: [.macOS])),
2730
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
2831
]
2932
),
@@ -44,6 +47,12 @@ let package = Package(
4447
.linkedLibrary("z"),
4548
]
4649
),
50+
.target(
51+
name: "MacOSPlatform",
52+
dependencies: [
53+
"SwiftlyCore",
54+
]
55+
),
4756
.systemLibrary(
4857
name: "CLibArchive",
4958
pkgConfig: "libarchive",

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Target: x86_64-unknown-linux-gnu
4141
- Linux-based platforms listed on https://swift.org/download
4242
- CentOS 7 will not be supported due to some dependencies of swiftly not supporting it, however.
4343

44-
Right now, swiftly is in the very early stages of development and is only supported on Linux, but the long term plan is to also support macOS. For more detailed information about swiftly's intended features and implementation, check out the [design document](DESIGN.md).
44+
Right now, swiftly is in early stages of development and is supported on Linux and macOS. For more detailed information about swiftly's intended features and implementation, check out the [design document](DESIGN.md).
4545

4646
## Command interface overview
4747

Sources/LinuxPlatform/Linux.swift

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ public struct Linux: Platform {
1717
}
1818
}
1919

20+
public var swiftlyBinDir: URL {
21+
SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) }
22+
?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) }
23+
?? FileManager.default.homeDirectoryForCurrentUser
24+
.appendingPathComponent(".local", isDirectory: true)
25+
.appendingPathComponent("bin", isDirectory: true)
26+
}
27+
28+
public var swiftlyToolchainsDir: URL {
29+
self.swiftlyHomeDir.appendingPathComponent("toolchains", isDirectory: true)
30+
}
31+
2032
public var toolchainFileExtension: String {
2133
"tar.gz"
2234
}
@@ -205,23 +217,5 @@ public struct Linux: Platform {
205217
}
206218
}
207219

208-
private func runProgram(_ args: String..., quiet: Bool = false) throws {
209-
let process = Process()
210-
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
211-
process.arguments = args
212-
213-
if quiet {
214-
process.standardOutput = nil
215-
process.standardError = nil
216-
}
217-
218-
try process.run()
219-
process.waitUntilExit()
220-
221-
guard process.terminationStatus == 0 else {
222-
throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)")
223-
}
224-
}
225-
226220
public static let currentPlatform: any Platform = Linux()
227221
}

Sources/MacOSPlatform/MacOS.swift

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import Foundation
2+
import SwiftlyCore
3+
4+
struct SwiftPkgInfo: Codable {
5+
var CFBundleIdentifier: String
6+
}
7+
8+
/// `Platform` implementation for macOS systems.
9+
public struct MacOS: Platform {
10+
public init() {}
11+
12+
public var appDataDirectory: URL {
13+
return FileManager.default.homeDirectoryForCurrentUser
14+
.appendingPathComponent("Library/Application Support", isDirectory: true)
15+
}
16+
17+
public var swiftlyBinDir: URL {
18+
SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) }
19+
?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) }
20+
?? FileManager.default.homeDirectoryForCurrentUser
21+
.appendingPathComponent("Library/Application Support/swiftly/bin", isDirectory: true)
22+
}
23+
24+
public var swiftlyToolchainsDir: URL {
25+
SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) }
26+
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true)
27+
}
28+
29+
public var toolchainFileExtension: String {
30+
"pkg"
31+
}
32+
33+
public func isSystemDependencyPresent(_: SystemDependency) -> Bool {
34+
// All system dependencies on macOS should be present
35+
true
36+
}
37+
38+
public func verifySystemPrerequisitesForInstall(requireSignatureValidation: Bool) throws {
39+
// All system prerequisites should be there for macOS
40+
}
41+
42+
public func install(from tmpFile: URL, version: ToolchainVersion) throws {
43+
guard tmpFile.fileExists() else {
44+
throw Error(message: "\(tmpFile) doesn't exist")
45+
}
46+
47+
if !self.swiftlyToolchainsDir.fileExists() {
48+
try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false)
49+
}
50+
51+
SwiftlyCore.print("Installing package in user home directory...")
52+
try runProgram("installer", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory")
53+
}
54+
55+
public func uninstall(_ toolchain: ToolchainVersion) throws {
56+
SwiftlyCore.print("Uninstalling package in user home directory...")
57+
58+
let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(toolchain.identifier).xctoolchain", isDirectory: true)
59+
60+
let decoder = PropertyListDecoder()
61+
let infoPlist = toolchainDir.appendingPathComponent("Info.plist")
62+
guard let data = try? Data(contentsOf: infoPlist) else {
63+
throw Error(message: "could not open \(infoPlist)")
64+
}
65+
66+
guard let pkgInfo = try? decoder.decode(SwiftPkgInfo.self, from: data) else {
67+
throw Error(message: "could not decode plist at \(infoPlist)")
68+
}
69+
70+
try FileManager.default.removeItem(at: toolchainDir)
71+
72+
let homedir = ProcessInfo.processInfo.environment["HOME"]!
73+
try runProgram("pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier)
74+
}
75+
76+
public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool {
77+
let toolchainBinURL = self.swiftlyToolchainsDir
78+
.appendingPathComponent(toolchain.identifier + ".xctoolchain", isDirectory: true)
79+
.appendingPathComponent("usr", isDirectory: true)
80+
.appendingPathComponent("bin", isDirectory: true)
81+
82+
// Delete existing symlinks from previously in-use toolchain.
83+
if let currentToolchain {
84+
try self.unUse(currentToolchain: currentToolchain)
85+
}
86+
87+
// Ensure swiftly doesn't overwrite any existing executables without getting confirmation first.
88+
let swiftlyBinDirContents = try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path)
89+
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path)
90+
let willBeOverwritten = Set(toolchainBinDirContents).intersection(swiftlyBinDirContents)
91+
if !willBeOverwritten.isEmpty {
92+
SwiftlyCore.print("The following existing executables will be overwritten:")
93+
94+
for executable in willBeOverwritten {
95+
SwiftlyCore.print(" \(self.swiftlyBinDir.appendingPathComponent(executable).path)")
96+
}
97+
98+
let proceed = SwiftlyCore.readLine(prompt: "Proceed? (y/n)") ?? "n"
99+
100+
guard proceed == "y" else {
101+
SwiftlyCore.print("Aborting use")
102+
return false
103+
}
104+
}
105+
106+
for executable in toolchainBinDirContents {
107+
let linkURL = self.swiftlyBinDir.appendingPathComponent(executable)
108+
let executableURL = toolchainBinURL.appendingPathComponent(executable)
109+
110+
// Deletion confirmed with user above.
111+
try linkURL.deleteIfExists()
112+
113+
try FileManager.default.createSymbolicLink(
114+
atPath: linkURL.path,
115+
withDestinationPath: executableURL.path
116+
)
117+
}
118+
119+
SwiftlyCore.print("""
120+
NOTE: On macOS it is possible that the shell will pick up the system Swift on the path
121+
instead of the one that swiftly has installed for you. You can run the 'hash -r'
122+
command to update the shell with the latest PATHs.
123+
124+
hash -r
125+
126+
"""
127+
)
128+
129+
return true
130+
}
131+
132+
public func unUse(currentToolchain: ToolchainVersion) throws {
133+
let currentToolchainBinURL = self.swiftlyToolchainsDir
134+
.appendingPathComponent(currentToolchain.identifier + ".xctoolchain", isDirectory: true)
135+
.appendingPathComponent("usr", isDirectory: true)
136+
.appendingPathComponent("bin", isDirectory: true)
137+
138+
for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: currentToolchainBinURL.path) {
139+
guard existingExecutable != "swiftly" else {
140+
continue
141+
}
142+
143+
let url = self.swiftlyBinDir.appendingPathComponent(existingExecutable)
144+
let vals = try url.resourceValues(forKeys: [URLResourceKey.isSymbolicLinkKey])
145+
146+
guard let islink = vals.isSymbolicLink, islink else {
147+
throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)")
148+
}
149+
let symlinkDest = url.resolvingSymlinksInPath()
150+
guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else {
151+
throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)")
152+
}
153+
154+
try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists()
155+
}
156+
}
157+
158+
public func listAvailableSnapshots(version _: String?) async -> [Snapshot] {
159+
[]
160+
}
161+
162+
public func getExecutableName(forArch: String) -> String {
163+
"swiftly-\(forArch)-macos-osx"
164+
}
165+
166+
public func currentToolchain() throws -> ToolchainVersion? { nil }
167+
168+
public func getTempFilePath() -> URL {
169+
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg")
170+
}
171+
172+
public func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws {
173+
// No signature verification is required on macOS since the pkg files have their own signing
174+
// mechanism and the swift.org downloadables are trusted by stock macOS installations.
175+
}
176+
177+
public static let currentPlatform: any Platform = MacOS()
178+
}

Sources/Swiftly/Config.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ public struct Config: Codable, Equatable {
5656
error: \"\(error)\".
5757
To use swiftly, modify the configuration file to fix the issue or perform a clean installation.
5858
"""
59+
#if !os(macOS)
5960
throw Error(message: msg)
61+
#else
62+
let pd = PlatformDefinition.init(name: "xcode", nameFull: "osx", namePretty: "macOS", architecture: nil)
63+
return Config.init(inUse: nil, installedToolchains: [], platform: pd)
64+
#endif
6065
}
6166
}
6267

Sources/Swiftly/Install.swift

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,25 +121,19 @@ struct Install: SwiftlyCommand {
121121
}
122122

123123
url += "swift-\(versionString)-release/"
124-
url += "\(platformString)/"
125-
url += "swift-\(versionString)-RELEASE/"
126-
url += "swift-\(versionString)-RELEASE-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
127124
case let .snapshot(release):
128-
let snapshotString: String
129125
switch release.branch {
130126
case let .release(major, minor):
131127
url += "swift-\(major).\(minor)-branch/"
132-
snapshotString = "swift-\(major).\(minor)-DEVELOPMENT-SNAPSHOT"
133128
case .main:
134129
url += "development/"
135-
snapshotString = "swift-DEVELOPMENT-SNAPSHOT"
136130
}
137-
138-
url += "\(platformString)/"
139-
url += "\(snapshotString)-\(release.date)-a/"
140-
url += "\(snapshotString)-\(release.date)-a-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
141131
}
142132

133+
url += "\(platformString)/"
134+
url += "\(version.identifier)/"
135+
url += "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
136+
143137
guard let url = URL(string: url) else {
144138
throw Error(message: "Invalid toolchain URL: \(url)")
145139
}
@@ -194,6 +188,7 @@ struct Install: SwiftlyCommand {
194188
try Swiftly.currentPlatform.install(from: tmpFile, version: version)
195189

196190
config.installedToolchains.insert(version)
191+
197192
try config.save()
198193

199194
// If this is the first installed toolchain, mark it as in-use regardless of whether the

Sources/Swiftly/Swiftly.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import ArgumentParser
22
import Foundation
33
#if os(Linux)
44
import LinuxPlatform
5+
#elseif os(macOS)
6+
import MacOSPlatform
57
#endif
68
import SwiftlyCore
79

810
@main
9-
@available(macOS 10.15, *)
1011
public struct Swiftly: SwiftlyCommand {
1112
public static var configuration = CommandConfiguration(
1213
abstract: "A utility for installing and managing Swift toolchains.",
@@ -37,6 +38,8 @@ public struct Swiftly: SwiftlyCommand {
3738

3839
#if os(Linux)
3940
internal static let currentPlatform = Linux.currentPlatform
41+
#elseif os(macOS)
42+
internal static let currentPlatform = MacOS.currentPlatform
4043
#endif
4144
}
4245

0 commit comments

Comments
 (0)