From f884770de3368c3dbcddd44eb93250ea5c5de33c Mon Sep 17 00:00:00 2001 From: Chris McGee <87777443+cmcgee1024@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:07:33 -0400 Subject: [PATCH] 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 --- Sources/LinuxPlatform/Linux.swift | 4 +- Sources/MacOSPlatform/MacOS.swift | 47 +++++++++++++---- Sources/Swiftly/Init.swift | 82 +++++++++++++++++------------- Sources/SwiftlyCore/Platform.swift | 5 +- 4 files changed, 92 insertions(+), 46 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index ce1abeb9..f5d86a80 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -37,7 +37,9 @@ public struct Linux: Platform { } public var swiftlyToolchainsDir: URL { - self.swiftlyHomeDir.appendingPathComponent("toolchains", isDirectory: true) + SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("toolchains", isDirectory: true) } + ?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share/swiftly/toolchains") } public var toolchainFileExtension: String { diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index fa4b075d..1e67ac39 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -18,6 +18,11 @@ public struct MacOS: Platform { .appendingPathComponent(".swiftly", isDirectory: true) } + public var defaultToolchainsDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) + } + public var swiftlyBinDir: URL { SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } @@ -27,8 +32,9 @@ public struct MacOS: Platform { 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) + ?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) } + // This is where the installer will put the toolchains, and where Xcode can find them + ?? self.defaultToolchainsDirectory } public var toolchainFileExtension: String { @@ -54,22 +60,45 @@ public struct MacOS: Platform { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } - if !self.swiftlyToolchainsDir.fileExists() { - try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false) + let toolchainsDir = self.swiftlyToolchainsDir + + if !toolchainsDir.fileExists() { + try FileManager.default.createDirectory( + at: toolchainsDir, withIntermediateDirectories: true + ) } - if SwiftlyCore.mockedHomeDir == nil { + if toolchainsDir == self.defaultToolchainsDirectory { + // If the toolchains go into the default user location then we use the installer to install them SwiftlyCore.print("Installing package in user home directory...") - try runProgram("installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory", quiet: !verbose) + try runProgram( + "installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory", + quiet: !verbose + ) } 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. + // Otherwise, we extract the pkg into the requested toolchains directory. SwiftlyCore.print("Expanding pkg...") let tmpDir = self.getTempFilePath() - let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(version.identifier).xctoolchain", isDirectory: true) + let toolchainDir = toolchainsDir.appendingPathComponent( + "\(version.identifier).xctoolchain", isDirectory: true + ) + if !toolchainDir.fileExists() { try FileManager.default.createDirectory(at: toolchainDir, withIntermediateDirectories: false) } + + SwiftlyCore.print("Checking package signature...") + do { + try runProgram("pkgutil", "--check-signature", tmpFile.path, quiet: !verbose) + } catch { + // If this is not a test that uses mocked toolchains then we must throw this error and abort installation + guard SwiftlyCore.mockedHomeDir != nil else { + throw error + } + + // We permit the signature verification to fail during testing + SwiftlyCore.print("Signature verification failed, which is allowable during testing with mocked toolchains") + } try runProgram("pkgutil", "--verbose", "--expand", tmpFile.path, tmpDir.path, quiet: !verbose) // 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. diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index 160e3138..e0c063fd 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -65,6 +65,8 @@ internal struct Init: SwiftlyCommand { // Give the user the prompt and the choice to abort at this point. if !assumeYes { + let toolchainsDir = Swiftly.currentPlatform.swiftlyToolchainsDir + var msg = """ Welcome to swiftly, the Swift toolchain manager for Linux and macOS! @@ -76,12 +78,20 @@ internal struct Init: SwiftlyCommand { \(Swiftly.currentPlatform.swiftlyHomeDir.path) - Directory for configuration files \(Swiftly.currentPlatform.swiftlyBinDir.path) - Links to the binaries of the active toolchain - \(Swiftly.currentPlatform.swiftlyToolchainsDir.path) - Directory hosting installed toolchains + \(toolchainsDir.path) - Directory hosting installed toolchains These locations can be changed by setting the environment variables - SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR before running 'swiftly init' again. + SWIFTLY_HOME_DIR, SWIFTLY_BIN_DIR, and SWIFTLY_TOOLCHAINS_DIR before running 'swiftly init' again. """ +#if os(macOS) + if toolchainsDir != FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) { + msg += """ + + NOTE: The toolchains are not being installed in a standard macOS location, so Xcode may not be able to find them. + """ + } +#endif if !skipInstall { msg += """ @@ -177,6 +187,7 @@ internal struct Init: SwiftlyCommand { env = """ set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir.path)" set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir.path)" + set -x SWIFTLY_TOOLCHAINS_DIR "\(Swiftly.currentPlatform.swiftlyToolchainsDir.path)" if not contains "$SWIFTLY_BIN_DIR" $PATH set -x PATH "$SWIFTLY_BIN_DIR" $PATH end @@ -186,6 +197,7 @@ internal struct Init: SwiftlyCommand { env = """ export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir.path)" export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir.path)" + export SWIFTLY_TOOLCHAINS_DIR="\(Swiftly.currentPlatform.swiftlyToolchainsDir.path)" if [[ ":$PATH:" != *":$SWIFTLY_BIN_DIR:"* ]]; then export PATH="$SWIFTLY_BIN_DIR:$PATH" fi @@ -241,50 +253,50 @@ internal struct Init: SwiftlyCommand { addEnvToProfile = true } - var postInstall: String? - var pathChanged = false - - if !skipInstall { - let latestVersion = try await Install.resolve(config: config, selector: ToolchainSelector.latest) - (postInstall, pathChanged) = try await Install.execute(version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes) - } - if addEnvToProfile { try Data(sourceLine.utf8).append(to: profileHome) + } + } - if !quietShellFollowup { - SwiftlyCore.print(""" - To begin using installed swiftly from your current shell, first run the following command: - \(sourceLine) + var postInstall: String? + var pathChanged = false - """) - } - } + if !skipInstall { + let latestVersion = try await Install.resolve(config: config, selector: ToolchainSelector.latest) + (postInstall, pathChanged) = try await Install.execute(version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes) + } + + if !quietShellFollowup { + SwiftlyCore.print(""" + To begin using installed swiftly from your current shell, first run the following command: + \(sourceLine) - // Fish doesn't have path caching, so this might only be needed for bash/zsh - if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") { - SwiftlyCore.print(""" - Your shell caches items on your path for better performance. Swiftly has added - items to your path that may not get picked up right away. You can update your - shell's environment by running + """) + } - hash -r + // Fish doesn't have path caching, so this might only be needed for bash/zsh + if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") { + SwiftlyCore.print(""" + Your shell caches items on your path for better performance. Swiftly has added + items to your path that may not get picked up right away. You can update your + shell's environment by running - or restarting your shell. + hash -r - """) - } + or restarting your shell. - if let postInstall { - SwiftlyCore.print(""" - There are some dependencies that should be installed before using this toolchain. - You can run the following script as the system administrator (e.g. root) to prepare - your system: + """) + } - \(postInstall) + if let postInstall { + SwiftlyCore.print(""" + There are some dependencies that should be installed before using this toolchain. + You can run the following script as the system administrator (e.g. root) to prepare + your system: - """) - } + \(postInstall) + + """) } } } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 2d863241..b608bc68 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -144,7 +144,10 @@ extension Platform { let tcPath = self.findToolchainLocation(toolchain).appendingPathComponent("usr/bin") guard tcPath.fileExists() else { - throw SwiftlyError(message: "Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again.") + throw SwiftlyError( + message: + "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." + ) } var pathComponents = (newEnv["PATH"] ?? "").split(separator: ":").map { String($0) }