From 7632343cd9f0924cd07fab67b1dc7e1a41a4e86b Mon Sep 17 00:00:00 2001 From: David Roman <2538074+davdroman@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:53:19 +0100 Subject: [PATCH] feat(uninstall): add permanent deletion option Offer an unchecked-by-default Delete permanently checkbox in the uninstall confirmation. Move unchecked uninstalls to the user's Trash and use the privileged helper only for permanent deletion when enabled. Perform local filesystem work off the main actor and let overlapping uninstalls finish their status refreshes independently. --- Xcodes/Backend/AppState.swift | 20 +- Xcodes/Frontend/MainWindow.swift | 23 +- Xcodes/Resources/Localizable.xcstrings | 176 ++++++++++++-- XcodesTests/AppStateTests.swift | 311 +++++++++++++++++++++++++ 4 files changed, 495 insertions(+), 35 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..622006a9 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -706,14 +706,13 @@ class AppState: ObservableObject { } // MARK: - Uninstall - func uninstall(xcode: Xcode) { + func uninstall(xcode: Xcode, permanently: Bool = false) { guard let installedXcodePath = xcode.installedPath else { return } if let index = allXcodes.firstIndex(where: { $0.id == xcode.id }) { allXcodes[index].installState = .uninstalling(installedXcodePath) } - uninstallTask?.cancel() let taskID = UUID() uninstallTaskID = taskID uninstallTask = Task { @MainActor in @@ -725,7 +724,7 @@ class AppState: ObservableObject { } do { try Task.checkCancellation() - try await uninstallXcodeAsync(path: installedXcodePath) + try await uninstallXcodeAsync(path: installedXcodePath, permanently: permanently) try Task.checkCancellation() await updateSelectedXcodePathAsync() await updateInstalledXcodesAsync() @@ -955,7 +954,7 @@ class AppState: ObservableObject { // MARK: - Private - private func uninstallXcodeAsync(path: Path) async throws { + private func uninstallXcodeAsync(path: Path, permanently: Bool) async throws { guard let xcode = InstalledXcode( path: path, contentsAtPath: { path in Current.files.contents(atPath: path) }, @@ -964,14 +963,17 @@ class AppState: ObservableObject { throw FileError.fileNotFound(path.string) } - if Current.helper.usePrivilegedHelperForFileOperations { + if permanently && Current.helper.usePrivilegedHelperForFileOperations { try await installHelperIfNecessaryAsync() try await Current.helper.removeAsync(xcode.path.string) } else { - _ = try XcodeUninstallService( - removeItem: { url in try Current.files.removeItem(at: url) }, - trashItem: { url in try Current.files.trashItem(at: url) } - ).uninstall(xcode, emptyTrash: false) + let files = Current.files + try await Task.detached(priority: .userInitiated) { + _ = try XcodeUninstallService( + removeItem: files.removeItem, + trashItem: files.trashItem + ).uninstall(xcode, emptyTrash: permanently) + }.value } } diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index b108f053..a1781eb7 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -8,6 +8,7 @@ struct MainWindow: View { @EnvironmentObject var appState: AppState @State private var selectedXcodeID: Xcode.ID? @State private var searchText: String = "" + @State private var deletePermanently = false @AppStorage("lastUpdated") private var lastUpdated: Double? // These two properties should be per-scene state managed by @SceneStorage property wrappers. // There's currently a bug with @SceneStorage on macOS, though, where quitting the app will discard the values, which removes a lot of its utility. @@ -22,11 +23,23 @@ struct MainWindow: View { NavigationSplitViewWrapper { XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly, architecture: architecture) .layoutPriority(1) - .alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in - Alert(title: Text(String(format: localizeString("Alert.Uninstall.Title"), xcode.description)), - message: Text("Alert.Uninstall.Message"), - primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }), - secondaryButton: .cancel(Text("Cancel"))) + .alert( + String(format: localizeString("Alert.Uninstall.Title"), appState.xcodeBeingConfirmedForUninstallation?.description ?? ""), + isPresented: $appState.xcodeBeingConfirmedForUninstallation.isNotNil, + presenting: appState.xcodeBeingConfirmedForUninstallation + ) { xcode in + Button("Uninstall", role: .destructive) { + appState.uninstall(xcode: xcode, permanently: deletePermanently) + } + Button("Cancel", role: .cancel) {} + } message: { _ in + Text("Alert.Uninstall.Message") + } + .dialogSuppressionToggle("Alert.Uninstall.DeletePermanently", isSuppressed: $deletePermanently) + .onChange(of: appState.xcodeBeingConfirmedForUninstallation?.id) { _, id in + if id != nil { + deletePermanently = false + } } .searchable(text: $searchText, placement: .sidebar) .mainToolbar( diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..9feea864 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -3890,6 +3890,138 @@ } } }, + "Alert.Uninstall.DeletePermanently" : { + "comment" : "Checkbox in the uninstall dialog that deletes Xcode permanently instead of moving it to the Trash.", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف نهائي" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suprimeix permanentment" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endgültig löschen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Οριστική διαγραφή" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete permanently" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar permanentemente" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista pysyvästi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer définitivement" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थायी रूप से डिलीट करें" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina definitivamente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完全に削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "영구적으로 삭제" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Definitief verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń trwale" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Excluir permanentemente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить безвозвратно" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลบอย่างถาวร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalıcı olarak sil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Видалити назавжди" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "永久删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "永久刪除" + } + } + } + }, "Alert.Uninstall.Error.Message.FileNotFound" : { "extractionState" : "manual", "localizations" : { @@ -4153,131 +4285,133 @@ } }, "Alert.Uninstall.Message" : { + "comment" : "Explains the default Trash behavior and warns that choosing permanent deletion cannot be undone.", + "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "سيتم نقله إلى سلة المهملات، ولكن لن يتم إفراغه." + "value" : "سيتم نقل Xcode إلى سلة المهملات ما لم تختر حذفه نهائيًا. لا يمكن التراجع عن الحذف النهائي." } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Es mourà a la paperera, però no es buidarà." + "value" : "Xcode es mourà a la paperera tret que decidiu suprimir-lo permanentment. La supressió permanent no es pot desfer." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die Anwendung wird in den Papierkorb verschoben, dieser wird aber nicht geleert." + "value" : "Xcode wird in den Papierkorb verschoben, sofern Sie es nicht endgültig löschen möchten. Das endgültige Löschen kann nicht rückgängig gemacht werden." } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Θα μετακινηθεί στον Κάδο, αλλά δεν θα γίνει άδειασμα." + "value" : "Το Xcode θα μετακινηθεί στον Κάδο, εκτός αν επιλέξετε να το διαγράψετε οριστικά. Η οριστική διαγραφή δεν μπορεί να αναιρεθεί." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "It will be moved to the Trash, but won't be emptied." + "value" : "Xcode will be moved to the Trash unless you choose to delete it permanently. Permanent deletion cannot be undone." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Se moverá a la Papelera, pero no se vaciará." + "value" : "Xcode se moverá a la Papelera a menos que elijas eliminarlo permanentemente. La eliminación permanente no se puede deshacer." } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Se siirretään roskakoriin, mutta sitä ei tyhjennetä." + "value" : "Xcode siirretään roskakoriin, ellet valitse sen poistamista pysyvästi. Pysyvää poistoa ei voi kumota." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Xcodes sera déplacé vers la corbeille, mais la corbeille ne sera pas vidée." + "value" : "Xcode sera déplacé vers la corbeille, sauf si vous choisissez de le supprimer définitivement. La suppression définitive est irréversible." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "इसे ट्रैश में ले जाया जाएगा, लेकिन खाली नहीं किया जाएगा।" + "value" : "Xcode को ट्रैश में ले जाया जाएगा, जब तक कि आप इसे स्थायी रूप से डिलीट करने का विकल्प नहीं चुनते। स्थायी रूप से डिलीट करने के बाद इस कार्रवाई को पूर्ववत नहीं किया जा सकता।" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Sarà spostato nel cestino, ma non svuotato." + "value" : "Xcode verrà spostato nel Cestino, a meno che tu non scelga di eliminarlo definitivamente. L’eliminazione definitiva non può essere annullata." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ゴミ箱に移動されますが、ゴミ箱を自動的に空にすることはありません。" + "value" : "完全に削除することを選択しない限り、Xcodeはゴミ箱に移動されます。完全に削除すると元に戻すことはできません。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "휴지통으로 이동되지만 비워지지는 않습니다." + "value" : "영구적으로 삭제하도록 선택하지 않으면 Xcode가 휴지통으로 이동됩니다. 영구 삭제는 되돌릴 수 없습니다." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Het zal worden verplaatst naar de Prullenbak, maar deze zal niet geleegd worden." + "value" : "Xcode wordt naar de prullenmand verplaatst, tenzij je ervoor kiest om het definitief te verwijderen. Definitief verwijderen kan niet ongedaan worden gemaakt." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Zostanie przeniesiony do kosza, ale nie zostanie opróżniony." + "value" : "Xcode zostanie przeniesiony do Kosza, chyba że wybierzesz trwałe usunięcie. Trwałego usunięcia nie można cofnąć." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Será movido para a lixeira, mas não será esvaziada." + "value" : "O Xcode será movido para o Lixo, a menos que você escolha excluí-lo permanentemente. A exclusão permanente não pode ser desfeita." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Файлы будут перемещены в корзину, но не будут очищены из неё автоматически." + "value" : "Xcode будет перемещён в Корзину, если вы не выберете безвозвратное удаление. Безвозвратное удаление нельзя отменить." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "มันจะถูกย้ายไปที่ถังขยะ แต่จะไม่ถูกลบอย่างถาวร" + "value" : "Xcode จะถูกย้ายไปที่ถังขยะ เว้นแต่คุณจะเลือกที่จะลบอย่างถาวร การลบอย่างถาวรไม่สามารถเลิกทำได้" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Uygulama Çöp Kutusuna taşınacaktır, fakat silinmeyecektir." + "value" : "Kalıcı olarak silmeyi seçmediğiniz sürece Xcode Çöp Sepeti’ne taşınacaktır. Kalıcı silme işlemi geri alınamaz." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Xcode буде просто переміщено до Кошика, без повного видалення." + "value" : "Xcode буде переміщено до Смітника, якщо ви не виберете видалення назавжди. Видалення назавжди не можна скасувати." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "它会被移动到废纸篓,但废纸篓不会清空。" + "value" : "除非您选择永久删除,否则 Xcode 将被移到废纸篓。永久删除无法撤销。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "將移到垃圾桶,但垃圾桶不會清空。" + "value" : "除非您選擇永久刪除,否則 Xcode 將移到垃圾桶。永久刪除無法復原。" } } } diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 2945b06c..3aea932a 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -2,6 +2,7 @@ import Combine import Cocoa import AsyncNetworkService @preconcurrency import Path +import class SwiftUI.NSHostingView import Version import XCTest import XcodesLoginKit @@ -365,6 +366,85 @@ class AppStateTests: XCTestCase { XCTAssertEqual(subject.selectedXcodePath, secondPath.string) } + func test_UninstallAlert_PermanentDeletionResetsAfterCancelAndUsesRemoveWhenConfirmed() async throws { + let (xcode, operations) = makeUninstallFixture(useHelper: false) + let previousKeyWindow = NSApp.keyWindow + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: MainWindow().environmentObject(subject)) + defer { + subject.xcodeBeingConfirmedForUninstallation = nil + subject.uninstallTask?.cancel() + if let sheet = window.attachedSheet { + window.endSheet(sheet, returnCode: .cancel) + sheet.orderOut(nil) + } + window.contentView = nil + window.close() + previousKeyWindow?.makeKey() + } + window.makeKeyAndOrderFront(nil) + window.contentView?.layoutSubtreeIfNeeded() + try await Task.sleep(nanoseconds: 100_000_000) + + func waitUntil(_ message: String, condition: @MainActor () -> Bool) async throws { + for _ in 0..<200 { + if condition() { return } + try await Task.sleep(nanoseconds: 25_000_000) + } + _ = try XCTUnwrap(condition() ? true : nil, message) + } + + let checkboxTitle = localizeString("Alert.Uninstall.DeletePermanently") + func sheetButton(titled title: String) -> NSButton? { + window.attachedSheet?.contentView?.recursiveSubviews(ofType: NSButton.self) + .first { $0.title == title } + } + + subject.xcodeBeingConfirmedForUninstallation = xcode + try await waitUntil("Expected the native uninstall sheet and suppression checkbox") { + sheetButton(titled: checkboxTitle) != nil + } + let firstCheckbox = try XCTUnwrap(sheetButton(titled: checkboxTitle)) + XCTAssertEqual(firstCheckbox.state, .off) + firstCheckbox.performClick(nil) + XCTAssertEqual(firstCheckbox.state, .on) + let cancelButton = try XCTUnwrap(sheetButton(titled: localizeString("Cancel"))) + cancelButton.performClick(nil) + try await waitUntil("Expected Cancel to dismiss the sheet and clear its target") { + window.attachedSheet == nil && self.subject.xcodeBeingConfirmedForUninstallation == nil + } + XCTAssertTrue(operations.read { $0.isEmpty }) + XCTAssertNil(subject.uninstallTask) + + subject.xcodeBeingConfirmedForUninstallation = xcode + try await waitUntil("Expected the uninstall sheet to reopen for the same Xcode") { + sheetButton(titled: checkboxTitle) != nil + } + let reopenedCheckbox = try XCTUnwrap(sheetButton(titled: checkboxTitle)) + XCTAssertEqual(reopenedCheckbox.state, .off) + reopenedCheckbox.performClick(nil) + XCTAssertEqual(reopenedCheckbox.state, .on) + let uninstallButton = try XCTUnwrap(sheetButton(titled: localizeString("Uninstall"))) + uninstallButton.performClick(nil) + try await waitUntil("Expected the mocked permanent uninstall to finish and dismiss its sheet") { + !operations.read { $0.isEmpty } + && self.subject.uninstallTask == nil + && self.subject.xcodeBeingConfirmedForUninstallation == nil + && window.attachedSheet == nil + } + + XCTAssertEqual(operations.read { $0 }, [ + "remove:\(xcode.installedPath!.string)", "refreshSelection", "refreshInstalled" + ]) + assertUninstallSucceeded() + } + func test_Uninstall_MissingXcodePresentsFileNotFoundError() async throws { let missingPath = try XCTUnwrap(Path("/Applications/Xcode-Missing.app")) let xcode = Xcode(version: Version("15.0.0")!, installState: .installed(missingPath), selected: false, icon: nil) @@ -411,6 +491,237 @@ class AppStateTests: XCTestCase { XCTAssertEqual(subject.allXcodes[0].installState, .notInstalled) } + func test_Uninstall_DefaultAndExplicitTrashUseCurrentUserWithEitherHelperPreference() async throws { + for useHelper in [false, true] { + for useDefault in [false, true] { + let (xcode, operations) = makeUninstallFixture(useHelper: useHelper) + + if useDefault { + subject.uninstall(xcode: xcode) + } else { + subject.uninstall(xcode: xcode, permanently: false) + } + XCTAssertEqual(subject.allXcodes[0].installState, .uninstalling(xcode.installedPath!)) + let task = try XCTUnwrap(subject.uninstallTask) + await task.value + + XCTAssertEqual(operations.read { $0 }, [ + "trash:\(xcode.installedPath!.string)", "refreshSelection", "refreshInstalled" + ]) + assertUninstallSucceeded() + } + } + } + + func test_Uninstall_PermanentRemovesOriginalBundleWithEitherHelperPreference() async throws { + for useHelper in [false, true] { + let (xcode, operations) = makeUninstallFixture(useHelper: useHelper) + + subject.uninstall(xcode: xcode, permanently: true) + XCTAssertEqual(subject.allXcodes[0].installState, .uninstalling(xcode.installedPath!)) + let task = try XCTUnwrap(subject.uninstallTask) + await task.value + + let deletion = useHelper + ? ["installHelper", "checkHelper", "helperRemove:\(xcode.installedPath!.string)"] + : ["remove:\(xcode.installedPath!.string)"] + XCTAssertEqual(operations.read { $0 }, deletion + ["refreshSelection", "refreshInstalled"]) + assertUninstallSucceeded() + } + } + + func test_Uninstall_FailureRestoresInstalledStateAndCleansUpTask() async throws { + for useHelper in [false, true] { + for permanently in [false, true] { + let failure = NSError(domain: "UninstallTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Deletion failed"]) + let (xcode, operations) = makeUninstallFixture(useHelper: useHelper, failure: failure) + + subject.uninstall(xcode: xcode, permanently: permanently) + let task = try XCTUnwrap(subject.uninstallTask) + await task.value + + let deletion: [String] + if permanently && useHelper { + deletion = ["installHelper", "checkHelper", "helperRemove:\(xcode.installedPath!.string)"] + } else { + deletion = ["\(permanently ? "remove" : "trash"):\(xcode.installedPath!.string)"] + } + XCTAssertEqual(operations.read { $0 }, deletion) + XCTAssertEqual(subject.allXcodes[0].installState, xcode.installState) + XCTAssertEqual(subject.error as NSError?, failure) + guard case let .generic(title, message) = subject.presentedAlert else { + return XCTFail("Expected generic uninstall error alert") + } + XCTAssertEqual(title, localizeString("Alert.Uninstall.Error.Title")) + XCTAssertEqual(message, failure.localizedDescription) + XCTAssertNil(subject.uninstallTask) + XCTAssertNil(subject.uninstallTaskID) + } + } + } + + func test_Uninstall_MissingMetadataPreventsAllDeletionAndHelperOperations() async throws { + for useHelper in [false, true] { + for permanently in [false, true] { + let (xcode, operations) = makeUninstallFixture(useHelper: useHelper) + Current.files.contentsAtPath = { _ in nil } + + subject.uninstall(xcode: xcode, permanently: permanently) + let task = try XCTUnwrap(subject.uninstallTask) + await task.value + + XCTAssertTrue(operations.read { $0.isEmpty }) + XCTAssertEqual(subject.allXcodes[0].installState, xcode.installState) + guard case let .fileNotFound(path) = subject.error as? FileError else { + return XCTFail("Expected file-not-found error") + } + XCTAssertEqual(path, xcode.installedPath!.string) + XCTAssertNotNil(subject.presentedAlert) + XCTAssertNil(subject.uninstallTask) + XCTAssertNil(subject.uninstallTaskID) + } + } + } + + func test_Uninstall_EarlierLocalDeletionRefreshesAfterLaterUninstallFails() async throws { + for permanently in [false, true] { + let (firstXcode, operations) = makeUninstallFixture(useHelper: false) + let secondPath = Path("/Applications/Xcode-1.0.0.app")! + let secondVersion = Version("1.0.0")! + let secondXcode = Xcode(version: secondVersion, installState: .installed(secondPath), selected: false, icon: nil) + let remainingXcode = InstalledXcode(path: secondPath, version: secondVersion) + subject.availableXcodes.append( + AvailableXcode(version: secondVersion, url: URL(string: "https://apple.com/second.xip")!, filename: "second.xip", releaseDate: nil) + ) + subject.allXcodes = [firstXcode, secondXcode] + + let contentsAtPath = Current.files.contentsAtPath + Current.files.contentsAtPath = { path in + path.hasPrefix(secondPath.string + "/") ? nil : contentsAtPath(path) + } + Current.files.installedXcodes = { _ in + operations.withValue { $0.append("refreshInstalled") } + return [remainingXcode] + } + Current.shell.xcodeSelectPrintPath = { + operations.withValue { $0.append("refreshSelection") } + return ProcessOutput(status: 0, out: secondPath.string, err: "") + } + + let deletionStarted = AsyncStream.makeStream() + let allowDeletionToFinish = DispatchSemaphore(value: 0) + defer { allowDeletionToFinish.signal() } + let delete: @Sendable (URL) -> Void = { url in + XCTAssertFalse(Thread.isMainThread) + operations.withValue { $0.append("delete:\(url.path)") } + deletionStarted.continuation.yield(()) + deletionStarted.continuation.finish() + XCTAssertEqual(allowDeletionToFinish.wait(timeout: .now() + 10), .success) + } + Current.files.removeItem = { delete($0) } + Current.files.trashItem = { + delete($0) + return URL(fileURLWithPath: "/Users/test/.Trash/Xcode-0.0.0.app") + } + + subject.uninstall(xcode: firstXcode, permanently: permanently) + let firstTask = try XCTUnwrap(subject.uninstallTask) + var started = deletionStarted.stream.makeAsyncIterator() + _ = await started.next() + + subject.uninstall(xcode: secondXcode, permanently: permanently) + let secondTask = try XCTUnwrap(subject.uninstallTask) + await secondTask.value + + XCTAssertFalse(firstTask.isCancelled) + XCTAssertEqual(subject.allXcodes.first { $0.id == firstXcode.id }?.installState, .uninstalling(firstXcode.installedPath!)) + XCTAssertEqual(subject.allXcodes.first { $0.id == secondXcode.id }?.installState, .installed(secondPath)) + guard case let .fileNotFound(path) = subject.error as? FileError else { + allowDeletionToFinish.signal() + await firstTask.value + return XCTFail("Expected the second uninstall to fail metadata validation") + } + XCTAssertEqual(path, secondPath.string) + XCTAssertNil(subject.uninstallTask) + XCTAssertNil(subject.uninstallTaskID) + + allowDeletionToFinish.signal() + await firstTask.value + + XCTAssertEqual(operations.read { $0 }, [ + "delete:\(firstXcode.installedPath!.string)", "refreshSelection", "refreshInstalled" + ]) + XCTAssertEqual(subject.allXcodes.first { $0.id == firstXcode.id }?.installState, .notInstalled) + XCTAssertEqual(subject.allXcodes.first { $0.id == secondXcode.id }?.installState, .installed(secondPath)) + XCTAssertEqual(subject.selectedXcodePath, secondPath.string) + XCTAssertEqual(subject.allXcodes.first { $0.id == secondXcode.id }?.selected, true) + XCTAssertNil(subject.uninstallTask) + XCTAssertNil(subject.uninstallTaskID) + } + } + + private func makeUninstallFixture( + useHelper: Bool, + failure: NSError? = nil + ) -> (Xcode, TestLockedBox<[String]>) { + Current = .mock + subject = AppState() + subject.helperInstallState = .notInstalled + let path = Path("/Applications/Xcode-0.0.0.app")! + let version = Version("0.0.0")! + let xcode = Xcode(version: version, installState: .installed(path), selected: true, icon: nil) + subject.availableXcodes = [ + AvailableXcode(version: version, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) + ] + subject.selectedXcodePath = path.string + subject.allXcodes = [xcode] + let operations = TestLockedBox<[String]>([]) + Current.defaults.bool = { key in + key == PreferenceKey.usePrivilegeHelperForFileOperations.rawValue ? useHelper : nil + } + Current.files.trashItem = { url in + XCTAssertFalse(Thread.isMainThread) + operations.withValue { $0.append("trash:\(url.path)") } + if let failure { throw failure } + return URL(fileURLWithPath: "/Users/test/.Trash/Xcode-0.0.0.app") + } + Current.files.removeItem = { url in + XCTAssertFalse(Thread.isMainThread) + operations.withValue { $0.append("remove:\(url.path)") } + if let failure { throw failure } + } + Current.helper.install = { + operations.withValue { $0.append("installHelper") } + } + Current.helper.checkIfLatestHelperIsInstalledAsync = { + operations.withValue { $0.append("checkHelper") } + return true + } + Current.helper.removeAsync = { path in + operations.withValue { $0.append("helperRemove:\(path)") } + if let failure { throw failure } + } + Current.shell.xcodeSelectPrintPath = { + operations.withValue { $0.append("refreshSelection") } + return ProcessOutput(status: 0, out: "", err: "") + } + Current.files.installedXcodes = { _ in + operations.withValue { $0.append("refreshInstalled") } + return [] + } + return (xcode, operations) + } + + private func assertUninstallSucceeded(file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(subject.allXcodes[0].installState, .notInstalled, file: file, line: line) + XCTAssertFalse(subject.allXcodes[0].selected, file: file, line: line) + XCTAssertEqual(subject.selectedXcodePath, "", file: file, line: line) + XCTAssertNil(subject.error, file: file, line: line) + XCTAssertNil(subject.presentedAlert, file: file, line: line) + XCTAssertNil(subject.uninstallTask, file: file, line: line) + XCTAssertNil(subject.uninstallTaskID, file: file, line: line) + } + func test_Signout_RemovesCookiesFromDownloadSession() throws { let session = URLSession(configuration: .ephemeral) Current.network.session = session