Skip to content

Follow XDG base directory specification for swiftly's home and bin directories on Linux #26

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

Merged
merged 10 commits into from
Jan 4, 2023
14 changes: 7 additions & 7 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ on:

jobs:
build:
runs-on: ubuntu-18.04
runs-on: ubuntu-20.04
steps:
- name: Download Swift
run: |
wget --no-verbose "https://download.swift.org/swift-5.7-release/ubuntu1804/swift-5.7-RELEASE/swift-5.7-RELEASE-ubuntu18.04.tar.gz"
tar -zxf swift-5.7-RELEASE-ubuntu18.04.tar.gz
wget --no-verbose "https://download.swift.org/swift-5.7-release/ubuntu2004/swift-5.7-RELEASE/swift-5.7-RELEASE-ubuntu20.04.tar.gz"
tar -zxf swift-5.7-RELEASE-ubuntu20.04.tar.gz
mkdir $HOME/.swift
mv swift-5.7-RELEASE-ubuntu18.04/usr $HOME/.swift
mv swift-5.7-RELEASE-ubuntu20.04/usr $HOME/.swift

- name: Update PATH
run: echo "$HOME/.swift/usr/bin" >> $GITHUB_PATH
Expand All @@ -31,7 +31,7 @@ jobs:
- name: Run tests
run: swift test
env:
SWIFTLY_PLATFORM_NAME: ubuntu1804
SWIFTLY_PLATFORM_NAME_FULL: ubuntu18.04
SWIFTLY_PLATFORM_NAME_PRETTY: Ubuntu 18.04
SWIFTLY_PLATFORM_NAME: ubuntu2004
SWIFTLY_PLATFORM_NAME_FULL: ubuntu20.04
SWIFTLY_PLATFORM_NAME_PRETTY: Ubuntu 20.04
SWIFTLY_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 changes: 9 additions & 19 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,33 +31,23 @@ We'll need a bootstrapping script which detects information about the OS and dow
- CentOS 8
- Amazon Linux 2

Once it has detected which platform the user is running, the script will then create `$HOME/.swiftly` (or a different path, if the user provides one. For an initial MVP, I think we can always install there). It'll also create `$HOME/.swiftly/bin`, download the prebuilt swiftly executable appropriate for the platform and drop it in there.

Finally, it will create a `$HOME/.swiftly/env` file, which contains only the following line:

```
export PATH="$HOME/.swiftly/bin:$PATH"
```

and print a message instructing the user to run source `~/.swiftly/env` and to add it to their shell configuration. We may have to do some discovery to determine which shell the user is running for this. Printing instructions for bash and zsh should be sufficient.
Once it has detected which platform the user is running, the script will then create `$HOME/.local/share/swiftly` (or a different path, if the user provides one. For an initial MVP, I think we can always install there). It'll also create `$HOME/.local/bin` if needed, download the prebuilt swiftly executable appropriate for the platform, and drop it in there.

### Installation of a Swift toolchain

A simple setup for managing the toolchains could look like this:

```
~/.swiftly
|
-- bin/
~/.local/share/swiftly
|
-- toolchains/
|
-- config.json
|
– env
```

The toolchains (i.e. the contents of a given Swift download tarball) would be contained in the toolchains directory, each named according to the major/minor/patch version. The bin folder would just contain symlinks to whatever toolchain was selected by `swiftly use`. `config.json` would contain any required metadata (e.g. the latest Swift version, which toolchain is selected, etc.). If pulling in Foundation to use `JSONEncoder`/`JSONDecoder` (or some other JSON tool) would be a problem, we could also use something simpler.
The toolchains (i.e. the contents of a given Swift download tarball) would be contained in the toolchains directory, each named according to the major/minor/patch version. `config.json` would contain any required metadata (e.g. the latest Swift version, which toolchain is selected, etc.). If pulling in Foundation to use `JSONEncoder`/`JSONDecoder` (or some other JSON tool) would be a problem, we could also use something simpler.

The `~/.local/bin` directory would include symlinks pointing to the `bin` directory of the "active" toolchain, if any.

This is all very similar to how rustup does things, but I figure there's no need to reinvent the wheel here.

Expand Down Expand Up @@ -365,7 +355,7 @@ Finally, swiftly will then get the toolchain's list of system dependencies, if a

#### Verifying system dependencies

In order to run Swift on Linux, there are a number of system dependencies that need to be installed. We could consider having swiftly detect and install these dependencies for the user, but we decided that it was best if it doesn't modify the system outside of handling toolchains in `~/.swiftly`. Instead, swiftly will just attempt to detect if any required system libraries are missing and, if so, print helpful, platform-specific messages indicating how a user could install them. In the future, swiftly will use an API from swift.org to discover the list of required dependencies per Swift version / platform. Until then, a list will manually be maintained in this repository.
In order to run Swift on Linux, there are a number of system dependencies that need to be installed. We could consider having swiftly detect and install these dependencies for the user, but we decided that it was best if it doesn't modify the system outside of handling toolchains in `~/.local/share/swiftly`. Instead, swiftly will just attempt to detect if any required system libraries are missing and, if so, print helpful, platform-specific messages indicating how a user could install them. In the future, swiftly will use an API from swift.org to discover the list of required dependencies per Swift version / platform. Until then, a list will manually be maintained in this repository.

