Compare commits

...

10 Commits

16 changed files with 1259 additions and 74 deletions
+5 -1
View File
@@ -1,6 +1,7 @@
.PHONY: build run test
.PHONY: build run install test
APP := .build/Conjure.app
INSTALL_DIR ?= /Applications
build:
swift build -c release
@@ -12,5 +13,8 @@ build:
run: build
swift Scripts/run.swift "$(APP)"
install: build
swift Scripts/install.swift "$(APP)" "$(INSTALL_DIR)"
test:
swift test
+44 -11
View File
@@ -1,8 +1,8 @@
# Conjure
A native macOS application launcher. Open it normally, search for an application, and press Return to open it or bring its existing instance forward. Global shortcuts are deliberately deferred.
A native macOS application launcher. Open it normally or activate it with a configured global shortcut, search for an application, and press Return to open it or bring its existing instance forward.
## Build and run
## Build, run, and install
Requires macOS 14 or later and a Swift 6 toolchain (Xcode 16 or later).
@@ -14,9 +14,18 @@ This builds a release executable, assembles an ad-hoc-signed application at `.bu
```sh
make build # Build the application without opening it.
make test # Run the discovery and search behavior tests.
make install # Install the application at /Applications/Conjure.app.
make test # Run behavior and installation safety tests.
```
`make install` rebuilds the signed bundle and installs it without launching it. To install for your user only, or if `/Applications` is not writable:
```sh
make install INSTALL_DIR="$HOME/Applications"
```
Installation verifies a temporary copy before replacing the entire existing bundle, so removed resources do not linger. Quit the app at the destination before reinstalling; other running copies are left alone. Unrelated apps and symbolic-link destinations are not replaced. Installation does not request administrator privileges, and the signature remains local/ad-hoc rather than notarized.
Open `Package.swift` in Xcode to edit the project. Use the bundled app above to run it with its application identity and metadata.
## Controls
@@ -24,25 +33,49 @@ 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.
- Escape, Command-W, or switching focus away dismisses the launcher.
- Reopen from Finder or the Dock to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
- 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.
- 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.
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.
## Global shortcut
Show the launcher and press `⌘,`, or right-click its background and choose **Settings…**. Click **Record Shortcut…** and press a combination containing Command, Option, or Control. Shift can be added. The shortcut is saved locally and restored when Conjure starts. No shortcut is claimed until you choose one; **Clear** disables it.
For Command-Space, click **Use ⌘Space**. If it conflicts, open **System Settings → Keyboard → Keyboard Shortcuts** and disable or change the conflicting Spotlight or Input Sources binding, or another app's shortcut, then try again. The Settings window includes an **Open Keyboard Settings…** button. Conjure reports conflicts rather than modifying macOS preferences, and a failed change preserves your working shortcut.
The global shortcut shows the launcher with an empty query and search focused. Press it again while the launcher is focused to dismiss it and return to the previously active app. If Conjure is running in the background, the shortcut brings it forward. Recording temporarily pauses global activation; Escape, moving focus out of the recorder, or closing Settings cancels recording and restores the saved binding.
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.
## 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, prefix, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Ties and empty searches 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. Ties are alphabetical. 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.
With an empty search, applications are ordered by successful launch count, then most recent launch, then alphabetically. Usage is stored locally in the app's preferences and survives restarts. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical.
Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
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.
## Native UI
The launcher now follows the Raycast reference: a 750 × 474-point, titlebar-free dark panel with rounded corners, native translucency and shadow, a large borderless search field, compact icon rows, neutral rounded selection, right-aligned application labels, and a bottom-right launch pill with a Return key hint. It deliberately uses dark appearance for this initial visual direction.
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.
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, semantic colors, and visual-effect materials. Custom styling is limited to layout, rounded surfaces, and selection drawing; increased-contrast settings add a selection outline. No third-party UI dependencies, copied Raycast branding, or nonfunctional Actions menu.
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.
- `Sources/Conjure`: application lifecycle, native window, and application launching.
- `Sources/ConjureKit`: filesystem discovery and search ranking.
- `Tests/ConjureKitTests`: deterministic discovery and ranking checks.
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.
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
@@ -22,6 +22,8 @@
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
+63
View File
@@ -0,0 +1,63 @@
import AppKit
struct InstallError: LocalizedError {
let errorDescription: String?
}
do {
guard CommandLine.arguments.count == 3, !CommandLine.arguments[2].isEmpty else {
throw InstallError(errorDescription: "Usage: swift Scripts/install.swift <Conjure.app> <installation directory>")
}
let manager = FileManager.default
let identifier = "dev.conjure.launcher"
let source = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath()
let directory = URL(fileURLWithPath: (CommandLine.arguments[2] as NSString).expandingTildeInPath, isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath()
let destination = directory.appendingPathComponent("Conjure.app", isDirectory: true)
guard source.path != destination.path else {
throw InstallError(errorDescription: "The build and installation paths must be different.")
}
guard Bundle(url: source)?.bundleIdentifier == identifier else {
throw InstallError(errorDescription: "The source is not a Conjure application bundle.")
}
func validateDestination() throws -> Bool {
let exists = manager.fileExists(atPath: destination.path)
if exists {
guard try destination.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink != true,
Bundle(url: destination)?.bundleIdentifier == identifier else {
throw InstallError(errorDescription: "Refusing to replace an unrelated app or symbolic link at \(destination.path).")
}
}
let running = NSRunningApplication.runningApplications(withBundleIdentifier: identifier)
.contains { $0.bundleURL?.resolvingSymlinksInPath().path == destination.path }
guard !running else {
throw InstallError(errorDescription: "Quit the installed Conjure at \(destination.path) before reinstalling.")
}
return exists
}
_ = try validateDestination()
try manager.createDirectory(at: directory, withIntermediateDirectories: true)
let staging = directory.appendingPathComponent(".Conjure-install-\(UUID().uuidString).app", isDirectory: true)
defer { try? manager.removeItem(at: staging) }
try manager.copyItem(at: source, to: staging)
let verification = Process()
verification.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
verification.arguments = ["--verify", "--strict", staging.path]
try verification.run()
verification.waitUntilExit()
guard verification.terminationStatus == 0 else {
throw InstallError(errorDescription: "Bundle signature verification failed; the installed app was not changed.")
}
if try validateDestination() {
_ = try manager.replaceItemAt(destination, withItemAt: staging, options: .usingNewMetadataOnly)
} else {
try manager.moveItem(at: staging, to: destination)
}
print("Installed Conjure at \(destination.path).")
} catch {
FileHandle.standardError.write(Data("Installation failed: \(error.localizedDescription)\n".utf8))
exit(1)
}
+32 -3
View File
@@ -1,4 +1,5 @@
import AppKit
import ConjureKit
@main
@MainActor
@@ -7,8 +8,8 @@ struct ConjureApp {
let application = NSApplication.shared
let delegate = ApplicationDelegate()
application.delegate = delegate
application.setActivationPolicy(.regular)
application.mainMenu = makeMainMenu()
application.setActivationPolicy(.accessory)
application.mainMenu = makeMainMenu(delegate: delegate)
// NSApplication holds its delegate weakly.
withExtendedLifetime(delegate) {
@@ -16,7 +17,7 @@ struct ConjureApp {
}
}
private static func makeMainMenu() -> NSMenu {
private static func makeMainMenu(delegate: ApplicationDelegate) -> NSMenu {
let menu = NSMenu()
let applicationItem = NSMenuItem(title: "Conjure", action: nil, keyEquivalent: "")
let applicationMenu = NSMenu(title: "Conjure")
@@ -28,6 +29,12 @@ struct ConjureApp {
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
keyEquivalent: ""
)
let settings = applicationMenu.addItem(
withTitle: "Settings…",
action: #selector(ApplicationDelegate.showSettings(_:)),
keyEquivalent: ","
)
settings.target = delegate
applicationMenu.addItem(.separator())
applicationMenu.addItem(
withTitle: "Hide Conjure",
@@ -84,9 +91,23 @@ struct ConjureApp {
@MainActor
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
private lazy var launcher = LauncherWindowController()
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() }
private lazy var settings = SettingsWindowController(shortcut: shortcut)
func applicationDidFinishLaunching(_ notification: Notification) {
let menu = NSMenu()
let settingsItem = menu.addItem(withTitle: "Settings…", action: #selector(showSettings(_:)), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(.separator())
let quitItem = menu.addItem(withTitle: "Quit Conjure", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
quitItem.target = NSApplication.shared
launcher.window?.contentView?.menu = menu
launcher.present()
do {
try shortcut.start()
} catch {
settings.present(error: error.localizedDescription)
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
@@ -94,6 +115,14 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
return false
}
@objc func showSettings(_ sender: Any?) {
settings.present()
}
func applicationWillTerminate(_ notification: Notification) {
shortcut.stop()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
+45
View File
@@ -0,0 +1,45 @@
import AppKit
@MainActor
enum SunsetPalette {
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.35)
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
static let selectionFill = accent.withAlphaComponent(0.12)
static let selectionBorder = accent.withAlphaComponent(0.30)
static let ember = NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1)
}
@MainActor
final class LauncherBackground: NSView {
private let amber = NSGradient(colors: [
SunsetPalette.accent.withAlphaComponent(0.18),
SunsetPalette.accent.withAlphaComponent(0.065),
SunsetPalette.accent.withAlphaComponent(0),
])!
private let ember = NSGradient(colors: [
SunsetPalette.ember.withAlphaComponent(0.26),
SunsetPalette.ember.withAlphaComponent(0.10),
SunsetPalette.ember.withAlphaComponent(0),
])!
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
SunsetPalette.background.setFill()
dirtyRect.fill()
let upperGlow = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
ember.draw(fromCenter: upperGlow, radius: 0, toCenter: upperGlow,
radius: bounds.width * 0.40, options: [])
let lowerGlow = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
amber.draw(fromCenter: lowerGlow, radius: 0, toCenter: lowerGlow,
radius: bounds.width * 0.55, options: [])
let leftGlow = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current?.cgContext.setAlpha(0.45)
amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow,
radius: bounds.width * 0.32, options: [])
NSGraphicsContext.restoreGraphicsState()
}
}
+145 -50
View File
@@ -8,12 +8,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory
private var applications: [Application] = []
private var results: [Application] = []
private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
private var previousApplication: NSRunningApplication?
init() {
init(history: LaunchHistory = LaunchHistory()) {
self.history = history
let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless],
@@ -33,6 +37,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
configureContent(in: window)
window.delegate = self
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
window.dismiss = { [weak self] in self?.dismiss() }
}
required init?(coder: NSCoder) {
@@ -40,6 +45,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
func present() {
let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
previousApplication = frontmost
}
searchField.stringValue = ""
updateResults()
window?.deminiaturize(nil)
@@ -47,13 +56,33 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(searchField)
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = .labelColor
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
refreshApplications()
}
func toggle() {
guard !isLaunching, window?.attachedSheet == nil else { return }
if window?.isVisible == true, window?.isKeyWindow == true {
dismiss()
} else {
present()
}
}
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
let previous = previousApplication
previousApplication = nil
window?.orderOut(nil)
if restoringFocus, let previous, !previous.isTerminated {
NSApp.yieldActivation(to: previous)
previous.activate(from: .current, options: [])
}
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
window?.orderOut(nil)
dismiss(restoringFocus: false)
}
private func configureContent(in window: NSWindow) {
@@ -61,19 +90,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
content.material = .hudWindow
content.blendingMode = .behindWindow
content.state = .active
content.wantsLayer = true
content.layer?.cornerRadius = 24
content.layer?.masksToBounds = true
roundMaterialCorners(content, radius: 24)
content.layer?.borderWidth = 0.5
window.contentView = content
let tint = NSView(frame: content.bounds)
tint.autoresizingMask = [.width, .height]
tint.wantsLayer = true
content.addSubview(tint)
let background = LauncherBackground(frame: content.bounds)
background.autoresizingMask = [.width, .height]
background.setAccessibilityElement(false)
content.addSubview(background)
searchField.placeholderString = "Search for apps…"
searchField.font = .systemFont(ofSize: 20)
searchField.textColor = .labelColor
let searchFont = NSFont.systemFont(ofSize: 20)
searchField.font = searchFont
searchField.placeholderAttributedString = NSAttributedString(
string: "Search for apps…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
)
searchField.textColor = SunsetPalette.text
searchField.isBordered = false
searchField.drawsBackground = false
searchField.focusRingType = .none
@@ -88,7 +119,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let symbol = NSImageView()
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
symbol.contentTintColor = .tertiaryLabelColor
symbol.contentTintColor = SunsetPalette.accent
symbol.setAccessibilityElement(false)
let header = NSStackView(views: [symbol, searchField])
header.orientation = .horizontal
@@ -97,7 +128,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let sectionLabel = NSTextField(labelWithString: "Applications")
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
sectionLabel.textColor = .secondaryLabelColor
sectionLabel.textColor = SunsetPalette.secondaryText
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
column.resizingMask = .autoresizingMask
@@ -115,7 +146,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
tableView.target = self
tableView.doubleAction = #selector(openSelection)
tableView.openSelection = { [weak self] in self?.openSelection() }
tableView.dismiss = { [weak self] in self?.window?.orderOut(nil) }
tableView.dismiss = { [weak self] in self?.dismiss() }
tableView.editSearch = { [weak self] event in
guard let self, let window = self.window else { return }
window.makeFirstResponder(self.searchField)
@@ -134,14 +165,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
scrollView.horizontalScrollElasticity = .none
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = .secondaryLabelColor
statusLabel.textColor = SunsetPalette.secondaryText
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
emptyLabel.textColor = .secondaryLabelColor
emptyLabel.textColor = SunsetPalette.secondaryText
emptyLabel.alignment = .center
openButton.isBordered = false
openButton.font = .systemFont(ofSize: 14, weight: .medium)
openButton.contentTintColor = .labelColor
openButton.contentTintColor = SunsetPalette.text
openButton.keyEquivalent = "\r"
openButton.target = self
openButton.action = #selector(openSelection)
@@ -149,7 +180,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let returnKey = NSTextField(labelWithString: "↵")
returnKey.font = .systemFont(ofSize: 14)
returnKey.textColor = .secondaryLabelColor
returnKey.textColor = SunsetPalette.secondaryText
returnKey.alignment = .center
returnKey.wantsLayer = true
returnKey.layer?.cornerRadius = 5
@@ -160,9 +191,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionPill.material = .popover
actionPill.blendingMode = .withinWindow
actionPill.state = .active
actionPill.wantsLayer = true
actionPill.layer?.cornerRadius = 18
actionPill.layer?.masksToBounds = true
roundMaterialCorners(actionPill, radius: 18)
actionPill.layer?.borderWidth = 0.5
for view in [openButton, returnKey] {
view.translatesAutoresizingMaskIntoConstraints = false
@@ -208,11 +237,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
returnKey.heightAnchor.constraint(equalToConstant: 18),
])
window.effectiveAppearance.performAsCurrentDrawingAppearance {
let border = NSColor.separatorColor.cgColor
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
content.layer?.borderColor = border
returnKey.layer?.borderColor = border
actionPill.layer?.borderColor = border
tint.layer?.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.85).cgColor
returnKey.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
actionPill.layer?.backgroundColor = SunsetPalette.accent.withAlphaComponent(0.08).cgColor
}
window.initialFirstResponder = searchField
}
@@ -226,17 +255,47 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}.value
guard let self else { return }
self.applications = applications
self.updateLocationLabels()
self.discoveryTask = nil
self.updateResults()
self.updateResults(preservingSelection: true)
}
}
private func updateResults() {
results = ApplicationSearch.results(for: searchField.stringValue, in: applications)
private func updateLocationLabels() {
var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count)
for application in applications { nameCounts[application.name, default: 0] += 1 }
var groups: [String: [(url: URL, folder: URL)]] = [:]
for application in applications where (nameCounts[application.name] ?? 0) > 1 {
groups[application.name, default: []].append(
(application.url, application.url.deletingLastPathComponent())
)
}
locationLabels.removeAll(keepingCapacity: true)
for group in groups.values {
var folderCounts: [URL: Int] = [:]
folderCounts.reserveCapacity(group.count)
for entry in group { folderCounts[entry.folder, default: 0] += 1 }
for entry in group {
let location = (folderCounts[entry.folder] ?? 0) > 1 ? entry.url : entry.folder
locationLabels[entry.url] = (location.path as NSString).abbreviatingWithTildeInPath
}
}
}
private func updateResults(preservingSelection: Bool = false) {
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
results = history.suggestions(in: applications)
} else {
results = ApplicationSearch.results(for: searchField.stringValue, in: applications)
}
tableView.reloadData()
if !results.isEmpty {
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
tableView.scrollRowToVisible(0)
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row)
}
emptyLabel.isHidden = !results.isEmpty
if discoveryTask != nil {
@@ -262,7 +321,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
case #selector(NSResponder.insertNewline(_:)):
openSelection()
case #selector(NSResponder.cancelOperation(_:)):
window?.orderOut(nil)
dismiss()
default:
return false
}
@@ -290,10 +349,13 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView)
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
?? makeCell(identifier: identifier)
let application = results[row]
cell.textField?.stringValue = application.name
let location = locationLabels[application.id]
cell.locationLabel.stringValue = location ?? ""
cell.locationLabel.isHidden = location == nil
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path
return cell
@@ -307,23 +369,34 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return view
}
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
let cell = NSTableCellView()
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell {
let cell = ApplicationCell()
cell.identifier = identifier
let icon = NSImageView()
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: 14, weight: .medium)
label.textColor = SunsetPalette.text
label.lineBreakMode = .byTruncatingTail
let location = cell.locationLabel
location.font = .systemFont(ofSize: 12)
location.textColor = SunsetPalette.secondaryText
location.lineBreakMode = .byTruncatingMiddle
location.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
location.isHidden = true
let title = NSStackView(views: [label, location])
title.orientation = .horizontal
title.alignment = .centerY
title.spacing = 8
let kind = NSTextField(labelWithString: "Application")
kind.font = .systemFont(ofSize: 14)
kind.textColor = .secondaryLabelColor
kind.textColor = SunsetPalette.secondaryText
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
kind.setAccessibilityElement(false)
cell.imageView = icon
cell.textField = label
for view in [icon, label, kind] {
for view in [icon, title, kind] {
view.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(view)
}
@@ -332,9 +405,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: 22),
icon.heightAnchor.constraint(equalToConstant: 22),
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
label.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
label.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
title.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
title.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
kind.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
])
@@ -349,7 +422,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
Task {
do {
try await AppLauncher.launch(application.url)
window?.orderOut(nil)
history.recordLaunch(of: application)
dismiss(restoringFocus: false)
} catch {
let alert = NSAlert(error: error)
alert.messageText = "Couldn’t open \(application.name)"
@@ -363,6 +437,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
@MainActor
private final class ApplicationCell: NSTableCellView {
let locationLabel = NSTextField(labelWithString: "")
}
@MainActor
private final class ApplicationTableView: NSTableView {
var openSelection: (() -> Void)?
@@ -392,6 +471,7 @@ private final class LauncherPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
var navigateSelection: ((Int) -> Void)?
var dismiss: (() -> Void)?
override func sendEvent(_ event: NSEvent) {
if event.type == .keyDown, attachedSheet == nil,
@@ -411,20 +491,35 @@ private final class LauncherPanel: NSPanel {
}
override func performClose(_ sender: Any?) {
close()
dismiss?()
}
}
@MainActor
private final class LauncherRowView: NSTableRowView {
override func drawSelection(in dirtyRect: NSRect) {
let selection = NSBezierPath(roundedRect: bounds, xRadius: 10, yRadius: 10)
NSColor.unemphasizedSelectedContentBackgroundColor.withAlphaComponent(0.55).setFill()
let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
SunsetPalette.selectionFill.setFill()
selection.fill()
if NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast {
NSColor.labelColor.setStroke()
selection.lineWidth = 1
selection.stroke()
}
let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
(increasedContrast ? SunsetPalette.accent : SunsetPalette.selectionBorder).setStroke()
selection.lineWidth = increasedContrast ? 1.5 : 0.5
selection.stroke()
}
}
@MainActor
private 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()
NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill()
return true
}
mask.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius)
mask.resizingMode = .stretch
view.wantsLayer = true
view.layer?.cornerRadius = radius
view.layer?.masksToBounds = true
view.maskImage = mask
}
@@ -0,0 +1,263 @@
import AppKit
import ConjureKit
@MainActor
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let shortcut: GlobalShortcut
private let recorder = ShortcutRecorderButton(title: "Record Shortcut…", target: nil, action: nil)
private let clearButton = NSButton(title: "Clear", target: nil, action: nil)
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let errorLabel = NSTextField(wrappingLabelWithString: "")
init(shortcut: GlobalShortcut) {
self.shortcut = shortcut
let window = ShortcutSettingsWindow(
contentRect: NSRect(x: 0, y: 0, width: 580, height: 520),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "Conjure Settings"
window.isReleasedWhenClosed = false
window.center()
super.init(window: window)
window.delegate = self
window.recorder = recorder
configureContent(in: window)
refresh()
}
required init?(coder: NSCoder) {
fatalError("Conjure creates its settings window programmatically")
}
func present(error: String? = nil) {
cancelRecording()
if let error {
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? error : "\(error)\n\(errorLabel.stringValue)"
}
refresh()
window?.deminiaturize(nil)
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
}
func windowDidResignKey(_ notification: Notification) {
cancelRecording()
}
func windowWillClose(_ notification: Notification) {
cancelRecording()
}
private func configureContent(in window: NSWindow) {
let heading = NSTextField(labelWithString: "Global Shortcut")
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
recorder.bezelStyle = .rounded
recorder.target = self
recorder.action = #selector(beginRecording)
recorder.setAccessibilityLabel("Record global shortcut")
recorder.capture = { [weak self] event in self?.capture(event) }
recorder.cancel = { [weak self] in self?.cancelRecording() }
recorder.modifiersChanged = { [weak self] in
self?.errorLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
}
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace))
commandSpaceButton.bezelStyle = .rounded
commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut")
clearButton.bezelStyle = .rounded
clearButton.target = self
clearButton.action = #selector(clearShortcut)
clearButton.setAccessibilityLabel("Clear global shortcut")
let controls = NSStackView(views: [recorder, commandSpaceButton, clearButton])
controls.orientation = .horizontal
controls.alignment = .centerY
controls.spacing = 8
let instructions = NSTextField(wrappingLabelWithString:
"Click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. The global shortcut is paused while recording."
)
instructions.textColor = .secondaryLabelColor
statusLabel.textColor = .secondaryLabelColor
statusLabel.setAccessibilityLabel("Global shortcut registration status")
errorLabel.textColor = .systemRed
errorLabel.setAccessibilityLabel("Shortcut feedback")
let spotlight = NSTextField(wrappingLabelWithString:
"To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings."
)
spotlight.textColor = .secondaryLabelColor
let keyboardSettings = NSButton(
title: "Open Keyboard Settings…", target: self, action: #selector(openKeyboardSettings)
)
keyboardSettings.bezelStyle = .rounded
let stack = NSStackView(views: [
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
let content = NSView()
window.contentView = content
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.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
])
for label in [instructions, statusLabel, errorLabel, spotlight] {
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
}
private func refresh() {
let name = shortcut.shortcut?.displayName
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
recorder.setAccessibilityValue(name ?? "Not set")
if recorder.isRecording {
statusLabel.stringValue = "Global shortcut paused while recording."
} else if shortcut.isRegistered {
statusLabel.stringValue = "Global shortcut is active."
} else if shortcut.shortcut != nil {
statusLabel.stringValue = "This shortcut is saved but is not active."
} else {
statusLabel.stringValue = "No global shortcut is configured."
}
}
@objc private func beginRecording() {
guard !recorder.isRecording else { return }
errorLabel.stringValue = ""
shortcut.suspend()
recorder.isRecording = true
refresh()
if window?.makeFirstResponder(recorder) != true {
cancelRecording()
}
}
private func capture(_ event: NSEvent) {
guard !event.isARepeat else { return }
if event.keyCode == 53, event.modifierFlags.intersection([.command, .option, .control]).isEmpty {
cancelRecording()
return
}
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else {
recorder.isRecording = false
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
restoreSavedShortcut()
refresh()
return
}
apply(candidate)
}
private func apply(_ candidate: KeyboardShortcut?) {
let wasRecording = recorder.isRecording
recorder.isRecording = false
errorLabel.stringValue = ""
do {
try shortcut.setShortcut(candidate)
} catch {
errorLabel.stringValue = error.localizedDescription
if wasRecording { restoreSavedShortcut() }
}
refresh()
}
private func cancelRecording() {
guard recorder.isRecording else { return }
recorder.isRecording = false
errorLabel.stringValue = ""
restoreSavedShortcut()
refresh()
}
private func restoreSavedShortcut() {
do {
try shortcut.setShortcut(shortcut.shortcut)
} catch {
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
}
}
@objc private func useCommandSpace() {
apply(.commandSpace)
}
@objc private func clearShortcut() {
apply(nil)
}
@objc private func openKeyboardSettings() {
cancelRecording()
let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!
if !NSWorkspace.shared.open(url) {
errorLabel.stringValue = "Could not open Keyboard settings. Open System Settings → Keyboard manually."
}
}
}
@MainActor
private final class ShortcutSettingsWindow: NSWindow {
weak var recorder: ShortcutRecorderButton?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if let recorder, recorder.isRecording, firstResponder === recorder {
return recorder.performKeyEquivalent(with: event)
}
return super.performKeyEquivalent(with: event)
}
}
@MainActor
private final class ShortcutRecorderButton: NSButton {
var isRecording = false
var capture: ((NSEvent) -> Void)?
var cancel: (() -> Void)?
var modifiersChanged: (() -> Void)?
override var acceptsFirstResponder: Bool { true }
override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard isRecording, window?.firstResponder === self else {
return super.performKeyEquivalent(with: event)
}
keyDown(with: event)
return true
}
override func keyDown(with event: NSEvent) {
guard isRecording else {
super.keyDown(with: event)
return
}
let activationModifiers: NSEvent.ModifierFlags = [.command, .option, .control]
if event.keyCode == 48, event.modifierFlags.intersection(activationModifiers).isEmpty {
if event.modifierFlags.contains(.shift) {
window?.selectPreviousKeyView(nil)
} else {
window?.selectNextKeyView(nil)
}
return
}
capture?(event)
}
override func flagsChanged(with event: NSEvent) {
if isRecording { modifiersChanged?() } else { super.flagsChanged(with: event) }
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned, isRecording { cancel?() }
return resigned
}
}
+34 -5
View File
@@ -6,16 +6,27 @@ public struct Application: Identifiable, Sendable {
public let name: String
fileprivate let searchableName: String
fileprivate let searchableCharacters: [Character]
fileprivate let searchableWordStarts: [Int]
public init(url: URL, name: String) {
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
}
fileprivate init(canonicalURL: URL, name: String) {
url = canonicalURL
url = canonicalURL.hasDirectoryPath
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name
searchableName = folded(name)
searchableCharacters = Array(searchableName)
let characters = Array(searchableName)
searchableCharacters = characters
var starts: [Int] = []
var previousWasWordCharacter = false
for index in characters.indices {
let isWordCharacter = characters[index].isLetter || characters[index].isNumber
if isWordCharacter && !previousWasWordCharacter { starts.append(index) }
previousWasWordCharacter = isWordCharacter
}
searchableWordStarts = starts
}
}
@@ -85,7 +96,15 @@ public enum ApplicationSearch {
let name = application.searchableName
if name == query { return (application, Rank(category: 0)) }
if name.hasPrefix(query) { return (application, Rank(category: 1)) }
if name.contains(query) { return (application, Rank(category: 2)) }
if application.searchableWordStarts.contains(where: {
$0 > 0 && application.searchableCharacters[$0...].starts(with: characters)
}) {
return (application, Rank(category: 2))
}
if matchesWordInitials(characters, in: application) {
return (application, Rank(category: 3))
}
if name.contains(query) { return (application, Rank(category: 4)) }
guard let rank = fuzzyRank(characters, in: application.searchableCharacters) else { return nil }
return (application, rank)
}
@@ -106,6 +125,16 @@ public enum ApplicationSearch {
}
}
private static func matchesWordInitials(_ query: [Character], in application: Application) -> Bool {
var matched = 0
for start in application.searchableWordStarts {
guard application.searchableCharacters[start] == query[matched] else { continue }
matched += 1
if matched == query.count { return true }
}
return false
}
// ponytail: quadratic in app-name length; use a linear matcher if long command text becomes searchable.
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
guard query.count <= name.count else { return nil }
@@ -118,7 +147,7 @@ public enum ApplicationSearch {
if name[end] == query[matched] { matched += 1 }
}
guard matched == query.count else { continue }
let rank = Rank(category: 3, gaps: end - start + 1 - query.count, start: start)
let rank = Rank(category: 5, gaps: end - start + 1 - query.count, start: start)
if let best, best <= rank { continue }
best = rank
}
@@ -137,7 +166,7 @@ private func folded(_ string: String) -> String {
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
}
private func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
let order = lhs.name.localizedStandardCompare(rhs.name)
if order != .orderedSame { return order == .orderedAscending }
if lhs.name != rhs.name { return lhs.name < rhs.name }
+228
View File
@@ -0,0 +1,228 @@
import AppKit
import Carbon
@MainActor
public final class GlobalShortcut {
public private(set) var shortcut: KeyboardShortcut?
public private(set) var isRegistered = false
private static let storageKey = "globalActivationShortcut"
private let defaults: UserDefaults
private let action: @MainActor () -> Void
private let native = NativeHotKey()
public init(defaults: UserDefaults = .standard, action: @escaping @MainActor () -> Void) {
self.defaults = defaults
self.action = action
shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: Self.storageKey))
native.owner = self
}
deinit {
native.disposeOnMainThread()
}
public func start() throws {
guard !isRegistered else { return }
if let saved = defaults.object(forKey: Self.storageKey) {
guard let restored = KeyboardShortcut(storedValue: saved) else {
throw ShortcutError.invalidSavedShortcut
}
try activate(restored)
shortcut = restored
}
}
public func setShortcut(_ candidate: KeyboardShortcut?) throws {
if candidate == shortcut, isRegistered { return }
if let candidate {
try activate(candidate)
defaults.set(candidate.storedValue, forKey: Self.storageKey)
} else {
try native.unregister()
isRegistered = false
defaults.removeObject(forKey: Self.storageKey)
}
shortcut = candidate
}
public func suspend() {
do {
try native.unregister()
isRegistered = false
} catch {
NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription)
}
}
public func stop() {
native.dispose()
isRegistered = false
}
fileprivate func activateFromEvent() {
action()
}
private func activate(_ candidate: KeyboardShortcut) throws {
try Self.rejectSystemConflict(candidate)
try native.register(candidate)
isRegistered = true
}
private static func rejectSystemConflict(_ candidate: KeyboardShortcut) throws {
var copied: Unmanaged<CFArray>?
let status = CopySymbolicHotKeys(&copied)
let result = copied?.takeRetainedValue()
guard status == noErr, let result else {
throw ShortcutError.native(operation: "read macOS keyboard shortcuts", status: status)
}
let hotKeys = result as NSArray
for case let record as NSDictionary in hotKeys {
guard let enabled = record[kHISymbolicHotKeyEnabled] as? NSNumber, enabled.boolValue,
let code = record[kHISymbolicHotKeyCode] as? NSNumber,
let modifiers = record[kHISymbolicHotKeyModifiers] as? NSNumber else { continue }
let relevantModifiers = UInt32(cmdKey | optionKey | controlKey | shiftKey)
if code.uint16Value == candidate.keyCode,
modifiers.uint32Value & relevantModifiers == candidate.carbonModifiers {
throw ShortcutError.systemConflict(candidate.displayName)
}
}
}
}
private enum ShortcutError: LocalizedError {
case invalidSavedShortcut
case systemConflict(String)
case native(operation: String, status: OSStatus)
var errorDescription: String? {
switch self {
case .invalidSavedShortcut:
return "The saved activation shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings."
case .systemConflict(let shortcut):
return "\(shortcut) is already an enabled macOS shortcut. Free it in System Settings → Keyboard → Keyboard Shortcuts (check Spotlight and Input Sources), or choose another shortcut. Conjure will not change your system settings."
case .native(let operation, let status):
if status == eventHotKeyExistsErr {
return "That shortcut is already registered by another app. Change or disable the other app’s shortcut, or choose another one. For Command-Space, also check Spotlight and Input Sources in System Settings → Keyboard → Keyboard Shortcuts."
}
return "Conjure could not \(operation) (macOS error \(status)). Choose another shortcut or try again."
}
}
}
// Carbon is main-thread-only. This separate lifetime lets a nonisolated Swift
// deinit enqueue teardown without capturing a dying owner or dangling C context.
private final class NativeHotKey: @unchecked Sendable {
@MainActor weak var owner: GlobalShortcut?
@MainActor private var handler: EventHandlerRef?
@MainActor private var hotKey: EventHotKeyRef?
@MainActor private var identifier: UInt32 = 0
@MainActor private var isPressed = false
@MainActor private static var nextIdentifier: UInt32 = 0
private static let signature: OSType = 0x434E4A52 // CNJR
@MainActor init() {}
@MainActor
func register(_ shortcut: KeyboardShortcut) throws {
precondition(Thread.isMainThread)
try installHandler()
Self.nextIdentifier &+= 1
if Self.nextIdentifier == 0 { Self.nextIdentifier = 1 }
let next = Self.nextIdentifier
var replacement: EventHotKeyRef?
let status = RegisterEventHotKey(
UInt32(shortcut.keyCode), shortcut.carbonModifiers,
EventHotKeyID(signature: Self.signature, id: next),
GetApplicationEventTarget(), UInt32(kEventHotKeyExclusive), &replacement
)
guard status == noErr, let replacement else {
throw ShortcutError.native(operation: "register the activation shortcut", status: status)
}
do {
try unregister()
} catch {
UnregisterEventHotKey(replacement)
throw error
}
hotKey = replacement
identifier = next
isPressed = false
}
@MainActor
func unregister() throws {
precondition(Thread.isMainThread)
guard let hotKey else { return }
let status = UnregisterEventHotKey(hotKey)
guard status == noErr else {
throw ShortcutError.native(operation: "release the previous activation shortcut", status: status)
}
self.hotKey = nil
identifier = 0
isPressed = false
}
@MainActor
func dispose() {
precondition(Thread.isMainThread)
if let hotKey { UnregisterEventHotKey(hotKey) }
hotKey = nil
identifier = 0
isPressed = false
if let handler { RemoveEventHandler(handler) }
handler = nil
}
func disposeOnMainThread() {
if Thread.isMainThread {
MainActor.assumeIsolated { dispose() }
} else {
DispatchQueue.main.async { self.dispose() }
}
}
@MainActor
private func installHandler() throws {
guard handler == nil else { return }
var types = [
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)),
]
let status = InstallEventHandler(
GetApplicationEventTarget(), { _, event, context in
guard let event, let context else { return OSStatus(eventNotHandledErr) }
return MainActor.assumeIsolated {
Unmanaged<NativeHotKey>.fromOpaque(context).takeUnretainedValue().receive(event)
}
}, types.count, &types, Unmanaged.passUnretained(self).toOpaque(), &handler
)
guard status == noErr else {
throw ShortcutError.native(operation: "install the activation shortcut handler", status: status)
}
}
@MainActor
private func receive(_ event: EventRef) -> OSStatus {
precondition(Thread.isMainThread)
var keyID = EventHotKeyID()
let status = GetEventParameter(
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
nil, MemoryLayout<EventHotKeyID>.size, nil, &keyID
)
guard status == noErr, keyID.signature == Self.signature,
hotKey != nil, keyID.id == identifier else { return OSStatus(eventNotHandledErr) }
switch GetEventKind(event) {
case UInt32(kEventHotKeyPressed):
guard !isPressed else { return noErr }
isPressed = true
owner?.activateFromEvent()
case UInt32(kEventHotKeyReleased):
isPressed = false
default:
return OSStatus(eventNotHandledErr)
}
return noErr
}
}
+133
View File
@@ -0,0 +1,133 @@
import AppKit
import Carbon
public struct KeyboardShortcut: Equatable, Sendable {
public let keyCode: UInt16
public let modifiers: NSEvent.ModifierFlags
public init?(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) {
let normalized = modifiers.intersection([.command, .option, .control, .shift])
guard !normalized.intersection([.command, .option, .control]).isEmpty,
Self.supports(keyCode) else { return nil }
self.keyCode = keyCode
self.modifiers = normalized
}
public static let commandSpace = KeyboardShortcut(keyCode: 49, modifiers: .command)!
@MainActor
public var displayName: String {
var result = ""
if modifiers.contains(.control) { result += "⌃" }
if modifiers.contains(.option) { result += "⌥" }
if modifiers.contains(.shift) { result += "⇧" }
if modifiers.contains(.command) { result += "⌘" }
return result + keyName
}
var carbonModifiers: UInt32 {
var result: UInt32 = 0
if modifiers.contains(.command) { result |= UInt32(cmdKey) }
if modifiers.contains(.option) { result |= UInt32(optionKey) }
if modifiers.contains(.control) { result |= UInt32(controlKey) }
if modifiers.contains(.shift) { result |= UInt32(shiftKey) }
return result
}
var storedValue: [String: Int] {
["keyCode": Int(keyCode), "modifiers": Int(modifiers.rawValue)]
}
init?(storedValue: Any?) {
guard let record = storedValue as? [String: Any],
let code = Self.storedInteger(record["keyCode"]),
let flags = Self.storedInteger(record["modifiers"]),
let keyCode = UInt16(exactly: code), flags >= 0,
let shortcut = Self(keyCode: keyCode, modifiers: .init(rawValue: UInt(flags))),
shortcut.modifiers.rawValue == UInt(flags) else { return nil }
self = shortcut
}
private static func storedInteger(_ value: Any?) -> Int? {
guard let number = value as? NSNumber,
CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
return Int(exactly: number.doubleValue)
}
private static func supports(_ keyCode: UInt16) -> Bool {
switch keyCode {
case 0...51, 53, 64, 65, 67, 69, 71, 75, 76, 78...88, 90...107, 109...111, 113...126:
return true
default:
return false
}
}
@MainActor
private var keyName: String {
switch keyCode {
case 36: return "Return"
case 48: return "Tab"
case 49: return "Space"
case 51: return "Delete"
case 53: return "Escape"
case 64: return "F17"
case 71: return "Clear"
case 76: return "Enter"
case 79: return "F18"
case 80: return "F19"
case 90: return "F20"
case 96: return "F5"
case 97: return "F6"
case 98: return "F7"
case 99: return "F3"
case 100: return "F8"
case 101: return "F9"
case 102: return "Eisu"
case 103: return "F11"
case 104: return "Kana"
case 105: return "F13"
case 106: return "F16"
case 107: return "F14"
case 109: return "F10"
case 110: return "Menu"
case 111: return "F12"
case 113: return "F15"
case 114: return "Help"
case 115: return "Home"
case 116: return "Page Up"
case 117: return "Forward Delete"
case 118: return "F4"
case 119: return "End"
case 120: return "F2"
case 121: return "Page Down"
case 122: return "F1"
case 123: return "←"
case 124: return "→"
case 125: return "↓"
case 126: return "↑"
default: return translatedKeyName ?? "Key \(keyCode)"
}
}
@MainActor
private var translatedKeyName: String? {
guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(),
let property = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return nil
}
let data = Unmanaged<CFData>.fromOpaque(property).takeUnretainedValue()
guard let bytes = CFDataGetBytePtr(data) else { return nil }
let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
var deadKeyState: UInt32 = 0
var length = 0
var characters = [UniChar](repeating: 0, count: 8)
let status = UCKeyTranslate(
layout, keyCode, UInt16(kUCKeyActionDisplay), 0, UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysMask), &deadKeyState,
characters.count, &length, &characters
)
guard status == noErr, length > 0 else { return nil }
return String(utf16CodeUnits: characters, count: length).uppercased()
}
}
+55
View File
@@ -0,0 +1,55 @@
import Foundation
@MainActor
public final class LaunchHistory {
private struct Usage {
var count: Int
var lastLaunch: Date
}
private static let storageKey = "applicationLaunchHistory"
private let defaults: UserDefaults
private let now: () -> Date
private var usage: [URL: Usage] = [:]
public init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
self.now = now
for (identifier, value) in defaults.dictionary(forKey: Self.storageKey) ?? [:] {
guard let url = URL(string: identifier), url.isFileURL,
let record = value as? [String: Any],
let count = record["count"] as? Int, count > 0,
let lastLaunch = record["lastLaunch"] as? Date,
lastLaunch.timeIntervalSinceReferenceDate.isFinite else { continue }
usage[url] = Usage(count: count, lastLaunch: lastLaunch)
}
}
public func recordLaunch(of application: Application) {
let timestamp = now()
var record = usage[application.id] ?? Usage(count: 0, lastLaunch: timestamp)
if record.count < Int.max { record.count += 1 }
record.lastLaunch = timestamp
usage[application.id] = record
var saved: [String: [String: Any]] = [:]
saved.reserveCapacity(usage.count)
for (url, record) in usage {
saved[url.absoluteString] = ["count": record.count, "lastLaunch": record.lastLaunch]
}
defaults.set(saved, forKey: Self.storageKey)
}
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)
}
}
}
+39 -4
View File
@@ -2,16 +2,51 @@ import ConjureKit
import Foundation
import Testing
@Test func searchRanksExactPrefixSubstringAndFuzzyMatches() {
let names = ["Caramel", "My cl Tool", "Cloud", "Cold", "CL", "Unrelated"]
@Test func searchRanksExactPrefixWordPrefixInitialsSubstringAndFuzzyMatches() {
let names = ["Logic", "Agc Utility", "Great Console", "Google Chrome", "Tool GC Helper", "GCompiler", "GC", "Unrelated"]
let applications = names.map(application)
#expect(ApplicationSearch.results(for: "cl", in: applications).map(\.name) == [
"CL", "Cloud", "My cl Tool", "Cold", "Caramel",
#expect(ApplicationSearch.results(for: "gc", in: applications).map(\.name) == [
"GC", "GCompiler", "Tool GC Helper", "Google Chrome", "Great Console", "Agc Utility", "Logic",
])
#expect(ApplicationSearch.results(for: "zq", in: applications).isEmpty)
}
@Test func wordPrefixesIncludePartialPhrasesWithoutPromotingInteriorSubstrings() {
let applications = ["ADisk Utility", "Open Disk Utility", "Disk Utility"].map(application)
#expect(ApplicationSearch.results(for: "disk u", in: applications).map(\.name) == [
"Disk Utility", "Open Disk Utility", "ADisk Utility",
])
}
@Test func wordStartsHandlePunctuationDigitsAndAccentFolding() {
let cases = [
("ss", "System_Settings", "Mississippi"),
("2m", "Audio 2 MIDI", "A2M Tools"),
(" \n ÉN \t ", "Éditeur Numérique", "Aen Utility"),
("sys", "★ System Settings", "Asystem"),
]
for (query, name, interiorMatch) in cases {
let applications = [interiorMatch, name].map(application)
#expect(ApplicationSearch.results(for: query, in: applications).map(\.name) == [
name, interiorMatch,
])
}
}
@Test func wordInitialsMatchInOrderAndCanSkipWords() {
let applications = ["Vocal", "Avoid Chords", "Voice Control", "Visual Studio Code"].map(application)
#expect(ApplicationSearch.results(for: "vc", in: applications).map(\.name) == [
"Visual Studio Code", "Voice Control", "Vocal", "Avoid Chords",
])
#expect(ApplicationSearch.results(for: "vsc", in: applications).map(\.name) == [
"Visual Studio Code",
])
#expect(ApplicationSearch.results(for: "cv", in: applications).isEmpty)
}
@Test func searchFoldsAccentsAndTrimsWhitespace() {
let applications = ["Decafé", "Caféteria", "Café", "Camera"].map(application)
@@ -0,0 +1,55 @@
import Foundation
import Testing
@Test func installationRefusesToReplaceAnUnrelatedApplication() throws {
let manager = FileManager.default
let root = manager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? manager.removeItem(at: root) }
let source = root.appendingPathComponent("Source/Conjure.app", isDirectory: true)
let contents = source.appendingPathComponent("Contents", isDirectory: true)
let executables = contents.appendingPathComponent("MacOS", isDirectory: true)
try manager.createDirectory(at: executables, withIntermediateDirectories: true)
try manager.copyItem(at: URL(fileURLWithPath: "/usr/bin/true"), to: executables.appendingPathComponent("Conjure"))
let info: [String: Any] = [
"CFBundleIdentifier": "dev.conjure.launcher",
"CFBundleExecutable": "Conjure",
"CFBundlePackageType": "APPL",
"CFBundleName": "Conjure",
]
try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0)
.write(to: contents.appendingPathComponent("Info.plist"))
let signing = try installationCommand("/usr/bin/codesign", ["--force", "--sign", "-", source.path])
guard signing.status == 0 else { throw InstallationFixtureError(output: signing.output) }
let directory = root.appendingPathComponent("Applications", isDirectory: true)
let unrelated = directory.appendingPathComponent("Conjure.app", isDirectory: true)
let unrelatedContents = unrelated.appendingPathComponent("Contents", isDirectory: true)
try manager.createDirectory(at: unrelatedContents, withIntermediateDirectories: true)
try PropertyListSerialization.data(fromPropertyList: ["CFBundleIdentifier": "example.unrelated"], format: .xml, options: 0)
.write(to: unrelatedContents.appendingPathComponent("Info.plist"))
let marker = unrelated.appendingPathComponent("UserData.txt")
try Data("Keep this unrelated app".utf8).write(to: marker)
let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
.deletingLastPathComponent().deletingLastPathComponent()
let result = try installationCommand("/usr/bin/swift", [repository.appendingPathComponent("Scripts/install.swift").path, source.path, directory.path])
#expect(result.status != 0)
#expect(try String(contentsOf: marker, encoding: .utf8) == "Keep this unrelated app")
#expect(Bundle(url: unrelated)?.bundleIdentifier == "example.unrelated")
}
private struct InstallationFixtureError: Error {
let output: String
}
private func installationCommand(_ executable: String, _ arguments: [String]) throws -> (status: Int32, output: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
let output = Pipe()
process.standardOutput = output
process.standardError = output
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return (process.terminationStatus, String(decoding: data, as: UTF8.self))
}
@@ -0,0 +1,32 @@
import AppKit
import ConjureKit
import Testing
@Test
func shortcutRequiresAnActivationModifierAndNormalizesEventFlags() {
#expect(KeyboardShortcut(keyCode: 0, modifiers: []) == nil)
#expect(KeyboardShortcut(keyCode: 0, modifiers: .shift) == nil)
#expect(KeyboardShortcut(keyCode: 0, modifiers: [.shift, .function, .capsLock]) == nil)
let activationModifiers: [NSEvent.ModifierFlags] = [.command, .option, .control]
for modifier in activationModifiers {
let shortcut = KeyboardShortcut(
keyCode: 0, modifiers: [modifier, .shift, .capsLock, .function, .numericPad]
)
#expect(shortcut?.modifiers == [modifier, .shift])
#expect(shortcut?.keyCode == 0)
}
#expect(KeyboardShortcut.commandSpace == KeyboardShortcut(keyCode: 49, modifiers: .command))
}
@Test
func shortcutRejectsModifierOnlyMediaAndUndefinedKeyCodes() {
let rejected: [UInt16] = [52, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 66, 68, 70, 72, 73, 74, 77, 89, 108, 112, 127, .max]
for code in rejected {
#expect(KeyboardShortcut(keyCode: code, modifiers: .command) == nil)
}
let accepted: [UInt16] = [0, 10, 36, 49, 53, 64, 65, 67, 69, 71, 75, 76, 78, 88, 90, 95, 96, 102, 104, 107, 110, 126]
for code in accepted {
#expect(KeyboardShortcut(keyCode: code, modifiers: [.control, .option]) != nil)
}
}
@@ -0,0 +1,84 @@
import ConjureKit
import Foundation
import Testing
@Test @MainActor
func suggestionsPreferFrequencyThenRecencyAndKeepUnusedAppsAlphabetical() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var instant = Date(timeIntervalSinceReferenceDate: 1_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let apps = ["Unused Zebra", "Recent", "Frequent", "Unused Alpha", "Older"].map(historyApplication)
#expect(history.suggestions(in: apps).map(\.name) == [
"Frequent", "Older", "Recent", "Unused Alpha", "Unused Zebra",
])
history.recordLaunch(of: apps[4])
instant += 10
history.recordLaunch(of: apps[2])
history.recordLaunch(of: apps[2])
instant += 10
history.recordLaunch(of: apps[1])
#expect(history.suggestions(in: apps).map(\.name) == [
"Frequent", "Recent", "Older", "Unused Alpha", "Unused Zebra",
])
}
@Test @MainActor
func suggestionsRetainUsageAcrossHistoryInstancesAndDoNotReintroduceMissingApps() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 2_000)
let alpha = historyApplication("Alpha")
let zebra = historyApplication("Zebra")
let removed = historyApplication("Removed")
let history = LaunchHistory(defaults: defaults, now: { instant })
history.recordLaunch(of: zebra)
history.recordLaunch(of: removed)
history.recordLaunch(of: removed)
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(reloaded.suggestions(in: [alpha, zebra]).map(\.name) == ["Zebra", "Alpha"])
reloaded.recordLaunch(of: alpha)
#expect(reloaded.suggestions(in: [zebra, alpha]).map(\.name) == ["Alpha", "Zebra"])
}
@Test @MainActor
func usageTracksApplicationIdentityRatherThanDisplayNameAndKeepsTiesDeterministic() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 3_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let first = Application(url: URL(fileURLWithPath: "/First/Editor.app"), name: "Editor")
let second = Application(url: URL(fileURLWithPath: "/Second/Editor.app"), name: "Editor")
#expect(history.suggestions(in: [second, first]).map(\.url) == [first.url, second.url])
history.recordLaunch(of: second)
#expect(history.suggestions(in: [first, second]).map(\.url) == [second.url, first.url])
let renamed = Application(url: second.url, name: "Renamed Editor")
#expect(history.suggestions(in: [first, renamed]).map(\.url) == [second.url, first.url])
history.recordLaunch(of: first)
#expect(history.suggestions(in: [second, first]).map(\.url) == [first.url, second.url])
}
@Test @MainActor
func applicationIdentityIgnoresDirectoryURLFormattingInLaunchHistory() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 4_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let path = "/Applications/HistoryTests/Editor.app"
let plain = Application(url: URL(fileURLWithPath: path, isDirectory: false), name: "Editor")
let directory = Application(url: URL(fileURLWithPath: path, isDirectory: true), name: "Editor")
#expect(plain.id == directory.id)
history.recordLaunch(of: directory)
#expect(history.suggestions(in: [historyApplication("Alpha"), plain]).first?.id == plain.id)
}
private func historyApplication(_ name: String) -> Application {
Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name)
}