Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

macOS support for swiftly #121

Merged
merged 17 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`.
adam-fowler marked this conversation as resolved.
Show resolved Hide resolved

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're bumping to 5.10 we should probably switch on strict concurrency checking

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'm definitely interested in enabling better checks. How does one turn on the strict concurrency checking?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your target definition in Package.swift add

swiftSettings: [.enableExperimentalFeature("StrictConcurrency=complete")]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I tried enabling this and there were a non-trivial number of warnings (errors in Swift 6). I've raised an issue #124 to address the concurrency problems and enable the check.


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()
}
201 changes: 201 additions & 0 deletions Sources/MacOSPlatform/MacOS.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import Foundation
import SwiftlyCore

public struct SwiftPkgInfo: Codable {
public var CFBundleIdentifier: String

public init(CFBundleIdentifier: String) {
self.CFBundleIdentifier = CFBundleIdentifier
}
}

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

public var appDataDirectory: URL {
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("Toolchains", isDirectory: true) }
// The toolchains are always installed here by the installer. We bypass the installer in the case of test mocks
?? 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)
adam-fowler marked this conversation as resolved.
Show resolved Hide resolved
}

if SwiftlyCore.mockedHomeDir == nil {
SwiftlyCore.print("Installing package in user home directory...")
try runProgram("installer", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory")
} else {
// In the case of a mock for testing purposes we won't use the installer, perferring a manual process because
// the installer will not install to an arbitrary path, only a volume or user home directory.
let tmpDir = self.getTempFilePath()
let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(version.identifier).xctoolchain", isDirectory: true)
if !toolchainDir.fileExists() {
try FileManager.default.createDirectory(at: toolchainDir, withIntermediateDirectories: false)
}
try runProgram("pkgutil", "--expand", tmpFile.path, tmpDir.path)
// There's a slight difference in the location of the special Payload file between official swift packages
// and the ones that are mocked here in the test framework.
var payload = tmpDir.appendingPathComponent("Payload")
if !payload.fileExists() {
payload = tmpDir.appendingPathComponent("\(version.identifier)-osx-package.pkg/Payload")
}
try runProgram("tar", "-C", toolchainDir.path, "-xf", payload.path)
}
}

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 {
patrickfreed marked this conversation as resolved.
Show resolved Hide resolved
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's worth us migration to NIOFileSystem, since we already have NIO as a dependency. Aside from the nicer API, it would be one less thing to use when depending on new Foundation which should make for a smaller binary

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can raise this as a separate issue. I believe that there are Foundation dependencies all over the code. Pulling all of that out would be a significant sweep.

I'm not familiar with NIOFileSystem. Something that I really like about FileManager API is that it appears to be very mockable so that we could refactor the tests to operate on a full mock of the filesystem. Is that capability available with NIOFS?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear I'm not suggesting we pull out all of Foundation, but minimising it to just FoundationEssentials etc could help reduce the size (especially if we don't need ICU).

Which specific parts would you use for mocking (not getting into a discussion about that word 🤣 )? NIOFileSystem has a similar API in that you have FileSystem.shared you perform the operations on, but the API is much more modern and built with Concurrency in mind (may also help solve some of the issues in #124 )

Copy link
Member

@FranzBusch FranzBusch Jun 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I agree that NIOFileSystem is more appropriate here since it uses async code to do the work. However, I would defer that from this PR since it is something we can do separately. WDYT @0xTim ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes let's defer this to another PR. I've added an issue #125

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah absolutely shouldn't be a blocker for this PR

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

"""
)
patrickfreed marked this conversation as resolved.
Show resolved Hide resolved

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()
}
Loading