import AppKit import ConjureKit @MainActor final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { private enum DiscoveryState { case loading case ready case failed(String) } private let application: Application private let uninstaller: ApplicationUninstaller private let search = NSSearchField() private let table = UninstallFilesTable() private let status = NSTextField(wrappingLabelWithString: "Finding associated files…") private let remove = NSButton(title: "Uninstall application", target: nil, action: nil) private let back = NSButton(title: "Back", target: nil, action: nil) private var candidates: [UninstallCandidate] = [] private var visible: [UninstallCandidate] = [] private var selected: Set = [] private var discoveryState = DiscoveryState.loading private var busy = false private var scanTask: Task? private var window: NSWindow? { view.window } var closed: (() -> Void)? var changed: (() -> Void)? var canClose: Bool { !busy } init(application: Application, uninstaller: ApplicationUninstaller) { self.application = application self.uninstaller = uninstaller super.init(nibName: nil, bundle: nil) configure() } required init?(coder: NSCoder) { fatalError("Conjure creates uninstall review programmatically") } func present() { guard let window else { return } discoveryState = .loading updateStatus() window.makeFirstResponder(search) (search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent scanTask = Task { [weak self, application] in do { let found = try await Task.detached(priority: .userInitiated) { try UninstallDiscovery.scan(application: application) }.value guard let self, !Task.isCancelled else { return } applyDiscoveryResult(.success(found)) } catch { guard let self, !Task.isCancelled else { return } applyDiscoveryResult(.failure(error)) } } } private func configure() { let content = UninstallReviewContent() content.wantsLayer = true view = content let context = NSTextField(wrappingLabelWithString: "Uninstall \(application.name)") context.font = .systemFont(ofSize: 14, weight: .medium) context.textColor = ThemePalette.text let guidance = NSTextField(wrappingLabelWithString: "Return toggles file inclusion · Command-Return opens confirmation") guidance.font = .systemFont(ofSize: 12) guidance.textColor = ThemePalette.secondaryText search.placeholderAttributedString = NSAttributedString( string: "Filter files and folders by name or path…", attributes: [.foregroundColor: ThemePalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)] ) if let cell = search.cell as? NSSearchFieldCell { cell.searchButtonCell = nil cell.cancelButtonCell = nil } search.delegate = self search.sendsSearchStringImmediately = true search.setAccessibilityLabel("Filter uninstall files for \(application.name)") search.setAccessibilityHelp(guidance.stringValue) search.font = .systemFont(ofSize: 20) search.textColor = ThemePalette.text search.isBordered = false search.drawsBackground = false search.focusRingType = .none status.font = .systemFont(ofSize: 12) status.textColor = ThemePalette.secondaryText status.setAccessibilityLabel("Uninstall selection and progress") back.target = self back.action = #selector(goBack) back.title = "" back.image = IconStyle.symbol("chevron.left", for: search.font!) back.imageScaling = .scaleProportionallyDown back.isBordered = false back.contentTintColor = ThemePalette.secondaryText back.setAccessibilityLabel("Back to applications") let header = NSStackView(views: [back, search]) header.orientation = .horizontal header.alignment = .centerY header.spacing = 12 remove.target = self remove.action = #selector(confirmUninstall) remove.contentTintColor = ThemePalette.error remove.isBordered = false remove.font = .systemFont(ofSize: 12, weight: .medium) remove.keyEquivalent = "\r" remove.keyEquivalentModifierMask = .command remove.toolTip = "Confirm uninstall (Command-Return). Return toggles the highlighted file." remove.isEnabled = false table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("file"))) table.headerView = nil table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle table.backgroundColor = .clear table.rowHeight = 56 table.style = .plain table.dataSource = self table.delegate = self table.allowsEmptySelection = false table.setAccessibilityLabel("Files to uninstall for \(application.name)") table.toggle = { [weak self] in self?.toggleHighlighted() } let scroll = NSScrollView() scroll.documentView = table scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.drawsBackground = false scroll.contentView.drawsBackground = false let pill = NSView() pill.wantsLayer = true pill.layer?.cornerRadius = 18 pill.layer?.borderWidth = 0.5 pill.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor pill.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor let shortcut = ShortcutIndicator("⌘", "↵") for child in [remove, shortcut] { child.translatesAutoresizingMaskIntoConstraints = false pill.addSubview(child) } NSLayoutConstraint.activate([ pill.heightAnchor.constraint(equalToConstant: 36), remove.leadingAnchor.constraint(equalTo: pill.leadingAnchor, constant: 14), remove.centerYAnchor.constraint(equalTo: pill.centerYAnchor), shortcut.leadingAnchor.constraint(equalTo: remove.trailingAnchor, constant: 8), shortcut.centerYAnchor.constraint(equalTo: pill.centerYAnchor), shortcut.trailingAnchor.constraint(equalTo: pill.trailingAnchor, constant: -12) ]) let footer = NSStackView(views: [NSView(), pill]) footer.orientation = .horizontal footer.spacing = 12 for view in [header, context, status, guidance, scroll, footer] { view.translatesAutoresizingMaskIntoConstraints = false content.addSubview(view) } NSLayoutConstraint.activate([ header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), header.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), header.heightAnchor.constraint(equalToConstant: 32), back.widthAnchor.constraint(equalToConstant: 22), back.heightAnchor.constraint(equalToConstant: 22), search.widthAnchor.constraint(equalTo: header.widthAnchor, constant: -34), context.leadingAnchor.constraint(equalTo: search.leadingAnchor), context.trailingAnchor.constraint(equalTo: search.trailingAnchor), context.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12), status.leadingAnchor.constraint(equalTo: search.leadingAnchor), status.trailingAnchor.constraint(equalTo: search.trailingAnchor), status.topAnchor.constraint(equalTo: context.bottomAnchor, constant: 6), guidance.leadingAnchor.constraint(equalTo: search.leadingAnchor), guidance.trailingAnchor.constraint(equalTo: search.trailingAnchor), guidance.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 6), scroll.topAnchor.constraint(equalTo: guidance.bottomAnchor, constant: 12), scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -16), footer.leadingAnchor.constraint(equalTo: search.leadingAnchor), footer.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8), footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8), footer.heightAnchor.constraint(equalToConstant: 36) ]) } func applyDiscoveryResult(_ result: Result<[UninstallCandidate], Error>, preservingSelection: Bool = false) { switch result { case .success(let found): candidates = found if preservingSelection { selected.formIntersection(Set(found.filter(\.isSelectable).map(\.url))) } else { selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url)) } discoveryState = .ready reload() case .failure(let error): discoveryState = .failed("Could not inspect application files: \(error.localizedDescription)") updateStatus() } } private func reload() { let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) visible = candidates.filter { query.isEmpty || $0.url.path.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil } table.reloadData() if !visible.isEmpty { table.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) } updateStatus() } private func updateStatus() { status.textColor = ThemePalette.secondaryText remove.isEnabled = false switch discoveryState { case .loading: status.stringValue = "Finding associated files…" case .failed(let message): status.stringValue = message status.textColor = ThemePalette.error case .ready: let chosen = candidates.filter { selected.contains($0.url) } let size = chosen.compactMap(\.size).reduce(Int64(0), +) let unknown = chosen.contains { $0.size == nil } ? " (some sizes unavailable)" : "" status.stringValue = "\(chosen.count) of \(candidates.count) files selected · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))\(unknown)" if visible.isEmpty && !candidates.isEmpty { status.stringValue += " · No matching files" } remove.isEnabled = !busy && !chosen.isEmpty } } func controlTextDidChange(_ notification: Notification) { reload() } func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { switch selector { case #selector(NSResponder.moveDown(_:)): moveSelection(1) case #selector(NSResponder.moveUp(_:)): moveSelection(-1) case #selector(NSResponder.insertNewline(_:)): toggleHighlighted() default: return false } return true } func moveSelection(_ offset: Int) { guard !visible.isEmpty else { return } let row = min(max(table.selectedRow + offset, 0), visible.count - 1) table.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) table.scrollRowToVisible(row) } func numberOfRows(in tableView: NSTableView) -> Int { visible.count } func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { LauncherRowView() } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let candidate = visible[row] let identifier = NSUserInterfaceItemIdentifier("uninstallFile") let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? UninstallFileCell) ?? UninstallFileCell() cell.identifier = identifier cell.toggle.target = self cell.toggle.action = #selector(toggleFile(_:)) cell.toggle.url = candidate.url cell.toggle.state = selected.contains(candidate.url) ? .on : .off cell.toggle.isEnabled = candidate.isSelectable && !busy cell.toggle.setAccessibilityLabel("Select \(candidate.url.lastPathComponent)") cell.name.stringValue = candidate.url.lastPathComponent cell.name.textColor = candidate.isSelectable ? ThemePalette.text : ThemePalette.secondaryText let path = (candidate.url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath cell.detail.stringValue = candidate.reason.map { "\(path) · \($0)" } ?? path cell.size.stringValue = candidate.size.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "—" cell.fileIcon.image = candidate.isSelectable ? NSWorkspace.shared.icon(forFile: candidate.url.path) : NSImage(systemSymbolName: "lock", accessibilityDescription: "Unavailable") cell.toolTip = candidate.url.path + (candidate.reason.map { "\n\($0)" } ?? "") return cell } @objc private func toggleFile(_ sender: UninstallFileToggle) { guard !busy, let url = sender.url else { return } if sender.state == .on { selected.insert(url) } else { selected.remove(url) } updateStatus() } private func toggleHighlighted() { guard !busy, visible.indices.contains(table.selectedRow) else { return } let candidate = visible[table.selectedRow] guard candidate.isSelectable else { return } if !selected.insert(candidate.url).inserted { selected.remove(candidate.url) } if let cell = table.view(atColumn: 0, row: table.selectedRow, makeIfNecessary: false) as? UninstallFileCell { NSAnimationContext.runAnimationGroup { context in context.duration = 0 context.allowsImplicitAnimation = false cell.toggle.state = selected.contains(candidate.url) ? .on : .off cell.toggle.needsDisplay = true } } updateStatus() } private func runningApplications() -> [NSRunningApplication] { NSWorkspace.shared.runningApplications.filter { guard let url = $0.bundleURL?.standardizedFileURL.resolvingSymlinksInPath() else { return false } return url.path == application.url.path || url.path.hasPrefix(application.url.path + "/") } } @objc private func confirmUninstall() { guard case .ready = discoveryState, !busy, !selected.isEmpty, let window else { return } let urls = candidates.filter { $0.isSelectable && selected.contains($0.url) }.map(\.url) guard !urls.isEmpty else { return } let running = runningApplications() let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Uninstall \(application.name)?" alert.informativeText = "Move \(urls.count) selected files and folders to Trash." if !running.isEmpty { alert.informativeText += " Save your work first; Conjure will ask the application to quit." } alert.addButton(withTitle: running.isEmpty ? "Uninstall application" : "Quit and Uninstall") alert.addButton(withTitle: "Cancel") setBusy(true) Task { guard await alert.beginSheetModal(for: window) == .alertFirstButtonReturn else { setBusy(false) return } do { status.textColor = ThemePalette.secondaryText status.stringValue = "Waiting for application to quit…" for app in running where !app.isTerminated { guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") } } for _ in 0..<100 { if runningApplications().isEmpty { break } try await Task.sleep(for: .milliseconds(100)) } guard runningApplications().isEmpty else { throw ReviewError("The application is still running. No files were moved.") } status.stringValue = "Uninstalling \(application.name)…" let report = try await uninstaller.execute(urls) changed?() if report.needsRecovery || !report.messages.isEmpty { await showFeedback(report.needsRecovery ? "Uninstall needs attention" : "Uninstall complete", report.messages.joined(separator: "\n"), allowRecovery: report.needsRecovery) } setBusy(false) goBack() } catch { changed?() await showFeedback("Could not uninstall \(application.name)", error.localizedDescription, allowRecovery: !(error is ReviewError)) discoveryState = .loading setBusy(false) // Refresh identities and availability after a failed operation before another review. do { let found = try await Task.detached { [application] in try UninstallDiscovery.scan(application: application) }.value applyDiscoveryResult(.success(found), preservingSelection: true) } catch { applyDiscoveryResult(.failure(error)) } } } } private func showFeedback(_ title: String, _ message: String, allowRecovery: Bool = false) async { guard let window else { return } var title = title var message = message var canRecover = allowRecovery while true { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.addButton(withTitle: "OK") if canRecover { alert.addButton(withTitle: "Retry recovery") } guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return } status.textColor = ThemePalette.secondaryText status.stringValue = "Recovering pending uninstall…" let report = await uninstaller.recover() changed?() title = report.needsRecovery ? "Recovery needs attention" : "Recovery complete" message = report.messages.isEmpty ? "No pending files need recovery." : report.messages.joined(separator: "\n") canRecover = report.needsRecovery } } private func setBusy(_ value: Bool) { busy = value search.isEnabled = !value back.isEnabled = !value remove.isEnabled = false table.reloadData() if !value { updateStatus() window?.makeFirstResponder(search) } } @objc func goBack() { guard canClose else { return } scanTask?.cancel() (view as! UninstallReviewContent).acceptsInteraction = false view.setAccessibilityHidden(true) closed?() } } @MainActor private final class UninstallReviewContent: NSView { var acceptsInteraction = true override func hitTest(_ point: NSPoint) -> NSView? { acceptsInteraction ? super.hitTest(point) : nil } } private struct ReviewError: LocalizedError { let errorDescription: String? init(_ message: String) { errorDescription = message } } @MainActor private final class UninstallFileToggle: NSButton { var url: URL? } @MainActor private final class UninstallFileCell: NSTableCellView { let toggle = UninstallFileToggle(checkboxWithTitle: "", target: nil, action: nil) let name = NSTextField(labelWithString: "") let detail = NSTextField(labelWithString: "") let size = NSTextField(labelWithString: "") let fileIcon: IconView init() { let font = NSFont.systemFont(ofSize: 14, weight: .medium) fileIcon = IconView(font: font) super.init(frame: .zero) name.font = font detail.font = .systemFont(ofSize: 11) detail.textColor = ThemePalette.secondaryText for label in [name, detail] { label.lineBreakMode = .byTruncatingMiddle label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } let labels = NSStackView(views: [name, detail]) labels.orientation = .vertical labels.alignment = .leading labels.spacing = 3 size.textColor = ThemePalette.secondaryText size.font = .systemFont(ofSize: 12) size.setContentCompressionResistancePriority(.required, for: .horizontal) let row = NSStackView(views: [toggle, labels, NSView(), size, fileIcon]) row.orientation = .horizontal row.alignment = .centerY row.spacing = 10 row.translatesAutoresizingMaskIntoConstraints = false addSubview(row) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), row.topAnchor.constraint(equalTo: topAnchor, constant: 4), row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) ]) } required init?(coder: NSCoder) { fatalError("Conjure creates uninstall rows programmatically") } } @MainActor private final class UninstallFilesTable: NSTableView { var toggle: (() -> Void)? override func keyDown(with event: NSEvent) { if [36, 76, 49].contains(event.keyCode), event.modifierFlags.intersection([.command, .option, .control]).isEmpty { toggle?() } else { super.keyDown(with: event) } } }