Determining whether the system has these installed or not is a bit of a tricky problem and varies from platform to platform. The mechanism for doing so on each will be as follows:

Expand Down Expand Up @@ -410,7 +400,7 @@ Given a version string `main-snapshot[-YYYY-MM-DD]` or `a.b-snapshot[-YYYY-MM-DD

#### Uninstalling a toolchain

Given a version string `a.b[.c]`, check that we have such a toolchain installed per config.json. If all of `a.b.c` is provided, this must match exactly. If only `a.b` is provided, all `a.b.c` will match and will be uninstalled. Always prompt the user before proceeding with the uninstallation, confirming all of the uninstallations are correct. If a matching version is installed, first delete the entry in `config.json` associated with that version. Then delete the folder in `~/.swiftly/toolchains` associated with it. If that toolchain was in use, use the installed toolchain with the latest Swift version, if any, per [Using a toolchain](#using-a-toolchain).
Given a version string `a.b[.c]`, check that we have such a toolchain installed per config.json. If all of `a.b.c` is provided, this must match exactly. If only `a.b` is provided, all `a.b.c` will match and will be uninstalled. Always prompt the user before proceeding with the uninstallation, confirming all of the uninstallations are correct. If a matching version is installed, first delete the entry in `config.json` associated with that version. Then delete the folder in `~/.local/share/swiftly/toolchains` associated with it. If that toolchain was in use, use the installed toolchain with the latest Swift version, if any, per [Using a toolchain](#using-a-toolchain).

Snapshots work similarly. If a date is provided in the snapshot version, attempt to uninstall only that snapshot. Otherwise, attempt to uninstall all matching snapshots after ensuring this is what the user intended.

Expand Down Expand Up @@ -461,15 +451,15 @@ https://download.swift.org/swift-5.5.1-release/ubuntu1604/swift-5.5.1-RELEASE/sw
`install` accepts a URL pointing to the downloaded `.tar.gz` file and executes the following to install it:

```
$ tar -xf <URL> --directory ~/.swiftly/toolchains
$ tar -xf <URL> --directory ~/.local/share/swiftly/toolchains
```

It also updates `config.json` to include this toolchain as the latest for the provided version. If installing a new patch release toolchain, the now-outdated one can be deleted (e.g. `5.5.0` can be deleted when `5.5.1` is installed).

Finally, the use implementation executes the following to update the link:

```
$ ln -s ~/.swiftly/toolchains/<toolchain>/usr/bin/swift ~/.swiftly/bin/swift
$ ln -s ~/.local/share/swiftly/toolchains/<toolchain>/usr/bin/swift ~/.local/bin/swift
```

It also updates `config.json` to include this version as the currently selected one.
Expand Down
107 changes: 69 additions & 38 deletions Sources/LinuxPlatform/Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,16 @@ import SwiftlyCore
/// This implementation can be reused for any supported Linux platform.
/// TODO: replace dummy implementations
public struct Linux: Platform {
private let platform: Config.PlatformDefinition

public init(platform: Config.PlatformDefinition) {
self.platform = platform
}

public var name: String {
self.platform.name
}

public var nameFull: String {
self.platform.nameFull
}

public var namePretty: String {
self.platform.namePretty
public init() {}

public var appDataDirectory: URL {
if let dir = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] {
return URL(fileURLWithPath: dir)
} else {
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local", isDirectory: true)
.appendingPathComponent("share", isDirectory: true)
}
}

public var toolchainFileExtension: String {
Expand All @@ -36,13 +30,12 @@ public struct Linux: Platform {
throw Error(message: "\(tmpFile) doesn't exist")
}

let toolchainsDir = SwiftlyCore.homeDir.appendingPathComponent("toolchains")
if !toolchainsDir.fileExists() {
try FileManager.default.createDirectory(at: toolchainsDir, withIntermediateDirectories: false)
if !self.swiftlyToolchainsDir.fileExists() {
try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false)
}

SwiftlyCore.print("Extracting toolchain...")
let toolchainDir = toolchainsDir.appendingPathComponent(version.name)
let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent(version.name)

if toolchainDir.fileExists() {
try FileManager.default.removeItem(at: toolchainDir)
Expand All @@ -52,41 +45,86 @@ public struct Linux: Platform {
// drop swift-a.b.c-RELEASE etc name from the extracted files.
let relativePath = name.drop { c in c != "/" }.dropFirst()

// prepend ~/.swiftly/toolchains/<toolchain> to each file name
// prepend /path/to/swiftlyHomeDir/toolchains/<toolchain> to each file name
return toolchainDir.appendingPathComponent(String(relativePath))
}
}

public func uninstall(_ toolchain: ToolchainVersion) throws {
let toolchainDir = SwiftlyCore.toolchainsDir.appendingPathComponent(toolchain.name)
let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent(toolchain.name)
try FileManager.default.removeItem(at: toolchainDir)
}

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

// Delete existing symlinks from previously in-use toolchain.
for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: SwiftlyCore.binDir.path) {
guard existingExecutable != "swiftly" else {
continue
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
}
try SwiftlyCore.binDir.appendingPathComponent(existingExecutable).deleteIfExists()
}

for executable in try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) {
let linkURL = SwiftlyCore.binDir.appendingPathComponent(executable)
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
)
}

return true
}

public func unUse(currentToolchain: ToolchainVersion) throws {
let currentToolchainBinURL = self.swiftlyToolchainsDir
.appendingPathComponent(currentToolchain.name, 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: [.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] {
Expand All @@ -101,12 +139,5 @@ public struct Linux: Platform {
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())")
}

public static let currentPlatform: any Platform = {
do {
let config = try Config.load()
return Linux(platform: config.platform)
} catch {
fatalError("error loading config: \(error)")
}
}()
public static let currentPlatform: any Platform = Linux()
}
18 changes: 15 additions & 3 deletions Sources/SwiftlyCore/Config.swift → Sources/Swiftly/Config.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import Foundation
import SwiftlyCore

/// Struct modelling the config.json file used to track installed toolchains,
/// the current in-use tooolchain, and information about the platform.
///
/// TODO: implement cache
public struct Config: Codable, Equatable {
public struct PlatformDefinition: Codable, Equatable {
/// The name of the platform as it is used in the Swift download URLs.
/// For instance, for Ubuntu 16.04 this would return “ubuntu1604”.
/// For macOS / Xcode, this would return “xcode”.
public let name: String

/// The full name of the platform as it is used in the Swift download URLs.
/// For instance, for Ubuntu 16.04 this would return “ubuntu16.04”.
public let nameFull: String

/// A human-readable / pretty-printed version of the platform’s name, used for terminal
/// output and logging.
/// For example, “Ubuntu 18.04” would be returned on Ubuntu 18.04.
public let namePretty: String
}

Expand All @@ -30,11 +41,12 @@ public struct Config: Codable, Equatable {
/// Read the config file from disk.
public static func load() throws -> Config {
do {
let data = try Data(contentsOf: SwiftlyCore.configFile)
let data = try Data(contentsOf: Swiftly.currentPlatform.swiftlyConfigFile)
return try JSONDecoder().decode(Config.self, from: data)
} catch {
let msg = """
Could not load swiftly's configuration file at \(SwiftlyCore.configFile.path) due to error: \"\(error)\".
Could not load swiftly's configuration file at \(Swiftly.currentPlatform.swiftlyConfigFile.path) due to
error: \"\(error)\".
To use swiftly, modify the configuration file to fix the issue or perform a clean installation.
"""
throw Error(message: msg)
Expand All @@ -44,7 +56,7 @@ public struct Config: Codable, Equatable {
/// Write the contents of this `Config` struct to disk.
public func save() throws {
let outData = try Self.makeEncoder().encode(self)
try outData.write(to: SwiftlyCore.configFile, options: .atomic)
try outData.write(to: Swiftly.currentPlatform.swiftlyConfigFile, options: .atomic)
}

public func listInstalledToolchains(selector: ToolchainSelector?) -> [ToolchainVersion] {
Expand Down
15 changes: 8 additions & 7 deletions Sources/Swiftly/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ struct Install: SwiftlyCommand {
}

internal static func execute(version: ToolchainVersion) async throws {
guard try !Config.load().installedToolchains.contains(version) else {
var config = try Config.load()

guard !config.installedToolchains.contains(version) else {
SwiftlyCore.print("\(version) is already installed, exiting.")
return
}
Expand All @@ -83,9 +85,9 @@ struct Install: SwiftlyCommand {
versionString += ".\(stableVersion.patch)"
}
url += "swift-\(versionString)-release/"
url += "\(Swiftly.currentPlatform.name)/"
url += "\(config.platform.name)/"
url += "swift-\(versionString)-RELEASE/"
url += "swift-\(versionString)-RELEASE-\(Swiftly.currentPlatform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
url += "swift-\(versionString)-RELEASE-\(config.platform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
case let .snapshot(release):
let snapshotString: String
switch release.branch {
Expand All @@ -97,9 +99,9 @@ struct Install: SwiftlyCommand {
snapshotString = "swift-DEVELOPMENT-SNAPSHOT"
}

url += "\(Swiftly.currentPlatform.name)/"
url += "\(config.platform.name)/"
url += "\(snapshotString)-\(release.date)-a/"
url += "\(snapshotString)-\(release.date)-a-\(Swiftly.currentPlatform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
url += "\(snapshotString)-\(release.date)-a-\(config.platform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
}

let animation = PercentProgressAnimation(
Expand Down Expand Up @@ -144,13 +146,12 @@ struct Install: SwiftlyCommand {

try Swiftly.currentPlatform.install(from: tmpFile, version: version)

var config = try Config.load()
config.installedToolchains.insert(version)
try config.save()

// If this is the first installed toolchain, mark it as in-use.
if config.inUse == nil {
try await Use.execute(version)
try await Use.execute(version, config: &config)
}

SwiftlyCore.print("\(version) installed successfully!")
Expand Down
Loading