Skip to content

Commit 5583a8a

Browse files
authored
Make the toolchains directory location configurable (#324)
Using a new environment variable SWIFTLY_TOOLCHAINS_DIR that follows a similar pattern as SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR make it possible for the user to configure the toolchain location on init in the same way. On macOS, this level of configuration requires a different approach to extracting the toolchain whenever the installation location is anything other than the default. Use the pkgutil utility of macOS to extract the toolchain in the location specified by the user. Installing the toolchains outside of the installer in a custom location means that Xcode may not be able to pick them up easily. Make a note of that in the init screen so that users are aware. When using the init `--no-modify-profile` flag the init was skiping steps like installing the latest toolchain, and emitting the notes about updating the current shell environment. Separate this logic so that the user can have these steps performed with the flag. * Add an E2E test for testing swiftly installation in a custom location
1 parent 7635eea commit 5583a8a

File tree

6 files changed

+131
-62
lines changed

6 files changed

+131
-62
lines changed

.github/workflows/pull_request.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@ jobs:
9494
- name: Extract and Run Workflow Tests
9595
run: cp swiftly-*.tar.gz /root/swiftly.tar.gz && cp test-swiftly-*.tar.gz /root && cd /root && tar zxf test-swiftly-*.tar.gz && ./test-swiftly -y ./swiftly.tar.gz
9696

97+
release-custom-install-test:
98+
name: Test Release - Custom Install Location
99+
needs: releasebuildcheck
100+
runs-on: ubuntu-latest
101+
container:
102+
image: "ubuntu:24.04"
103+
steps:
104+
- name: Prepare System
105+
run: apt-get update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get -y install ca-certificates gpg tzdata
106+
- name: Download Release
107+
uses: actions/download-artifact@v4
108+
with:
109+
name: swiftly-release-x86_64
110+
- name: Download Tests
111+
uses: actions/download-artifact@v4
112+
with:
113+
name: swiftly-tests-x86_64
114+
- name: Extract and Run Workflow Tests
115+
run: cp swiftly-*.tar.gz /root/swiftly.tar.gz && cp test-swiftly-*.tar.gz /root && cd /root && tar zxf test-swiftly-*.tar.gz && ./test-swiftly -y --custom-location ./swiftly.tar.gz
116+
97117
formatcheck:
98118
name: Format Check
99119
runs-on: ubuntu-latest

Sources/LinuxPlatform/Linux.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ public struct Linux: Platform {
3535
}
3636

3737
public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL {
38-
self.swiftlyHomeDir(ctx).appendingPathComponent("toolchains", isDirectory: true)
38+
ctx.mockedHomeDir.map { $0.appendingPathComponent("toolchains", isDirectory: true) }
39+
?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) }
40+
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share/swiftly/toolchains")
3941
}
4042

4143
public var toolchainFileExtension: String {

Sources/MacOSPlatform/MacOS.swift

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ public struct MacOS: Platform {
1818
.appendingPathComponent(".swiftly", isDirectory: true)
1919
}
2020

21+
public var defaultToolchainsDirectory: URL {
22+
FileManager.default.homeDirectoryForCurrentUser
23+
.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true)
24+
}
25+
2126
public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL {
2227
ctx.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) }
2328
?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) }
@@ -27,10 +32,9 @@ public struct MacOS: Platform {
2732

2833
public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL {
2934
ctx.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(
32-
"Library/Developer/Toolchains", isDirectory: true
33-
)
35+
?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) }
36+
// This is where the installer will put the toolchains, and where Xcode can find them
37+
?? self.defaultToolchainsDirectory
3438
}
3539

