// The global shortcut and the settings file it comes from. // // The shortcut uses Carbon's RegisterEventHotKey: no Accessibility or Input // Monitoring permission, the key is consumed, and a clash with another app // is reported instead of failing silently. import AppKit import Carbon.HIToolbox // MARK: settings file struct Settings: Equatable { var hotkey = "ctrl+opt+cmd+e" var jumpHotkey = "ctrl+opt+cmd+u" var searchHotkey = "ctrl+opt+cmd+shift+u" static var directory: String { environment["DINGGER_CONFIG_DIR"] ?? (NSHomeDirectory() + "/.config/dingger") } static var path: String { directory + "/config.toml" } static let template = """ # dingger settings. The menu bar app reloads this file when you save it. # Shortcuts: modifiers ctrl, opt, cmd, shift; keys a-z, 0-9, f1-f12, space. [shortcuts] # Show or hide the floating panel. hotkey = "ctrl+opt+cmd+e" # Jump to the agent at the top of the waiting list (blocked first, then # the longest done), like clicking the tmux status line. jump_hotkey = "ctrl+opt+cmd+u" # Search all agents by name, session, window, harness, status, or # directory, and jump to one. search_hotkey = "ctrl+opt+cmd+shift+u" """ /// Earlier templates, replaced on upgrade if the user never edited them. private static let olderTemplates = [firstTemplate, secondTemplate] private static let secondTemplate = """ # dingger settings. The menu bar app reloads this file when you save it. # Shortcuts: modifiers ctrl, opt, cmd, shift; keys a-z, 0-9, f1-f12, space. [shortcuts] # Show or hide the floating panel. hotkey = "ctrl+opt+cmd+e" # Jump to the agent at the top of the waiting list (blocked first, then # the longest done), like clicking the tmux status line. jump_hotkey = "ctrl+opt+cmd+u" """ private static let firstTemplate = """ # dingger settings. The menu bar app reloads this file when you save it. [panel] # Global shortcut that shows or hides the floating panel. # Modifiers: ctrl, opt, cmd, shift. Keys: a-z, 0-9, f1-f12, space. hotkey = "ctrl+opt+cmd+e" """ /// Create the file with defaults and comments if it does not exist yet; /// upgrade it if it is still an unedited older template. static func ensureFile() { let existing = try? String(contentsOfFile: path, encoding: .utf8) guard existing == nil || olderTemplates.contains(existing!) else { return } try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) try? template.write(toFile: path, atomically: true, encoding: .utf8) } /// Read `key = "value"` lines; unknown keys are ignored. static func load() -> Settings { var settings = Settings() guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return settings } for line in text.split(separator: "\n") { let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } guard parts.count == 2, !parts[0].hasPrefix("#") else { continue } let value = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\"")) switch parts[0] { case "hotkey": settings.hotkey = value case "jump_hotkey": settings.jumpHotkey = value case "search_hotkey": settings.searchHotkey = value default: break } } return settings } static func modified() -> Date? { (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date } } // MARK: shortcut parsing struct Hotkey: Equatable { let keyCode: UInt32 let carbonModifiers: UInt32 let modifierFlags: NSEvent.ModifierFlags let key: String /// How macOS writes the shortcut, e.g. "⌃⌥⌘E". var display: String { var text = "" if modifierFlags.contains(.control) { text += "⌃" } if modifierFlags.contains(.option) { text += "⌥" } if modifierFlags.contains(.shift) { text += "⇧" } if modifierFlags.contains(.command) { text += "⌘" } return text + key.uppercased() } private static let keyCodes: [String: Int] = { var codes: [String: Int] = [ "a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E, "f": kVK_ANSI_F, "g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J, "k": kVK_ANSI_K, "l": kVK_ANSI_L, "m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O, "p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R, "s": kVK_ANSI_S, "t": kVK_ANSI_T, "u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X, "y": kVK_ANSI_Y, "z": kVK_ANSI_Z, "0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4, "5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9, "space": kVK_Space, ] let functionKeys = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10, kVK_F11, kVK_F12] for (index, code) in functionKeys.enumerated() { codes["f\(index + 1)"] = code } return codes }() /// Parse "ctrl+opt+cmd+e". Requires at least one modifier and one key. static func parse(_ spec: String) -> Hotkey? { var carbon: UInt32 = 0 var flags: NSEvent.ModifierFlags = [] var key: String? for part in spec.lowercased().split(separator: "+").map({ $0.trimmingCharacters(in: .whitespaces) }) { switch part { case "ctrl", "control": carbon |= UInt32(controlKey); flags.insert(.control) case "opt", "option", "alt": carbon |= UInt32(optionKey); flags.insert(.option) case "cmd", "command": carbon |= UInt32(cmdKey); flags.insert(.command) case "shift": carbon |= UInt32(shiftKey); flags.insert(.shift) default: guard key == nil, keyCodes[part] != nil else { return nil } key = part } } guard let key, let code = keyCodes[key], carbon != 0 else { return nil } return Hotkey(keyCode: UInt32(code), carbonModifiers: carbon, modifierFlags: flags, key: key) } } // MARK: registration /// Process-wide global shortcuts, each with its own id. enum GlobalHotkey { enum Id: UInt32 { case panel = 1 case jump = 2 case search = 3 } private static var references: [UInt32: EventHotKeyRef] = [:] private static var actions: [UInt32: () -> Void] = [:] private static var handlerInstalled = false /// Register `hotkey` under `id`, replacing what was there. Returns false /// if another app already owns the combination. static func register(_ id: Id, _ hotkey: Hotkey, action: @escaping () -> Void) -> Bool { unregister(id) actions[id.rawValue] = action if !handlerInstalled { var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in var pressed = EventHotKeyID() GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &pressed) let id = pressed.id DispatchQueue.main.async { GlobalHotkey.actions[id]?() } return noErr }, 1, &spec, nil, nil) handlerInstalled = true } var reference: EventHotKeyRef? let status = RegisterEventHotKey(hotkey.keyCode, hotkey.carbonModifiers, EventHotKeyID(signature: OSType(0x4447_4752), id: id.rawValue), GetApplicationEventTarget(), 0, &reference) if status == noErr, let reference { references[id.rawValue] = reference } return status == noErr } static func unregister(_ id: Id) { if let reference = references.removeValue(forKey: id.rawValue) { UnregisterEventHotKey(reference) } } }