Skip to content
This repository was archived by the owner on Oct 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ All notable changes to this project are documented here. The format follows [Kee
- "No Results" when nothing matches and web search is off.
- Scopes, as in Spotlight on macOS 26: ⌘1 Apps, ⌘2 Files, ⌘3 Actions, also as buttons in the field. With nothing typed, a scope lists every app and pane, recent files, or all actions.
- Actions: run shortcuts from the Shortcuts app, and quit or hide running apps.
- A live preview of the panel at the top of Settings.
- A warning in Settings and the menu bar menu when the shortcut is also Spotlight's or Alfred's, saying where to change theirs. macOS accepts both without an error, so the clash was silent.
- VoiceOver reads the selected result as it changes.

### Changed

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Requires macOS 14 or later.

### Using ⌘Space

Spotlight owns ⌘Space. To give it to Common Command, turn off **Show Spotlight search** in **System Settings › Keyboard › Keyboard Shortcuts › Spotlight**, then record ⌘Space in Common Command's Settings. Settings has a button that opens the right page.
Spotlight owns ⌘Space on a new Mac, and launchers such as Alfred often take it. macOS lets two apps register the same shortcut without an error, and then only one of them opens, so Common Command reads Spotlight's and Alfred's shortcuts and tells you in Settings and the menu bar menu when yours clashes, and where to change theirs. To use ⌘Space, turn off **Show Spotlight search** in **System Settings › Keyboard › Keyboard Shortcuts › Spotlight** (or change Alfred's hotkey in **Alfred Preferences › General**), then record ⌘Space in Common Command's Settings.

## Usage

Expand Down Expand Up @@ -64,7 +64,7 @@ Each of these except apps can be turned off in Settings. The last query comes ba
## Settings

- **Shortcut**: click and press a new one. Escape cancels; Delete restores ⌥Space.
- **Appearance**: theme (System, Light, Dark), size (Small, Medium, Large), translucency, and how many results show before the list scrolls.
- **Appearance**: theme (System, Light, Dark), size (Small, Medium, Large), translucency, and how many results show before the list scrolls. A preview of the panel at the top of Settings shows each change as you make it.
- **Search**: System Settings, files, calculator, and web search on or off, and the search engine.
- **General**: show in the menu bar, and open at login. With the menu bar item hidden, open Common Command from Finder to get back to Settings.

Expand Down
52 changes: 52 additions & 0 deletions Sources/CommandKit/Conflicts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

/// Other apps' global shortcuts, read from where they keep them. macOS lets two apps register the same
/// shortcut without an error, so the only way to warn about a clash is to look.
public enum Conflicts {
public struct Holder: Equatable, Sendable {
public var owner: String
public var combo: KeyCombo
/// Where to change it, in words.
public var whereToChange: String
}

/// Spotlight's two shortcuts from `com.apple.symbolichotkeys`: 64 shows Spotlight, 65 its Finder window.
public static func spotlight(_ symbolicHotKeys: [String: Any]) -> [Holder] {
let names = ["64": "Spotlight", "65": "Spotlight's Finder search"]
return names.keys.sorted().compactMap { id in
guard let entry = symbolicHotKeys[id] as? [String: Any], (entry["enabled"] as? NSNumber)?.boolValue == true,
let parameters = (entry["value"] as? [String: Any])?["parameters"] as? [NSNumber], parameters.count == 3
else { return nil }
return Holder(
owner: names[id]!,
combo: KeyCombo(
keyCode: parameters[1].uint16Value,
modifiers: KeyCombo.Modifiers(rawValue: parameters[2].uintValue)),
whereToChange: "System Settings › Keyboard › Keyboard Shortcuts › Spotlight")
}
}

/// Where Alfred keeps this Mac's hotkey, from its `prefs.json`: the preferences folder (which may be
/// synced elsewhere) and this Mac's subfolder.
public static func alfredHotkeyFile(prefsJSON: Data) -> URL? {
guard let json = try? JSONSerialization.jsonObject(with: prefsJSON) as? [String: Any],
let folder = json["current"] as? String, let hash = json["localhash"] as? String
else { return nil }
return URL(filePath: folder).appending(path: "preferences/local/\(hash)/hotkey/prefs.plist")
}

public static func alfred(_ hotkeyPrefs: [String: Any]) -> Holder? {
guard let main = hotkeyPrefs["default"] as? [String: Any], let key = main["key"] as? NSNumber,
let modifiers = main["mod"] as? NSNumber
else { return nil }
return Holder(
owner: "Alfred",
combo: KeyCombo(keyCode: key.uint16Value, modifiers: KeyCombo.Modifiers(rawValue: modifiers.uintValue)),
whereToChange: "Alfred Preferences › General › Alfred Hotkey")
}

/// The first app that also holds `combo`.
public static func holder(of combo: KeyCombo, among holders: [Holder]) -> Holder? {
holders.first { $0.combo == combo }
}
}
28 changes: 25 additions & 3 deletions Sources/CommonCommand/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ final class AppController: NSObject {
let preferences: Preferences
private let library = Library()
private let hotKey = HotKey()
private lazy var panel = PanelController(preferences: preferences, library: library)
private(set) lazy var panel = PanelController(preferences: preferences, library: library)
private lazy var settings = SettingsWindow(controller: self)
private var statusItem: NSStatusItem?
private(set) var hotKeyProblem: String?
/// Spotlight's and Alfred's shortcuts, read when the shortcut is set and when Settings opens.
private(set) var otherShortcuts: [Conflicts.Holder] = []

/// Another app that also answers to our shortcut. macOS accepts both without complaint, and then only
/// one of them opens.
var hotKeyConflict: Conflicts.Holder? {
Conflicts.holder(of: preferences.hotKey, among: otherShortcuts)
}

init(snapshot: Snapshot.Request? = nil) {
// A snapshot mustn't change the real settings.
Expand All @@ -33,7 +41,7 @@ final class AppController: NSObject {
panel.perform = { [weak self] item, reveal in self?.perform(item, reveal: reveal) }
hotKey.onPress = { [weak self] in self?.panel.toggle() }
if let snapshot {
Snapshot.run(snapshot, panel: panel, preferences: preferences)
Snapshot.run(snapshot, controller: self)
return
}
registerHotKey()
Expand All @@ -49,7 +57,12 @@ final class AppController: NSObject {

// MARK: Shortcut

func refreshOtherShortcuts() {
otherShortcuts = otherAppsShortcuts()
}

func registerHotKey() {
refreshOtherShortcuts()
do {
try hotKey.register(preferences.hotKey)
hotKeyProblem = nil
Expand All @@ -67,6 +80,8 @@ final class AppController: NSObject {
// MARK: Settings

@objc func showSettings() {
// Spotlight's or Alfred's shortcut may have changed since; the warning in Settings should be current.
refreshOtherShortcuts()
panel.hide()
settings.show()
}
Expand Down Expand Up @@ -97,7 +112,14 @@ final class AppController: NSObject {
let open = NSMenuItem(title: "Open Common Command", action: #selector(showPanel), keyEquivalent: "")
open.target = self
menu.addItem(open)
if let hotKeyProblem {
if let conflict = hotKeyConflict {
let problem = NSMenuItem(
title: "\(preferences.hotKey.displayName) also opens \(conflict.owner)",
action: #selector(showSettings),
keyEquivalent: "")
problem.target = self
menu.addItem(problem)
} else if let hotKeyProblem {
let problem = NSMenuItem(title: hotKeyProblem, action: nil, keyEquivalent: "")
problem.isEnabled = false
menu.addItem(problem)
Expand Down
16 changes: 16 additions & 0 deletions Sources/CommonCommand/HotKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,19 @@ func keyName(_ keyCode: UInt16) -> String? {
extension KeyCombo {
var displayName: String { display(keyName: keyName) }
}

/// The global shortcuts Spotlight and Alfred hold on this Mac, read from their preferences. Missing or
/// unreadable files just mean nothing to warn about.
func otherAppsShortcuts() -> [Conflicts.Holder] {
var holders: [Conflicts.Holder] = []
if let symbolic = UserDefaults(suiteName: "com.apple.symbolichotkeys")?.dictionary(forKey: "AppleSymbolicHotKeys") {
holders += Conflicts.spotlight(symbolic)
}
let alfred = URL.applicationSupportDirectory.appending(path: "Alfred/prefs.json")
if let data = try? Data(contentsOf: alfred), let file = Conflicts.alfredHotkeyFile(prefsJSON: data),
let prefs = NSDictionary(contentsOf: file) as? [String: Any], let holder = Conflicts.alfred(prefs)
{
holders.append(holder)
}
return holders
}
28 changes: 28 additions & 0 deletions Sources/CommonCommand/PanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,34 @@ final class PanelController: NSObject, NSWindowDelegate {
return rep
}

/// A second model for the preview in Settings, searching "s" so it shows a few sections.
private(set) lazy var previewModel: SearchModel = {
let model = SearchModel(library: library, preferences: preferences)
model.query = "s"
return model
}()

/// The panel as it looks with the current settings, for Settings to show. It doesn't take input.
func makePreview() -> PanelContent {
let noop = SearchField.Commands(
move: { _ in }, submit: {}, cancel: {}, leaveScope: { false }, toggleScope: { _ in },
made: { field in
field.isEditable = false
field.isSelectable = false
field.refusesFirstResponder = true
})
return PanelContent(
size: preferences.size, translucent: preferences.translucent,
field: FieldView(model: previewModel, preferences: preferences, commands: noop),
card: CardView(model: previewModel, preferences: preferences) { _ in })
}

func previewHeight() -> CGFloat {
let size = preferences.size
return size.height(
rows: previewModel.results.count, headers: previewModel.sections.count, visible: preferences.visibleRows)
}

/// The panel's height for what the model shows now.
private var height: CGFloat {
let size = preferences.size
Expand Down
63 changes: 63 additions & 0 deletions Sources/CommonCommand/PanelPreview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import AppKit
import CommandKit
import SwiftUI

/// The panel at the top of Settings, drawn by the same views as the real one, so theme, size,
/// translucency, and the number of results show as you change them.
struct PanelPreview: View {
let panel: PanelController
let size: PanelSize
let translucent: Bool
let theme: Theme
let visibleRows: Int
/// The width Settings has room for; the preview scales down to it.
let width: CGFloat

var body: some View {
let height = panel.previewHeight()
let scale = min(1, width / size.width)
PanelPreviewView(panel: panel, size: size, translucent: translucent, theme: theme, height: height)
.frame(width: size.width * scale, height: height * scale)
.allowsHitTesting(false)
.accessibilityElement()
.accessibilityLabel("Preview of the panel")
}
}

/// Holds the panel's content at full size inside a frame scaled to fit, by giving the view smaller
/// bounds than frame: AppKit scales everything inside, glass included.
private struct PanelPreviewView: NSViewRepresentable {
let panel: PanelController
let size: PanelSize
let translucent: Bool
let theme: Theme
let height: CGFloat

final class Container: NSView {
var built: (PanelSize, Bool)?
var content: PanelContent?
var fullSize = NSSize.zero

override func layout() {
super.layout()
guard fullSize.width > 0 else { return }
bounds = NSRect(origin: .zero, size: fullSize)
content?.frame = bounds
}
}

func makeNSView(context: Context) -> Container { Container() }

func updateNSView(_ container: Container, context: Context) {
if container.built.map({ $0 != (size, translucent) }) ?? true {
container.content?.removeFromSuperview()
let content = panel.makePreview()
container.addSubview(content)
container.content = content
container.built = (size, translucent)
}
container.appearance = theme.appearance
container.fullSize = NSSize(width: size.width, height: height)
container.needsLayout = true
}
}
21 changes: 19 additions & 2 deletions Sources/CommonCommand/SearchModel.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import AppKit
import CommandKit
import Foundation

/// The query, its results, and which one is selected.
@MainActor @Observable
Expand All @@ -14,7 +14,9 @@ final class SearchModel {
private(set) var sections: [ResultSection] = []
/// Every row in display order, which is the order the selection moves through.
private(set) var results: [Ranked] = []
var selection = 0
var selection = 0 {
didSet { announceSelection() }
}
/// Everything, or only apps, files, or actions. With nothing typed, a scope lists what it holds.
var scope = Scope.all {
didSet {
Expand Down Expand Up @@ -110,5 +112,20 @@ final class SearchModel {
selection = 0
}
onResultsChange?()
announceSelection()
}

@ObservationIgnored private var announcedID: String?

/// VoiceOver reads the selected result whenever it changes, by key or because typing changed the top
/// result, so the list can be used without looking at it.
private func announceSelection() {
guard NSWorkspace.shared.isVoiceOverEnabled, let item = selected, item.id != announcedID else { return }
announcedID = item.id
let section = sections.first { $0.rows.contains { $0.id == item.id } }?.title
let words = [item.title, item.subtitle, section ?? ""].filter { !$0.isEmpty }.joined(separator: ", ")
NSAccessibility.post(
element: NSApp as Any, notification: .announcementRequested,
userInfo: [.announcement: words, .priority: NSAccessibilityPriorityLevel.high.rawValue])
}
}
36 changes: 32 additions & 4 deletions Sources/CommonCommand/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ struct SettingsView: View {
@State private var openAtLogin = LoginItem.isEnabled
@State private var loginError: String?

private static let commandSpace = KeyCombo(keyCode: 49, modifiers: .command)
private static let keyboardShortcuts = URL(
string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!

var body: some View {
Form {
Section {
PanelPreview(
panel: controller.panel, size: preferences.size, translucent: preferences.translucent,
theme: preferences.theme, visibleRows: preferences.visibleRows, width: 400
)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.listRowBackground(Color.clear)
}

Section {
LabeledContent("Open Common Command") {
ShortcutRecorder(controller: controller, combo: $preferences.hotKey)
Expand All @@ -23,13 +34,30 @@ struct SettingsView: View {
Text("Shortcut")
} footer: {
VStack(alignment: .leading, spacing: 6) {
if let problem = controller.hotKeyProblem {
if let conflict = Conflicts.holder(of: preferences.hotKey, among: controller.otherShortcuts) {
Text(
"\(preferences.hotKey.displayName) also opens \(conflict.owner), so only one of them will. Change it in \(conflict.whereToChange), or choose another shortcut here."
)
.foregroundStyle(.orange)
} else if let problem = controller.hotKeyProblem {
Text(problem).foregroundStyle(.red)
}
Text("To use ⌘Space, first turn off Spotlight's shortcut in Keyboard Shortcuts.")
if let spaceHolder = Conflicts.holder(of: Self.commandSpace, among: controller.otherShortcuts),
preferences.hotKey != Self.commandSpace
{
Text(
"⌘Space belongs to \(spaceHolder.owner). To use it here, change it in \(spaceHolder.whereToChange) first."
)
.foregroundStyle(.secondary)
Button("Open Keyboard Shortcuts…") { NSWorkspace.shared.open(Self.keyboardShortcuts) }
.buttonStyle(.link)
}
// The button only helps when the clash is Spotlight's; Alfred's is changed in Alfred.
let involved = [preferences.hotKey, Self.commandSpace].compactMap {
Conflicts.holder(of: $0, among: controller.otherShortcuts)
}
if involved.contains(where: { $0.owner.hasPrefix("Spotlight") }) {
Button("Open Keyboard Shortcuts…") { NSWorkspace.shared.open(Self.keyboardShortcuts) }
.buttonStyle(.link)
}
}
.font(.callout)
}
Expand Down
Loading
Loading