From 7bfb94d75a596312e19f4599e9acacdb8280b3bb Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Thu, 31 Dec 2020 12:36:31 -0600 Subject: [PATCH] Uninstall a xcode version --- Xcodes/Backend/AppState.swift | 28 ++++++++- Xcodes/Backend/HelperClient.swift | 24 ++++++++ Xcodes/Backend/XcodeCommands.swift | 60 ++++++++++++++----- Xcodes/Frontend/XcodeList/InfoPane.swift | 20 +++++++ .../Frontend/XcodeList/XcodeListViewRow.swift | 8 +-- 5 files changed, 117 insertions(+), 23 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 42b91b8..527ccfa 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -12,6 +12,7 @@ class AppState: ObservableObject { private let helperClient = HelperClient() private var cancellables = Set() private var selectPublisher: AnyCancellable? + private var uninstallPublisher: AnyCancellable? @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { @@ -32,7 +33,6 @@ class AppState: ObservableObject { @Published var presentingSignInAlert = false @Published var isProcessingAuthRequest = false @Published var secondFactorData: SecondFactorData? - @Published var xcodeBeingConfirmedForUninstallation: Xcode? @Published var helperInstallState: HelperInstallState = .notInstalled init() { @@ -200,14 +200,36 @@ class AppState: ObservableObject { .store(in: &cancellables) } - // MARK: - + // MARK: - Install func install(id: Xcode.ID) { // TODO: } + // MARK: - Uninstall func uninstall(id: Xcode.ID) { - // TODO: + if helperInstallState == .notInstalled { + installHelper() + } + + guard + let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }), + uninstallPublisher == nil + else { return } + + uninstallPublisher = HelperClient().uninstallXcode(installedXcode.path) + .flatMap { [unowned self] _ in + self.updateSelectedXcodePath() + } + .sink( + receiveCompletion: { [unowned self] completion in + if case let .failure(error) = completion { + self.error = AlertContent(title: "Error uninstalling Xcode", message: error.legibleLocalizedDescription) + } + self.uninstallPublisher = nil + }, + receiveValue: { _ in } + ) } func reveal(id: Xcode.ID) { diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index 5ab5c7b..0c9d246 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import Path final class HelperClient { private var connection: NSXPCConnection? @@ -103,4 +104,27 @@ final class HelperClient { .map { $0.0 } .eraseToAnyPublisher() } + + func uninstallXcode(_ path: Path) -> AnyPublisher { + let connectionErrorSubject = PassthroughSubject() + + return Deferred { + Future { promise in + do { + try Current.files.trashItem(at: path.url) + promise(.success(())) + } catch { + promise(.failure(error)) + } + } + } + // Take values, but fail when connectionErrorSubject fails + .zip( + connectionErrorSubject + .prepend("") + .map { _ in Void() } + ) + .map { $0.0 } + .eraseToAnyPublisher() + } } diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index 9a4b1c3..d9c75e1 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -9,6 +9,7 @@ struct XcodeCommands: Commands { var body: some Commands { CommandMenu("Xcode") { Group { + InstallCommand() Divider() @@ -17,6 +18,7 @@ struct XcodeCommands: Commands { OpenCommand() RevealCommand() CopyPathCommand() + UninstallCommand() } .environmentObject(appState) } @@ -31,24 +33,15 @@ struct InstallButton: View { let xcode: Xcode? var body: some View { - Button(action: uninstallOrInstall) { - if let xcode = xcode { - Text(xcode.installed == true ? "Uninstall" : "Install") - .help(xcode.installed == true ? "Uninstall" : "Install") - } else { - Text("Install") - .help("Install") - } + Button(action: install) { + Text("Install") + .help("Install") } } - private func uninstallOrInstall() { + private func install() { guard let xcode = xcode else { return } - if xcode.installed { - appState.xcodeBeingConfirmedForUninstallation = xcode - } else { - appState.install(id: xcode.id) - } + appState.install(id: xcode.id) } } @@ -91,6 +84,30 @@ struct OpenButton: View { } } +struct UninstallButton: View { + @EnvironmentObject var appState: AppState + let xcode: Xcode? + + @State private var showingAlert = false + var alert: Alert { + Alert(title: Text("Uninstall Xcode \(xcode!.description)?"), + message: Text("It will be moved to the Trash, but won't be emptied."), + primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: xcode!.id) }), + secondaryButton: .cancel(Text("Cancel"))) + } + + var body: some View { + Button(action: { + self.showingAlert = true + }) { + Text("Uninstall") + } + .foregroundColor(.red) + .help("Uninstall") + .alert(isPresented:$showingAlert, content: { self.alert }) + } +} + struct RevealButton: View { @EnvironmentObject var appState: AppState let xcode: Xcode? @@ -133,8 +150,8 @@ struct InstallCommand: View { var body: some View { InstallButton(xcode: selectedXcode.unwrapped) - .keyboardShortcut(selectedXcode.unwrapped?.installed == true ? "u" : "i", modifiers: [.command, .option]) - .disabled(selectedXcode.unwrapped == nil) + .keyboardShortcut("i", modifiers: [.command, .option]) + .disabled(selectedXcode.unwrapped?.installed == true) } } @@ -181,3 +198,14 @@ struct CopyPathCommand: View { .disabled(selectedXcode.unwrapped?.installed != true) } } + +struct UninstallCommand: View { + @EnvironmentObject var appState: AppState + @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? + + var body: some View { + UninstallButton(xcode: selectedXcode.unwrapped) + .keyboardShortcut("u", modifiers: [.command, .option]) + .disabled(selectedXcode.unwrapped?.installed != true) + } +} diff --git a/Xcodes/Frontend/XcodeList/InfoPane.swift b/Xcodes/Frontend/XcodeList/InfoPane.swift index 9395fab..15bfd24 100644 --- a/Xcodes/Frontend/XcodeList/InfoPane.swift +++ b/Xcodes/Frontend/XcodeList/InfoPane.swift @@ -50,6 +50,11 @@ struct InfoPane: View { sdks(for: xcode) compilers(for: xcode) + if xcode.path != nil { + VStack(alignment: .leading) { + UninstallButton(xcode: xcode) + } + } Spacer() } } else { @@ -250,6 +255,21 @@ struct InfoPane_Previews: PreviewProvider { }) .previewDisplayName("Populated, Uninstalled") + InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + .init( + version: Version(major: 12, minor: 3, patch: 0), + installState: .installed, + selected: false, + path: "/Applications/Xcode-12.3.0.app", + icon: nil, + sdks: nil, + compilers: nil) + ] + }) + .previewDisplayName("Basic, installed") + InfoPane(selectedXcodeID: nil) .environmentObject(configure(AppState()) { $0.allXcodes = [ diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 1b1b16c..ad8e2ec 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -26,15 +26,15 @@ struct XcodeListViewRow: View { installControl(for: xcode) } .contextMenu { - InstallButton(xcode: xcode) - - Divider() - if xcode.installed { SelectButton(xcode: xcode) OpenButton(xcode: xcode) RevealButton(xcode: xcode) CopyPathButton(xcode: xcode) + Divider() + UninstallButton(xcode: xcode) + } else { + InstallButton(xcode: xcode) } } }