feat: add contextual actions and recoverable application uninstall
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user