Skip to content

Commit bcfd843

Browse files
authored
macOS support for swiftly (#121)
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. 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. Add support for macOS CI. Read environment variables for a possible HTTP proxy, and use it for the http client when running the tests. Refactor the mechanisms used to override the http client and the request executor to support proxies, while still supporting the mock of both toolchain downloads and using the request handler lambda.
1 parent 364b02b commit bcfd843

21 files changed

+596
-166
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ xcuserdata/
66
DerivedData/
77
.swiftpm/
88
.vscode/
9+
**/*.swp

.swift-version

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
5.7
1+
5.10

DESIGN.md

+5-10
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

+11-2
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

+1-1
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

+13-19
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
}
@@ -201,25 +213,7 @@ public struct Linux: Platform {
201213
do {
202214
try self.runProgram("gpg", "--verify", sigFile.path, archive.path)
203215
} catch {
204-
throw Error(message: "Toolchain signature verification failed: \(error)")
205-
}
206-
}
207-
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)")
216+
throw Error(message: "Toolchain signature verification failed: \(error).")
223217
}
224218
}
225219

Sources/MacOSPlatform/MacOS.swift

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

0 commit comments

Comments
 (0)