Skip to content

Commit ca1195c

Browse files
authored
Follow XDG base directory specification for swiftly's home and bin directories on Linux (#26)
1 parent d8c39b7 commit ca1195c

File tree

13 files changed

+318
-185
lines changed

13 files changed

+318
-185
lines changed

.github/workflows/tests.yml

+7-7
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ on:
88

99
jobs:
1010
build:
11-
runs-on: ubuntu-18.04
11+
runs-on: ubuntu-20.04
1212
steps:
1313
- name: Download Swift
1414
run: |
15-
wget --no-verbose "https://download.swift.org/swift-5.7-release/ubuntu1804/swift-5.7-RELEASE/swift-5.7-RELEASE-ubuntu18.04.tar.gz"
16-
tar -zxf swift-5.7-RELEASE-ubuntu18.04.tar.gz
15+
wget --no-verbose "https://download.swift.org/swift-5.7-release/ubuntu2004/swift-5.7-RELEASE/swift-5.7-RELEASE-ubuntu20.04.tar.gz"
16+
tar -zxf swift-5.7-RELEASE-ubuntu20.04.tar.gz
1717
mkdir $HOME/.swift
18-
mv swift-5.7-RELEASE-ubuntu18.04/usr $HOME/.swift
18+
mv swift-5.7-RELEASE-ubuntu20.04/usr $HOME/.swift
1919
2020
- name: Update PATH
2121
run: echo "$HOME/.swift/usr/bin" >> $GITHUB_PATH
@@ -31,7 +31,7 @@ jobs:
3131
- name: Run tests
3232
run: swift test
3333
env:
34-
SWIFTLY_PLATFORM_NAME: ubuntu1804
35-
SWIFTLY_PLATFORM_NAME_FULL: ubuntu18.04
36-
SWIFTLY_PLATFORM_NAME_PRETTY: Ubuntu 18.04
34+
SWIFTLY_PLATFORM_NAME: ubuntu2004
35+
SWIFTLY_PLATFORM_NAME_FULL: ubuntu20.04
36+
SWIFTLY_PLATFORM_NAME_PRETTY: Ubuntu 20.04
3737
SWIFTLY_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

DESIGN.md

+9-19
Original file line numberDiff line numberDiff line change
@@ -31,33 +31,23 @@ We'll need a bootstrapping script which detects information about the OS and dow
3131
- CentOS 8
3232
- Amazon Linux 2
3333

34-
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.
35-
36-
Finally, it will create a `$HOME/.swiftly/env` file, which contains only the following line:
37-
38-
```
39-
export PATH="$HOME/.swiftly/bin:$PATH"
40-
```
41-
42-
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.
34+
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.
4335

4436
### Installation of a Swift toolchain
4537

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

4840
```
49-
~/.swiftly
50-
|
51-
-- bin/
41+
~/.local/share/swiftly
5242
|
5343
-- toolchains/
5444
|
5545
-- config.json
56-
|
57-
– env
5846
```
5947

60-
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.
48+
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.
49+
50+
The `~/.local/bin` directory would include symlinks pointing to the `bin` directory of the "active" toolchain, if any.
6151

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

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

366356
#### Verifying system dependencies
367357

368-
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.
358+
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.
369359

370360
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:
371361

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

411401
#### Uninstalling a toolchain
412402

413-
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).
403+
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).
414404

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

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

463453
```
464-
$ tar -xf <URL> --directory ~/.swiftly/toolchains
454+
$ tar -xf <URL> --directory ~/.local/share/swiftly/toolchains
465455
```
466456

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

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

471461
```
472-
$ ln -s ~/.swiftly/toolchains/<toolchain>/usr/bin/swift ~/.swiftly/bin/swift
462+
$ ln -s ~/.local/share/swiftly/toolchains/<toolchain>/usr/bin/swift ~/.local/bin/swift
473463
```
474464

475465
It also updates `config.json` to include this version as the currently selected one.

Sources/LinuxPlatform/Linux.swift

