Files
conjure/Sources/Conjure/UninstallReviewController.swift

501 lines
23 KiB
Swift

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 themeObservation: ThemeObservation?
private var statusIsError = false
private var candidates: [UninstallCandidate] = []
private var visible: [UninstallCandidate] = []
private var selected: Set<URL> = []
private var discoveryState = DiscoveryState.loading
private var busy = false
private var scanTask: Task<Void, Never>?
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)
])
themeObservation = ThemeObservation { [weak self, weak context, weak guidance, weak pill] in
guard let self else { return }
context?.textColor = ThemePalette.text
guidance?.textColor = ThemePalette.secondaryText
self.search.applyTheme()
(self.search.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText
self.back.contentTintColor = ThemePalette.secondaryText
self.remove.contentTintColor = ThemePalette.error
pill?.layer?.borderColor = ThemePalette.accent.withAlphaComponent(0.35).cgColor
pill?.layer?.backgroundColor = ThemePalette.background.withAlphaComponent(0.25).cgColor
}
}
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
statusIsError = false
remove.isEnabled = false
switch discoveryState {
case .loading:
status.stringValue = "Finding associated files…"
case .failed(let message):
status.stringValue = message
status.textColor = ThemePalette.error
statusIsError = true
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.selectable = candidate.isSelectable
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.window.appearance = ThemePalette.appearance
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
statusIsError = false
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.window.appearance = ThemePalette.appearance
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
statusIsError = false
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
private var themeObservation: ThemeObservation?
var selectable = true { didSet { applyTheme() } }
private func applyTheme() {
name.textColor = selectable ? ThemePalette.text : ThemePalette.secondaryText
detail.textColor = ThemePalette.secondaryText
size.textColor = ThemePalette.secondaryText
}
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)
])
themeObservation = ThemeObservation { [weak self] in self?.applyTheme() }
}
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) }
}
}