472 lines
22 KiB
Swift
472 lines
22 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 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)
|
|
])
|
|
}
|
|
|
|
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) }
|
|
}
|
|
}
|