feat: add contextual actions and recoverable application uninstall

This commit is contained in:
2026-10-12 15:01:15 +02:00
parent 8d8e2278ab
commit 2598f6f1dd
10 changed files with 2361 additions and 12 deletions
+18 -3
View File
@@ -33,6 +33,7 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
- Type to filter applications; the first match is selected automatically. - Type to filter applications; the first match is selected automatically.
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text. - Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
- Return, keypad Enter, the Open Application button, or a double-click opens the selection. - Return, keypad Enter, the Open Application button, or a double-click opens the selection.
- Command-K or the bottom-right Actions button opens a searchable selector for the selected application. Return chooses **Uninstall application**; Escape closes the selector and returns focus to search.
- Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back. - Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back.
- Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it. - Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
- Every activation brings the launcher to the currently active macOS Space, including full-screen Spaces, without switching desktops. - Every activation brings the launcher to the currently active macOS Space, including full-screen Spaces, without switching desktops.
@@ -74,6 +75,20 @@ Background discovery preserves the selected application even if its row changes.
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes. Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
## Application actions and uninstall
Select an application and open **Actions ⌘K**. The red **Uninstall application** action opens a review sheet with file checkboxes, paths, sizes, and a selected-item count. Filtering by name or path does not change selections. Up/Down highlights rows; Space toggles the highlighted eligible item when the file list has focus. Back or Escape returns to the launcher. Return or the uninstall button opens a final confirmation; nothing is moved merely by choosing the action.
Discovery includes the application bundle and exact bundle-ID paths in your Library: Preferences, Caches, Application Support, Logs, Saved Application State, Application Scripts, and Containers. Embedded extension identifiers are included. App-group entitlements are read from validated native code signatures; related shared Group Containers and Application Scripts remain unchecked because other applications may use them. Conjure does not guess name-based leftovers or include package receipts. This is conservative discovery, not a guarantee that every associated file is found.
System-managed applications, Conjure itself, invalid bundles, symbolic-link paths, locked items, and files owned by another user are not removable. Locked candidates show a reason. A locked application bundle can still have eligible user-owned associated data; removing that data does not remove the locked application itself. No administrator authorization is requested. Running instances of the selected application and its embedded applications are asked to quit gracefully; refusal or timeout stops the operation before moving files.
Uninstall freezes the selected paths and records filesystem identities, then stages items by exclusive same-volume moves into private quarantine directories. A durable journal records each intended move before it happens. Staging failure attempts reverse-order restoration without overwriting recreated original paths. Only after every item is staged is the operation committed and finalized into Trash. A finalization failure retains committed quarantine data rather than trying to undo an already committed uninstall.
Interrupted operations are recovered on startup: uncommitted operations restore their originals; committed operations finish moving items to Trash. Unresolved operations block new uninstalls. Failure feedback offers **Retry recovery** and identifies retained journal/quarantine paths. Startup recovery feedback and retry remain available in Settings without taking focus during a login launch. Authenticated journals live under `~/Library/Application Support/Conjure/UninstallTransactions`; completed journals retain original-to-Trash mappings in `Completed`.
Files are never permanently deleted by uninstall. Trash moves preserve file metadata, but Finder's **Put Back** metadata is not created; restore completed items manually using the retained original-path mappings. Rollback is best-effort, not atomic: inaccessible or disconnected volumes and recreated paths can require manual recovery. Same-user malicious code with access to the journal key is outside the journal-authentication guarantee. Unsupported filesystem semantics fail closed.
## Native UI ## Native UI
The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded panel, large borderless search field, compact icon rows, right-aligned application labels, and bottom-right launch pill. Its Sunset palette uses a dark brown background, soft amber and rust glows, warm cream text, and orange accents on the caret, selection, and action outline. The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded panel, large borderless search field, compact icon rows, right-aligned application labels, and bottom-right launch pill. Its Sunset palette uses a dark brown background, soft amber and rust glows, warm cream text, and orange accents on the caret, selection, and action outline.
@@ -86,8 +101,8 @@ The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView`
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection. AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
- `Sources/Conjure`: application lifecycle, launcher and Settings windows, and application launching. - `Sources/Conjure`: application lifecycle, launcher, Settings, contextual actions and uninstall review windows, and application launching.
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, and launcher alignment geometry. - `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery.
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, and installation safety checks. - `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, installation safety, and uninstall failure/recovery checks.
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)). The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
@@ -0,0 +1,121 @@
import AppKit
import ConjureKit
@MainActor
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSPopoverDelegate {
private let popover = NSPopover()
private let search = ActionsSearchField()
private let action = NSButton(title: "Uninstall application", target: nil, action: nil)
private let empty = NSTextField(labelWithString: "No matching actions")
private let applicationLabel = NSTextField(labelWithString: "")
var uninstall: (() -> Void)?
var closed: (() -> Void)?
var isShown: Bool { popover.isShown }
init() {
super.init(nibName: nil, bundle: nil)
popover.contentViewController = self
popover.behavior = .semitransient
popover.delegate = self
}
required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") }
override func loadView() {
let content = NSVisualEffectView()
content.material = .popover
content.state = .active
content.appearance = NSAppearance(named: .darkAqua)
view = content
preferredContentSize = NSSize(width: 340, height: 150)
applicationLabel.textColor = SunsetPalette.secondaryText
applicationLabel.lineBreakMode = .byTruncatingTail
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
action.isBordered = false
action.alignment = .left
action.font = .systemFont(ofSize: 16, weight: .medium)
action.contentTintColor = .systemRed
action.attributedTitle = NSAttributedString(string: action.title, attributes: [
.foregroundColor: NSColor.systemRed, .font: NSFont.systemFont(ofSize: 16, weight: .medium)
])
action.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil)
action.imagePosition = .imageLeading
action.target = self
action.action = #selector(chooseAction)
action.setAccessibilityLabel("Uninstall application")
search.placeholderString = "Search actions…"
search.delegate = self
search.sendsSearchStringImmediately = true
search.setAccessibilityLabel("Search application actions")
search.closeActions = { [weak self] in self?.close() }
empty.textColor = SunsetPalette.secondaryText
empty.isHidden = true
let stack = NSStackView(views: [applicationLabel, action, empty, search])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 14),
search.widthAnchor.constraint(equalTo: stack.widthAnchor),
action.widthAnchor.constraint(equalTo: stack.widthAnchor),
applicationLabel.widthAnchor.constraint(equalTo: stack.widthAnchor)
])
}
func show(for application: Application, relativeTo anchor: NSView) {
if isShown { close(); return }
_ = view
applicationLabel.stringValue = application.name
search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
view.window?.makeFirstResponder(search)
}
func close() { popover.performClose(nil) }
func popoverDidClose(_ notification: Notification) { closed?() }
func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
let matches = query.isEmpty || action.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
action.isHidden = !matches
empty.isHidden = matches
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
switch selector {
case #selector(NSResponder.insertNewline(_:)):
if !action.isHidden { chooseAction() }
case #selector(NSResponder.cancelOperation(_:)):
close()
case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)):
return true // There is currently one contextual action.
default: return false
}
return true
}
@objc private func chooseAction() {
close()
uninstall?()
}
}
@MainActor
private final class ActionsSearchField: NSSearchField {
var closeActions: (() -> Void)?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
event.charactersIgnoringModifiers?.lowercased() == "k" {
closeActions?()
return true
}
return super.performKeyEquivalent(with: event)
}
}
+14 -2
View File
@@ -90,10 +90,12 @@ struct ConjureApp {
@MainActor @MainActor
private final class ApplicationDelegate: NSObject, NSApplicationDelegate { private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
private lazy var launcher = LauncherWindowController() private let uninstaller = ApplicationUninstaller()
private lazy var launcher = LauncherWindowController(uninstaller: uninstaller)
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() } private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() }
private lazy var settings = SettingsWindowController(shortcut: shortcut) private lazy var settings = SettingsWindowController(shortcut: shortcut)
private var startupShortcutError: String? private var startupShortcutError: String?
private var startupRecoveryMessage: String?
private var isLoginLaunch: Bool { private var isLoginLaunch: Bool {
let event = NSAppleEventManager.shared().currentAppleEvent let event = NSAppleEventManager.shared().currentAppleEvent
@@ -109,6 +111,15 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
let quitItem = menu.addItem(withTitle: "Quit Conjure", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") let quitItem = menu.addItem(withTitle: "Quit Conjure", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
quitItem.target = NSApplication.shared quitItem.target = NSApplication.shared
launcher.window?.contentView?.menu = menu launcher.window?.contentView?.menu = menu
let loginLaunch = isLoginLaunch
settings.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() }
Task {
let report = await uninstaller.recover()
guard !report.messages.isEmpty else { return }
startupRecoveryMessage = report.messages.joined(separator: "\n")
settings.setUninstallRecoveryReport(report)
if !loginLaunch { showSettings(nil) }
}
if !isLoginLaunch { if !isLoginLaunch {
launcher.present() launcher.present()
} }
@@ -125,7 +136,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
guard !isLoginLaunch else { return false } guard !isLoginLaunch else { return false }
launcher.present() launcher.present()
if startupShortcutError != nil { if startupShortcutError != nil || startupRecoveryMessage != nil {
showSettings(nil) showSettings(nil)
} }
return false return false
@@ -134,6 +145,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
@objc func showSettings(_ sender: Any?) { @objc func showSettings(_ sender: Any?) {
settings.present(error: startupShortcutError) settings.present(error: startupShortcutError)
startupShortcutError = nil startupShortcutError = nil
startupRecoveryMessage = nil
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
+54 -5
View File
@@ -17,9 +17,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var previousApplication: NSRunningApplication? private var previousApplication: NSRunningApplication?
private var alignmentGuides: LauncherAlignmentGuides? private var alignmentGuides: LauncherAlignmentGuides?
private static let positionKey = "launcherPosition" private static let positionKey = "launcherPosition"
private let uninstaller: ApplicationUninstaller
private let actionsButton = NSButton(title: "Actions ⌘K", target: nil, action: nil)
private let actions = ApplicationActionsController()
private var uninstallReview: UninstallReviewController?
init(history: LaunchHistory = LaunchHistory()) { init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history self.history = history
self.uninstaller = uninstaller
let window = LauncherPanel( let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless], styleMask: [.borderless],
@@ -46,6 +51,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.alignmentGuides?.end(snapping: self.window) self.alignmentGuides?.end(snapping: self.window)
self.savePosition() self.savePosition()
} }
actions.closed = { [weak self] in
guard let self else { return }
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
else { self.dismiss(restoringFocus: false) }
}
restorePosition() restorePosition()
} }
@@ -81,6 +91,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) { func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return } guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
actions.close()
alignmentGuides?.end() alignmentGuides?.end()
let previous = previousApplication let previous = previousApplication
previousApplication = nil previousApplication = nil
@@ -132,7 +143,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
func windowDidResignKey(_ notification: Notification) { func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return } guard !isLaunching, !actions.isShown, window?.attachedSheet == nil else { return }
dismiss(restoringFocus: false) dismiss(restoringFocus: false)
} }
@@ -228,6 +239,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openButton.target = self openButton.target = self
openButton.action = #selector(openSelection) openButton.action = #selector(openSelection)
openButton.isEnabled = false openButton.isEnabled = false
actionsButton.isBordered = false
actionsButton.font = .systemFont(ofSize: 14, weight: .medium)
actionsButton.contentTintColor = SunsetPalette.text
actionsButton.keyEquivalent = "k"
actionsButton.keyEquivalentModifierMask = .command
actionsButton.target = self
actionsButton.action = #selector(showActions)
actionsButton.setAccessibilityLabel("Application actions")
actionsButton.toolTip = "Actions for the selected application (Command-K)"
actionsButton.isEnabled = false
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select." openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let returnKey = NSTextField(labelWithString: "↵") let returnKey = NSTextField(labelWithString: "↵")
returnKey.font = .systemFont(ofSize: 14) returnKey.font = .systemFont(ofSize: 14)
@@ -244,7 +265,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionPill.state = .active actionPill.state = .active
roundMaterialCorners(actionPill, radius: 18) roundMaterialCorners(actionPill, radius: 18)
actionPill.layer?.borderWidth = 0.5 actionPill.layer?.borderWidth = 0.5
for view in [openButton, returnKey] { for view in [openButton, returnKey, actionsButton] {
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
actionPill.addSubview(view) actionPill.addSubview(view)
} }
@@ -282,7 +303,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14), openButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14),
openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.leadingAnchor.constraint(equalTo: openButton.trailingAnchor, constant: 8), returnKey.leadingAnchor.constraint(equalTo: openButton.trailingAnchor, constant: 8),
returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12), actionsButton.leadingAnchor.constraint(equalTo: returnKey.trailingAnchor, constant: 14),
actionsButton.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -14),
actionsButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.widthAnchor.constraint(equalToConstant: 18), returnKey.widthAnchor.constraint(equalToConstant: 18),
returnKey.heightAnchor.constraint(equalToConstant: 18), returnKey.heightAnchor.constraint(equalToConstant: 18),
@@ -305,6 +328,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
ApplicationCatalog.discover() ApplicationCatalog.discover()
}.value }.value
guard let self else { return } guard let self else { return }
guard !Task.isCancelled else { return }
self.applications = applications self.applications = applications
self.updateLocationLabels() self.updateLocationLabels()
self.discoveryTask = nil self.discoveryTask = nil
@@ -388,6 +412,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func updateOpenButton() { private func updateOpenButton() {
openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow) openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow)
actionsButton.isEnabled = openButton.isEnabled
} }
func tableViewSelectionDidChange(_ notification: Notification) { func tableViewSelectionDidChange(_ notification: Notification) {
@@ -465,6 +490,30 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return cell return cell
} }
@objc private func showActions() {
guard !isLaunching, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
actions.show(for: application, relativeTo: actionsButton)
}
private func reviewUninstall(_ application: Application) {
guard let window, window.attachedSheet == nil else { return }
let review = UninstallReviewController(application: application, uninstaller: uninstaller)
uninstallReview = review
review.closed = { [weak self] in
self?.uninstallReview = nil
self?.window?.makeFirstResponder(self?.searchField)
}
review.changed = { [weak self] in
guard let self else { return }
self.discoveryTask?.cancel()
self.discoveryTask = nil
self.refreshApplications()
}
review.present(on: window)
}
@objc private func openSelection() { @objc private func openSelection() {
guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return } guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow] let application = results[tableView.selectedRow]
@@ -551,7 +600,7 @@ private final class LauncherPanel: NSPanel {
} }
@MainActor @MainActor
private final class LauncherRowView: NSTableRowView { final class LauncherRowView: NSTableRowView {
override func drawSelection(in dirtyRect: NSRect) { override func drawSelection(in dirtyRect: NSRect) {
let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10) let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
SunsetPalette.selectionFill.setFill() SunsetPalette.selectionFill.setFill()
+40 -2
View File
@@ -12,6 +12,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let loginToggle = NSButton(checkboxWithTitle: "Launch at login", target: nil, action: nil) private let loginToggle = NSButton(checkboxWithTitle: "Launch at login", target: nil, action: nil)
private let loginStatus = NSTextField(wrappingLabelWithString: "") private let loginStatus = NSTextField(wrappingLabelWithString: "")
private let loginSettings = NSButton(title: "Open Login Items Settings…", target: nil, action: nil) private let loginSettings = NSButton(title: "Open Login Items Settings…", target: nil, action: nil)
private let recoveryLabel = NSTextField(wrappingLabelWithString: "")
private let recoveryButton = NSButton(title: "Retry uninstall recovery…", target: nil, action: nil)
private var recoveryReport: UninstallReport?
var retryUninstallRecovery: (() async -> UninstallReport)?
init(shortcut: GlobalShortcut) { init(shortcut: GlobalShortcut) {
self.shortcut = shortcut self.shortcut = shortcut
@@ -113,6 +117,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginSettings.action = #selector(openLoginSettings) loginSettings.action = #selector(openLoginSettings)
loginSettings.bezelStyle = .rounded loginSettings.bezelStyle = .rounded
loginStatus.setAccessibilityLabel("Launch at login status") loginStatus.setAccessibilityLabel("Launch at login status")
recoveryLabel.textColor = SunsetPalette.secondaryText
recoveryLabel.isHidden = true
recoveryLabel.setAccessibilityLabel("Uninstall recovery status")
recoveryButton.isHidden = true
recoveryButton.bezelStyle = .rounded
recoveryButton.target = self
recoveryButton.action = #selector(showUninstallRecovery)
heading.font = .systemFont(ofSize: 20, weight: .semibold) heading.font = .systemFont(ofSize: 20, weight: .semibold)
heading.textColor = SunsetPalette.text heading.textColor = SunsetPalette.text
@@ -128,7 +139,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
let stack = NSStackView(views: [ let stack = NSStackView(views: [
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
loginHeading, loginToggle, loginStatus, loginSettings loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton
]) ])
stack.orientation = .vertical stack.orientation = .vertical
stack.alignment = .leading stack.alignment = .leading
@@ -152,11 +163,38 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24), stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190) recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
]) ])
for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus] { for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] {
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
} }
} }
func setUninstallRecoveryReport(_ report: UninstallReport) {
recoveryReport = report
recoveryLabel.isHidden = report.messages.isEmpty && !report.needsRecovery
recoveryButton.isHidden = recoveryLabel.isHidden
recoveryLabel.stringValue = report.needsRecovery
? "An interrupted uninstall needs attention. Files have been retained for recovery."
: "Interrupted uninstall recovery completed."
recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…"
}
@objc private func showUninstallRecovery() {
guard let report = recoveryReport, let window else { return }
recoveryButton.isEnabled = false
Task {
let result: UninstallReport
if report.needsRecovery, let retryUninstallRecovery {
result = await retryUninstallRecovery()
setUninstallRecoveryReport(result)
} else { result = report }
let alert = NSAlert()
alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete"
alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n")
await alert.beginSheetModal(for: window)
recoveryButton.isEnabled = true
}
}
private func refreshLogin() { private func refreshLogin() {
let status = SMAppService.mainApp.status let status = SMAppService.mainApp.status
loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off
@@ -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) }
}
}
@@ -0,0 +1,738 @@
import CryptoKit
import Darwin
import Foundation
public struct UninstallReport: Sendable {
public let messages: [String]
public let needsRecovery: Bool
public init(messages: [String], needsRecovery: Bool) {
self.messages = messages
self.needsRecovery = needsRecovery
}
}
// Internal checkpoints interrupt the real filesystem implementation, rather than replace it.
enum UninstallCheckpoint: Sendable {
case beforeStage(Int), afterStageMove(Int), beforeCommit
case beforeRollback(Int), beforeTrash(Int), afterTrashMove(Int)
}
struct UninstallInterruption: Error {}
public actor ApplicationUninstaller {
private let journalDirectory: URL
private let trashDirectory: @Sendable (URL) throws -> URL
private let checkpoint: @Sendable (UninstallCheckpoint) throws -> Void
public init(journalDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/Conjure/UninstallTransactions", isDirectory: true)) {
self.journalDirectory = journalDirectory
trashDirectory = Self.nativeTrashDirectory
checkpoint = { _ in }
}
init(
journalDirectory: URL,
trashDirectory: @escaping @Sendable (URL) throws -> URL,
checkpoint: @escaping @Sendable (UninstallCheckpoint) throws -> Void = { _ in }
) {
self.journalDirectory = journalDirectory
self.trashDirectory = trashDirectory
self.checkpoint = checkpoint
}
public func execute(_ urls: [URL]) throws -> UninstallReport {
let store = try Store(directory: journalDirectory)
let recovery = recover(in: store)
guard !recovery.needsRecovery else {
throw Failure("Uninstall cannot start until recovery is resolved. " + recovery.messages.joined(separator: " "))
}
let originals = try selectedPaths(urls)
guard !originals.isEmpty else { throw Failure("No items were selected for uninstall.") }
let key = try store.key(create: true)
var journal = Journal(id: UUID().uuidString, roots: [], entries: [], committed: false)
do {
for (index, original) in originals.enumerated() {
let parent = try Directory(path: parentPath(original))
let info = try requiredInfo(parent.fd, leaf(original))
try validateCandidate(original, info: info)
let parentIdentity = try parent.identity()
guard parentIdentity.device == info.identity.device else {
throw Failure("A mounted volume itself cannot be uninstalled: \(original).")
}
let rootIndex: Int
if let existing = journal.roots.firstIndex(where: { $0.identity.device == info.identity.device }) {
rootIndex = existing
} else {
let name = ".conjure-uninstall-\(journal.id)-\(info.identity.device)"
try makeDirectory(parent.fd, name, mode: 0o700)
let root = try Directory(path: originalParentPath(original, name))
try root.requirePrivate()
let identity = try root.identity()
guard identity.device == info.identity.device else { throw Failure("Quarantine is not on the selected item's volume.") }
journal.roots.append(Root(path: root.path, identity: identity, parentIdentity: parentIdentity))
rootIndex = journal.roots.count - 1
}
journal.entries.append(Entry(original: original, identity: info.identity,
parentIdentity: parentIdentity, root: rootIndex,
stagedName: "item-\(index)", state: .planned, trash: nil))
}
try store.save(journal, key: key)
} catch {
// Initial persistence may have succeeded before its durability acknowledgement failed.
if (try? store.contains(journal)) != false {
throw Failure("Uninstall did not start: \(error.localizedDescription)" + retained(journal, store: store))
}
let cleanup = removeEmptyRoots(journal)
throw Failure("Uninstall did not start: \(error.localizedDescription). No selected items were moved."
+ (cleanup.isEmpty ? "" : retained(journal, store: store, details: cleanup)))
}
do {
for index in journal.entries.indices {
journal.entries[index].state = .stageIntent
try store.save(journal, key: key)
try checkpoint(.beforeStage(index))
let entry = journal.entries[index]
let parent = try originalParent(entry)
let root = try quarantine(journal.roots[entry.root])
try requireIdentity(parent.fd, leaf(entry.original), entry.identity)
try move(parent, leaf(entry.original), root, entry.stagedName)
try checkpoint(.afterStageMove(index))
try requireIdentity(root.fd, entry.stagedName, entry.identity)
journal.entries[index].state = .staged
try store.save(journal, key: key)
}
try checkpoint(.beforeCommit)
} catch is UninstallInterruption {
throw Failure("Uninstall was interrupted before commit; recovery will restore staged items." + retained(journal, store: store))
} catch {
let failures = rollback(&journal, store: store, key: key)
let restored = journal.entries.allSatisfy { $0.state == .restored }
let outcome = restored ? " All selected items were restored." : " Rollback could not restore every item."
throw Failure("Uninstall failed: \(error.localizedDescription)." + outcome
+ (failures.isEmpty ? "" : retained(journal, store: store, details: failures)))
}
journal.committed = true
do {
try store.save(journal, key: key)
} catch {
// A failed durability acknowledgement may still have persisted the commit. Never guess and roll back.
return UninstallReport(messages: recovery.messages + ["Commit persistence needs recovery: \(error.localizedDescription)"
+ retained(journal, store: store)], needsRecovery: true)
}
let failures = finalize(&journal, store: store, key: key)
if !failures.isEmpty {
return UninstallReport(messages: recovery.messages + ["Uninstall committed, but Trash finalization needs recovery."
+ retained(journal, store: store, details: failures)], needsRecovery: true)
}
return UninstallReport(messages: recovery.messages + ["Moved \(journal.entries.count) selected item(s) to Trash. Finder's Put Back command is not available; original paths and Trash destinations are retained in \(store.path)/Completed/\(journal.id).json."], needsRecovery: false)
}
public func recover() -> UninstallReport {
do { return recover(in: try Store(directory: journalDirectory)) }
catch { return UninstallReport(messages: ["Uninstall recovery could not open \(journalDirectory.path): \(error.localizedDescription). No files were moved."], needsRecovery: true) }
}
private func recover(in store: Store) -> UninstallReport {
var messages: [String] = []
var needsRecovery = false
do {
let names = try store.journalNames()
guard !names.isEmpty else { return UninstallReport(messages: [], needsRecovery: false) }
let key = try store.key(create: false)
for name in names {
do {
var journal = try store.load(name, key: key)
try validate(journal, name: name)
let committed = journal.committed
let failures = committed ? finalize(&journal, store: store, key: key)
: rollback(&journal, store: store, key: key)
if failures.isEmpty {
messages.append(committed ? "Recovered a committed uninstall and finished moving its items to Trash. Original paths are retained in \(store.path)/Completed/\(journal.id).json; Finder Put Back is unavailable."
: "Recovered an interrupted uninstall and restored its original items.")
} else {
needsRecovery = true
messages.append("Uninstall recovery needs attention." + retained(journal, store: store, details: failures))
}
} catch {
needsRecovery = true
messages.append("Recovery refused journal \(store.path)/\(name): \(error.localizedDescription). No moves were authorized by this journal; retain it and its quarantine for manual inspection.")
}
}
} catch {
needsRecovery = true
messages.append("Recovery refused journals in \(store.path): \(error.localizedDescription). No files were moved.")
}
return UninstallReport(messages: messages, needsRecovery: needsRecovery)
}
private func rollback(_ journal: inout Journal, store: Store, key: SymmetricKey) -> [String] {
var failures: [String] = []
for index in journal.entries.indices.reversed() {
if journal.entries[index].state == .restored { continue }
do {
let entry = journal.entries[index]
let parent = try originalParent(entry)
let root = try quarantine(journal.roots[entry.root])
let original = try fileInfo(parent.fd, leaf(entry.original))
let staged = try fileInfo(root.fd, entry.stagedName)
if let staged {
guard staged.identity == entry.identity else { throw Failure("Quarantined identity changed; nothing at that path will be moved.") }
guard original == nil else { throw Failure("Original path is occupied; it will not be overwritten.") }
journal.entries[index].state = .rollbackIntent
try store.save(journal, key: key)
try checkpoint(.beforeRollback(index))
try requireIdentity(root.fd, entry.stagedName, entry.identity)
try move(root, entry.stagedName, parent, leaf(entry.original))
try requireIdentity(parent.fd, leaf(entry.original), entry.identity)
} else {
guard original?.identity == entry.identity else { throw Failure("Original identity is missing or changed; no unrelated file will be moved.") }
}
journal.entries[index].state = .restored
try store.save(journal, key: key)
} catch {
failures.append("\(journal.entries[index].original): \(error.localizedDescription)")
}
}
if failures.isEmpty { failures += finish(journal, store: store) }
return failures
}
private func finalize(_ journal: inout Journal, store: Store, key: SymmetricKey) -> [String] {
var failures: [String] = []
for index in journal.entries.indices {
if journal.entries[index].state == .trashed { continue }
do {
var entry = journal.entries[index]
let root = try quarantine(journal.roots[entry.root])
if entry.trash == nil {
let url = try trashDirectory(URL(fileURLWithPath: entry.original))
let trash = try Directory(path: checkedPath(url))
try trash.requirePrivate()
let identity = try trash.identity()
guard identity.device == entry.identity.device else { throw Failure("Trash is on another volume; the committed item remains in quarantine.") }
let name = trashName(original: entry.original, id: journal.id, index: index)
entry.trash = Location(path: trash.path, identity: identity, name: name)
entry.state = .trashIntent
journal.entries[index] = entry
try store.save(journal, key: key)
}
guard let destination = entry.trash else { throw Failure("Missing Trash intent.") }
let trash = try Directory(path: destination.path)
try trash.requirePrivate()
guard try trash.identity() == destination.identity else { throw Failure("Trash directory identity changed.") }
let staged = try fileInfo(root.fd, entry.stagedName)
let alreadyTrashed = try fileInfo(trash.fd, destination.name)
if let staged {
guard staged.identity == entry.identity, alreadyTrashed == nil else { throw Failure("Staging identity changed or Trash destination is occupied; neither item will be overwritten.") }
try checkpoint(.beforeTrash(index))
try requireIdentity(root.fd, entry.stagedName, entry.identity)
try move(root, entry.stagedName, trash, destination.name)
try checkpoint(.afterTrashMove(index))
try requireIdentity(trash.fd, destination.name, entry.identity)
} else {
guard alreadyTrashed?.identity == entry.identity else { throw Failure("Cannot identify the item in either quarantine or its recorded Trash destination.") }
}
journal.entries[index].state = .trashed
try store.save(journal, key: key)
} catch is UninstallInterruption {
failures.append("Interrupted finalization of \(journal.entries[index].original).")
break
} catch {
failures.append("\(journal.entries[index].original): \(error.localizedDescription)")
}
}
if failures.isEmpty { failures += finish(journal, store: store) }
return failures
}
private func finish(_ journal: Journal, store: Store) -> [String] {
let failures = removeEmptyRoots(journal)
guard failures.isEmpty else { return failures }
do {
if journal.committed { try store.archive(journal) }
else { try store.remove(journal) }
return []
} catch { return ["Journal cleanup failed: \(error.localizedDescription)"] }
}
private func removeEmptyRoots(_ journal: Journal) -> [String] {
journal.roots.compactMap { root in
do {
let parent = try Directory(path: parentPath(root.path))
guard try parent.identity() == root.parentIdentity else { throw Failure("Quarantine parent identity changed.") }
guard let current = try fileInfo(parent.fd, leaf(root.path)) else { return nil }
guard current.identity == root.identity else { throw Failure("Quarantine identity changed.") }
guard unlinkat(parent.fd, leaf(root.path), AT_REMOVEDIR) == 0 else { throw posixFailure("Removing empty quarantine", root.path) }
try parent.sync()
return nil
} catch { return "Retained quarantine \(root.path): \(error.localizedDescription)" }
}
}
private func selectedPaths(_ urls: [URL]) throws -> [String] {
let paths = try Set(urls.map(checkedPath)).sorted()
guard paths.count <= 512 else { throw Failure("Too many uninstall items were selected.") }
var selected: [String] = []
var identities = Set<Identity>()
for path in paths {
if selected.contains(where: { path.hasPrefix($0 + "/") }) { continue }
let parent = try Directory(path: parentPath(path))
let info = try requiredInfo(parent.fd, leaf(path))
try validateCandidate(path, info: info)
guard identities.insert(info.identity).inserted else { throw Failure("Selected paths refer to the same filesystem identity: \(path).") }
selected.append(path)
}
return selected
}
private func validateCandidate(_ path: String, info: FileInfo) throws {
let own = Bundle.main.bundleURL.resolvingSymlinksInPath().path
let home = FileManager.default.homeDirectoryForCurrentUser.resolvingSymlinksInPath().path
let journal = try checkedPath(journalDirectory)
let protected = ["/System", "/usr", "/bin", "/sbin", "/dev", "/private/etc", "/Library/Apple"]
guard path != "/", path != "/private", path != home,
!protected.contains(where: { path == $0 || path.hasPrefix($0 + "/") }),
path != own, !own.hasPrefix(path + "/"), !path.hasPrefix(own + "/"),
path != journal, !journal.hasPrefix(path + "/"), !path.hasPrefix(journal + "/") else {
throw Failure("Protected path cannot be uninstalled: \(path).")
}
guard info.owner == getuid(), info.isRegular || info.isDirectory else {
throw Failure("Only your own regular files or directories without symlink ancestry can be uninstalled: \(path).")
}
guard info.flags & UInt32(UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND) == 0 else {
throw Failure("Locked item cannot be uninstalled: \(path).")
}
}
private func validate(_ journal: Journal, name: String) throws {
guard UUID(uuidString: journal.id)?.uuidString == journal.id,
name == journal.id + ".json", journal.version == 1,
!journal.entries.isEmpty, journal.entries.count <= 512,
!journal.roots.isEmpty, journal.roots.count <= journal.entries.count else { throw Failure("Invalid journal structure.") }
var paths: [String] = []
var identities = Set<Identity>()
var usedRoots = Set<Int>()
for (index, entry) in journal.entries.enumerated() {
guard try checkedPath(URL(fileURLWithPath: entry.original)) == entry.original,
journal.roots.indices.contains(entry.root),
entry.stagedName == "item-\(index)",
identities.insert(entry.identity).inserted,
!paths.contains(where: { entry.original == $0 || entry.original.hasPrefix($0 + "/") || $0.hasPrefix(entry.original + "/") }) else { throw Failure("Invalid immutable selection in journal.") }
let root = journal.roots[entry.root]
guard root.identity.device == entry.identity.device,
root.identity.kind == UInt32(S_IFDIR),
!entry.original.hasPrefix(root.path + "/") else { throw Failure("Invalid quarantine location.") }
if usedRoots.insert(entry.root).inserted {
guard root.parentIdentity == entry.parentIdentity,
root.path == originalParentPath(entry.original, ".conjure-uninstall-\(journal.id)-\(entry.identity.device)") else {
throw Failure("Invalid quarantine parent or location.")
}
}
if journal.committed {
guard [.staged, .trashIntent, .trashed].contains(entry.state),
(entry.state == .staged) == (entry.trash == nil) else { throw Failure("Invalid committed journal state.") }
} else {
guard entry.trash == nil, ![.trashIntent, .trashed].contains(entry.state) else { throw Failure("Invalid precommit journal state.") }
}
if let trash = entry.trash {
guard try checkedPath(URL(fileURLWithPath: trash.path)) == trash.path,
trash.identity.device == entry.identity.device,
trash.identity.kind == UInt32(S_IFDIR),
trash.name == trashName(original: entry.original, id: journal.id, index: index) else {
throw Failure("Invalid Trash intent.")
}
}
// Signed journals also undergo protected-path validation without trusting live candidate contents.
let synthetic = FileInfo(identity: entry.identity, owner: getuid(), flags: 0)
try validateCandidate(entry.original, info: synthetic)
paths.append(entry.original)
}
guard usedRoots.count == journal.roots.count else { throw Failure("Unused quarantine in journal.") }
}
private func originalParent(_ entry: Entry) throws -> Directory {
let parent = try Directory(path: parentPath(entry.original))
guard try parent.identity() == entry.parentIdentity else { throw Failure("Original parent directory identity changed; recovery will not use a replacement.") }
return parent
}
private func quarantine(_ root: Root) throws -> Directory {
let directory = try Directory(path: root.path)
try directory.requirePrivate()
guard try directory.identity() == root.identity else { throw Failure("Quarantine directory identity changed.") }
return directory
}
private func retained(_ journal: Journal, store: Store, details: [String] = []) -> String {
" Journal: \(store.path)/\(journal.id).json. Quarantine: "
+ journal.roots.map(\.path).joined(separator: ", ") + ". " + details.joined(separator: " ")
}
private static func nativeTrashDirectory(_ original: URL) throws -> URL {
let parent = try Directory(path: parentPath(checkedPath(original)))
let sourceDevice = try parent.identity().device
let homeTrash = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash", isDirectory: true)
let home = try Directory(path: homeTrash.deletingLastPathComponent().path)
if try home.identity().device == sourceDevice {
try makeDirectory(home.fd, ".Trash", mode: 0o700, allowExisting: true)
return homeTrash
}
var filesystem = statfs()
guard fstatfs(parent.fd, &filesystem) == 0 else { throw posixFailure("Finding volume", original.path) }
let mount = withUnsafePointer(to: &filesystem.f_mntonname) {
$0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { String(cString: $0) }
}
return try volumeTrashDirectory(URL(fileURLWithPath: mount, isDirectory: true))
}
// Also used by real-filesystem tests without requiring an external disk to be mounted.
static func volumeTrashDirectory(_ url: URL) throws -> URL {
let volume = try Directory(path: checkedPath(url))
try makeDirectory(volume.fd, ".Trashes", mode: 0o1777, allowExisting: true)
let trashes = try Directory(path: volume.path + "/.Trashes")
var value = stat()
guard fstat(trashes.fd, &value) == 0,
value.st_uid == 0 || value.st_uid == getuid(),
value.st_mode & 0o002 == 0 || value.st_mode & mode_t(S_ISVTX) != 0 else {
throw Failure("Untrusted volume Trash directory: \(trashes.path).")
}
let user = String(getuid())
try makeDirectory(trashes.fd, user, mode: 0o700, allowExisting: true)
let destination = try Directory(path: trashes.path + "/" + user)
try destination.requirePrivate()
return URL(fileURLWithPath: destination.path, isDirectory: true)
}
}
private struct Failure: LocalizedError {
let errorDescription: String?
init(_ description: String) { errorDescription = description }
}
private struct Identity: Codable, Hashable {
let device: UInt64
let inode: UInt64
let birthSeconds: Int64
let birthNanoseconds: Int64
let kind: UInt32
init(_ value: stat) {
device = UInt64(UInt32(bitPattern: value.st_dev))
inode = UInt64(value.st_ino)
birthSeconds = Int64(value.st_birthtimespec.tv_sec)
birthNanoseconds = Int64(value.st_birthtimespec.tv_nsec)
kind = UInt32(value.st_mode) & UInt32(S_IFMT)
}
}
private struct FileInfo {
let identity: Identity
let owner: uid_t
let flags: UInt32
var isRegular: Bool { identity.kind == UInt32(S_IFREG) }
var isDirectory: Bool { identity.kind == UInt32(S_IFDIR) }
}
private struct Root: Codable {
let path: String
let identity: Identity
let parentIdentity: Identity
}
private struct Location: Codable {
let path: String
let identity: Identity
let name: String
}
private enum State: String, Codable { case planned, stageIntent, staged, rollbackIntent, restored, trashIntent, trashed }
private struct Entry: Codable {
let original: String
let identity: Identity
let parentIdentity: Identity
let root: Int
let stagedName: String
var state: State
var trash: Location?
}
private struct Journal: Codable {
var version = 1
let id: String
var roots: [Root]
var entries: [Entry]
var committed: Bool
}
private struct Envelope: Codable {
let payload: Data
let authentication: Data
}
// Descriptor-relative walks never follow ancestor symlinks. Renames are exclusive and never copy.
private final class Directory {
let path: String
let fd: Int32
init(path: String, create: Bool = false) throws {
self.path = try checkedPath(URL(fileURLWithPath: path))
// Directory operations need search access, not permission to list contents (notably macOS Trash).
var current = open("/", O_SEARCH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
guard current >= 0 else { throw posixFailure("Opening root", path) }
do {
for component in self.path.split(separator: "/") {
let part = String(component)
if create { try makeDirectory(current, part, mode: 0o700, allowExisting: true) }
let next = openat(current, part, O_SEARCH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
guard next >= 0 else { throw posixFailure("Opening directory without symlink traversal", self.path) }
close(current)
current = next
}
fd = current
} catch { close(current); throw error }
}
deinit { close(fd) }
func identity() throws -> Identity {
var value = stat()
guard fstat(fd, &value) == 0 else { throw posixFailure("Reading directory identity", path) }
return Identity(value)
}
func requirePrivate() throws {
var value = stat()
guard fstat(fd, &value) == 0, value.st_uid == getuid(), value.st_mode & 0o077 == 0 else {
throw Failure("Directory must be owned by you with private permissions: \(path).")
}
}
func sync() throws {
guard fsync(fd) == 0 else { throw posixFailure("Persisting directory changes", path) }
}
}
private final class Store {
let directory: Directory
let lock: Int32
var path: String { directory.path }
private var savedIdentities: [String: Identity] = [:]
init(directory url: URL) throws {
directory = try Directory(path: checkedPath(url), create: true)
try directory.requirePrivate()
lock = openat(directory.fd, ".lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0o600)
guard lock >= 0 else { throw posixFailure("Opening uninstall lock", directory.path) }
do {
try privateFile(lock, path: directory.path + "/.lock")
guard flock(lock, LOCK_EX | LOCK_NB) == 0 else { throw Failure("Another uninstall or recovery is active. Try again when it finishes.") }
} catch { close(lock); throw error }
}
deinit { close(lock) }
func journalNames() throws -> [String] {
let copy = openat(directory.fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
guard copy >= 0, let stream = fdopendir(copy) else {
if copy >= 0 { close(copy) }
throw posixFailure("Listing uninstall journals", path)
}
defer { closedir(stream) }
var names: [String] = []
while let item = readdir(stream) {
let name = withUnsafePointer(to: &item.pointee.d_name) {
$0.withMemoryRebound(to: CChar.self, capacity: Int(item.pointee.d_namlen) + 1) { String(cString: $0) }
}
if name == "." || name == ".." || name == ".lock" || name == "key" || name.hasPrefix(".tmp-") { continue }
if name == "Completed" {
let completed = try Directory(path: path + "/Completed")
try completed.requirePrivate()
continue
}
guard name.hasSuffix(".json") else { throw Failure("Unexpected entry in private journal directory: \(path)/\(name).") }
names.append(name)
}
return names.sorted()
}
func key(create: Bool) throws -> SymmetricKey {
if let _ = try fileInfo(directory.fd, "key") {
let bytes = try readFile("key", maximum: 32)
guard bytes.count == 32 else { throw Failure("Invalid uninstall journal authentication key.") }
return SymmetricKey(data: bytes)
}
guard create else { throw Failure("Journal authentication key is missing; existing journals cannot be trusted.") }
let key = SymmetricKey(size: .bits256)
let bytes = key.withUnsafeBytes { Data($0) }
try writeFile(bytes, name: "key")
try directory.sync()
return key
}
func load(_ name: String, key: SymmetricKey) throws -> Journal {
let bytes = try readFile(name, maximum: 4 * 1024 * 1024)
let envelope = try JSONDecoder().decode(Envelope.self, from: bytes)
guard HMAC<SHA256>.isValidAuthenticationCode(envelope.authentication, authenticating: envelope.payload, using: key) else {
throw Failure("Journal authentication failed (modified or untrusted journal).")
}
return try JSONDecoder().decode(Journal.self, from: envelope.payload)
}
func contains(_ journal: Journal) throws -> Bool {
try fileInfo(directory.fd, journal.id + ".json") != nil
}
func archive(_ journal: Journal) throws {
try makeDirectory(directory.fd, "Completed", mode: 0o700, allowExisting: true)
let completed = try Directory(path: path + "/Completed")
try completed.requirePrivate()
let name = journal.id + ".json"
try requireIdentity(directory.fd, name, savedIdentities[name])
try move(directory, name, completed, name)
savedIdentities.removeValue(forKey: name)
}
func save(_ journal: Journal, key: SymmetricKey) throws {
let payload = try JSONEncoder().encode(journal)
let authentication = Data(HMAC<SHA256>.authenticationCode(for: payload, using: key))
let bytes = try JSONEncoder().encode(Envelope(payload: payload, authentication: authentication))
guard bytes.count <= 4 * 1024 * 1024 else { throw Failure("Transaction journal exceeds its recoverable size limit.") }
let name = journal.id + ".json"
let temporary = ".tmp-" + UUID().uuidString
try writeFile(bytes, name: temporary)
if let current = try fileInfo(directory.fd, name) {
guard savedIdentities[name] == current.identity else { throw Failure("Journal identity changed; refusing to replace it: \(path)/\(name).") }
guard renameat(directory.fd, temporary, directory.fd, name) == 0 else { throw posixFailure("Replacing journal", path + "/" + name) }
} else {
guard savedIdentities[name] == nil else { throw Failure("Journal disappeared; refusing to recreate it.") }
guard renameatx_np(directory.fd, temporary, directory.fd, name, UInt32(RENAME_EXCL)) == 0 else { throw posixFailure("Creating journal", path + "/" + name) }
}
savedIdentities[name] = try requiredInfo(directory.fd, name).identity
try directory.sync()
}
func remove(_ journal: Journal) throws {
let name = journal.id + ".json"
try requireIdentity(directory.fd, name, savedIdentities[name])
guard unlinkat(directory.fd, name, 0) == 0 else { throw posixFailure("Removing completed journal", path + "/" + name) }
savedIdentities.removeValue(forKey: name)
try directory.sync()
}
private func readFile(_ name: String, maximum: Int) throws -> Data {
let fd = openat(directory.fd, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
guard fd >= 0 else { throw posixFailure("Reading journal file", path + "/" + name) }
defer { close(fd) }
try privateFile(fd, path: path + "/" + name)
var value = stat()
guard fstat(fd, &value) == 0, value.st_size >= 0, value.st_size <= maximum else { throw Failure("Journal file exceeds its size limit.") }
var data = Data(count: Int(value.st_size))
try data.withUnsafeMutableBytes { bytes in
var offset = 0
while offset < bytes.count {
let count = Darwin.read(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
if count < 0, errno == EINTR { continue }
guard count > 0 else { throw posixFailure("Reading complete journal", path + "/" + name) }
offset += count
}
}
savedIdentities[name] = Identity(value)
return data
}
private func writeFile(_ bytes: Data, name: String) throws {
let fd = openat(directory.fd, name, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC | O_EXCL, 0o600)
guard fd >= 0 else { throw posixFailure("Writing journal file", path + "/" + name) }
defer { close(fd) }
try privateFile(fd, path: path + "/" + name)
try bytes.withUnsafeBytes { buffer in
var offset = 0
while offset < buffer.count {
let count = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset)
if count < 0, errno == EINTR { continue }
guard count > 0 else { throw posixFailure("Writing complete journal", path + "/" + name) }
offset += count
}
}
guard fsync(fd) == 0 else { throw posixFailure("Persisting journal", path + "/" + name) }
// Full sync asks macOS to flush drive caches too; some filesystems support only fsync.
if fcntl(fd, F_FULLFSYNC) != 0, errno != EINVAL, errno != ENOTSUP { throw posixFailure("Flushing journal", path + "/" + name) }
}
}
private func privateFile(_ fd: Int32, path: String) throws {
var value = stat()
guard fstat(fd, &value) == 0, value.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
value.st_uid == getuid(), value.st_mode & 0o077 == 0, value.st_nlink == 1 else {
throw Failure("Untrusted journal file (type, owner, permissions, or hard links): \(path).")
}
}
private func checkedPath(_ url: URL) throws -> String {
let path = url.path
guard url.isFileURL, url.host == nil || url.host == "" || url.host == "localhost",
url.query == nil, url.fragment == nil,
path.hasPrefix("/"), !path.contains("\0"), path.utf8.count < 16_384,
!path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == "." || $0 == ".." }) else {
throw Failure("Uninstall requires an absolute local path without dot components.")
}
return path == "/" ? path : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
private func parentPath(_ path: String) -> String { URL(fileURLWithPath: path).deletingLastPathComponent().path }
private func leaf(_ path: String) -> String { URL(fileURLWithPath: path).lastPathComponent }
private func originalParentPath(_ original: String, _ name: String) -> String { URL(fileURLWithPath: parentPath(original)).appendingPathComponent(name).path }
private func trashName(original: String, id: String, index: Int) -> String {
let url = URL(fileURLWithPath: original)
let ext = url.pathExtension
let suffix = !ext.isEmpty && ext.utf8.count <= 32 ? "." + ext : ""
let base = suffix.isEmpty ? url.lastPathComponent : url.deletingPathExtension().lastPathComponent
// Leave room for a collision-free transaction suffix even for maximum-length source filenames.
return String(decoding: base.utf8.prefix(120), as: UTF8.self) + " (Conjure \(id)-\(index))" + suffix
}
private func fileInfo(_ parent: Int32, _ name: String) throws -> FileInfo? {
var value = stat()
guard fstatat(parent, name, &value, AT_SYMLINK_NOFOLLOW) == 0 else {
if errno == ENOENT { return nil }
throw posixFailure("Reading filesystem identity", name)
}
return FileInfo(identity: Identity(value), owner: value.st_uid, flags: value.st_flags)
}
private func requiredInfo(_ parent: Int32, _ name: String) throws -> FileInfo {
guard let info = try fileInfo(parent, name) else { throw Failure("Selected item is missing: \(name).") }
return info
}
private func requireIdentity(_ parent: Int32, _ name: String, _ identity: Identity?) throws {
guard let identity, try requiredInfo(parent, name).identity == identity else {
throw Failure("Filesystem identity changed: \(name). No replacement will be moved.")
}
}
private func makeDirectory(_ parent: Int32, _ name: String, mode: mode_t, allowExisting: Bool = false) throws {
if mkdirat(parent, name, mode) == 0 {
guard fsync(parent) == 0 else { throw posixFailure("Persisting new directory", name) }
} else if !(allowExisting && errno == EEXIST) { throw posixFailure("Creating directory", name) }
}
private func move(_ source: Directory, _ name: String, _ destination: Directory, _ target: String) throws {
guard try source.identity().device == destination.identity().device else { throw Failure("Refusing a cross-volume move.") }
guard renameatx_np(source.fd, name, destination.fd, target, UInt32(RENAME_EXCL)) == 0 else {
throw posixFailure("Moving without overwriting", source.path + "/" + name + " → " + destination.path + "/" + target)
}
try destination.sync()
try source.sync()
}
private func posixFailure(_ action: String, _ path: String) -> Failure {
let code = errno
return Failure("\(action) \(path): \(String(cString: strerror(code))) (\(code)).")
}
+235
View File
@@ -0,0 +1,235 @@
import Darwin
import Foundation
import Security
public struct UninstallCandidate: Identifiable, Sendable {
public var id: URL { url }
public let url: URL
public let size: Int64?
public let isSelectable: Bool
public let defaultSelected: Bool
public let reason: String?
public init(url: URL, size: Int64?, isSelectable: Bool, defaultSelected: Bool, reason: String?) {
self.url = url
self.size = size
self.isSelectable = isSelectable
self.defaultSelected = isSelectable && defaultSelected
self.reason = reason
}
}
public enum UninstallDiscovery {
/// Performs filesystem and signing reads synchronously; callers must run this off the UI thread.
public static func scan(
application: Application,
home: URL = FileManager.default.homeDirectoryForCurrentUser,
ownApplicationURL: URL? = Bundle.main.bundleURL
) throws -> [UninstallCandidate] {
let app = application.url.standardizedFileURL
let ownApp = ownApplicationURL?.standardizedFileURL.resolvingSymlinksInPath()
let invalidReason = applicationProblem(at: app, ownApp: ownApp)
let appCandidate = candidate(at: app, shared: false, ownApp: ownApp, problem: invalidReason)
guard invalidReason == nil, let identifier = bundleIdentifier(at: app) else {
return [appCandidate]
}
let extensions = embeddedExtensions(in: app)
let identifiers = Set([identifier] + extensions.compactMap { bundleIdentifier(at: $0) })
if let ownApp, let ownIdentifier = bundleIdentifier(at: ownApp), identifiers.contains(ownIdentifier) {
return [candidate(
at: app, shared: false, ownApp: ownApp,
problem: "This application shares Conjure's bundle identifier and could remove Conjure's own data."
)]
}
let library = home.standardizedFileURL.appendingPathComponent("Library", isDirectory: true)
var paths: [URL: Bool] = [:]
for identifier in identifiers {
let relativePaths = [
"Preferences/\(identifier).plist",
"Caches/\(identifier)",
"Application Support/\(identifier)",
"Logs/\(identifier)",
"Saved Application State/\(identifier).savedState",
"Application Scripts/\(identifier)",
"Containers/\(identifier)",
]
for path in relativePaths {
paths[library.appendingPathComponent(path)] = false
}
}
for bundle in [app] + extensions {
for group in signedApplicationGroups(at: bundle) {
paths[library.appendingPathComponent("Group Containers/\(group)")] = true
paths[library.appendingPathComponent("Application Scripts/\(group)")] = true
}
}
let associated = paths.keys.sorted { $0.path < $1.path }.compactMap { url -> UninstallCandidate? in
// Inspect ancestors before existence/size queries, so a linked Library never reads its target.
guard ancestryProblem(at: url, includeLeaf: false) == nil,
fileStatus(at: url) != nil else { return nil }
return candidate(at: url, shared: paths[url] == true, ownApp: ownApp)
}
return [appCandidate] + associated
}
private static func applicationProblem(at url: URL, ownApp: URL?) -> String? {
if let problem = protectedProblem(at: url, ownApp: ownApp) { return problem }
if let problem = ancestryProblem(at: url) { return problem }
guard url.isFileURL, url.pathExtension.lowercased() == "app",
let status = fileStatus(at: url), status.st_mode & S_IFMT == S_IFDIR,
let info = bundleInfo(at: url), info["CFBundlePackageType"] as? String == "APPL",
let executable = info["CFBundleExecutable"] as? String, safeComponent(executable),
let identifier = info["CFBundleIdentifier"] as? String, safeIdentifier(identifier) else {
return "This application does not have a valid application bundle and bundle identifier."
}
let binary = url.appendingPathComponent("Contents/MacOS/\(executable)")
guard ancestryProblem(at: binary) == nil,
let binaryStatus = fileStatus(at: binary), binaryStatus.st_mode & S_IFMT == S_IFREG,
FileManager.default.isExecutableFile(atPath: binary.path) else {
return "This application does not have a valid executable."
}
return nil
}
private static func bundleInfo(at url: URL) -> [String: Any]? {
let infoURL = url.appendingPathComponent("Contents/Info.plist")
guard ancestryProblem(at: infoURL) == nil,
let status = fileStatus(at: infoURL), status.st_mode & S_IFMT == S_IFREG,
let data = try? Data(contentsOf: infoURL),
let info = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) else {
return nil
}
return info as? [String: Any]
}
private static func bundleIdentifier(at url: URL) -> String? {
guard let identifier = bundleInfo(at: url)?["CFBundleIdentifier"] as? String,
safeIdentifier(identifier) else { return nil }
return identifier
}
private static func safeIdentifier(_ value: String) -> Bool {
!value.isEmpty && value.utf8.count <= 255
&& value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty }
&& value.utf8.allSatisfy {
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 46
}
}
private static func safeComponent(_ value: String) -> Bool {
!value.isEmpty && value != "." && value != ".." && !value.contains("/") && !value.contains("\0")
}
private static func embeddedExtensions(in app: URL) -> [URL] {
var pending = [app]
var result: [URL] = []
while let bundle = pending.popLast() {
for relativePath in ["Contents/PlugIns", "Contents/Extensions"] {
let directory = bundle.appendingPathComponent(relativePath)
guard ancestryProblem(at: directory) == nil,
let entries = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil, options: []
) else { continue }
for entry in entries where entry.pathExtension.lowercased() == "appex" {
guard ancestryProblem(at: entry) == nil,
let status = fileStatus(at: entry), status.st_mode & S_IFMT == S_IFDIR,
bundleIdentifier(at: entry) != nil else { continue }
result.append(entry)
pending.append(entry)
}
}
}
return result
}
private static func signedApplicationGroups(at url: URL) -> [String] {
var code: SecStaticCode?
guard SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(rawValue: 0), &code) == errSecSuccess,
let code,
SecStaticCodeCheckValidity(code, SecCSFlags(rawValue: 0), nil) == errSecSuccess else { return [] }
var information: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
let info = information as? [String: Any],
let entitlements = info[kSecCodeInfoEntitlementsDict as String] as? [String: Any],
let groups = entitlements["com.apple.security.application-groups"] as? [String] else { return [] }
return groups.filter(safeIdentifier)
}
private static func candidate(at url: URL, shared: Bool, ownApp: URL?, problem: String? = nil) -> UninstallCandidate {
let lock = problem ?? protectedProblem(at: url, ownApp: ownApp)
?? ancestryProblem(at: url) ?? removalProblem(at: url)
let canReadSize = protectedProblem(at: url, ownApp: ownApp) == nil
&& ancestryProblem(at: url, includeLeaf: false) == nil
let size = canReadSize ? logicalSize(at: url) : nil
return UninstallCandidate(
url: url, size: size, isSelectable: lock == nil, defaultSelected: !shared,
reason: lock ?? (shared ? "Shared app-group data may be used by other applications; left unchecked." : nil)
)
}
private static func protectedProblem(at url: URL, ownApp: URL?) -> String? {
for root in ["/System", "/usr", "/bin", "/sbin", "/Library/Apple"] {
if contains(URL(fileURLWithPath: root), url) { return "System-managed applications and files cannot be uninstalled." }
}
if let ownApp, contains(url, ownApp) || contains(ownApp, url) {
return "Conjure cannot uninstall itself or its own files."
}
return nil
}
private static func contains(_ parent: URL, _ child: URL) -> Bool {
child.path == parent.path || child.path.hasPrefix(parent.path + "/")
}
private static func ancestryProblem(at url: URL, includeLeaf: Bool = true) -> String? {
guard url.isFileURL, url.path.hasPrefix("/") else { return "This is not a local filesystem path." }
let path = includeLeaf ? url : url.deletingLastPathComponent()
var current = URL(fileURLWithPath: "/", isDirectory: true)
for component in path.pathComponents.dropFirst() {
current.appendPathComponent(component)
if let status = fileStatus(at: current), status.st_mode & S_IFMT == S_IFLNK {
return "This path contains a symbolic link; its target will not be followed or removed."
}
}
return nil
}
private static func removalProblem(at url: URL) -> String? {
guard let status = fileStatus(at: url) else { return "This item no longer exists or cannot be inspected." }
guard status.st_uid == geteuid() else {
return "This item is owned by another user or an administrator and cannot be uninstalled without authorization."
}
let immutableFlags = UInt32(UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND)
guard status.st_flags & immutableFlags == 0 else { return "This item is locked or immutable and cannot be moved to Trash." }
let parent = url.deletingLastPathComponent()
if let parentStatus = fileStatus(at: parent), parentStatus.st_flags & immutableFlags != 0 {
return "The containing folder is locked or immutable."
}
guard access(parent.path, W_OK | X_OK) == 0 else {
return "You do not have permission to remove this item from its containing folder."
}
return nil
}
private static func fileStatus(at url: URL) -> stat? {
var status = stat()
return lstat(url.path, &status) == 0 ? status : nil
}
private static func logicalSize(at url: URL) -> Int64? {
guard let status = fileStatus(at: url) else { return nil }
if status.st_mode & S_IFMT != S_IFDIR { return Int64(status.st_size) }
guard let children = try? FileManager.default.contentsOfDirectory(
at: url, includingPropertiesForKeys: nil, options: []
) else { return nil }
var total: Int64 = 0
for child in children {
guard let size = logicalSize(at: child) else { return nil }
let sum = total.addingReportingOverflow(size)
guard !sum.overflow else { return nil }
total = sum.partialValue
}
return total
}
}
@@ -0,0 +1,530 @@
@testable import ConjureKit
import Darwin
import Foundation
import Testing
@Test func uninstallStagesRealFilesAndRetainsOriginalPathManifestInTrash() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor.app", text: "selected data")
let report = try await fixture.engine().execute([original])
#expect(!report.needsRecovery)
#expect(!fixture.exists(original))
let trashed = try #require(fixture.trashItems().first)
#expect(try fixture.contents(trashed) == "selected data")
#expect(trashed.pathExtension == "app")
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.quarantines().isEmpty)
let completed = try #require(fixture.completedJournals().first)
let entries = try fixture.journalEntries(completed)
#expect(entries.count == 1)
#expect(entries[0]["original"] as? String == original.path)
let destination = try #require(entries[0]["trash"] as? [String: Any])
#expect(destination["path"] as? String == fixture.trash.path)
#expect(destination["name"] as? String == trashed.lastPathComponent)
#expect(report.messages.joined().contains(completed.path))
#expect(!(await fixture.engine().recover()).needsRecovery)
}
@Test func uninstallMidStageFailureRollsBackInReverseOrder() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "alpha")
let beta = try fixture.file("Beta", text: "beta")
let gamma = try fixture.file("Gamma", text: "gamma")
let engine = fixture.engine { event in
switch event {
case .beforeStage(2): throw UninstallTestFailure()
case .beforeRollback(1):
#expect(!fixture.exists(alpha))
#expect(!fixture.exists(beta))
case .beforeRollback(0):
#expect(fixture.exists(beta))
#expect(!fixture.exists(alpha))
default: break
}
}
_ = await uninstallFailure(engine, [gamma, beta, alpha])
#expect(try fixture.contents(alpha) == "alpha")
#expect(try fixture.contents(beta) == "beta")
#expect(try fixture.contents(gamma) == "gamma")
#expect(try fixture.quarantines().isEmpty)
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallRollbackConflictPreservesRecreatedOriginalAndQuarantinedData() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "original alpha")
let beta = try fixture.file("Beta", text: "original beta")
let engine = fixture.engine { event in
if case .beforeStage(1) = event {
try Data("recreated alpha".utf8).write(to: alpha)
throw UninstallTestFailure()
}
}
let message = await uninstallFailure(engine, [alpha, beta])
#expect(message.contains(fixture.journals.path))
let quarantine = try #require(fixture.quarantines().first)
#expect(message.contains(quarantine.path))
#expect(try fixture.contents(alpha) == "recreated alpha")
#expect(try fixture.contents(quarantine.appendingPathComponent("item-0")) == "original alpha")
#expect(try fixture.contents(beta) == "original beta")
#expect(try fixture.activeJournals().count == 1)
let next = try fixture.file("Next", text: "must stay")
_ = await uninstallFailure(fixture.engine(), [next])
#expect(try fixture.contents(next) == "must stay")
#expect(try fixture.contents(alpha) == "recreated alpha")
try FileManager.default.moveItem(at: alpha, to: fixture.base.appendingPathComponent("RecreatedAlpha"))
let recovered = await fixture.engine().recover()
#expect(!recovered.needsRecovery)
#expect(try fixture.contents(alpha) == "original alpha")
#expect(try fixture.activeJournals().isEmpty)
}
@Test func uninstallRecoversMoveThatInterruptedBeforeStageJournalUpdate() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "recover me")
let interrupted = fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}
_ = await uninstallFailure(interrupted, [original])
#expect(!fixture.exists(original))
let journal = try #require(fixture.activeJournals().first)
#expect(try fixture.journalEntries(journal)[0]["state"] as? String == "stageIntent")
let report = await fixture.engine().recover()
#expect(!report.needsRecovery)
#expect(try fixture.contents(original) == "recover me")
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.quarantines().isEmpty)
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallRecoversPartialCommittedTrashFinalizationWithoutRollingBack() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "alpha")
let beta = try fixture.file("Beta", text: "beta")
let failing = fixture.engine { event in
if case .beforeTrash(1) = event { throw UninstallTestFailure() }
}
let report = try await failing.execute([alpha, beta])
#expect(report.needsRecovery)
#expect(try fixture.trashItems().count == 1)
#expect(!fixture.exists(alpha))
#expect(!fixture.exists(beta))
let firstTrash = try #require(fixture.trashItems().first)
try Data("new alpha".utf8).write(to: alpha)
let recovery = await fixture.engine().recover()
#expect(!recovery.needsRecovery)
#expect(try fixture.contents(alpha) == "new alpha")
#expect(!fixture.exists(beta))
#expect(try fixture.contents(firstTrash) == "alpha")
let contents = try fixture.trashItems().map { try fixture.contents($0) }.sorted()
#expect(contents == ["alpha", "beta"])
#expect(try fixture.activeJournals().isEmpty)
}
@Test func uninstallRecoversTrashMoveThatInterruptedBeforeJournalUpdate() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "alpha")
let beta = try fixture.file("Beta", text: "beta")
let interrupted = fixture.engine { event in
if case .afterTrashMove(0) = event { throw UninstallInterruption() }
}
let report = try await interrupted.execute([alpha, beta])
#expect(report.needsRecovery)
#expect(try fixture.trashItems().count == 1)
let journal = try #require(fixture.activeJournals().first)
#expect(try fixture.journalEntries(journal)[0]["state"] as? String == "trashIntent")
#expect(!(await fixture.engine().recover()).needsRecovery)
#expect(try fixture.trashItems().count == 2)
#expect(!fixture.exists(alpha))
#expect(!fixture.exists(beta))
}
@Test func uninstallCollapsesNestedAndRepeatedSelectionsIntoOneMove() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let bundle = fixture.selected.appendingPathComponent("Editor.app", isDirectory: true)
try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: false)
let child = bundle.appendingPathComponent("Settings")
try Data("nested data".utf8).write(to: child)
let report = try await fixture.engine().execute([child, bundle, bundle])
#expect(!report.needsRecovery)
#expect(try fixture.trashItems().count == 1)
let trashed = try #require(fixture.trashItems().first)
#expect(try fixture.contents(trashed.appendingPathComponent("Settings")) == "nested data")
let completed = try #require(fixture.completedJournals().first)
#expect(try fixture.journalEntries(completed).count == 1)
}
@Test func uninstallRejectsChangedIdentityBetweenIntentAndMove() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "reviewed identity")
let displaced = fixture.base.appendingPathComponent("Displaced")
let engine = fixture.engine { event in
if case .beforeStage(0) = event {
try FileManager.default.moveItem(at: original, to: displaced)
try Data("replacement identity".utf8).write(to: original)
}
}
_ = await uninstallFailure(engine, [original])
#expect(try fixture.contents(original) == "replacement identity")
#expect(try fixture.contents(displaced) == "reviewed identity")
#expect(try fixture.activeJournals().count == 1)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(original) == "replacement identity")
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallRecoveryRejectsChangedQuarantineIdentity() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected identity")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let root = try #require(fixture.quarantines().first)
let staged = root.appendingPathComponent("item-0")
let displaced = fixture.base.appendingPathComponent("Displaced")
try FileManager.default.moveItem(at: staged, to: displaced)
try Data("untrusted replacement".utf8).write(to: staged)
let recovery = await fixture.engine().recover()
#expect(recovery.needsRecovery)
#expect(!fixture.exists(original))
#expect(try fixture.contents(staged) == "untrusted replacement")
#expect(try fixture.contents(displaced) == "selected identity")
#expect(try fixture.activeJournals().count == 1)
}
@Test func uninstallTamperedJournalNeverAuthorizesAnArbitraryMove() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let victim = try fixture.file("Victim", text: "unrelated data")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let journal = try #require(fixture.activeJournals().first)
var envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: journal)) as? [String: Any])
let encoded = try #require(envelope["payload"] as? String)
let data = try #require(Data(base64Encoded: encoded))
var payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
var entries = try #require(payload["entries"] as? [[String: Any]])
entries[0]["original"] = victim.path
payload["entries"] = entries
envelope["payload"] = try JSONSerialization.data(withJSONObject: payload).base64EncodedString()
try JSONSerialization.data(withJSONObject: envelope).write(to: journal)
let recovery = await fixture.engine().recover()
#expect(recovery.needsRecovery)
#expect(try fixture.contents(victim) == "unrelated data")
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
#expect(fixture.exists(journal))
}
@Test func uninstallRefusesJournalSymlinksAndMissingAuthenticationKeys() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let journal = try #require(fixture.activeJournals().first)
let saved = fixture.base.appendingPathComponent("SavedJournal")
try FileManager.default.moveItem(at: journal, to: saved)
let victim = try fixture.file("Victim", text: "untrusted journal target")
try FileManager.default.createSymbolicLink(at: journal, withDestinationURL: victim)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(victim) == "untrusted journal target")
#expect(!fixture.exists(original))
try FileManager.default.removeItem(at: journal)
try FileManager.default.moveItem(at: saved, to: journal)
try FileManager.default.removeItem(at: fixture.journals.appendingPathComponent("key"))
let report = await fixture.engine().recover()
#expect(report.needsRecovery)
#expect(fixture.exists(journal))
#expect(!fixture.exists(original))
}
@Test func uninstallRejectsCandidateAndAncestorSymlinksWithoutFollowingThem() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "must stay")
let alias = fixture.selected.appendingPathComponent("Alias")
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: original)
_ = await uninstallFailure(fixture.engine(), [alias])
let linked = fixture.base.appendingPathComponent("Linked")
try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: fixture.selected)
_ = await uninstallFailure(fixture.engine(), [linked.appendingPathComponent("Editor")])
#expect(try fixture.contents(original) == "must stay")
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.quarantines().isEmpty)
}
@Test func uninstallAncestorSymlinkRaceCannotMoveVictimData() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected")
let displaced = fixture.base.appendingPathComponent("Displaced", isDirectory: true)
let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true)
try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false)
let victim = victimDirectory.appendingPathComponent("Editor")
try Data("victim".utf8).write(to: victim)
let engine = fixture.engine { event in
if case .beforeStage(0) = event {
try FileManager.default.moveItem(at: fixture.selected, to: displaced)
try FileManager.default.createSymbolicLink(at: fixture.selected, withDestinationURL: victimDirectory)
}
}
_ = await uninstallFailure(engine, [original])
#expect(try fixture.contents(victim) == "victim")
#expect(try fixture.contents(displaced.appendingPathComponent("Editor")) == "selected")
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallCreatesMissingPerVolumeTrashAndLeavesOriginalMapping() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor.app", text: "external volume data")
let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true)
try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false)
let engine = ApplicationUninstaller(journalDirectory: fixture.journals,
trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) })
#expect(!fixture.exists(volume.appendingPathComponent(".Trashes")))
let report = try await engine.execute([original])
#expect(!report.needsRecovery)
let userTrash = volume.appendingPathComponent(".Trashes/\(getuid())", isDirectory: true)
let items = try FileManager.default.contentsOfDirectory(at: userTrash, includingPropertiesForKeys: nil)
#expect(items.count == 1)
#expect(try fixture.contents(try #require(items.first)) == "external volume data")
#expect(try fixture.completedJournals().count == 1)
}
@Test func uninstallInaccessibleTrashRetainsCommitForLaterRecovery() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "retained data")
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fixture.trash.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) }
let report = try await fixture.engine().execute([original])
#expect(report.needsRecovery)
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "retained data")
#expect(try fixture.activeJournals().count == 1)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path)
#expect(!(await fixture.engine().recover()).needsRecovery)
#expect(try fixture.trashItems().count == 1)
}
@Test func uninstallSystemAndCurrentApplicationSelectionsFailClosed() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
for url in [URL(fileURLWithPath: "/System", isDirectory: true), Bundle.main.bundleURL.resolvingSymlinksInPath()] {
_ = await uninstallFailure(fixture.engine(), [url])
}
#expect(try fixture.activeJournals().isEmpty)
}
@Test func uninstallPreservesOccupiedTrashDestinationAndRetainsQuarantine() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let engine = fixture.engine { event in
if case .beforeTrash(0) = event {
let journal = try #require(fixture.activeJournals().first)
let entry = try fixture.journalEntries(journal)[0]
let destination = try #require(entry["trash"] as? [String: Any])
let name = try #require(destination["name"] as? String)
try Data("unrelated Trash data".utf8).write(to: fixture.trash.appendingPathComponent(name))
}
}
let report = try await engine.execute([original])
#expect(report.needsRecovery)
#expect(!fixture.exists(original))
let unrelated = try #require(fixture.trashItems().first)
#expect(try fixture.contents(unrelated) == "unrelated Trash data")
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(unrelated) == "unrelated Trash data")
}
@Test func uninstallDoesNotDeleteUnknownFilesAddedToQuarantine() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let engine = fixture.engine { event in
if case .beforeTrash(0) = event {
let root = try #require(fixture.quarantines().first)
try Data("untrusted data".utf8).write(to: root.appendingPathComponent("Unknown"))
}
}
let report = try await engine.execute([original])
#expect(report.needsRecovery)
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data")
#expect(try fixture.activeJournals().count == 1)
#expect(try fixture.trashItems().count == 1)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data")
}
@Test func uninstallMalformedJournalRetainsAllStagedData() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let journal = try #require(fixture.activeJournals().first)
try Data("not a journal".utf8).write(to: journal)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
#expect(try fixture.contents(journal) == "not a journal")
}
@Test func uninstallHandlesMaximumLengthFilenamesWithoutLosingOriginalAttribution() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let name = String(repeating: "A", count: 250) + ".app"
let original = try fixture.file(name, text: "long filename")
let report = try await fixture.engine().execute([original])
#expect(!report.needsRecovery)
let trashed = try #require(fixture.trashItems().first)
#expect(trashed.lastPathComponent.utf8.count <= 255)
#expect(trashed.pathExtension == "app")
#expect(try fixture.contents(trashed) == "long filename")
let completed = try #require(fixture.completedJournals().first)
#expect(try fixture.journalEntries(completed)[0]["original"] as? String == original.path)
}
@Test func uninstallRejectsJournalDirectoryAndVolumeTrashSymlinks() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true)
try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
let victim = victimDirectory.appendingPathComponent("Keep")
try Data("unrelated data".utf8).write(to: victim)
try FileManager.default.createSymbolicLink(at: fixture.journals, withDestinationURL: victimDirectory)
_ = await uninstallFailure(fixture.engine(), [original])
#expect(try fixture.contents(original) == "selected data")
#expect(try fixture.contents(victim) == "unrelated data")
#expect(!fixture.exists(victimDirectory.appendingPathComponent(".lock")))
try FileManager.default.removeItem(at: fixture.journals)
let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true)
try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false)
try FileManager.default.createSymbolicLink(at: volume.appendingPathComponent(".Trashes"), withDestinationURL: victimDirectory)
let engine = ApplicationUninstaller(journalDirectory: fixture.journals,
trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) })
let report = try await engine.execute([original])
#expect(report.needsRecovery)
#expect(try fixture.contents(victim) == "unrelated data")
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
}
@Test func uninstallNeedsSearchAndWritePermissionButNotDirectoryListingPermission() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
for directory in [fixture.selected, fixture.trash] {
try FileManager.default.setAttributes([.posixPermissions: 0o300], ofItemAtPath: directory.path)
}
defer {
for directory in [fixture.selected, fixture.trash] {
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
}
}
let report = try await fixture.engine().execute([original])
#expect(!report.needsRecovery)
#expect(!fixture.exists(original))
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path)
let trashed = try #require(fixture.trashItems().first)
#expect(try fixture.contents(trashed) == "selected data")
}
private struct UninstallTestFailure: Error {}
private func uninstallFailure(_ engine: ApplicationUninstaller, _ urls: [URL]) async -> String {
do {
_ = try await engine.execute(urls)
Issue.record("Expected uninstall execution to throw.")
return ""
} catch { return error.localizedDescription }
}
private struct UninstallFixture: Sendable {
let base: URL
let selected: URL
let trash: URL
let journals: URL
init() throws {
guard let temporary = realpath(FileManager.default.temporaryDirectory.path, nil) else {
throw CocoaError(.fileReadUnknown)
}
defer { free(temporary) }
base = URL(fileURLWithPath: String(cString: temporary), isDirectory: true)
.appendingPathComponent("ConjureUninstallTests-" + UUID().uuidString, isDirectory: true)
selected = base.appendingPathComponent("Selected", isDirectory: true)
trash = base.appendingPathComponent("Trash", isDirectory: true)
journals = base.appendingPathComponent("Journals", isDirectory: true)
for url in [base, selected, trash] {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
}
}
func engine(checkpoint: @escaping @Sendable (UninstallCheckpoint) throws -> Void = { _ in }) -> ApplicationUninstaller {
let destination = trash
return ApplicationUninstaller(journalDirectory: journals, trashDirectory: { _ in destination }, checkpoint: checkpoint)
}
func file(_ name: String, text: String) throws -> URL {
let url = selected.appendingPathComponent(name)
try Data(text.utf8).write(to: url)
return url
}
func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) }
func contents(_ url: URL) throws -> String { String(decoding: try Data(contentsOf: url), as: UTF8.self) }
func trashItems() throws -> [URL] { try children(trash) }
func quarantines() throws -> [URL] { try children(selected).filter { $0.lastPathComponent.hasPrefix(".conjure-uninstall-") } }
func activeJournals() throws -> [URL] { try children(journals).filter { $0.pathExtension == "json" } }
func completedJournals() throws -> [URL] { try children(journals.appendingPathComponent("Completed", isDirectory: true)) }
func journalEntries(_ url: URL) throws -> [[String: Any]] {
let envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any])
let encoded = try #require(envelope["payload"] as? String)
let bytes = try #require(Data(base64Encoded: encoded))
let payload = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
return try #require(payload["entries"] as? [[String: Any]])
}
private func children(_ url: URL) throws -> [URL] {
if !exists(url) { return [] }
return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil).sorted { $0.path < $1.path }
}
func remove() { try? FileManager.default.removeItem(at: base) }
}
@@ -0,0 +1,228 @@
import ConjureKit
import Darwin
import Foundation
import Testing
@Test func uninstallDiscoveryUsesOnlyExactApplicationAndExtensionIdentifiersWithoutWriting() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let identifier = "test.conjure.editor"
let extensionIdentifier = "test.conjure.editor.share"
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: identifier)
let extensionURL = app.appendingPathComponent("Contents/PlugIns/Share.appex")
_ = try uninstallDiscoveryBundle(at: extensionURL, identifier: extensionIdentifier, packageType: "XPC!")
let relatives = [
"Preferences/\(identifier).plist",
"Caches/\(identifier)",
"Application Support/\(identifier)",
"Logs/\(identifier)",
"Saved Application State/\(identifier).savedState",
"Application Scripts/\(identifier)",
"Containers/\(identifier)",
"Preferences/\(extensionIdentifier).plist",
"Caches/\(extensionIdentifier)",
]
let candidates = try relatives.map { try uninstallDiscoveryFile(root.appendingPathComponent("Library/\($0)"), data: Data("safe".utf8)) }
let lookalikes = try [
"Caches/\(identifier).other", "Caches/prefix.\(identifier)",
"Application Support/Editor", "Preferences/\(identifier).plist.backup",
"Group Containers/group.\(identifier)",
].map { try uninstallDiscoveryFile(root.appendingPathComponent("Library/\($0)"), data: Data("unrelated".utf8)) }
let before = try FileManager.default.subpathsOfDirectory(atPath: root.path).sorted()
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(result.map(\.url) == [app] + candidates.sorted { $0.path < $1.path })
#expect(result.allSatisfy { $0.isSelectable && $0.defaultSelected && $0.reason == nil })
#expect(result.dropFirst().allSatisfy { $0.size == 4 })
#expect(try FileManager.default.subpathsOfDirectory(atPath: root.path).sorted() == before)
for url in candidates { #expect(try Data(contentsOf: url) == Data("safe".utf8)) }
for url in lookalikes { #expect(try Data(contentsOf: url) == Data("unrelated".utf8)) }
}
@Test func uninstallDiscoveryIncludesNestedExtensionsButNeverLinkedExtensionIdentifiers() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
let first = try uninstallDiscoveryBundle(
at: app.appendingPathComponent("Contents/Extensions/First.appex"), identifier: "test.editor.first", packageType: "XPC!"
)
_ = try uninstallDiscoveryBundle(
at: first.appendingPathComponent("Contents/PlugIns/Nested.appex"), identifier: "test.editor.nested", packageType: "XPC!"
)
let external = try uninstallDiscoveryBundle(at: root.appendingPathComponent("External.appex"), identifier: "test.external", packageType: "XPC!")
try FileManager.default.createSymbolicLink(at: app.appendingPathComponent("Contents/Extensions/Linked.appex"), withDestinationURL: external)
let firstData = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor.first"))
let nestedData = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor.nested"))
_ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.external"))
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(Set(result.map(\.url)) == Set([app, firstData, nestedData]))
}
@Test func uninstallDiscoveryPreservesAndLocksLinksWithoutSizingTheirTargets() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
let outside = root.appendingPathComponent("Outside", isDirectory: true)
_ = try uninstallDiscoveryFile(outside.appendingPathComponent("payload"), data: Data(repeating: 1, count: 100_000))
let cache = root.appendingPathComponent("Library/Caches/test.editor", isDirectory: true)
try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true)
_ = try uninstallDiscoveryFile(cache.appendingPathComponent("local"), data: Data("123".utf8))
let childLink = cache.appendingPathComponent("external")
try FileManager.default.createSymbolicLink(at: childLink, withDestinationURL: outside)
let logs = root.appendingPathComponent("Library/Logs/test.editor")
try FileManager.default.createDirectory(at: logs.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: logs, withDestinationURL: outside)
let preferences = root.appendingPathComponent("Library/Preferences")
try FileManager.default.createSymbolicLink(at: preferences, withDestinationURL: outside)
_ = try uninstallDiscoveryFile(outside.appendingPathComponent("test.editor.plist"))
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
let cacheCandidate = try #require(result.first { $0.url.path == cache.path })
let logsCandidate = try #require(result.first { $0.url.path == logs.path })
#expect(result.count == 3)
#expect(cacheCandidate.isSelectable)
#expect(cacheCandidate.size == 3 + Int64(outside.path.utf8.count))
#expect(!logsCandidate.isSelectable && !logsCandidate.defaultSelected)
#expect(logsCandidate.url.path != outside.path)
#expect(logsCandidate.size == Int64(outside.path.utf8.count))
#expect(try Data(contentsOf: outside.appendingPathComponent("payload")).count == 100_000)
}
@Test func uninstallDiscoveryLeavesActuallySignedSharedGroupsUncheckedAndIgnoresUnsignedClaims() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor", nativeExecutable: true)
let extensionURL = try uninstallDiscoveryBundle(
at: app.appendingPathComponent("Contents/PlugIns/Share.appex"), identifier: "test.editor.share", packageType: "XPC!", nativeExecutable: true
)
try uninstallDiscoverySign(extensionURL, groups: ["group.test.extension"], in: root)
try uninstallDiscoverySign(app, groups: ["group.test.shared", "../unsafe"], in: root)
let shared = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.shared"))
let scripts = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Application Scripts/group.test.shared"))
let extensionGroup = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.extension"))
_ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.editor"))
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(Set(result.map(\.url)) == Set([app, shared, scripts, extensionGroup]))
#expect(result.dropFirst().allSatisfy { $0.isSelectable && !$0.defaultSelected })
let unsigned = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Unsigned.app"), identifier: "test.unsigned")
let unsignedInfo = unsigned.appendingPathComponent("Contents/Info.plist")
var info = try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: unsignedInfo), format: nil) as? [String: Any])
info["com.apple.security.application-groups"] = ["group.test.shared"]
try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: unsignedInfo)
let unsignedResult = try UninstallDiscovery.scan(application: Application(url: unsigned, name: "Unsigned"), home: root, ownApplicationURL: nil)
#expect(unsignedResult.map(\.url) == [unsigned])
}
@Test func uninstallDiscoveryLocksProtectedAndInvalidApplicationsWithClearReasons() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Conjure.app"), identifier: "test.conjure")
_ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.conjure"))
let own = try UninstallDiscovery.scan(application: Application(url: app, name: "Conjure"), home: root, ownApplicationURL: app)
#expect(own.count == 1 && own[0].url == app)
#expect(!own[0].isSelectable && !own[0].defaultSelected)
let system = try UninstallDiscovery.scan(
application: Application(url: URL(fileURLWithPath: "/System/Applications/Protected.app"), name: "Protected"),
home: root, ownApplicationURL: nil
)
#expect(system.count == 1 && !system[0].isSelectable)
#expect(system[0].size == nil)
let duplicate = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Duplicate.app"), identifier: "test.conjure")
let duplicateResult = try UninstallDiscovery.scan(
application: Application(url: duplicate, name: "Duplicate"), home: root, ownApplicationURL: app
)
#expect(duplicateResult.count == 1 && !duplicateResult[0].isSelectable)
for identifier in ["../outside", "test/escape", "test..invalid", ""] {
let invalid = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Invalid.app"), identifier: identifier)
let result = try UninstallDiscovery.scan(application: Application(url: invalid, name: "Invalid"), home: root, ownApplicationURL: nil)
#expect(result.count == 1 && !result[0].isSelectable && !result[0].defaultSelected)
}
let missing = try UninstallDiscovery.scan(application: Application(url: root.appendingPathComponent("Missing.app"), name: "Missing"), home: root, ownApplicationURL: nil)
#expect(missing.count == 1 && !missing[0].isSelectable && missing[0].reason != nil)
}
@Test func uninstallDiscoveryExplainsLockedFileAndParentPermissions() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
let cache = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor"))
#expect(chflags(cache.path, UInt32(UF_IMMUTABLE)) == 0)
defer { _ = chflags(cache.path, 0) }
let locked = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
let candidate = try #require(locked.first { $0.url == cache })
#expect(!candidate.isSelectable && !candidate.defaultSelected)
#expect(candidate.size == 4)
// Root can bypass POSIX permissions; the immutable-file check above applies to every runner.
if geteuid() != 0 {
let preferences = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Preferences/test.editor.plist"))
let parent = preferences.deletingLastPathComponent()
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: parent.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: parent.path) }
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
let denied = try #require(result.first { $0.url == preferences })
#expect(!denied.isSelectable && !denied.defaultSelected)
}
}
@Test func uninstallDiscoveryRejectsUnownedRootsWhenFixtureOwnershipCanBeChanged() throws {
guard geteuid() == 0 else { return }
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
#expect(chown(app.path, 501, gid_t.max) == 0)
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(result.count == 1 && !result[0].isSelectable && !result[0].defaultSelected)
}
private func uninstallDiscoveryRoot() throws -> URL {
let root = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".ConjureDiscoveryTests-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}
@discardableResult
private func uninstallDiscoveryFile(_ url: URL, data: Data = Data("data".utf8)) throws -> URL {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: url)
return url
}
@discardableResult
private func uninstallDiscoveryBundle(at url: URL, identifier: String, packageType: String = "APPL", nativeExecutable: Bool = false) throws -> URL {
let url = URL(fileURLWithPath: url.path, isDirectory: true)
let binary = url.appendingPathComponent("Contents/MacOS/Launcher")
try FileManager.default.createDirectory(at: binary.deletingLastPathComponent(), withIntermediateDirectories: true)
let info: [String: Any] = ["CFBundleIdentifier": identifier, "CFBundlePackageType": packageType, "CFBundleExecutable": "Launcher"]
try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: url.appendingPathComponent("Contents/Info.plist"))
if nativeExecutable {
try FileManager.default.copyItem(at: URL(fileURLWithPath: "/bin/echo"), to: binary)
} else {
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: binary)
}
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
return url
}
private func uninstallDiscoverySign(_ url: URL, groups: [String], in root: URL) throws {
let entitlements = root.appendingPathComponent("\(url.lastPathComponent).entitlements")
try PropertyListSerialization.data(
fromPropertyList: ["com.apple.security.application-groups": groups], format: .xml, options: 0
).write(to: entitlements)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
process.arguments = ["--force", "--sign", "-", "--timestamp=none", "--entitlements", entitlements.path, url.path]
let errors = Pipe()
process.standardError = errors
try process.run()
process.waitUntilExit()
let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
guard process.terminationStatus == 0 else { throw NSError(domain: "UninstallDiscoveryTests.codesign", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message]) }
}