Compare commits

..

19 Commits

Author SHA1 Message Date
hl-valdemar 47aa757526 fix: morph launcher into uninstall review and align its filter 2026-10-12 23:14:00 +02:00
hl-valdemar b69152e2c0 feat: add Open as the default application action 2026-10-12 22:11:15 +02:00
hl-valdemar 95cc55b8ce feat: animate actions panel and footer transitions 2026-10-12 17:24:36 +02:00
hl-valdemar ff8407e771 style: match action icons and input text to application rows 2026-10-12 17:02:46 +02:00
hl-valdemar 5537bc85b8 style: align actions panel with footer and tighten its layout 2026-10-12 16:40:29 +02:00
hl-valdemar eb99269c68 style: anchor launcher-styled actions above the footer 2026-10-12 16:33:39 +02:00
hl-valdemar 2598f6f1dd feat: add contextual actions and recoverable application uninstall 2026-10-12 15:44:36 +02:00
hl-valdemar 8d8e2278ab feat: remember launcher position across restarts 2026-10-12 14:35:15 +02:00
hl-valdemar 0da8b8e241 feat: use launch history to break search ranking ties 2026-10-12 14:24:14 +02:00
hl-valdemar a0a6f25c03 style: increase spacing between Settings sections 2026-10-12 14:14:55 +02:00
hl-valdemar a1f3e0bcfe style: remove Settings section separator 2026-10-12 14:13:35 +02:00
hl-valdemar 57e87bc109 fix: extend Settings material behind the title bar 2026-10-12 14:09:10 +02:00
hl-valdemar fa4cf9c154 feat: add launch-at-login settings and Sunset styling 2026-10-12 14:05:28 +02:00
hl-valdemar 37f6cab256 feat: add a bundled sparkle application icon 2026-10-12 13:59:40 +02:00
hl-valdemar ec30ceb8ee fix: keep alignment guides continuous behind launcher 2026-10-12 13:13:09 +02:00
hl-valdemar 54a7d8f494 feat: snap launcher on release at upper-third alignment 2026-10-12 13:13:09 +02:00
hl-valdemar dc7cf07204 feat: snap launcher to stippled edge alignment guides 2026-10-12 13:13:09 +02:00
hl-valdemar 97420f51e1 feat: show stippled centering guides while dragging launcher 2026-10-12 13:13:09 +02:00
hl-valdemar d4312eb839 feat: keep launcher hidden when started at login 2026-10-12 13:13:09 +02:00
19 changed files with 3131 additions and 44 deletions
+3 -1
View File
@@ -5,9 +5,11 @@ INSTALL_DIR ?= /Applications
build:
swift build -c release
mkdir -p $(APP)/Contents/MacOS
mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
cp Resources/Info.plist $(APP)/Contents/Info.plist
swift Scripts/icon.swift .build/Conjure.iconset
iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns
codesign --force --sign - $(APP)
run: build
+35 -5
View File
@@ -33,13 +33,15 @@ 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. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. 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.
- Standard macOS text-editing shortcuts work in the search field.
- Command-comma opens the native Settings window.
- Right-click the launcher background for Settings and Quit.
- Drag the panel background to move it.
- Drag the panel background to move it freely. Three stippled guides mark the target left, right, and top edges: the launcher is horizontally centered, its top edge sits one-fifth down the current display's usable area, and the side guides are exactly one launcher-width apart. Each axis brightens within its 12-point snap range, but the window snaps only when the mouse button is released. Releasing outside the range leaves that axis untouched; dismissal cancels without snapping. Guides remain continuous underneath the frosted launcher rather than being cut out around it. They disappear on release or dismissal and do not intercept input.
- The final dragged or snapped position is saved locally with the display's stable identifier and restored across restarts. Before showing the launcher, Conjure checks that the saved display is connected and the whole panel fits its current usable area. Missing displays, invalid coordinates, or off-screen positions fall back to the default centered, one-fifth-down alignment on an available display; the fallback is kept inside the usable area when the panel fits. First launch uses the same default alignment.
Conjure runs as a native accessory app: no Dock icon, app-switcher entry, or menu-bar menus. Keyboard menu shortcuts remain available while its window is focused.
@@ -53,11 +55,17 @@ The global shortcut shows the launcher with an empty query and search focused. P
Activation uses the native macOS hotkey API, without an Accessibility permission or a global keyboard monitor. It works only while Conjure is running; it does not launch a quit app or enable launch at login.
## Launch at login
Open Settings with `⌘,` and enable **Launch at login**. Conjure uses macOS's native login-item service and reads its actual registration status rather than storing a separate preference. If macOS requires approval, Settings explains this and offers **Open Login Items Settings…**. Registration errors are shown beside the control, and the checkbox returns to the actual system state. Install Conjure in `/Applications` before enabling it; disable the toggle to unregister it.
Conjure recognizes the native [login-launch Apple event](https://developer.apple.com/documentation/coreservices/1556410-launch_apple_event_constants) and starts without showing a window or taking focus, while still registering its saved global shortcut. Opening it manually from Finder still shows the launcher, including when it is already running. If shortcut registration fails at login, the error is retained and shown in Settings when you next open Conjure manually. Normal manual launches continue to report startup errors immediately.
## Discovery and search
Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths.
Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Ties are alphabetical. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher.
Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches are ordered by successful launch count, then most recent launch, then alphabetically. Usage never promotes a weaker text match above a stronger one or changes which applications match. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher.
Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code.
@@ -67,16 +75,38 @@ 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**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
The red **Uninstall application** action morphs the existing launcher into a file-review screen with checkboxes, paths, sizes, and a selected-item count; it does not create another window or review sheet. A native 180-millisecond resize and content crossfade runs in both directions, or switches immediately with Reduce Motion. The filter uses a centered, intrinsic-height text field without built-in search/cancel icons, beside an explicit back chevron. Filtering files by name or path does not change file selections. Up/Down or Control-K/Control-J highlights files; Space toggles the highlighted eligible item when the file list has focus. Back or Escape restores the original launcher frame, query, and selection. Temporary review geometry is not saved as the launcher position. Leaving is blocked during confirmation or an uninstall operation. Return or the uninstall button still opens a native final confirmation dialog; nothing is moved merely by entering review.
The actions selector is an arrowless panel inside the launcher, aligned to the bottom and right edges of the footer action pill, replacing it visually while open rather than floating above it. It shares the launcher's frosted Sunset background, 38-point selection row height, 22×22-point icon boxes with 12-point icon-to-text spacing, 14-point action and search text, rounded selection styling, and Return key badge; selection width follows the panel. Application and command rows use the same sizing constants. SF Symbol optical alignment insets are removed so command icons render inside the same bounds as application icons. The application name appears at the top and borderless action search sits eight points from the bottom, without a divider. Native animations crossfade the panel and footer pill: 160 milliseconds to open and 120 milliseconds to close, with unchanged geometry. Reduce Motion disables the animation. Rapid reopening invalidates stale dismissal completions, and closing panels immediately stop intercepting input. Escape, Command-K, an outside click, or switching away closes it and restores the pill. Closing while Conjure remains active restores launcher search focus; switching away does not steal focus back. Return in action search executes the highlighted action: Open launches the application, while Uninstall opens its review.
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.
The application icon uses the same orange `sparkles` SF Symbol on a self-contained, rounded dark-brown tile. `Scripts/icon.swift` renders every standard and Retina icon size, and `make build` packages the resulting `.icns` in the signed bundle for Finder and other system icon surfaces. Conjure remains an accessory app; adding an application icon does not add a Dock or app-switcher entry.
Settings shares the launcher's dark appearance, frosted HUD material, Sunset background glows, cream headings, and orange control accents. The material and tint extend behind the title bar, while controls stay below it. Native window controls and shortcut recording behavior remain available.
The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView` using the native HUD material. This exposes macOS's blurred backdrop while retaining the warm palette and glows; the system material supplies an opaque fallback when Reduce Transparency is enabled. Both the panel and launch pill use native rounded alpha masks alongside layer clipping, keeping material inside its rounded outline and making the window shadow follow the same shape.
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, and global shortcut registration.
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, 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:)).
+2
View File
@@ -14,6 +14,8 @@
<string>Conjure</string>
<key>CFBundleDisplayName</key>
<string>Conjure</string>
<key>CFBundleIconFile</key>
<string>Conjure.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+55
View File
@@ -0,0 +1,55 @@
import AppKit
// Render the launcher's SF Symbol directly at every macOS icon resolution.
precondition(CommandLine.arguments.count == 2, "Usage: swift Scripts/icon.swift <output.iconset>")
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
guard let symbol = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)?
.withSymbolConfiguration(NSImage.SymbolConfiguration(paletteColors: [accent])) else {
fatalError("The sparkles SF Symbol is unavailable.")
}
let gradient = NSGradient(starting: NSColor(srgbRed: 0.27, green: 0.19, blue: 0.13, alpha: 1),
ending: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1))!
for size in [16, 32, 128, 256, 512] {
for scale in [1, 2] {
let pixels = size * scale
let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
isPlanar: false, colorSpaceName: .deviceRGB,
bytesPerRow: 0, bitsPerPixel: 0)!
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
let context = NSGraphicsContext.current!.cgContext
context.scaleBy(x: CGFloat(pixels) / 1024, y: CGFloat(pixels) / 1024)
let tile = NSBezierPath(roundedRect: NSRect(x: 100, y: 100, width: 824, height: 824),
xRadius: 184, yRadius: 184)
NSGraphicsContext.saveGraphicsState()
let shadow = NSShadow()
shadow.shadowColor = NSColor.black.withAlphaComponent(0.28)
shadow.shadowBlurRadius = 24
shadow.shadowOffset = NSSize(width: 0, height: -12)
shadow.set()
NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1).setFill()
tile.fill()
NSGraphicsContext.restoreGraphicsState()
gradient.draw(in: tile, angle: -90)
NSColor.white.withAlphaComponent(0.10).setStroke()
tile.lineWidth = 2
tile.stroke()
let glyphHeight: CGFloat = 540
let glyphWidth = glyphHeight * symbol.size.width / symbol.size.height
symbol.draw(in: NSRect(x: (1024 - glyphWidth) / 2, y: (1024 - glyphHeight) / 2,
width: glyphWidth, height: glyphHeight),
from: .zero, operation: .sourceOver, fraction: 1)
NSGraphicsContext.restoreGraphicsState()
let suffix = scale == 2 ? "@2x" : ""
let output = directory.appendingPathComponent("icon_\(size)x\(size)\(suffix).png")
guard let png = bitmap.representation(using: .png, properties: [:]) else {
fatalError("Could not encode \(output.lastPathComponent).")
}
try png.write(to: output)
}
}
@@ -0,0 +1,318 @@
import AppKit
import ConjureKit
@MainActor
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate {
private let search = ActionsSearchField()
private let entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
]
private var visibleEntries = [0, 1]
private var selectedEntry: Int? = 0
private var rowTopConstraints: [NSLayoutConstraint] = []
private var heightConstraint: NSLayoutConstraint?
private let empty = NSTextField(labelWithString: "No matching actions")
private let applicationLabel = NSTextField(labelWithString: "")
private var mouseMonitor: Any?
private weak var coveredPill: NSView?
private var coveredPillAlpha: CGFloat = 1
private var isClosing = false
private var transitionGeneration = 0
private var panel: ActionsPanelView { view as! ActionsPanelView }
var open: (() -> Void)?
var uninstall: (() -> Void)?
var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
init() { super.init(nibName: nil, bundle: nil) }
required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") }
override func loadView() {
view = ActionsPanelView()
view.wantsLayer = true
view.layer?.shadowColor = NSColor.black.cgColor
view.layer?.shadowOpacity = 0.35
view.layer?.shadowRadius = 14
view.layer?.shadowOffset = NSSize(width: 0, height: -4)
let content = NSVisualEffectView()
content.material = .hudWindow
content.blendingMode = .withinWindow
content.state = .active
roundMaterialCorners(content, radius: 18)
content.layer?.borderWidth = 0.5
let background = LauncherBackground(frame: .zero)
background.setAccessibilityElement(false)
content.addSubview(background)
applicationLabel.textColor = SunsetPalette.secondaryText
applicationLabel.lineBreakMode = .byTruncatingTail
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
search.font = .systemFont(ofSize: LauncherRowView.textSize)
search.textColor = SunsetPalette.text
search.placeholderAttributedString = NSAttributedString(string: "Search for actions…", attributes: [
.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)
])
search.isBordered = false
search.drawsBackground = false
search.focusRingType = .none
if let cell = search.cell as? NSSearchFieldCell {
cell.searchButtonCell = nil
cell.cancelButtonCell = nil
}
search.delegate = self
search.sendsSearchStringImmediately = true
search.setAccessibilityLabel("Search application actions")
search.closeActions = { [weak self] in self?.close() }
empty.textColor = SunsetPalette.secondaryText
empty.font = .systemFont(ofSize: 14)
empty.isHidden = true
content.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(content)
for child in [background, applicationLabel, empty, search] {
child.translatesAutoresizingMaskIntoConstraints = false
if child !== background { content.addSubview(child) }
}
for (index, entry) in entries.enumerated() {
entry.view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(entry.view)
entry.button.tag = index
entry.button.target = self
entry.button.action = #selector(chooseButton(_:))
entry.view.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(chooseRow(_:))))
let top = entry.view.topAnchor.constraint(equalTo: applicationLabel.bottomAnchor,
constant: 10 + CGFloat(index) * LauncherRowView.rowHeight)
rowTopConstraints.append(top)
NSLayoutConstraint.activate([
entry.view.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
entry.view.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
top
])
}
let height = view.heightAnchor.constraint(equalToConstant: 116 + LauncherRowView.rowHeight)
heightConstraint = height
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 360), height,
content.leadingAnchor.constraint(equalTo: view.leadingAnchor),
content.trailingAnchor.constraint(equalTo: view.trailingAnchor),
content.topAnchor.constraint(equalTo: view.topAnchor),
content.bottomAnchor.constraint(equalTo: view.bottomAnchor),
background.leadingAnchor.constraint(equalTo: content.leadingAnchor),
background.trailingAnchor.constraint(equalTo: content.trailingAnchor),
background.topAnchor.constraint(equalTo: content.topAnchor),
background.bottomAnchor.constraint(equalTo: content.bottomAnchor),
applicationLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
applicationLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
applicationLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 14),
empty.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
empty.centerYAnchor.constraint(equalTo: entries[0].view.centerYAnchor),
search.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
search.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
search.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
search.heightAnchor.constraint(equalToConstant: 24)
])
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
updateSelection()
}
func show(for application: Application, relativeTo anchor: NSView) {
if isShown { close(); return }
guard let window = anchor.window, let host = window.contentView else { return }
let pill = anchor.superview ?? anchor
_ = view
applicationLabel.stringValue = application.name
search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
transitionGeneration += 1
isClosing = false
panel.acceptsInteraction = true
panel.setAccessibilityHidden(false)
if view.superview == nil {
view.alphaValue = 0
view.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(view)
NSLayoutConstraint.activate([
view.trailingAnchor.constraint(equalTo: pill.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pill.bottomAnchor)
])
coveredPill = pill
coveredPillAlpha = pill.alphaValue
}
pill.setAccessibilityHidden(true)
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
view.animator().alphaValue = 1
pill.animator().alphaValue = 0
}
window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self, weak pill] event in
MainActor.assumeIsolated {
guard let self, self.isShown, event.window === self.view.window else { return }
let point = self.view.convert(event.locationInWindow, from: nil)
let pillPoint = pill?.convert(event.locationInWindow, from: nil)
if !self.view.bounds.contains(point), !(pillPoint.map { pill?.bounds.contains($0) == true } ?? false) {
self.close()
}
}
return event
}
}
func close() {
guard isShown else { return }
transitionGeneration += 1
let generation = transitionGeneration
isClosing = true
panel.acceptsInteraction = false
panel.setAccessibilityHidden(true)
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) }
mouseMonitor = nil
coveredPill?.setAccessibilityHidden(false)
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.12
view.animator().alphaValue = 0
coveredPill?.animator().alphaValue = coveredPillAlpha
} completionHandler: { [weak self] in
MainActor.assumeIsolated {
guard let self, self.transitionGeneration == generation, self.isClosing else { return }
self.view.removeFromSuperview()
self.coveredPill = nil
self.isClosing = false
}
}
closed?()
}
func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = entries.indices.filter { query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil }
selectedEntry = visibleEntries.first
for index in entries.indices {
let position = visibleEntries.firstIndex(of: index)
entries[index].view.isHidden = position == nil
rowTopConstraints[index].constant = 10 + CGFloat(position ?? 0) * LauncherRowView.rowHeight
}
heightConstraint?.constant = 116 + CGFloat(max(visibleEntries.count, 1) - 1) * LauncherRowView.rowHeight
empty.isHidden = !visibleEntries.isEmpty
updateSelection()
}
func moveSelection(by offset: Int) {
guard !visibleEntries.isEmpty else { return }
let current = selectedEntry.flatMap { visibleEntries.firstIndex(of: $0) } ?? 0
selectedEntry = visibleEntries[min(max(current + offset, 0), visibleEntries.count - 1)]
updateSelection()
}
private func updateSelection() {
for index in entries.indices {
let selected = selectedEntry == index
entries[index].view.isSelected = selected
entries[index].returnKey.isHidden = !selected
entries[index].button.setAccessibilityValue(selected ? "Selected" : "Not selected")
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
switch selector {
case #selector(NSResponder.insertNewline(_:)):
if let selectedEntry { chooseAction(at: selectedEntry) }
case #selector(NSResponder.cancelOperation(_:)): close()
case #selector(NSResponder.moveUp(_:)): moveSelection(by: -1)
case #selector(NSResponder.moveDown(_:)): moveSelection(by: 1)
default: return false
}
return true
}
@objc private func chooseButton(_ sender: NSButton) { chooseAction(at: sender.tag) }
@objc private func chooseRow(_ sender: NSGestureRecognizer) {
if let index = entries.firstIndex(where: { $0.view === sender.view }) { chooseAction(at: index) }
}
private func chooseAction(at index: Int) {
guard isShown, visibleEntries.contains(index) else { return }
close()
if index == 0 { open?() } else { uninstall?() }
}
}
@MainActor
private struct ActionEntry {
let view = LauncherRowView()
let button: NSButton
let returnKey = NSTextField(labelWithString: "↵")
init(title: String, symbol: String, color: NSColor) {
button = NSButton(title: title, target: nil, action: nil)
button.isBordered = false
button.alignment = .left
button.font = .systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
button.contentTintColor = color
button.attributedTitle = NSAttributedString(string: title, attributes: [
.foregroundColor: color, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
])
button.setAccessibilityLabel(title)
let icon = ActionIconView()
icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)
icon.contentTintColor = color
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
returnKey.font = .systemFont(ofSize: 14)
returnKey.textColor = SunsetPalette.secondaryText
returnKey.alignment = .center
returnKey.wantsLayer = true
returnKey.layer?.cornerRadius = 5
returnKey.layer?.borderWidth = 1
returnKey.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
returnKey.setAccessibilityElement(false)
for child in [icon, button, returnKey] {
child.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child)
}
NSLayoutConstraint.activate([
view.heightAnchor.constraint(equalToConstant: LauncherRowView.rowHeight),
icon.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: view.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
icon.heightAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
button.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
button.trailingAnchor.constraint(equalTo: returnKey.leadingAnchor, constant: -12),
returnKey.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
returnKey.centerYAnchor.constraint(equalTo: view.centerYAnchor),
returnKey.widthAnchor.constraint(equalToConstant: 18),
returnKey.heightAnchor.constraint(equalToConstant: 18)
])
}
}
@MainActor
private final class ActionsPanelView: NSView {
var acceptsInteraction = true
override func hitTest(_ point: NSPoint) -> NSView? {
acceptsInteraction ? super.hitTest(point) : nil
}
}
@MainActor
private final class ActionIconView: NSImageView {
// SF Symbol optical insets otherwise enlarge a 22-point icon box beyond application icon sizing.
override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) }
}
@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)
}
}
+33 -4
View File
@@ -90,9 +90,18 @@ 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
return event?.eventID == kAEOpenApplication
&& event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem
}
func applicationDidFinishLaunching(_ notification: Notification) {
let menu = NSMenu()
@@ -102,21 +111,41 @@ 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
launcher.present()
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()
}
do {
try shortcut.start()
} catch {
settings.present(error: error.localizedDescription)
startupShortcutError = error.localizedDescription
if !isLoginLaunch {
showSettings(nil)
}
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
guard !isLoginLaunch else { return false }
launcher.present()
if startupShortcutError != nil || startupRecoveryMessage != nil {
showSettings(nil)
}
return false
}
@objc func showSettings(_ sender: Any?) {
settings.present()
settings.present(error: startupShortcutError)
startupShortcutError = nil
startupRecoveryMessage = nil
}
func applicationWillTerminate(_ notification: Notification) {
@@ -0,0 +1,90 @@
import AppKit
import ConjureKit
@MainActor
final class LauncherAlignmentGuides {
private let view = AlignmentGuideView()
private lazy var overlay: NSPanel = {
let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = false
panel.ignoresMouseEvents = true
panel.isReleasedWhenClosed = false
panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
panel.contentView = view
panel.setAccessibilityElement(false)
return panel
}()
private var dragging = false
func begin(for window: NSWindow) {
guard !dragging, window.screen != nil else { return }
dragging = true
update(for: window)
overlay.level = window.level
overlay.order(.below, relativeTo: window.windowNumber)
}
func update(for window: NSWindow) {
guard dragging, let screen = window.screen else { return }
let frame = screen.visibleFrame
let alignment = LauncherAlignment(windowFrame: window.frame, visibleFrame: frame)
if overlay.frame != frame {
overlay.setFrame(frame, display: false)
}
view.targetFrame = alignment.targetFrame.offsetBy(dx: -frame.minX, dy: -frame.minY)
view.snapsHorizontally = alignment.snappedOrigin.x == alignment.targetFrame.minX
view.snapsVertically = alignment.snappedOrigin.y == alignment.targetFrame.minY
view.needsDisplay = true
}
func end(snapping window: NSWindow? = nil) {
guard dragging else { return }
dragging = false
overlay.orderOut(nil)
if let window, let screen = window.screen {
let origin = LauncherAlignment(windowFrame: window.frame, visibleFrame: screen.visibleFrame).snappedOrigin
if window.frame.origin != origin {
window.setFrameOrigin(origin)
}
}
}
}
@MainActor
private final class AlignmentGuideView: NSView {
var targetFrame = NSRect.zero
var snapsHorizontally = false
var snapsVertically = false
private static let dash: [CGFloat] = [1, 5]
private static let muted = SunsetPalette.accent.withAlphaComponent(0.4).cgColor
private static let aligned = SunsetPalette.accent.withAlphaComponent(0.95).cgColor
private static let shadow = NSColor.black.withAlphaComponent(0.6).cgColor
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
defer { context.restoreGState() }
context.addRect(bounds)
context.clip()
context.setLineWidth(1.5)
context.setLineCap(.round)
context.setLineDash(phase: 0, lengths: Self.dash)
context.setShadow(offset: .zero, blur: 2, color: Self.shadow)
context.setStrokeColor(snapsHorizontally ? Self.aligned : Self.muted)
context.move(to: NSPoint(x: targetFrame.minX, y: bounds.minY))
context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY))
context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY))
context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY))
context.strokePath()
context.setStrokeColor(snapsVertically ? Self.aligned : Self.muted)
context.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY))
context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY))
context.strokePath()
}
}
+191 -16
View File
@@ -15,9 +15,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
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?
private var launcherViews: [NSView] = []
private var launcherFrameBeforeReview: NSRect?
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],
@@ -37,8 +46,25 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
super.init(window: window)
configureContent(in: window)
window.delegate = self
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
window.navigateSelection = { [weak self] offset in
guard let self else { return }
if let review = self.uninstallReview { review.moveSelection(offset) }
else if self.actions.isShown { self.actions.moveSelection(by: offset) }
else { self.moveSelection(by: offset) }
}
window.dismiss = { [weak self] in self?.dismiss() }
window.dragEnded = { [weak self] in
guard let self else { return }
self.alignmentGuides?.end(snapping: self.window)
self.savePosition()
}
actions.closed = { [weak self] in
guard let self else { return }
self.openButton.keyEquivalent = "\r"
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
else { self.dismiss(restoringFocus: false) }
}
restorePosition()
}
required init?(coder: NSCoder) {
@@ -46,11 +72,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
func present() {
if launcherFrameBeforeReview != nil {
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
return
}
let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
previousApplication = frontmost
}
searchField.stringValue = ""
restorePosition()
updateResults()
window?.deminiaturize(nil)
showWindow(nil)
@@ -72,6 +105,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); return }
guard launcherFrameBeforeReview == nil else { return }
actions.close()
alignmentGuides?.end()
let previous = previousApplication
previousApplication = nil
window?.orderOut(nil)
@@ -81,8 +118,48 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func savePosition() {
guard launcherFrameBeforeReview == nil, let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
UserDefaults.standard.set([
"display": identifier, "x": window.frame.minX, "y": window.frame.minY
], forKey: Self.positionKey)
}
private func restorePosition() {
guard let window else { return }
let saved = UserDefaults.standard.dictionary(forKey: Self.positionKey)
let savedScreen = NSScreen.screens.first { displayIdentifier($0) == saved?["display"] as? String }
guard let screen = savedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
var origin: CGPoint?
if savedScreen != nil, let x = saved?["x"] as? Double, let y = saved?["y"] as? Double {
origin = CGPoint(x: x, y: y)
}
let frame = LauncherAlignment.restoredFrame(
savedOrigin: origin, windowSize: window.frame.size, visibleFrame: screen.visibleFrame
)
if window.frame.origin != frame.origin { window.setFrameOrigin(frame.origin) }
}
private func displayIdentifier(_ screen: NSScreen) -> String? {
guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber,
let uuid = CGDisplayCreateUUIDFromDisplayID(number.uint32Value)?.takeRetainedValue() else { return nil }
return CFUUIDCreateString(nil, uuid) as String
}
func windowWillMove(_ notification: Notification) {
guard let window else { return }
let guides = alignmentGuides ?? LauncherAlignmentGuides()
alignmentGuides = guides
guides.begin(for: window)
}
func windowDidMove(_ notification: Notification) {
guard let window else { return }
alignmentGuides?.update(for: window)
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
dismiss(restoringFocus: false)
}
@@ -137,7 +214,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
tableView.headerView = nil
tableView.style = .plain
tableView.backgroundColor = .clear
tableView.rowHeight = 38
tableView.rowHeight = LauncherRowView.rowHeight
tableView.intercellSpacing = .zero
tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
tableView.allowsMultipleSelection = false
@@ -178,6 +255,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)
@@ -194,7 +281,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)
}
@@ -232,7 +319,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),
@@ -245,6 +334,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionPill.layer?.backgroundColor = SunsetPalette.accent.withAlphaComponent(0.08).cgColor
}
window.initialFirstResponder = searchField
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
}
private func refreshApplications() {
@@ -255,6 +345,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
@@ -290,7 +381,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
results = history.suggestions(in: applications)
} else {
results = ApplicationSearch.results(for: searchField.stringValue, in: applications)
results = history.results(for: searchField.stringValue, in: applications)
}
tableView.reloadData()
if !results.isEmpty {
@@ -337,7 +428,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
private func updateOpenButton() {
openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow)
openButton.isEnabled = !isLaunching && launcherFrameBeforeReview == nil && results.indices.contains(tableView.selectedRow)
actionsButton.isEnabled = openButton.isEnabled
}
func tableViewSelectionDidChange(_ notification: Notification) {
@@ -377,7 +469,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: 14, weight: .medium)
label.font = .systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
label.textColor = SunsetPalette.text
label.lineBreakMode = .byTruncatingTail
let location = cell.locationLabel
@@ -404,9 +496,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
NSLayoutConstraint.activate([
icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: 22),
icon.heightAnchor.constraint(equalToConstant: 22),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
icon.widthAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
icon.heightAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
title.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
title.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
@@ -415,9 +507,83 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return cell
}
@objc private func openSelection() {
guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return }
@objc private func showActions() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) }
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
actions.show(for: application, relativeTo: actionsButton)
openButton.keyEquivalent = actions.isShown ? "" : "\r"
}
private func reviewUninstall(_ application: Application) {
guard let window, let content = window.contentView, launcherFrameBeforeReview == nil, window.attachedSheet == nil else { return }
let review = UninstallReviewController(application: application, uninstaller: uninstaller)
uninstallReview = review
launcherFrameBeforeReview = window.frame
review.closed = { [weak self, weak review] in
guard let self, let review else { return }
self.restoreLauncher(from: review)
}
review.changed = { [weak self] in
guard let self else { return }
self.discoveryTask?.cancel()
self.discoveryTask = nil
self.refreshApplications()
}
review.view.translatesAutoresizingMaskIntoConstraints = false
review.view.alphaValue = 0
content.addSubview(review.view)
NSLayoutConstraint.activate([
review.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
review.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
review.view.topAnchor.constraint(equalTo: content.topAnchor),
review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor)
])
for view in launcherViews { view.setAccessibilityHidden(true) }
openButton.keyEquivalent = ""
updateOpenButton()
var frame = window.frame
frame.size.height = min(540, window.screen?.visibleFrame.height ?? 540)
frame.origin.y = window.frame.maxY - frame.height
if let screen = window.screen { frame.origin.y = max(frame.origin.y, screen.visibleFrame.minY) }
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
window.animator().setFrame(frame, display: true)
for view in launcherViews { view.animator().alphaValue = 0 }
review.view.animator().alphaValue = 1
}
review.present()
}
private func restoreLauncher(from review: UninstallReviewController) {
guard let window, let frame = launcherFrameBeforeReview, uninstallReview === review else { return }
uninstallReview = nil
for view in launcherViews { view.setAccessibilityHidden(false) }
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
window.animator().setFrame(frame, display: true)
for view in launcherViews { view.animator().alphaValue = 1 }
review.view.animator().alphaValue = 0
} completionHandler: { [weak self] in
MainActor.assumeIsolated {
review.view.removeFromSuperview()
guard let self else { return }
self.launcherFrameBeforeReview = nil
self.openButton.keyEquivalent = "\r"
self.updateOpenButton()
self.window?.makeFirstResponder(self.searchField)
}
}
}
@objc private func openSelection() {
guard results.indices.contains(tableView.selectedRow) else { return }
openApplication(results[tableView.selectedRow])
}
private func openApplication(_ application: Application) {
guard !isLaunching else { return }
isLaunching = true
updateOpenButton()
Task {
@@ -473,6 +639,7 @@ private final class LauncherPanel: NSPanel {
override var canBecomeMain: Bool { true }
var navigateSelection: ((Int) -> Void)?
var dismiss: (() -> Void)?
var dragEnded: (() -> Void)?
override func sendEvent(_ event: NSEvent) {
if event.type == .keyDown, attachedSheet == nil,
@@ -489,6 +656,9 @@ private final class LauncherPanel: NSPanel {
}
}
super.sendEvent(event)
if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) {
dragEnded?()
}
}
override func performClose(_ sender: Any?) {
@@ -497,7 +667,12 @@ private final class LauncherPanel: NSPanel {
}
@MainActor
private final class LauncherRowView: NSTableRowView {
final class LauncherRowView: NSTableRowView {
static let rowHeight: CGFloat = 38
static let textSize: CGFloat = 14
static let iconSize: CGFloat = 22
static let iconToTextSpacing: CGFloat = 12
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()
@@ -510,7 +685,7 @@ private final class LauncherRowView: NSTableRowView {
}
@MainActor
private func roundMaterialCorners(_ view: NSVisualEffectView, radius: CGFloat) {
func roundMaterialCorners(_ view: NSVisualEffectView, radius: CGFloat) {
let size = NSSize(width: radius * 2 + 1, height: radius * 2 + 1)
let mask = NSImage(size: size, flipped: false) { rect in
NSColor.black.setFill()
+124 -6
View File
@@ -1,5 +1,6 @@
import AppKit
import ConjureKit
import ServiceManagement
@MainActor
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
@@ -8,16 +9,28 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let clearButton = NSButton(title: "Clear", target: nil, action: nil)
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let errorLabel = NSTextField(wrappingLabelWithString: "")
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
let window = ShortcutSettingsWindow(
contentRect: NSRect(x: 0, y: 0, width: 580, height: 520),
styleMask: [.titled, .closable, .miniaturizable],
contentRect: NSRect(x: 0, y: 0, width: 620, height: 640),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Conjure Settings"
window.appearance = NSAppearance(named: .darkAqua)
window.titlebarAppearsTransparent = true
window.backgroundColor = .clear
window.isOpaque = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.center()
super.init(window: window)
@@ -43,6 +56,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
window?.makeKeyAndOrderFront(nil)
}
func windowDidBecomeKey(_ notification: Notification) {
refreshLogin()
}
func windowDidResignKey(_ notification: Notification) {
cancelRecording()
}
@@ -94,29 +111,130 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
)
keyboardSettings.bezelStyle = .rounded
loginToggle.target = self
loginToggle.action = #selector(toggleLogin)
loginSettings.target = self
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
for label in [instructions, statusLabel, spotlight, loginStatus] {
label.textColor = SunsetPalette.secondaryText
}
for button in [recorder, commandSpaceButton, clearButton, keyboardSettings, loginToggle, loginSettings] {
button.contentTintColor = SunsetPalette.accent
}
let loginHeading = NSTextField(labelWithString: "Startup")
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
loginHeading.textColor = SunsetPalette.text
let stack = NSStackView(views: [
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.setCustomSpacing(32, after: keyboardSettings)
stack.translatesAutoresizingMaskIntoConstraints = false
let content = NSView()
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)
content.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
stack.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor, constant: 24),
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
])
for label in [instructions, statusLabel, errorLabel, spotlight] {
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
loginSettings.isHidden = status != .requiresApproval
switch status {
case .enabled:
loginStatus.stringValue = "Conjure starts in the background when you log in."
case .requiresApproval:
loginStatus.stringValue = "Allow Conjure in System Settings → General → Login Items & Extensions."
case .notRegistered:
loginStatus.stringValue = "Start Conjure automatically so your global shortcut is available."
case .notFound:
loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login."
@unknown default:
loginStatus.stringValue = "Launch at login status is unavailable."
}
}
@objc private func toggleLogin() {
cancelRecording()
do {
if loginToggle.state == .on {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
refreshLogin()
} catch {
refreshLogin()
loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)"
}
}
@objc private func openLoginSettings() {
cancelRecording()
SMAppService.openSystemSettingsLoginItems()
}
private func refresh() {
refreshLogin()
let name = shortcut.shortcut?.displayName
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
recorder.setAccessibilityValue(name ?? "Not set")
@@ -0,0 +1,397 @@
import AppKit
import ConjureKit
@MainActor
final class UninstallReviewController: NSViewController, 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>?
private var window: NSWindow? { view.window }
var closed: (() -> Void)?
var changed: (() -> Void)?
init(application: Application, uninstaller: ApplicationUninstaller) {
self.application = application
self.uninstaller = uninstaller
super.init(nibName: nil, bundle: nil)
configure()
}
required init?(coder: NSCoder) { fatalError("Conjure creates uninstall review programmatically") }
func present() {
guard let window else { return }
window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
scanTask = Task { [weak self, application] in
do {
let found = try await Task.detached(priority: .userInitiated) {
try UninstallDiscovery.scan(application: application)
}.value
guard let self, !Task.isCancelled else { return }
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() {
let content = UninstallReviewContent()
content.wantsLayer = true
view = content
search.placeholderAttributedString = NSAttributedString(
string: "Filter files and folders by name or path…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)]
)
if let cell = search.cell as? NSSearchFieldCell {
cell.searchButtonCell = nil
cell.cancelButtonCell = nil
}
search.delegate = self
search.sendsSearchStringImmediately = true
search.setAccessibilityLabel("Filter uninstall files")
search.font = .systemFont(ofSize: 20)
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.title = ""
back.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil)
back.imageScaling = .scaleProportionallyDown
back.isBordered = false
back.contentTintColor = SunsetPalette.secondaryText
back.setAccessibilityLabel("Back to applications")
let header = NSStackView(views: [back, search])
header.orientation = .horizontal
header.alignment = .centerY
header.spacing = 12
remove.target = self
remove.action = #selector(confirmUninstall)
remove.contentTintColor = .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: [NSView(), remove])
footer.orientation = .horizontal
footer.spacing = 12
for view in [header, status, scroll, footer] {
view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view)
}
NSLayoutConstraint.activate([
header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
header.topAnchor.constraint(equalTo: content.topAnchor, constant: 20),
header.heightAnchor.constraint(equalToConstant: 32),
back.widthAnchor.constraint(equalToConstant: 22),
back.heightAnchor.constraint(equalToConstant: 22),
search.widthAnchor.constraint(equalTo: header.widthAnchor, constant: -34),
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
}
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()
window?.makeFirstResponder(search)
}
}
override func cancelOperation(_ sender: Any?) { goBack() }
@objc func goBack() {
guard !busy else { return }
scanTask?.cancel()
(view as! UninstallReviewContent).acceptsInteraction = false
view.setAccessibilityHidden(true)
closed?()
}
}
@MainActor
private final class UninstallReviewContent: NSView {
var acceptsInteraction = true
override func hitTest(_ point: NSPoint) -> NSView? {
acceptsInteraction ? super.hitTest(point) : nil
}
}
private struct ReviewError: LocalizedError {
let errorDescription: String?
init(_ message: String) { errorDescription = message }
}
@MainActor
private final class UninstallFileToggle: NSButton {
var url: URL?
}
@MainActor
private final class UninstallFileCell: NSTableCellView {
let toggle = UninstallFileToggle(checkboxWithTitle: "", target: nil, action: nil)
let name = NSTextField(labelWithString: "")
let detail = NSTextField(labelWithString: "")
let size = NSTextField(labelWithString: "")
let fileIcon = 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)).")
}
+9 -2
View File
@@ -89,8 +89,15 @@ public enum ApplicationCatalog {
public enum ApplicationSearch {
public static func results(for query: String, in applications: [Application]) -> [Application] {
results(for: query, in: applications, breakingTiesWith: alphabetical)
}
static func results(
for query: String, in applications: [Application],
breakingTiesWith preferred: (Application, Application) -> Bool
) -> [Application] {
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines))
guard !query.isEmpty else { return applications.sorted(by: alphabetical) }
guard !query.isEmpty else { return applications.sorted(by: preferred) }
let characters = Array(query)
var ranked = applications.compactMap { application -> (Application, Rank)? in
let name = application.searchableName
@@ -110,7 +117,7 @@ public enum ApplicationSearch {
}
ranked.sort { lhs, rhs in
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
return alphabetical(lhs.0, rhs.0)
return preferred(lhs.0, rhs.0)
}
return ranked.map(\.0)
}
+16 -10
View File
@@ -40,16 +40,22 @@ public final class LaunchHistory {
}
public func suggestions(in applications: [Application]) -> [Application] {
applications.sorted { lhs, rhs in
let left = usage[lhs.id]
let right = usage[rhs.id]
let leftCount = left?.count ?? 0
let rightCount = right?.count ?? 0
if leftCount != rightCount { return leftCount > rightCount }
if let left, let right, left.lastLaunch != right.lastLaunch {
return left.lastLaunch > right.lastLaunch
}
return alphabetical(lhs, rhs)
applications.sorted(by: preferred)
}
public func results(for query: String, in applications: [Application]) -> [Application] {
ApplicationSearch.results(for: query, in: applications, breakingTiesWith: preferred)
}
private func preferred(_ lhs: Application, _ rhs: Application) -> Bool {
let left = usage[lhs.id]
let right = usage[rhs.id]
let leftCount = left?.count ?? 0
let rightCount = right?.count ?? 0
if leftCount != rightCount { return leftCount > rightCount }
if let left, let right, left.lastLaunch != right.lastLaunch {
return left.lastLaunch > right.lastLaunch
}
return alphabetical(lhs, rhs)
}
}
@@ -0,0 +1,33 @@
import Foundation
public struct LauncherAlignment: Sendable {
public let targetFrame: CGRect
public let snappedOrigin: CGPoint
public init(windowFrame: CGRect, visibleFrame: CGRect) {
// Whole-point origins keep the guides aligned with AppKit's window positioning.
targetFrame = CGRect(
x: (visibleFrame.midX - windowFrame.width / 2).rounded(.down),
y: (visibleFrame.maxY - visibleFrame.height / 5 - windowFrame.height).rounded(.down),
width: windowFrame.width,
height: windowFrame.height
)
snappedOrigin = CGPoint(
x: abs(windowFrame.minX - targetFrame.minX) <= 12 ? targetFrame.minX : windowFrame.minX,
y: abs(windowFrame.maxY - targetFrame.maxY) <= 12 ? targetFrame.minY : windowFrame.minY
)
}
public static func restoredFrame(savedOrigin: CGPoint?, windowSize: CGSize, visibleFrame: CGRect) -> CGRect {
if let savedOrigin, savedOrigin.x.isFinite, savedOrigin.y.isFinite {
let saved = CGRect(origin: savedOrigin, size: windowSize)
if visibleFrame.contains(saved) { return saved }
}
var frame = LauncherAlignment(
windowFrame: CGRect(origin: .zero, size: windowSize), visibleFrame: visibleFrame
).targetFrame
frame.origin.x = max(visibleFrame.minX, min(frame.minX, visibleFrame.maxX - frame.width))
frame.origin.y = max(visibleFrame.minY, min(frame.minY, visibleFrame.maxY - frame.height))
return frame
}
}
+235
View File
@@ -0,0 +1,235 @@
import Darwin
import Foundation
import Security
public struct UninstallCandidate: Identifiable, Sendable {
public var id: URL { url }
public let url: URL
public let size: Int64?
public let isSelectable: Bool
public let defaultSelected: Bool
public let reason: String?
public init(url: URL, size: Int64?, isSelectable: Bool, defaultSelected: Bool, reason: String?) {
self.url = url
self.size = size
self.isSelectable = isSelectable
self.defaultSelected = isSelectable && defaultSelected
self.reason = reason
}
}
public enum UninstallDiscovery {
/// Performs filesystem and signing reads synchronously; callers must run this off the UI thread.
public static func scan(
application: Application,
home: URL = FileManager.default.homeDirectoryForCurrentUser,
ownApplicationURL: URL? = Bundle.main.bundleURL
) throws -> [UninstallCandidate] {
let app = application.url.standardizedFileURL
let ownApp = ownApplicationURL?.standardizedFileURL.resolvingSymlinksInPath()
let invalidReason = applicationProblem(at: app, ownApp: ownApp)
let appCandidate = candidate(at: app, shared: false, ownApp: ownApp, problem: invalidReason)
guard invalidReason == nil, let identifier = bundleIdentifier(at: app) else {
return [appCandidate]
}
let extensions = embeddedExtensions(in: app)
let identifiers = Set([identifier] + extensions.compactMap { bundleIdentifier(at: $0) })
if let ownApp, let ownIdentifier = bundleIdentifier(at: ownApp), identifiers.contains(ownIdentifier) {
return [candidate(
at: app, shared: false, ownApp: ownApp,
problem: "This application shares Conjure's bundle identifier and could remove Conjure's own data."
)]
}
let library = home.standardizedFileURL.appendingPathComponent("Library", isDirectory: true)
var paths: [URL: Bool] = [:]
for identifier in identifiers {
let relativePaths = [
"Preferences/\(identifier).plist",
"Caches/\(identifier)",
"Application Support/\(identifier)",
"Logs/\(identifier)",
"Saved Application State/\(identifier).savedState",
"Application Scripts/\(identifier)",
"Containers/\(identifier)",
]
for path in relativePaths {
paths[library.appendingPathComponent(path)] = false
}
}
for bundle in [app] + extensions {
for group in signedApplicationGroups(at: bundle) {
paths[library.appendingPathComponent("Group Containers/\(group)")] = true
paths[library.appendingPathComponent("Application Scripts/\(group)")] = true
}
}
let associated = paths.keys.sorted { $0.path < $1.path }.compactMap { url -> UninstallCandidate? in
// Inspect ancestors before existence/size queries, so a linked Library never reads its target.
guard ancestryProblem(at: url, includeLeaf: false) == nil,
fileStatus(at: url) != nil else { return nil }
return candidate(at: url, shared: paths[url] == true, ownApp: ownApp)
}
return [appCandidate] + associated
}
private static func applicationProblem(at url: URL, ownApp: URL?) -> String? {
if let problem = protectedProblem(at: url, ownApp: ownApp) { return problem }
if let problem = ancestryProblem(at: url) { return problem }
guard url.isFileURL, url.pathExtension.lowercased() == "app",
let status = fileStatus(at: url), status.st_mode & S_IFMT == S_IFDIR,
let info = bundleInfo(at: url), info["CFBundlePackageType"] as? String == "APPL",
let executable = info["CFBundleExecutable"] as? String, safeComponent(executable),
let identifier = info["CFBundleIdentifier"] as? String, safeIdentifier(identifier) else {
return "This application does not have a valid application bundle and bundle identifier."
}
let binary = url.appendingPathComponent("Contents/MacOS/\(executable)")
guard ancestryProblem(at: binary) == nil,
let binaryStatus = fileStatus(at: binary), binaryStatus.st_mode & S_IFMT == S_IFREG,
FileManager.default.isExecutableFile(atPath: binary.path) else {
return "This application does not have a valid executable."
}
return nil
}
private static func bundleInfo(at url: URL) -> [String: Any]? {
let infoURL = url.appendingPathComponent("Contents/Info.plist")
guard ancestryProblem(at: infoURL) == nil,
let status = fileStatus(at: infoURL), status.st_mode & S_IFMT == S_IFREG,
let data = try? Data(contentsOf: infoURL),
let info = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) else {
return nil
}
return info as? [String: Any]
}
private static func bundleIdentifier(at url: URL) -> String? {
guard let identifier = bundleInfo(at: url)?["CFBundleIdentifier"] as? String,
safeIdentifier(identifier) else { return nil }
return identifier
}
private static func safeIdentifier(_ value: String) -> Bool {
!value.isEmpty && value.utf8.count <= 255
&& value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty }
&& value.utf8.allSatisfy {
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 46
}
}
private static func safeComponent(_ value: String) -> Bool {
!value.isEmpty && value != "." && value != ".." && !value.contains("/") && !value.contains("\0")
}
private static func embeddedExtensions(in app: URL) -> [URL] {
var pending = [app]
var result: [URL] = []
while let bundle = pending.popLast() {
for relativePath in ["Contents/PlugIns", "Contents/Extensions"] {
let directory = bundle.appendingPathComponent(relativePath)
guard ancestryProblem(at: directory) == nil,
let entries = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil, options: []
) else { continue }
for entry in entries where entry.pathExtension.lowercased() == "appex" {
guard ancestryProblem(at: entry) == nil,
let status = fileStatus(at: entry), status.st_mode & S_IFMT == S_IFDIR,
bundleIdentifier(at: entry) != nil else { continue }
result.append(entry)
pending.append(entry)
}
}
}
return result
}
private static func signedApplicationGroups(at url: URL) -> [String] {
var code: SecStaticCode?
guard SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(rawValue: 0), &code) == errSecSuccess,
let code,
SecStaticCodeCheckValidity(code, SecCSFlags(rawValue: 0), nil) == errSecSuccess else { return [] }
var information: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
let info = information as? [String: Any],
let entitlements = info[kSecCodeInfoEntitlementsDict as String] as? [String: Any],
let groups = entitlements["com.apple.security.application-groups"] as? [String] else { return [] }
return groups.filter(safeIdentifier)
}
private static func candidate(at url: URL, shared: Bool, ownApp: URL?, problem: String? = nil) -> UninstallCandidate {
let lock = problem ?? protectedProblem(at: url, ownApp: ownApp)
?? ancestryProblem(at: url) ?? removalProblem(at: url)
let canReadSize = protectedProblem(at: url, ownApp: ownApp) == nil
&& ancestryProblem(at: url, includeLeaf: false) == nil
let size = canReadSize ? logicalSize(at: url) : nil
return UninstallCandidate(
url: url, size: size, isSelectable: lock == nil, defaultSelected: !shared,
reason: lock ?? (shared ? "Shared app-group data may be used by other applications; left unchecked." : nil)
)
}
private static func protectedProblem(at url: URL, ownApp: URL?) -> String? {
for root in ["/System", "/usr", "/bin", "/sbin", "/Library/Apple"] {
if contains(URL(fileURLWithPath: root), url) { return "System-managed applications and files cannot be uninstalled." }
}
if let ownApp, contains(url, ownApp) || contains(ownApp, url) {
return "Conjure cannot uninstall itself or its own files."
}
return nil
}
private static func contains(_ parent: URL, _ child: URL) -> Bool {
child.path == parent.path || child.path.hasPrefix(parent.path + "/")
}
private static func ancestryProblem(at url: URL, includeLeaf: Bool = true) -> String? {
guard url.isFileURL, url.path.hasPrefix("/") else { return "This is not a local filesystem path." }
let path = includeLeaf ? url : url.deletingLastPathComponent()
var current = URL(fileURLWithPath: "/", isDirectory: true)
for component in path.pathComponents.dropFirst() {
current.appendPathComponent(component)
if let status = fileStatus(at: current), status.st_mode & S_IFMT == S_IFLNK {
return "This path contains a symbolic link; its target will not be followed or removed."
}
}
return nil
}
private static func removalProblem(at url: URL) -> String? {
guard let status = fileStatus(at: url) else { return "This item no longer exists or cannot be inspected." }
guard status.st_uid == geteuid() else {
return "This item is owned by another user or an administrator and cannot be uninstalled without authorization."
}
let immutableFlags = UInt32(UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND)
guard status.st_flags & immutableFlags == 0 else { return "This item is locked or immutable and cannot be moved to Trash." }
let parent = url.deletingLastPathComponent()
if let parentStatus = fileStatus(at: parent), parentStatus.st_flags & immutableFlags != 0 {
return "The containing folder is locked or immutable."
}
guard access(parent.path, W_OK | X_OK) == 0 else {
return "You do not have permission to remove this item from its containing folder."
}
return nil
}
private static func fileStatus(at url: URL) -> stat? {
var status = stat()
return lstat(url.path, &status) == 0 ? status : nil
}
private static func logicalSize(at url: URL) -> Int64? {
guard let status = fileStatus(at: url) else { return nil }
if status.st_mode & S_IFMT != S_IFDIR { return Int64(status.st_size) }
guard let children = try? FileManager.default.contentsOfDirectory(
at: url, includingPropertiesForKeys: nil, options: []
) else { return nil }
var total: Int64 = 0
for child in children {
guard let size = logicalSize(at: child) else { return nil }
let sum = total.addingReportingOverflow(size)
guard !sum.overflow else { return nil }
total = sum.partialValue
}
return total
}
}
@@ -0,0 +1,530 @@
@testable import ConjureKit
import Darwin
import Foundation
import Testing
@Test func uninstallStagesRealFilesAndRetainsOriginalPathManifestInTrash() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor.app", text: "selected data")
let report = try await fixture.engine().execute([original])
#expect(!report.needsRecovery)
#expect(!fixture.exists(original))
let trashed = try #require(fixture.trashItems().first)
#expect(try fixture.contents(trashed) == "selected data")
#expect(trashed.pathExtension == "app")
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.quarantines().isEmpty)
let completed = try #require(fixture.completedJournals().first)
let entries = try fixture.journalEntries(completed)
#expect(entries.count == 1)
#expect(entries[0]["original"] as? String == original.path)
let destination = try #require(entries[0]["trash"] as? [String: Any])
#expect(destination["path"] as? String == fixture.trash.path)
#expect(destination["name"] as? String == trashed.lastPathComponent)
#expect(report.messages.joined().contains(completed.path))
#expect(!(await fixture.engine().recover()).needsRecovery)
}
@Test func uninstallMidStageFailureRollsBackInReverseOrder() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "alpha")
let beta = try fixture.file("Beta", text: "beta")
let gamma = try fixture.file("Gamma", text: "gamma")
let engine = fixture.engine { event in
switch event {
case .beforeStage(2): throw UninstallTestFailure()
case .beforeRollback(1):
#expect(!fixture.exists(alpha))
#expect(!fixture.exists(beta))
case .beforeRollback(0):
#expect(fixture.exists(beta))
#expect(!fixture.exists(alpha))
default: break
}
}
_ = await uninstallFailure(engine, [gamma, beta, alpha])
#expect(try fixture.contents(alpha) == "alpha")
#expect(try fixture.contents(beta) == "beta")
#expect(try fixture.contents(gamma) == "gamma")
#expect(try fixture.quarantines().isEmpty)
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallRollbackConflictPreservesRecreatedOriginalAndQuarantinedData() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "original alpha")
let beta = try fixture.file("Beta", text: "original beta")
let engine = fixture.engine { event in
if case .beforeStage(1) = event {
try Data("recreated alpha".utf8).write(to: alpha)
throw UninstallTestFailure()
}
}
let message = await uninstallFailure(engine, [alpha, beta])
#expect(message.contains(fixture.journals.path))
let quarantine = try #require(fixture.quarantines().first)
#expect(message.contains(quarantine.path))
#expect(try fixture.contents(alpha) == "recreated alpha")
#expect(try fixture.contents(quarantine.appendingPathComponent("item-0")) == "original alpha")
#expect(try fixture.contents(beta) == "original beta")
#expect(try fixture.activeJournals().count == 1)
let next = try fixture.file("Next", text: "must stay")
_ = await uninstallFailure(fixture.engine(), [next])
#expect(try fixture.contents(next) == "must stay")
#expect(try fixture.contents(alpha) == "recreated alpha")
try FileManager.default.moveItem(at: alpha, to: fixture.base.appendingPathComponent("RecreatedAlpha"))
let recovered = await fixture.engine().recover()
#expect(!recovered.needsRecovery)
#expect(try fixture.contents(alpha) == "original alpha")
#expect(try fixture.activeJournals().isEmpty)
}
@Test func uninstallRecoversMoveThatInterruptedBeforeStageJournalUpdate() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "recover me")
let interrupted = fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}
_ = await uninstallFailure(interrupted, [original])
#expect(!fixture.exists(original))
let journal = try #require(fixture.activeJournals().first)
#expect(try fixture.journalEntries(journal)[0]["state"] as? String == "stageIntent")
let report = await fixture.engine().recover()
#expect(!report.needsRecovery)
#expect(try fixture.contents(original) == "recover me")
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.quarantines().isEmpty)
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallRecoversPartialCommittedTrashFinalizationWithoutRollingBack() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "alpha")
let beta = try fixture.file("Beta", text: "beta")
let failing = fixture.engine { event in
if case .beforeTrash(1) = event { throw UninstallTestFailure() }
}
let report = try await failing.execute([alpha, beta])
#expect(report.needsRecovery)
#expect(try fixture.trashItems().count == 1)
#expect(!fixture.exists(alpha))
#expect(!fixture.exists(beta))
let firstTrash = try #require(fixture.trashItems().first)
try Data("new alpha".utf8).write(to: alpha)
let recovery = await fixture.engine().recover()
#expect(!recovery.needsRecovery)
#expect(try fixture.contents(alpha) == "new alpha")
#expect(!fixture.exists(beta))
#expect(try fixture.contents(firstTrash) == "alpha")
let contents = try fixture.trashItems().map { try fixture.contents($0) }.sorted()
#expect(contents == ["alpha", "beta"])
#expect(try fixture.activeJournals().isEmpty)
}
@Test func uninstallRecoversTrashMoveThatInterruptedBeforeJournalUpdate() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let alpha = try fixture.file("Alpha", text: "alpha")
let beta = try fixture.file("Beta", text: "beta")
let interrupted = fixture.engine { event in
if case .afterTrashMove(0) = event { throw UninstallInterruption() }
}
let report = try await interrupted.execute([alpha, beta])
#expect(report.needsRecovery)
#expect(try fixture.trashItems().count == 1)
let journal = try #require(fixture.activeJournals().first)
#expect(try fixture.journalEntries(journal)[0]["state"] as? String == "trashIntent")
#expect(!(await fixture.engine().recover()).needsRecovery)
#expect(try fixture.trashItems().count == 2)
#expect(!fixture.exists(alpha))
#expect(!fixture.exists(beta))
}
@Test func uninstallCollapsesNestedAndRepeatedSelectionsIntoOneMove() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let bundle = fixture.selected.appendingPathComponent("Editor.app", isDirectory: true)
try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: false)
let child = bundle.appendingPathComponent("Settings")
try Data("nested data".utf8).write(to: child)
let report = try await fixture.engine().execute([child, bundle, bundle])
#expect(!report.needsRecovery)
#expect(try fixture.trashItems().count == 1)
let trashed = try #require(fixture.trashItems().first)
#expect(try fixture.contents(trashed.appendingPathComponent("Settings")) == "nested data")
let completed = try #require(fixture.completedJournals().first)
#expect(try fixture.journalEntries(completed).count == 1)
}
@Test func uninstallRejectsChangedIdentityBetweenIntentAndMove() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "reviewed identity")
let displaced = fixture.base.appendingPathComponent("Displaced")
let engine = fixture.engine { event in
if case .beforeStage(0) = event {
try FileManager.default.moveItem(at: original, to: displaced)
try Data("replacement identity".utf8).write(to: original)
}
}
_ = await uninstallFailure(engine, [original])
#expect(try fixture.contents(original) == "replacement identity")
#expect(try fixture.contents(displaced) == "reviewed identity")
#expect(try fixture.activeJournals().count == 1)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(original) == "replacement identity")
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallRecoveryRejectsChangedQuarantineIdentity() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected identity")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let root = try #require(fixture.quarantines().first)
let staged = root.appendingPathComponent("item-0")
let displaced = fixture.base.appendingPathComponent("Displaced")
try FileManager.default.moveItem(at: staged, to: displaced)
try Data("untrusted replacement".utf8).write(to: staged)
let recovery = await fixture.engine().recover()
#expect(recovery.needsRecovery)
#expect(!fixture.exists(original))
#expect(try fixture.contents(staged) == "untrusted replacement")
#expect(try fixture.contents(displaced) == "selected identity")
#expect(try fixture.activeJournals().count == 1)
}
@Test func uninstallTamperedJournalNeverAuthorizesAnArbitraryMove() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let victim = try fixture.file("Victim", text: "unrelated data")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let journal = try #require(fixture.activeJournals().first)
var envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: journal)) as? [String: Any])
let encoded = try #require(envelope["payload"] as? String)
let data = try #require(Data(base64Encoded: encoded))
var payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
var entries = try #require(payload["entries"] as? [[String: Any]])
entries[0]["original"] = victim.path
payload["entries"] = entries
envelope["payload"] = try JSONSerialization.data(withJSONObject: payload).base64EncodedString()
try JSONSerialization.data(withJSONObject: envelope).write(to: journal)
let recovery = await fixture.engine().recover()
#expect(recovery.needsRecovery)
#expect(try fixture.contents(victim) == "unrelated data")
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
#expect(fixture.exists(journal))
}
@Test func uninstallRefusesJournalSymlinksAndMissingAuthenticationKeys() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let journal = try #require(fixture.activeJournals().first)
let saved = fixture.base.appendingPathComponent("SavedJournal")
try FileManager.default.moveItem(at: journal, to: saved)
let victim = try fixture.file("Victim", text: "untrusted journal target")
try FileManager.default.createSymbolicLink(at: journal, withDestinationURL: victim)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(victim) == "untrusted journal target")
#expect(!fixture.exists(original))
try FileManager.default.removeItem(at: journal)
try FileManager.default.moveItem(at: saved, to: journal)
try FileManager.default.removeItem(at: fixture.journals.appendingPathComponent("key"))
let report = await fixture.engine().recover()
#expect(report.needsRecovery)
#expect(fixture.exists(journal))
#expect(!fixture.exists(original))
}
@Test func uninstallRejectsCandidateAndAncestorSymlinksWithoutFollowingThem() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "must stay")
let alias = fixture.selected.appendingPathComponent("Alias")
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: original)
_ = await uninstallFailure(fixture.engine(), [alias])
let linked = fixture.base.appendingPathComponent("Linked")
try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: fixture.selected)
_ = await uninstallFailure(fixture.engine(), [linked.appendingPathComponent("Editor")])
#expect(try fixture.contents(original) == "must stay")
#expect(try fixture.activeJournals().isEmpty)
#expect(try fixture.quarantines().isEmpty)
}
@Test func uninstallAncestorSymlinkRaceCannotMoveVictimData() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected")
let displaced = fixture.base.appendingPathComponent("Displaced", isDirectory: true)
let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true)
try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false)
let victim = victimDirectory.appendingPathComponent("Editor")
try Data("victim".utf8).write(to: victim)
let engine = fixture.engine { event in
if case .beforeStage(0) = event {
try FileManager.default.moveItem(at: fixture.selected, to: displaced)
try FileManager.default.createSymbolicLink(at: fixture.selected, withDestinationURL: victimDirectory)
}
}
_ = await uninstallFailure(engine, [original])
#expect(try fixture.contents(victim) == "victim")
#expect(try fixture.contents(displaced.appendingPathComponent("Editor")) == "selected")
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.trashItems().isEmpty)
}
@Test func uninstallCreatesMissingPerVolumeTrashAndLeavesOriginalMapping() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor.app", text: "external volume data")
let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true)
try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false)
let engine = ApplicationUninstaller(journalDirectory: fixture.journals,
trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) })
#expect(!fixture.exists(volume.appendingPathComponent(".Trashes")))
let report = try await engine.execute([original])
#expect(!report.needsRecovery)
let userTrash = volume.appendingPathComponent(".Trashes/\(getuid())", isDirectory: true)
let items = try FileManager.default.contentsOfDirectory(at: userTrash, includingPropertiesForKeys: nil)
#expect(items.count == 1)
#expect(try fixture.contents(try #require(items.first)) == "external volume data")
#expect(try fixture.completedJournals().count == 1)
}
@Test func uninstallInaccessibleTrashRetainsCommitForLaterRecovery() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "retained data")
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fixture.trash.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) }
let report = try await fixture.engine().execute([original])
#expect(report.needsRecovery)
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "retained data")
#expect(try fixture.activeJournals().count == 1)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path)
#expect(!(await fixture.engine().recover()).needsRecovery)
#expect(try fixture.trashItems().count == 1)
}
@Test func uninstallSystemAndCurrentApplicationSelectionsFailClosed() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
for url in [URL(fileURLWithPath: "/System", isDirectory: true), Bundle.main.bundleURL.resolvingSymlinksInPath()] {
_ = await uninstallFailure(fixture.engine(), [url])
}
#expect(try fixture.activeJournals().isEmpty)
}
@Test func uninstallPreservesOccupiedTrashDestinationAndRetainsQuarantine() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let engine = fixture.engine { event in
if case .beforeTrash(0) = event {
let journal = try #require(fixture.activeJournals().first)
let entry = try fixture.journalEntries(journal)[0]
let destination = try #require(entry["trash"] as? [String: Any])
let name = try #require(destination["name"] as? String)
try Data("unrelated Trash data".utf8).write(to: fixture.trash.appendingPathComponent(name))
}
}
let report = try await engine.execute([original])
#expect(report.needsRecovery)
#expect(!fixture.exists(original))
let unrelated = try #require(fixture.trashItems().first)
#expect(try fixture.contents(unrelated) == "unrelated Trash data")
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(unrelated) == "unrelated Trash data")
}
@Test func uninstallDoesNotDeleteUnknownFilesAddedToQuarantine() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let engine = fixture.engine { event in
if case .beforeTrash(0) = event {
let root = try #require(fixture.quarantines().first)
try Data("untrusted data".utf8).write(to: root.appendingPathComponent("Unknown"))
}
}
let report = try await engine.execute([original])
#expect(report.needsRecovery)
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data")
#expect(try fixture.activeJournals().count == 1)
#expect(try fixture.trashItems().count == 1)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data")
}
@Test func uninstallMalformedJournalRetainsAllStagedData() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
_ = await uninstallFailure(fixture.engine { event in
if case .afterStageMove(0) = event { throw UninstallInterruption() }
}, [original])
let journal = try #require(fixture.activeJournals().first)
try Data("not a journal".utf8).write(to: journal)
#expect((await fixture.engine().recover()).needsRecovery)
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
#expect(try fixture.contents(journal) == "not a journal")
}
@Test func uninstallHandlesMaximumLengthFilenamesWithoutLosingOriginalAttribution() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let name = String(repeating: "A", count: 250) + ".app"
let original = try fixture.file(name, text: "long filename")
let report = try await fixture.engine().execute([original])
#expect(!report.needsRecovery)
let trashed = try #require(fixture.trashItems().first)
#expect(trashed.lastPathComponent.utf8.count <= 255)
#expect(trashed.pathExtension == "app")
#expect(try fixture.contents(trashed) == "long filename")
let completed = try #require(fixture.completedJournals().first)
#expect(try fixture.journalEntries(completed)[0]["original"] as? String == original.path)
}
@Test func uninstallRejectsJournalDirectoryAndVolumeTrashSymlinks() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true)
try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
let victim = victimDirectory.appendingPathComponent("Keep")
try Data("unrelated data".utf8).write(to: victim)
try FileManager.default.createSymbolicLink(at: fixture.journals, withDestinationURL: victimDirectory)
_ = await uninstallFailure(fixture.engine(), [original])
#expect(try fixture.contents(original) == "selected data")
#expect(try fixture.contents(victim) == "unrelated data")
#expect(!fixture.exists(victimDirectory.appendingPathComponent(".lock")))
try FileManager.default.removeItem(at: fixture.journals)
let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true)
try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false)
try FileManager.default.createSymbolicLink(at: volume.appendingPathComponent(".Trashes"), withDestinationURL: victimDirectory)
let engine = ApplicationUninstaller(journalDirectory: fixture.journals,
trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) })
let report = try await engine.execute([original])
#expect(report.needsRecovery)
#expect(try fixture.contents(victim) == "unrelated data")
#expect(!fixture.exists(original))
let root = try #require(fixture.quarantines().first)
#expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data")
}
@Test func uninstallNeedsSearchAndWritePermissionButNotDirectoryListingPermission() async throws {
let fixture = try UninstallFixture()
defer { fixture.remove() }
let original = try fixture.file("Editor", text: "selected data")
for directory in [fixture.selected, fixture.trash] {
try FileManager.default.setAttributes([.posixPermissions: 0o300], ofItemAtPath: directory.path)
}
defer {
for directory in [fixture.selected, fixture.trash] {
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
}
}
let report = try await fixture.engine().execute([original])
#expect(!report.needsRecovery)
#expect(!fixture.exists(original))
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path)
let trashed = try #require(fixture.trashItems().first)
#expect(try fixture.contents(trashed) == "selected data")
}
private struct UninstallTestFailure: Error {}
private func uninstallFailure(_ engine: ApplicationUninstaller, _ urls: [URL]) async -> String {
do {
_ = try await engine.execute(urls)
Issue.record("Expected uninstall execution to throw.")
return ""
} catch { return error.localizedDescription }
}
private struct UninstallFixture: Sendable {
let base: URL
let selected: URL
let trash: URL
let journals: URL
init() throws {
guard let temporary = realpath(FileManager.default.temporaryDirectory.path, nil) else {
throw CocoaError(.fileReadUnknown)
}
defer { free(temporary) }
base = URL(fileURLWithPath: String(cString: temporary), isDirectory: true)
.appendingPathComponent("ConjureUninstallTests-" + UUID().uuidString, isDirectory: true)
selected = base.appendingPathComponent("Selected", isDirectory: true)
trash = base.appendingPathComponent("Trash", isDirectory: true)
journals = base.appendingPathComponent("Journals", isDirectory: true)
for url in [base, selected, trash] {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
}
}
func engine(checkpoint: @escaping @Sendable (UninstallCheckpoint) throws -> Void = { _ in }) -> ApplicationUninstaller {
let destination = trash
return ApplicationUninstaller(journalDirectory: journals, trashDirectory: { _ in destination }, checkpoint: checkpoint)
}
func file(_ name: String, text: String) throws -> URL {
let url = selected.appendingPathComponent(name)
try Data(text.utf8).write(to: url)
return url
}
func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) }
func contents(_ url: URL) throws -> String { String(decoding: try Data(contentsOf: url), as: UTF8.self) }
func trashItems() throws -> [URL] { try children(trash) }
func quarantines() throws -> [URL] { try children(selected).filter { $0.lastPathComponent.hasPrefix(".conjure-uninstall-") } }
func activeJournals() throws -> [URL] { try children(journals).filter { $0.pathExtension == "json" } }
func completedJournals() throws -> [URL] { try children(journals.appendingPathComponent("Completed", isDirectory: true)) }
func journalEntries(_ url: URL) throws -> [[String: Any]] {
let envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any])
let encoded = try #require(envelope["payload"] as? String)
let bytes = try #require(Data(base64Encoded: encoded))
let payload = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
return try #require(payload["entries"] as? [[String: Any]])
}
private func children(_ url: URL) throws -> [URL] {
if !exists(url) { return [] }
return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil).sorted { $0.path < $1.path }
}
func remove() { try? FileManager.default.removeItem(at: base) }
}
@@ -79,6 +79,36 @@ func applicationIdentityIgnoresDirectoryURLFormattingInLaunchHistory() {
#expect(history.suggestions(in: [historyApplication("Alpha"), plain]).first?.id == plain.id)
}
@Test @MainActor
func typedSearchUsesHistoryOnlyForEqualTextRanks() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var instant = Date(timeIntervalSinceReferenceDate: 5_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let apps = ["Code", "Code Alpha", "Code Recent", "Code Frequent", "Open Code", "Unrelated"].map(historyApplication)
history.recordLaunch(of: apps[3])
history.recordLaunch(of: apps[3])
instant += 10
history.recordLaunch(of: apps[2])
for _ in 0..<3 { history.recordLaunch(of: apps[4]) }
history.recordLaunch(of: apps[5])
#expect(history.results(for: "code", in: apps).map(\.name) == [
"Code", "Code Frequent", "Code Recent", "Code Alpha", "Open Code",
])
instant += 10
history.recordLaunch(of: apps[1])
#expect(history.results(for: "code", in: apps).map(\.name) == [
"Code", "Code Frequent", "Code Alpha", "Code Recent", "Open Code",
])
let fuzzy = ["AyB", "AzB", "xAyB", "AxxxxB"].map(historyApplication)
history.recordLaunch(of: fuzzy[1])
for _ in 0..<3 { history.recordLaunch(of: fuzzy[3]) }
#expect(history.results(for: "ab", in: fuzzy).map(\.name) == [
"AzB", "AyB", "xAyB", "AxxxxB",
])
}
private func historyApplication(_ name: String) -> Application {
Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name)
}
@@ -0,0 +1,64 @@
import ConjureKit
import Foundation
import Testing
@Test
func launcherAlignmentPositionsWindowEdgesOnOffsetDisplaysAndTracksWindowSize() {
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040)
let window = CGRect(x: -1500, y: 450, width: 750, height: 474)
let alignment = LauncherAlignment(windowFrame: window, visibleFrame: display)
#expect(alignment.targetFrame == CGRect(x: -1335, y: 398, width: 750, height: 474))
#expect(alignment.snappedOrigin == window.origin)
let resized = LauncherAlignment(windowFrame: CGRect(x: -1500, y: 400, width: 840, height: 500), visibleFrame: display)
#expect(resized.targetFrame == CGRect(x: -1380, y: 372, width: 840, height: 500))
}
@Test
func launcherAlignmentSnapsAxesIndependentlyAtBothThresholdBoundaries() {
let display = CGRect(x: 0, y: 0, width: 1500, height: 1000)
let target = CGPoint(x: 375, y: 326)
for offset: CGFloat in [-12, 12] {
let horizontal = LauncherAlignment(windowFrame: CGRect(x: target.x + offset, y: target.y + 40, width: 750, height: 474), visibleFrame: display)
#expect(horizontal.snappedOrigin == CGPoint(x: target.x, y: target.y + 40))
let vertical = LauncherAlignment(windowFrame: CGRect(x: target.x + 40, y: target.y + offset, width: 750, height: 474), visibleFrame: display)
#expect(vertical.snappedOrigin == CGPoint(x: target.x + 40, y: target.y))
}
let both = LauncherAlignment(windowFrame: CGRect(x: target.x - 6, y: target.y + 9, width: 750, height: 474), visibleFrame: display)
#expect(both.snappedOrigin == target)
}
@Test
func launcherAlignmentReleasesOutsideEitherSideOfTheSnapThreshold() {
let display = CGRect(x: 0, y: 0, width: 1500, height: 1000)
for offset: CGFloat in [-12.01, 12.01] {
let window = CGRect(x: 375 + offset, y: 326 + offset, width: 750, height: 474)
#expect(LauncherAlignment(windowFrame: window, visibleFrame: display).snappedOrigin == window.origin)
}
}
@Test
func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() {
let display = CGRect(x: 0, y: 0, width: 1512, height: 949)
let window = CGRect(x: 390, y: 280, width: 750, height: 474)
let alignment = LauncherAlignment(windowFrame: window, visibleFrame: display)
#expect(alignment.targetFrame == CGRect(x: 381, y: 285, width: 750, height: 474))
#expect(alignment.snappedOrigin == CGPoint(x: 381, y: 285))
let placed = CGRect(origin: alignment.snappedOrigin, size: window.size)
#expect(LauncherAlignment(windowFrame: placed, visibleFrame: display).snappedOrigin == placed.origin)
}
@Test
func launcherPositionRestoresVisibleOriginsAndFallsBackForMissingOrOffscreenPositions() {
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040)
let size = CGSize(width: 750, height: 474)
let origin = CGPoint(x: -1500, y: 450)
#expect(LauncherAlignment.restoredFrame(savedOrigin: origin, windowSize: size, visibleFrame: display)
== CGRect(origin: origin, size: size))
for invalid: CGPoint? in [nil, CGPoint(x: 0, y: 450), CGPoint(x: -1500, y: 39), CGPoint(x: CGFloat.nan, y: 450)] {
#expect(LauncherAlignment.restoredFrame(savedOrigin: invalid, windowSize: size, visibleFrame: display)
== CGRect(x: -1335, y: 398, width: 750, height: 474))
}
let small = CGRect(x: 0, y: 40, width: 800, height: 500)
#expect(LauncherAlignment.restoredFrame(savedOrigin: nil, windowSize: size, visibleFrame: small)
== CGRect(x: 25, y: 40, width: 750, height: 474))
}
@@ -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]) }
}