+69-38
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,16 @@ import SwiftlyCore
55
/// This implementation can be reused for any supported Linux platform.
66
/// TODO: replace dummy implementations
77
public struct Linux: Platform {
8-
private let platform: Config.PlatformDefinition
9-
10-
public init(platform: Config.PlatformDefinition) {
11-
self.platform = platform
12-
}
13-
14-
public var name: String {
15-
self.platform.name
16-
}
17-
18-
public var nameFull: String {
19-
self.platform.nameFull
20-
}
21-
22-
public var namePretty: String {
23-
self.platform.namePretty
8+
public init() {}
9+
10+
public var appDataDirectory: URL {
11+
if let dir = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] {
12+
return URL(fileURLWithPath: dir)
13+
} else {
14+
return FileManager.default.homeDirectoryForCurrentUser
15+
.appendingPathComponent(".local", isDirectory: true)
16+
.appendingPathComponent("share", isDirectory: true)
17+
}
2418
}
2519

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

39-
let toolchainsDir = SwiftlyCore.homeDir.appendingPathComponent("toolchains")
40-
if !toolchainsDir.fileExists() {
41-
try FileManager.default.createDirectory(at: toolchainsDir, withIntermediateDirectories: false)
33+
if !self.swiftlyToolchainsDir.fileExists() {
34+
try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false)
4235
}
4336

4437
SwiftlyCore.print("Extracting toolchain...")
45-
let toolchainDir = toolchainsDir.appendingPathComponent(version.name)
38+
let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent(version.name)
4639

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

55-
// prepend ~/.swiftly/toolchains/<toolchain> to each file name
48+
// prepend /path/to/swiftlyHomeDir/toolchains/<toolchain> to each file name
5649
return toolchainDir.appendingPathComponent(String(relativePath))
5750
}
5851
}
5952

6053
public func uninstall(_ toolchain: ToolchainVersion) throws {
61-
let toolchainDir = SwiftlyCore.toolchainsDir.appendingPathComponent(toolchain.name)
54+
let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent(toolchain.name)
6255
try FileManager.default.removeItem(at: toolchainDir)
6356
}
6457

65-
public func use(_ toolchain: ToolchainVersion) throws {
66-
let toolchainBinURL = SwiftlyCore.toolchainsDir
58+
public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool {
59+
let toolchainBinURL = self.swiftlyToolchainsDir
6760
.appendingPathComponent(toolchain.name, isDirectory: true)
6861
.appendingPathComponent("usr", isDirectory: true)
6962
.appendingPathComponent("bin", isDirectory: true)
7063

7164
// Delete existing symlinks from previously in-use toolchain.
72-
for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: SwiftlyCore.binDir.path) {
73-
guard existingExecutable != "swiftly" else {
74-
continue
65+
if let currentToolchain {
66+
try self.unUse(currentToolchain: currentToolchain)
67+
}
68+
69+
// Ensure swiftly doesn't overwrite any existing executables without getting confirmation first.
70+
let swiftlyBinDirContents = try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path)
71+
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path)
72+
let willBeOverwritten = Set(toolchainBinDirContents).intersection(swiftlyBinDirContents)
73+
if !willBeOverwritten.isEmpty {
74+
SwiftlyCore.print("The following existing executables will be overwritten:")
75+
76+
for executable in willBeOverwritten {
77+
SwiftlyCore.print(" \(self.swiftlyBinDir.appendingPathComponent(executable).path)")
78+
}
79+
80+
let proceed = SwiftlyCore.readLine(prompt: "Proceed? (y/n)") ?? "n"
81+
82+
guard proceed == "y" else {
83+
SwiftlyCore.print("Aborting use")
84+
return false
7585
}
76-
try SwiftlyCore.binDir.appendingPathComponent(existingExecutable).deleteIfExists()
7786
}
7887

79-
for executable in try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) {
80-
let linkURL = SwiftlyCore.binDir.appendingPathComponent(executable)
88+
for executable in toolchainBinDirContents {
89+
let linkURL = self.swiftlyBinDir.appendingPathComponent(executable)
8190
let executableURL = toolchainBinURL.appendingPathComponent(executable)
8291

92+
// Deletion confirmed with user above.
8393
try linkURL.deleteIfExists()
8494

8595
try FileManager.default.createSymbolicLink(
8696
atPath: linkURL.path,
8797
withDestinationPath: executableURL.path
8898
)
8999
}
100+
101+
return true
102+
}
103+
104+
public func unUse(currentToolchain: ToolchainVersion) throws {
105+
let currentToolchainBinURL = self.swiftlyToolchainsDir
106+
.appendingPathComponent(currentToolchain.name, isDirectory: true)
107+
.appendingPathComponent("usr", isDirectory: true)
108+
.appendingPathComponent("bin", isDirectory: true)
109+
110+
for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: currentToolchainBinURL.path) {
111+
guard existingExecutable != "swiftly" else {
112+
continue
113+
}
114+
115+
let url = self.swiftlyBinDir.appendingPathComponent(existingExecutable)
116+
let vals = try url.resourceValues(forKeys: [.isSymbolicLinkKey])
117+
118+
guard let islink = vals.isSymbolicLink, islink else {
119+
throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)")
120+
}
121+
let symlinkDest = url.resolvingSymlinksInPath()
122+
guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else {
123+
throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)")
124+
}
125+
126+
try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists()
127+
}
90128
}
91129

