feat: add contextual actions and recoverable application uninstall
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
- `Sources/Conjure`: application lifecycle, launcher and Settings windows, and application launching.
|
||||
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, and launcher alignment geometry.
|
||||
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, and installation safety checks.
|
||||
- `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, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery.
|
||||
- `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:)).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -90,10 +90,12 @@ struct ConjureApp {
|
||||
|
||||
@MainActor
|
||||
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 settings = SettingsWindowController(shortcut: shortcut)
|
||||
private var startupShortcutError: String?
|
||||
private var startupRecoveryMessage: String?
|
||||
|
||||
private var isLoginLaunch: Bool {
|
||||
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")
|
||||
quitItem.target = NSApplication.shared
|
||||
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 {
|
||||
launcher.present()
|
||||
}
|
||||
@@ -125,7 +136,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
||||
guard !isLoginLaunch else { return false }
|
||||
launcher.present()
|
||||
if startupShortcutError != nil {
|
||||
if startupShortcutError != nil || startupRecoveryMessage != nil {
|
||||
showSettings(nil)
|
||||
}
|
||||
return false
|
||||
@@ -134,6 +145,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
@objc func showSettings(_ sender: Any?) {
|
||||
settings.present(error: startupShortcutError)
|
||||
startupShortcutError = nil
|
||||
startupRecoveryMessage = nil
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
|
||||
@@ -17,9 +17,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private var previousApplication: NSRunningApplication?
|
||||
private var alignmentGuides: LauncherAlignmentGuides?
|
||||
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.uninstaller = uninstaller
|
||||
let window = LauncherPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||||
styleMask: [.borderless],
|
||||
@@ -46,6 +51,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
self.alignmentGuides?.end(snapping: self.window)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -81,6 +91,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
|
||||
func dismiss(restoringFocus: Bool = true) {
|
||||
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
||||
actions.close()
|
||||
alignmentGuides?.end()
|
||||
let previous = previousApplication
|
||||
previousApplication = nil
|
||||
@@ -132,7 +143,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||
guard !isLaunching, !actions.isShown, window?.attachedSheet == nil else { return }
|
||||
dismiss(restoringFocus: false)
|
||||
}
|
||||
|
||||
@@ -228,6 +239,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
openButton.target = self
|
||||
openButton.action = #selector(openSelection)
|
||||
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."
|
||||
let returnKey = NSTextField(labelWithString: "↵")
|
||||
returnKey.font = .systemFont(ofSize: 14)
|
||||
@@ -244,7 +265,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
actionPill.state = .active
|
||||
roundMaterialCorners(actionPill, radius: 18)
|
||||
actionPill.layer?.borderWidth = 0.5
|
||||
for view in [openButton, returnKey] {
|
||||
for view in [openButton, returnKey, actionsButton] {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
actionPill.addSubview(view)
|
||||
}
|
||||
@@ -282,7 +303,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
openButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14),
|
||||
openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||||
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.widthAnchor.constraint(equalToConstant: 18),
|
||||
returnKey.heightAnchor.constraint(equalToConstant: 18),
|
||||
@@ -305,6 +328,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
ApplicationCatalog.discover()
|
||||
}.value
|
||||
guard let self else { return }
|
||||
guard !Task.isCancelled else { return }
|
||||
self.applications = applications
|
||||
self.updateLocationLabels()
|
||||
self.discoveryTask = nil
|
||||
@@ -388,6 +412,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
|
||||
private func updateOpenButton() {
|
||||
openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow)
|
||||
actionsButton.isEnabled = openButton.isEnabled
|
||||
}
|
||||
|
||||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||
@@ -465,6 +490,30 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
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() {
|
||||
guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return }
|
||||
let application = results[tableView.selectedRow]
|
||||
@@ -551,7 +600,7 @@ private final class LauncherPanel: NSPanel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LauncherRowView: NSTableRowView {
|
||||
final class LauncherRowView: NSTableRowView {
|
||||
override func drawSelection(in dirtyRect: NSRect) {
|
||||
let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
|
||||
SunsetPalette.selectionFill.setFill()
|
||||
|
||||
@@ -12,6 +12,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
private let loginToggle = NSButton(checkboxWithTitle: "Launch at login", target: nil, action: nil)
|
||||
private let loginStatus = NSTextField(wrappingLabelWithString: "")
|
||||
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) {
|
||||
self.shortcut = shortcut
|
||||
@@ -113,6 +117,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
loginSettings.action = #selector(openLoginSettings)
|
||||
loginSettings.bezelStyle = .rounded
|
||||
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.textColor = SunsetPalette.text
|
||||
@@ -128,7 +139,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
let stack = NSStackView(views: [
|
||||
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
|
||||
loginHeading, loginToggle, loginStatus, loginSettings
|
||||
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton
|
||||
])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
@@ -152,11 +163,38 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
let status = SMAppService.mainApp.status
|
||||
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)).")
|
||||
}
|
||||
@@ -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]) }
|
||||
}
|
||||
Reference in New Issue
Block a user