Files
conjure/Sources/Conjure/UninstallReviewController.swift
T

384 lines
18 KiB
Swift

import AppKit
import ConjureKit
@MainActor
final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
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<URL> = []
private var busy = false
private var scanTask: Task<Void, Never>?
var closed: (() -> Void)?
var changed: (() -> Void)?
init(application: Application, uninstaller: ApplicationUninstaller) {
self.application = application
self.uninstaller = uninstaller
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 540),
styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false)
window.title = "Uninstall \(application.name)"
window.appearance = NSAppearance(named: .darkAqua)
window.titlebarAppearsTransparent = true
window.backgroundColor = .clear
window.isOpaque = false
window.isReleasedWhenClosed = false
super.init(window: window)
window.delegate = self
configure(window)
}
required init?(coder: NSCoder) { fatalError("Conjure creates uninstall review programmatically") }
func present(on parent: NSWindow) {
guard let window else { return }
parent.beginSheet(window)
window.makeFirstResponder(search)
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 }
candidates = found
selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url))
reload()
} catch {
guard let self, !Task.isCancelled else { return }
status.stringValue = "Could not inspect application files: \(error.localizedDescription)"
}
}
}
private func configure(_ window: NSWindow) {
let content = NSVisualEffectView()
content.material = .hudWindow
content.blendingMode = .behindWindow
content.state = .active
window.contentView = content
let background = LauncherBackground(frame: content.bounds)
background.autoresizingMask = [.width, .height]
background.setAccessibilityElement(false)
content.addSubview(background)
search.placeholderString = "Filter files and folders by name or path…"
search.delegate = self
search.sendsSearchStringImmediately = true
search.setAccessibilityLabel("Filter uninstall files")
search.font = .systemFont(ofSize: 18)
search.textColor = SunsetPalette.text
search.isBordered = false
search.drawsBackground = false
search.focusRingType = .none
status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText
status.setAccessibilityLabel("Uninstall selection and progress")
back.target = self
back.action = #selector(goBack)
back.bezelStyle = .rounded
remove.target = self
remove.action = #selector(confirmUninstall)
remove.contentTintColor = .systemRed
remove.bezelStyle = .rounded
remove.keyEquivalent = "\r"
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")
table.toggle = { [weak self] in self?.toggleHighlighted() }
table.dismiss = { [weak self] in self?.goBack() }
let scroll = NSScrollView()
scroll.documentView = table
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.contentView.drawsBackground = false
let footer = NSStackView(views: [back, NSView(), remove])
footer.orientation = .horizontal
footer.spacing = 12
for view in [search, status, scroll, footer] {
view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view)
}
NSLayoutConstraint.activate([
search.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
search.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
search.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor, constant: 16),
search.heightAnchor.constraint(equalToConstant: 32),
status.leadingAnchor.constraint(equalTo: search.leadingAnchor),
status.trailingAnchor.constraint(equalTo: search.trailingAnchor),
status.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12),
scroll.topAnchor.constraint(equalTo: status.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: search.trailingAnchor),
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
footer.heightAnchor.constraint(equalToConstant: 32)
])
}
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() {
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(_:)): confirmUninstall()
case #selector(NSResponder.cancelOperation(_:)): goBack()
default: return false
}
return true
}
private 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 ? SunsetPalette.text : SunsetPalette.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) }
table.reloadData()
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 !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.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))
setBusy(false)
// Refresh identities and availability after a failed operation before another review.
do {
candidates = try await Task.detached { [application] in
try UninstallDiscovery.scan(application: application)
}.value
selected.formIntersection(Set(candidates.filter(\.isSelectable).map(\.url)))
reload()
} catch { status.stringValue = error.localizedDescription; remove.isEnabled = false }
}
}
}
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.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 = !value && !selected.isEmpty
table.reloadData()
if !value { updateStatus() }
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
if !busy { goBack() }
return false
}
@objc private func goBack() {
guard !busy, let window, let parent = window.sheetParent else { return }
scanTask?.cancel()
parent.endSheet(window)
window.orderOut(nil)
closed?()
}
}
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 = NSImageView()
init() {
super.init(frame: .zero)
name.font = .systemFont(ofSize: 14, weight: .medium)
detail.font = .systemFont(ofSize: 11)
detail.textColor = SunsetPalette.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 = SunsetPalette.secondaryText
size.font = .systemFont(ofSize: 12)
size.setContentCompressionResistancePriority(.required, for: .horizontal)
fileIcon.imageScaling = .scaleProportionallyDown
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),
fileIcon.widthAnchor.constraint(equalToConstant: 22),
fileIcon.heightAnchor.constraint(equalToConstant: 22)
])
}
required init?(coder: NSCoder) { fatalError("Conjure creates uninstall rows programmatically") }
}
@MainActor
private final class UninstallFilesTable: NSTableView {
var toggle: (() -> Void)?
var dismiss: (() -> Void)?
override func keyDown(with event: NSEvent) {
if event.keyCode == 49 { toggle?() }
else if event.keyCode == 53 { dismiss?() }
else { super.keyDown(with: event) }
}
}