92130
public func listAvailableSnapshots(version _: String?) async -> [Snapshot] {
@@ -101,12 +139,5 @@ public struct Linux: Platform {
101139
FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())")
102140
}
103141

104-
public static let currentPlatform: any Platform = {
105-
do {
106-
let config = try Config.load()
107-
return Linux(platform: config.platform)
108-
} catch {
109-
fatalError("error loading config: \(error)")
110-
}
111-
}()
142+
public static let currentPlatform: any Platform = Linux()
112143
}

Sources/SwiftlyCore/Config.swift renamed to Sources/Swiftly/Config.swift

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import Foundation
2+
import SwiftlyCore
23

34
/// Struct modelling the config.json file used to track installed toolchains,
45
/// the current in-use tooolchain, and information about the platform.
56
///
67
/// TODO: implement cache
78
public struct Config: Codable, Equatable {
89
public struct PlatformDefinition: Codable, Equatable {
10+
/// The name of the platform as it is used in the Swift download URLs.
11+
/// For instance, for Ubuntu 16.04 this would return “ubuntu1604”.
12+
/// For macOS / Xcode, this would return “xcode”.
913
public let name: String
14+
15+
/// The full name of the platform as it is used in the Swift download URLs.
16+
/// For instance, for Ubuntu 16.04 this would return “ubuntu16.04”.
1017
public let nameFull: String
18+
19+
/// A human-readable / pretty-printed version of the platform’s name, used for terminal
20+
/// output and logging.
21+
/// For example, “Ubuntu 18.04” would be returned on Ubuntu 18.04.
1122
public let namePretty: String
1223
}
1324

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

5062
public func listInstalledToolchains(selector: ToolchainSelector?) -> [ToolchainVersion] {

Sources/Swiftly/Install.swift

+8-7
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ struct Install: SwiftlyCommand {
6161
}
6262

6363
internal static func execute(version: ToolchainVersion) async throws {
64-
guard try !Config.load().installedToolchains.contains(version) else {
64+
var config = try Config.load()
65+
66+
guard !config.installedToolchains.contains(version) else {
6567
SwiftlyCore.print("\(version) is already installed, exiting.")
6668
return
6769
}
@@ -83,9 +85,9 @@ struct Install: SwiftlyCommand {
8385
versionString += ".\(stableVersion.patch)"
8486
}
8587
url += "swift-\(versionString)-release/"
86-
url += "\(Swiftly.currentPlatform.name)/"
88+
url += "\(config.platform.name)/"
8789
url += "swift-\(versionString)-RELEASE/"
88-
url += "swift-\(versionString)-RELEASE-\(Swiftly.currentPlatform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
90+
url += "swift-\(versionString)-RELEASE-\(config.platform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
8991
case let .snapshot(release):
9092
let snapshotString: String
9193
switch release.branch {
@@ -97,9 +99,9 @@ struct Install: SwiftlyCommand {
9799
snapshotString = "swift-DEVELOPMENT-SNAPSHOT"
98100
}
99101

100-
url += "\(Swiftly.currentPlatform.name)/"
102+
url += "\(config.platform.name)/"
101103
url += "\(snapshotString)-\(release.date)-a/"
102-
url += "\(snapshotString)-\(release.date)-a-\(Swiftly.currentPlatform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
104+
url += "\(snapshotString)-\(release.date)-a-\(config.platform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)"
103105
}
104106

105107
let animation = PercentProgressAnimation(
@@ -144,13 +146,12 @@ struct Install: SwiftlyCommand {
144146

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

147-
var config = try Config.load()
148149
config.installedToolchains.insert(version)
149150
try config.save()
150151

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

156157
SwiftlyCore.print("\(version) installed successfully!")

0 commit comments

Comments
 (0)