Skip to content

Commit

Permalink
macOS support for swiftly
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
cmcgee1024 committed Jun 18, 2024
1 parent ab38db0 commit dfb441d
Show file tree
Hide file tree
Showing 13 changed files with 358 additions and 105 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ xcuserdata/
DerivedData/
.swiftpm/
.vscode/
**/*.swp
2 changes: 1 addition & 1 deletion .swift-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.7
5.10
15 changes: 5 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,25 @@ This is all very similar to how rustup does things, but I figure there's no need
## macOS
### Installation of swiftly

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`.
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`.

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`.

### Installation of a Swift toolchain

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

```
~/.swiftly
|
-- bin/
|
-- active-toolchain/
~/Library/Application Support/swiftly
|
-- config.json
|
– env
```

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.
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.

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.
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.

## Interface

Expand Down
13 changes: 11 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// swift-tools-version:5.7
// swift-tools-version:5.10

import PackageDescription

let package = Package(
name: "swiftly",
platforms: [.macOS(.v13)],
platforms: [
.macOS(.v13)
],
products: [
.executable(
name: "swiftly",
Expand All @@ -24,6 +26,7 @@ let package = Package(
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.target(name: "SwiftlyCore"),
.target(name: "LinuxPlatform", condition: .when(platforms: [.linux])),
.target(name: "MacOSPlatform", condition: .when(platforms: [.macOS])),
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
]
),
Expand All @@ -44,6 +47,12 @@ let package = Package(
.linkedLibrary("z"),
]
),
.target(
name: "MacOSPlatform",
dependencies: [
"SwiftlyCore",
]
),
.systemLibrary(
name: "CLibArchive",
pkgConfig: "libarchive",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Target: x86_64-unknown-linux-gnu
- Linux-based platforms listed on https://swift.org/download
- CentOS 7 will not be supported due to some dependencies of swiftly not supporting it, however.

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).
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).

## Command interface overview

Expand Down
30 changes: 12 additions & 18 deletions Sources/LinuxPlatform/Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ public struct Linux: Platform {
}
}

public var swiftlyBinDir: URL {
SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) }
?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) }
?? FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)
}

public var swiftlyToolchainsDir: URL {
self.swiftlyHomeDir.appendingPathComponent("toolchains", isDirectory: true)
}

public var toolchainFileExtension: String {
"tar.gz"
}
Expand Down Expand Up @@ -205,23 +217,5 @@ public struct Linux: Platform {
}
}

private func runProgram(_ args: String..., quiet: Bool = false) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = args

if quiet {
process.standardOutput = nil
process.standardError = nil
}

try process.run()
process.waitUntilExit()

guard process.terminationStatus == 0 else {
throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)")
}
}

public static let currentPlatform: any Platform = Linux()
}
178 changes: 178 additions & 0 deletions Sources/MacOSPlatform/MacOS.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import Foundation
import SwiftlyCore

struct SwiftPkgInfo: Codable {
var CFBundleIdentifier: String
}

/// `Platform` implementation for macOS systems.
public struct MacOS: Platform {
public init() {}

public var appDataDirectory: URL {
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support", isDirectory: true)
}

public var swiftlyBinDir: URL {
SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) }
?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) }
?? FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/swiftly/bin", isDirectory: true)
}

public var swiftlyToolchainsDir: URL {
SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) }
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true)
}

public var toolchainFileExtension: String {
"pkg"
}

public func isSystemDependencyPresent(_: SystemDependency) -> Bool {
// All system dependencies on macOS should be present
true
}

public func verifySystemPrerequisitesForInstall(requireSignatureValidation: Bool) throws {
// All system prerequisites should be there for macOS
}

public func install(from tmpFile: URL, version: ToolchainVersion) throws {
guard tmpFile.fileExists() else {
throw Error(message: "\(tmpFile) doesn't exist")
}

if !self.swiftlyToolchainsDir.fileExists() {
try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false)
}

SwiftlyCore.print("Installing package in user home directory...")
try runProgram("installer", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory")
}

public func uninstall(_ toolchain: ToolchainVersion) throws {
SwiftlyCore.print("Uninstalling package in user home directory...")

let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(toolchain.identifier).xctoolchain", isDirectory: true)

let decoder = PropertyListDecoder()
let infoPlist = toolchainDir.appendingPathComponent("Info.plist")
guard let data = try? Data(contentsOf: infoPlist) else {
throw Error(message: "could not open \(infoPlist)")
}

guard let pkgInfo = try? decoder.decode(SwiftPkgInfo.self, from: data) else {
throw Error(message: "could not decode plist at \(infoPlist)")
}

try FileManager.default.removeItem(at: toolchainDir)

let homedir = ProcessInfo.processInfo.environment["HOME"]!
try runProgram("pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier)
}

public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool {
let toolchainBinURL = self.swiftlyToolchainsDir
.appendingPathComponent(toolchain.identifier + ".xctoolchain", isDirectory: true)
.appendingPathComponent("usr", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)

// Delete existing symlinks from previously in-use toolchain.
if let currentToolchain {
try self.unUse(currentToolchain: currentToolchain)
}

// Ensure swiftly doesn't overwrite any existing executables without getting confirmation first.
let swiftlyBinDirContents = try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path)
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path)
let willBeOverwritten = Set(toolchainBinDirContents).intersection(swiftlyBinDirContents)
if !willBeOverwritten.isEmpty {
SwiftlyCore.print("The following existing executables will be overwritten:")

for executable in willBeOverwritten {
SwiftlyCore.print(" \(self.swiftlyBinDir.appendingPathComponent(executable).path)")
}

let proceed = SwiftlyCore.readLine(prompt: "Proceed? (y/n)") ?? "n"

guard proceed == "y" else {
SwiftlyCore.print("Aborting use")
return false
}
}

for executable in toolchainBinDirContents {
let linkURL = self.swiftlyBinDir.appendingPathComponent(executable)
let executableURL = toolchainBinURL.appendingPathComponent(executable)

// Deletion confirmed with user above.
try linkURL.deleteIfExists()

try FileManager.default.createSymbolicLink(
atPath: linkURL.path,
withDestinationPath: executableURL.path
)
}

SwiftlyCore.print("""
NOTE: On macOS it is possible that the shell will pick up the system Swift on the path
instead of the one that swiftly has installed for you. You can run the 'hash -r'
command to update the shell with the latest PATHs.
hash -r
"""
)

return true
}

public func unUse(currentToolchain: ToolchainVersion) throws {
let currentToolchainBinURL = self.swiftlyToolchainsDir
.appendingPathComponent(currentToolchain.identifier + ".xctoolchain", isDirectory: true)
.appendingPathComponent("usr", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)

for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: currentToolchainBinURL.path) {
guard existingExecutable != "swiftly" else {
continue
}

let url = self.swiftlyBinDir.appendingPathComponent(existingExecutable)
let vals = try url.resourceValues(forKeys: [URLResourceKey.isSymbolicLinkKey])

guard let islink = vals.isSymbolicLink, islink else {
throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)")
}
let symlinkDest = url.resolvingSymlinksInPath()
guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else {
throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)")
}

try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists()
}
}

public func listAvailableSnapshots(version _: String?) async -> [Snapshot] {
[]
}

public func getExecutableName(forArch: String) -> String {
"swiftly-\(forArch)-macos-osx"
}

public func currentToolchain() throws -> ToolchainVersion? { nil }

public func getTempFilePath() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg")
}

public func verifySignature(httpClient: SwiftlyHTTPClient, archiveDownloadURL: URL, archive: URL) async throws {
// No signature verification is required on macOS since the pkg files have their own signing
// mechanism and the swift.org downloadables are trusted by stock macOS installations.
}

public static let currentPlatform: any Platform = MacOS()
}
5 changes: 5 additions & 0 deletions Sources/Swiftly/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ public struct Config: Codable, Equatable {
error: \"\(error)\".
To use swiftly, modify the configuration file to fix the issue or perform a clean installation.
"""
#if !os(macOS)
throw Error(message: msg)
#else
let pd = PlatformDefinition.init(name: "xcode", nameFull: "osx", namePretty: "macOS", architecture: nil)
return Config.init(inUse: nil, installedToolchains: [], platform: pd)
#endif
}
}

Expand Down
15 changes: 5 additions & 10 deletions Sources/Swiftly/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,25 +121,19 @@ struct Install: SwiftlyCommand {
}

url += "swift-\(versionString)-release/"
url += "\(platformString)/"
url += "swift-\(versionString)-RELEASE/"
url += "swift-\(versionString)-RELEASE-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
case let .snapshot(release):
let snapshotString: String
switch release.branch {
case let .release(major, minor):
url += "swift-\(major).\(minor)-branch/"
snapshotString = "swift-\(major).\(minor)-DEVELOPMENT-SNAPSHOT"
case .main:
url += "development/"
snapshotString = "swift-DEVELOPMENT-SNAPSHOT"
}

url += "\(platformString)/"
url += "\(snapshotString)-\(release.date)-a/"
url += "\(snapshotString)-\(release.date)-a-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"
}

url += "\(platformString)/"
url += "\(version.identifier)/"
url += "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)"

guard let url = URL(string: url) else {
throw Error(message: "Invalid toolchain URL: \(url)")
}
Expand Down Expand Up @@ -194,6 +188,7 @@ struct Install: SwiftlyCommand {
try Swiftly.currentPlatform.install(from: tmpFile, version: version)

config.installedToolchains.insert(version)

try config.save()

// If this is the first installed toolchain, mark it as in-use regardless of whether the
Expand Down
5 changes: 4 additions & 1 deletion Sources/Swiftly/Swiftly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import ArgumentParser
import Foundation
#if os(Linux)
import LinuxPlatform
#elseif os(macOS)
import MacOSPlatform
#endif
import SwiftlyCore

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

#if os(Linux)
internal static let currentPlatform = Linux.currentPlatform
#elseif os(macOS)
internal static let currentPlatform = MacOS.currentPlatform
#endif
}

Expand Down
Loading

0 comments on commit dfb441d

Please sign in to comment.