diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index eb5d977..907b7e2 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -15,13 +15,25 @@ extension AppState { let lastUpdated = Current.defaults.date(forKey: "lastUpdated"), // This is bad date math but for this use case it doesn't need to be exact lastUpdated < Current.date().addingTimeInterval(-60 * 60 * 24) - else { return } + else { + updatePublisher = updateSelectedXcodePath() + .sink( + receiveCompletion: { _ in + self.updatePublisher = nil + }, + receiveValue: { _ in } + ) + return + } update() as Void } func update() { guard !isUpdating else { return } - updatePublisher = updateAvailableXcodes(from: self.dataSource) + updatePublisher = updateSelectedXcodePath() + .flatMap { _ in + self.updateAvailableXcodes(from: self.dataSource) + } .sink( receiveCompletion: { [unowned self] completion in switch completion { @@ -36,6 +48,15 @@ extension AppState { receiveValue: { _ in } ) } + + func updateSelectedXcodePath() -> AnyPublisher { + Current.shell.xcodeSelectPrintPath() + .handleEvents(receiveOutput: { output in self.selectedXcodePath = output.out }) + // Ignore xcode-select failures + .map { _ in Void() } + .catch { _ in Just(()) } + .eraseToAnyPublisher() + } private func updateAvailableXcodes(from dataSource: DataSource) -> AnyPublisher<[AvailableXcode], Error> { switch dataSource { diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 4410fe3..cef6b90 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -16,10 +16,15 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { - updateAllXcodes(newValue) + updateAllXcodes(availableXcodes: newValue, selectedXcodePath: selectedXcodePath) } } var allXcodes: [Xcode] = [] + @Published var selectedXcodePath: String? { + willSet { + updateAllXcodes(availableXcodes: availableXcodes, selectedXcodePath: newValue) + } + } @Published var updatePublisher: AnyCancellable? var isUpdating: Bool { updatePublisher != nil } @Published var error: AlertContent? @@ -226,6 +231,9 @@ class AppState: ObservableObject { else { return } selectPublisher = HelperClient().switchXcodePath(installedXcode.path.string) + .flatMap { [unowned self] _ in + self.updateSelectedXcodePath() + } .sink( receiveCompletion: { [unowned self] completion in if case let .failure(error) = completion { @@ -251,9 +259,9 @@ class AppState: ObservableObject { // MARK: - Private - private func updateAllXcodes(_ xcodes: [AvailableXcode]) { + private func updateAllXcodes(availableXcodes: [AvailableXcode], selectedXcodePath: String?) { let installedXcodes = Current.files.installedXcodes(Path.root/"Applications") - var allXcodeVersions = xcodes.map { $0.version } + var allXcodeVersions = availableXcodes.map { $0.version } for installedXcode in installedXcodes { // If an installed version isn't listed online, add the installed version if !allXcodeVersions.contains(where: { version in @@ -274,11 +282,11 @@ class AppState: ObservableObject { .sorted(by: >) .map { xcodeVersion in let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) - let availableXcode = xcodes.first { $0.version == xcodeVersion } + let availableXcode = availableXcodes.first { $0.version == xcodeVersion } return Xcode( version: xcodeVersion, - installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled, - selected: false, + installState: installedXcode != nil ? .installed : .notInstalled, + selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, path: installedXcode?.path.string, icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), requiredMacOSVersion: availableXcode?.requiredMacOSVersion, diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index fa3be9d..99f84be 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import PromiseKit import PMKFoundation @@ -50,11 +51,7 @@ public struct Shell { authenticateSudoerIfNecessary(passwordInput) } - public var xcodeSelectPrintPath: () -> Promise = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } - public var xcodeSelectSwitch: (String?, String) -> Promise = { Process.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) } - public func xcodeSelectSwitch(password: String?, path: String) -> Promise { - xcodeSelectSwitch(password, path) - } + public var xcodeSelectPrintPath: () -> AnyPublisher = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } } public struct Files { diff --git a/Xcodes/Backend/Process.swift b/Xcodes/Backend/Process.swift index dd79217..779f151 100644 --- a/Xcodes/Backend/Process.swift +++ b/Xcodes/Backend/Process.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import PromiseKit import PMKFoundation @@ -38,4 +39,47 @@ extension Process { return (process.terminationStatus, output, error) } } + + @discardableResult + static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher { + return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + } + + @discardableResult + static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) -> AnyPublisher { + Deferred { + Future { promise in + let process = Process() + process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() + process.executableURL = executable + process.arguments = arguments + + let (stdout, stderr) = (Pipe(), Pipe()) + process.standardOutput = stdout + process.standardError = stderr + + if let input = input { + let inputPipe = Pipe() + process.standardInput = inputPipe.fileHandleForReading + inputPipe.fileHandleForWriting.write(Data(input.utf8)) + inputPipe.fileHandleForWriting.closeFile() + } + + do { + try process.run() + process.waitUntilExit() + + let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + promise(.success((process.terminationStatus, output, error))) + } catch { + promise(.failure(error)) + } + } + } + .subscribe(on: DispatchQueue.global()) + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } } diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index df00299..4ec374a 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -58,6 +58,7 @@ struct SelectButton: View { Button(action: select) { Text("Select") } + .disabled(xcode?.selected != false) } private func select() { diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index a2fa65a..a2c5d6c 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -42,13 +42,14 @@ struct XcodeListView: View { .foregroundColor(appState.selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) } + + Spacer() + if xcode.selected { Tag(text: "SELECTED") .foregroundColor(.green) } - Spacer() - Button(xcode.installed ? "INSTALLED" : "INSTALL") { print("Installing...") } @@ -90,10 +91,10 @@ struct XcodeListView_Previews: PreviewProvider { .environmentObject({ () -> AppState in let a = AppState() a.allXcodes = [ - Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: nil, icon: nil), + Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-12.3.0.app", icon: nil), Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), Xcode(version: Version("12.1.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: nil, icon: nil), + Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: "/Applications/Xcode-12.3.0.app", icon: nil), ] return a }())