3640
public var toolchainFileExtension: String {
@@ -56,31 +60,47 @@ public struct MacOS: Platform {
5660
throw SwiftlyError(message: "\(tmpFile) doesn't exist")
5761
}
5862

59-
if !self.swiftlyToolchainsDir(ctx).fileExists() {
63+
let toolchainsDir = self.swiftlyToolchainsDir(ctx)
64+
65+
if !toolchainsDir.fileExists() {
6066
try FileManager.default.createDirectory(
61-
at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false
67+
at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: true
6268
)
6369
}
6470

65-
if ctx.mockedHomeDir == nil {
71+
if toolchainsDir == self.defaultToolchainsDirectory {
72+
// If the toolchains go into the default user location then we use the installer to install them
6673
await ctx.print("Installing package in user home directory...")
6774
try runProgram(
6875
"installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory",
6976
quiet: !verbose
7077
)
7178
} else {
72-
// In the case of a mock for testing purposes we won't use the installer, perferring a manual process because
73-
// the installer will not install to an arbitrary path, only a volume or user home directory.
79+
// Otherwise, we extract the pkg into the requested toolchains directory.
7480
await ctx.print("Expanding pkg...")
7581
let tmpDir = self.getTempFilePath()
76-
let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent(
82+
let toolchainDir = toolchainsDir.appendingPathComponent(
7783
"\(version.identifier).xctoolchain", isDirectory: true
7884
)
85+
7986
if !toolchainDir.fileExists() {
8087
try FileManager.default.createDirectory(
8188
at: toolchainDir, withIntermediateDirectories: false
8289
)
8390
}
91+
92+
await ctx.print("Checking package signature...")
93+
do {
94+
try runProgram("pkgutil", "--check-signature", tmpFile.path, quiet: !verbose)
95+
} catch {
96+
// If this is not a test that uses mocked toolchains then we must throw this error and abort installation
97+
guard ctx.mockedHomeDir != nil else {
98+
throw error
99+
}
100+
101+
// We permit the signature verification to fail during testing
102+
await ctx.print("Signature verification failed, which is allowable during testing with mocked toolchains")
103+
}
84104
try runProgram("pkgutil", "--verbose", "--expand", tmpFile.path, tmpDir.path, quiet: !verbose)
85105
// There's a slight difference in the location of the special Payload file between official swift packages
86106
// and the ones that are mocked here in the test framework.

Sources/Swiftly/Init.swift

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ struct Init: SwiftlyCommand {
7070

7171
// Give the user the prompt and the choice to abort at this point.
7272
if !assumeYes {
73+
let toolchainsDir = Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)
74+
7375
var msg = """
7476
Welcome to swiftly, the Swift toolchain manager for Linux and macOS!
7577
@@ -81,12 +83,20 @@ struct Init: SwiftlyCommand {
8183
8284
\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path) - Directory for configuration files
8385
\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path) - Links to the binaries of the active toolchain
84-
\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path) - Directory hosting installed toolchains
86+
\(toolchainsDir.path) - Directory hosting installed toolchains
8587
8688
These locations can be changed by setting the environment variables
87-
SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR before running 'swiftly init' again.
89+
SWIFTLY_HOME_DIR, SWIFTLY_BIN_DIR, and SWIFTLY_TOOLCHAINS_DIR before running 'swiftly init' again.
8890
8991
"""
92+
#if os(macOS)
93+
if toolchainsDir != FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) {
94+
msg += """
95+
96+
NOTE: The toolchains are not being installed in a standard macOS location, so Xcode may not be able to find them.
97+
"""
98+
}
99+
#endif
90100
if !skipInstall {
91101
msg += """
92102
@@ -186,6 +196,7 @@ struct Init: SwiftlyCommand {
186196
env = """
187197
set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)"
188198
set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)"
199+
set -x SWIFTLY_TOOLCHAINS_DIR "\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path)"
189200
if not contains "$SWIFTLY_BIN_DIR" $PATH
190201
set -x PATH "$SWIFTLY_BIN_DIR" $PATH
191202
end
@@ -195,6 +206,7 @@ struct Init: SwiftlyCommand {
195206
env = """
196207
export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)"
197208
export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)"
209+
export SWIFTLY_TOOLCHAINS_DIR="\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path)"
198210
if [[ ":$PATH:" != *":$SWIFTLY_BIN_DIR:"* ]]; then
199211
export PATH="$SWIFTLY_BIN_DIR:$PATH"
200212
fi
@@ -250,50 +262,50 @@ struct Init: SwiftlyCommand {
250262
addEnvToProfile = true
251263
}
252264

253-
var postInstall: String?
254-
var pathChanged = false
255-
256-
if !skipInstall {
257-
let latestVersion = try await Install.resolve(ctx, config: config, selector: ToolchainSelector.latest)
258-
(postInstall, pathChanged) = try await Install.execute(ctx, version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes)
259-
}
260-
261265
if addEnvToProfile {
262266
try Data(sourceLine.utf8).append(to: profileHome)
267+
}
268+
}
263269

264-
if !quietShellFollowup {
265-
await ctx.print("""
266-
To begin using installed swiftly from your current shell, first run the following command:
267-
\(sourceLine)
270+
var postInstall: String?
271+
var pathChanged = false
268272

269-
""")
270-
}
271-
}
273+
if !skipInstall {
274+
let latestVersion = try await Install.resolve(ctx, config: config, selector: ToolchainSelector.latest)
275+
(postInstall, pathChanged) = try await Install.execute(ctx, version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes)
276+
}
277+
278+
if !quietShellFollowup {
279+
await ctx.print("""
280+
To begin using installed swiftly from your current shell, first run the following command:
281+
\(sourceLine)
272282
273-
// Fish doesn't have path caching, so this might only be needed for bash/zsh
274-
if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") {
275-
await ctx.print("""
276-
Your shell caches items on your path for better performance. Swiftly has added
277-
items to your path that may not get picked up right away. You can update your
278-
shell's environment by running
283+
""")
284+
}
279285

280-
hash -r
286+
// Fish doesn't have path caching, so this might only be needed for bash/zsh
287+
if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") {
288+
await ctx.print("""
289+
Your shell caches items on your path for better performance. Swiftly has added
290+
items to your path that may not get picked up right away. You can update your
291+
shell's environment by running
281292
282-
or restarting your shell.
293+
hash -r
283294
284-
""")
285-
}
295+
or restarting your shell.
286296
287-
if let postInstall {
288-
await ctx.print("""
289-
There are some dependencies that should be installed before using this toolchain.
290-
You can run the following script as the system administrator (e.g. root) to prepare
291-
your system:
297+
""")
298+
}
292299

293-
\(postInstall)
300+
if let postInstall {
301+
await ctx.print("""
302+
There are some dependencies that should be installed before using this toolchain.
303+
You can run the following script as the system administrator (e.g. root) to prepare
304+
your system:
294305
295-
""")
296-
}
306+
\(postInstall)
307+
308+
""")
297309
}
298310
}
299311
}

Sources/SwiftlyCore/Platform.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ extension Platform {
171171
guard tcPath.fileExists() else {
172172
throw SwiftlyError(
173173
message:
174-
"Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again."
174+
"Toolchain \(toolchain) could not be located in \(tcPath). You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again."
175175
)
176176
}
177177

Sources/TestSwiftly/TestSwiftly.swift

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ struct TestSwiftly: AsyncParsableCommand {
2121
@Flag(name: [.customShort("y"), .long], help: "Disable confirmation prompts by assuming 'yes'")
2222
var assumeYes: Bool = false
2323

24+
@Flag(help: "Install swiftly to a custom location, not added to the user profile.")
25+
var customLocation: Bool = false
26+
2427
@Argument var swiftlyArchive: String? = nil
2528

2629
mutating func run() async throws {
@@ -41,30 +44,40 @@ struct TestSwiftly: AsyncParsableCommand {
4144
try currentPlatform.runProgram("installer", "-pkg", swiftlyArchive, "-target", "CurrentUserHomeDirectory", quiet: false)
4245
#endif
4346

44-
print("Running 'swiftly init --assume-yes --verbose' to install swiftly and the latest toolchain")
45-
4647
#if os(Linux)
4748
let extractedSwiftly = "./swiftly"
4849
#elseif os(macOS)
4950
let extractedSwiftly = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".swiftly/bin/swiftly").path
5051
#endif
5152

52-
try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--skip-install", quiet: false)
53-
53+
var env = ProcessInfo.processInfo.environment
5454
let shell = try await currentPlatform.getShell()
55+
var customLoc: URL?
5556

56-
var env = ProcessInfo.processInfo.environment
57+
if self.customLocation {
58+
customLoc = currentPlatform.getTempFilePath()
5759

58-
// Setting this environment helps to ensure that the profile gets sourced with bash, even if it is not in an interactive shell
59-
if shell.hasSuffix("bash") {
60-
env["BASH_ENV"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".profile").path
61-
} else if shell.hasSuffix("zsh") {
62-
env["ZDOTDIR"] = FileManager.default.homeDirectoryForCurrentUser.path
63-
} else if shell.hasSuffix("fish") {
64-
env["XDG_CONFIG_HOME"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config").path
65-
}
60+
print("Installing swiftly to custom location \(customLoc!.path)")
61+
env["SWIFTLY_HOME_DIR"] = customLoc!.path
62+
env["SWIFTLY_BIN_DIR"] = customLoc!.appendingPathComponent("bin").path
63+
env["SWIFTLY_TOOLCHAINS_DIR"] = customLoc!.appendingPathComponent("toolchains").path
6664

67-
try currentPlatform.runProgram(shell, "-l", "-c", "swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env)
65+
try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--no-modify-profile", "--skip-install", quiet: false, env: env)
66+
try currentPlatform.runProgram(shell, "-l", "-c", ". \"\(customLoc!.path)/env.sh\" && swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env)
67+
} else {
68+
print("Installing swiftly to the default location.")
69+
// Setting this environment helps to ensure that the profile gets sourced with bash, even if it is not in an interactive shell
70+
if shell.hasSuffix("bash") {
71+
env["BASH_ENV"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".profile").path
72+
} else if shell.hasSuffix("zsh") {
73+
env["ZDOTDIR"] = FileManager.default.homeDirectoryForCurrentUser.path
74+
} else if shell.hasSuffix("fish") {
75+
env["XDG_CONFIG_HOME"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config").path
76+
}
77+
78+
try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--skip-install", quiet: false, env: env)
79+
try currentPlatform.runProgram(shell, "-l", "-c", "swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env)
80+
}
6881

6982
var swiftReady = false
7083

@@ -77,7 +90,9 @@ struct TestSwiftly: AsyncParsableCommand {
7790
swiftReady = true
7891
}
7992

80-
if swiftReady {
93+
if let customLoc = customLoc, swiftReady {
94+
try currentPlatform.runProgram(shell, "-l", "-c", ". \"\(customLoc.path)/env.sh\" && swift --version", quiet: false, env: env)
95+
} else if swiftReady {
8196
try currentPlatform.runProgram(shell, "-l", "-c", "swift --version", quiet: false, env: env)
8297
}
8398
}

0 commit comments

Comments
 (0)