|
| 1 | +import Foundation |
| 2 | +import SwiftlyCore |
| 3 | + |
| 4 | +public struct SwiftPkgInfo: Codable { |
| 5 | + public var CFBundleIdentifier: String |
| 6 | + |
| 7 | + public init(CFBundleIdentifier: String) { |
| 8 | + self.CFBundleIdentifier = CFBundleIdentifier |
| 9 | + } |
| 10 | +} |
| 11 | + |
| 12 | +/// `Platform` implementation for macOS systems. |
| 13 | +public struct MacOS: Platform { |
| 14 | + public init() {} |
| 15 | + |
| 16 | + public var appDataDirectory: URL { |
| 17 | + FileManager.default.homeDirectoryForCurrentUser |
| 18 | + .appendingPathComponent("Library/Application Support", isDirectory: true) |
| 19 | + } |
| 20 | + |
| 21 | + public var swiftlyBinDir: URL { |
| 22 | + SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } |
| 23 | + ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } |
| 24 | + ?? FileManager.default.homeDirectoryForCurrentUser |
| 25 | + .appendingPathComponent("Library/Application Support/swiftly/bin", isDirectory: true) |
| 26 | + } |
| 27 | + |
| 28 | + public var swiftlyToolchainsDir: URL { |
| 29 | + SwiftlyCore.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("Library/Developer/Toolchains", isDirectory: true) |
| 32 | + } |
| 33 | + |
| 34 | + public var toolchainFileExtension: String { |
| 35 | + "pkg" |
| 36 | + } |
| 37 | + |
| 38 | + public func isSystemDependencyPresent(_: SystemDependency) -> Bool { |
| 39 | + // All system dependencies on macOS should be present |
| 40 | + true |
| 41 | + } |
| 42 | + |
| 43 | + public func verifySystemPrerequisitesForInstall(requireSignatureValidation _: Bool) throws { |
| 44 | + // All system prerequisites should be there for macOS |
| 45 | + } |
| 46 | + |
| 47 | + public func install(from tmpFile: URL, version: ToolchainVersion) throws { |
| 48 | + guard tmpFile.fileExists() else { |
| 49 | + throw Error(message: "\(tmpFile) doesn't exist") |
| 50 | + } |
| 51 | + |
| 52 | + if !self.swiftlyToolchainsDir.fileExists() { |
| 53 | + try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false) |
| 54 | + } |
| 55 | + |
| 56 | + if SwiftlyCore.mockedHomeDir == nil { |
| 57 | + SwiftlyCore.print("Installing package in user home directory...") |
| 58 | + try runProgram("installer", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory") |
| 59 | + } else { |
| 60 | + // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because |
| 61 | + // the installer will not install to an arbitrary path, only a volume or user home directory. |
| 62 | + let tmpDir = self.getTempFilePath() |
| 63 | + let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(version.identifier).xctoolchain", isDirectory: true) |
| 64 | + if !toolchainDir.fileExists() { |
| 65 | + try FileManager.default.createDirectory(at: toolchainDir, withIntermediateDirectories: false) |
| 66 | + } |
| 67 | + try runProgram("pkgutil", "--expand", tmpFile.path, tmpDir.path) |
| 68 | + // There's a slight difference in the location of the special Payload file between official swift packages |
| 69 | + // and the ones that are mocked here in the test framework. |
| 70 | + var payload = tmpDir.appendingPathComponent("Payload") |
| 71 | + if !payload.fileExists() { |
| 72 | + payload = tmpDir.appendingPathComponent("\(version.identifier)-osx-package.pkg/Payload") |
| 73 | + } |
| 74 | + try runProgram("tar", "-C", toolchainDir.path, "-xf", payload.path) |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + public func uninstall(_ toolchain: ToolchainVersion) throws { |
| 79 | + SwiftlyCore.print("Uninstalling package in user home directory...") |
| 80 | + |
| 81 | + let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(toolchain.identifier).xctoolchain", isDirectory: true) |
| 82 | + |
| 83 | + let decoder = PropertyListDecoder() |
| 84 | + let infoPlist = toolchainDir.appendingPathComponent("Info.plist") |
| 85 | + guard let data = try? Data(contentsOf: infoPlist) else { |
| 86 | + throw Error(message: "could not open \(infoPlist)") |
| 87 | + } |
| 88 | + |
| 89 | + guard let pkgInfo = try? decoder.decode(SwiftPkgInfo.self, from: data) else { |
| 90 | + throw Error(message: "could not decode plist at \(infoPlist)") |
| 91 | + } |
| 92 | + |
| 93 | + try FileManager.default.removeItem(at: toolchainDir) |
| 94 | + |
| 95 | + let homedir = ProcessInfo.processInfo.environment["HOME"]! |
| 96 | + try? runProgram("pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier) |
| 97 | + } |
| 98 | + |
| 99 | + public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool { |
| 100 | + let toolchainBinURL = self.swiftlyToolchainsDir |
| 101 | + .appendingPathComponent(toolchain.identifier + ".xctoolchain", isDirectory: true) |
| 102 | + .appendingPathComponent("usr", isDirectory: true) |
| 103 | + .appendingPathComponent("bin", isDirectory: true) |
| 104 | + |
| 105 | + // Delete existing symlinks from previously in-use toolchain. |
| 106 | + if let currentToolchain { |
| 107 | + try self.unUse(currentToolchain: currentToolchain) |
| 108 | + } |
| 109 | + |
| 110 | + // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. |
| 111 | + let swiftlyBinDirContents = try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path) |
| 112 | + let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) |
| 113 | + let willBeOverwritten = Set(toolchainBinDirContents).intersection(swiftlyBinDirContents) |
| 114 | + if !willBeOverwritten.isEmpty { |
| 115 | + SwiftlyCore.print("The following existing executables will be overwritten:") |
| 116 | + |
| 117 | + for executable in willBeOverwritten { |
| 118 | + SwiftlyCore.print(" \(self.swiftlyBinDir.appendingPathComponent(executable).path)") |
| 119 | + } |
| 120 | + |
| 121 | + let proceed = SwiftlyCore.readLine(prompt: "Proceed? (y/n)") ?? "n" |
| 122 | + |
| 123 | + guard proceed == "y" else { |
| 124 | + SwiftlyCore.print("Aborting use") |
| 125 | + return false |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + for executable in toolchainBinDirContents { |
| 130 | + let linkURL = self.swiftlyBinDir.appendingPathComponent(executable) |
| 131 | + let executableURL = toolchainBinURL.appendingPathComponent(executable) |
| 132 | + |
| 133 | + // Deletion confirmed with user above. |
| 134 | + try linkURL.deleteIfExists() |
| 135 | + |
| 136 | + try FileManager.default.createSymbolicLink( |
| 137 | + atPath: linkURL.path, |
| 138 | + withDestinationPath: executableURL.path |
| 139 | + ) |
| 140 | + } |
| 141 | + |
| 142 | + SwiftlyCore.print(""" |
| 143 | + NOTE: On macOS it is possible that the shell will pick up the system Swift on the path |
| 144 | + instead of the one that swiftly has installed for you. You can run the 'hash -r' |
| 145 | + command to update the shell with the latest PATHs. |
| 146 | +
|
| 147 | + hash -r |
| 148 | +
|
| 149 | + """ |
| 150 | + ) |
| 151 | + |
| 152 | + return true |
| 153 | + } |
| 154 | + |
| 155 | + public func unUse(currentToolchain: ToolchainVersion) throws { |
| 156 | + let currentToolchainBinURL = self.swiftlyToolchainsDir |
| 157 | + .appendingPathComponent(currentToolchain.identifier + ".xctoolchain", isDirectory: true) |
| 158 | + .appendingPathComponent("usr", isDirectory: true) |
| 159 | + .appendingPathComponent("bin", isDirectory: true) |
| 160 | + |
| 161 | + for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: currentToolchainBinURL.path) { |
| 162 | + guard existingExecutable != "swiftly" else { |
| 163 | + continue |
| 164 | + } |
| 165 | + |
| 166 | + let url = self.swiftlyBinDir.appendingPathComponent(existingExecutable) |
| 167 | + let vals = try url.resourceValues(forKeys: [URLResourceKey.isSymbolicLinkKey]) |
| 168 | + |
| 169 | + guard let islink = vals.isSymbolicLink, islink else { |
| 170 | + throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)") |
| 171 | + } |
| 172 | + let symlinkDest = url.resolvingSymlinksInPath() |
| 173 | + guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else { |
| 174 | + throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)") |
| 175 | + } |
| 176 | + |
| 177 | + try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + public func listAvailableSnapshots(version _: String?) async -> [Snapshot] { |
| 182 | + [] |
| 183 | + } |
| 184 | + |
| 185 | + public func getExecutableName(forArch: String) -> String { |
| 186 | + "swiftly-\(forArch)-macos-osx" |
| 187 | + } |
| 188 | + |
| 189 | + public func currentToolchain() throws -> ToolchainVersion? { nil } |
| 190 | + |
| 191 | + public func getTempFilePath() -> URL { |
| 192 | + FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg") |
| 193 | + } |
| 194 | + |
| 195 | + public func verifySignature(httpClient _: SwiftlyHTTPClient, archiveDownloadURL _: URL, archive _: URL) async throws { |
| 196 | + // No signature verification is required on macOS since the pkg files have their own signing |
| 197 | + // mechanism and the swift.org downloadables are trusted by stock macOS installations. |
| 198 | + } |
| 199 | + |
| 200 | + public static let currentPlatform: any Platform = MacOS() |
| 201 | +} |
0 commit comments