From dded78eb1f4bc9492daaaf60710fab0a964e9fef Mon Sep 17 00:00:00 2001 From: katkrasner Date: Sun, 16 Aug 2026 02:12:00 +0300 Subject: [PATCH] Let the hotkey be any key or combination, and warn when it's taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger was a fixed picker of nine modifiers. Settings now records whatever is pressed: a single modifier, a chord of modifiers, modifiers plus one regular key, or a bare key like F13. - HotkeyBinding: a side-aware modifier set plus an optional key, stored as JSON. Existing "hotkey"/"hotkeySecondary" preferences are read once and converted, so upgrades keep the configured key. - HotkeyMonitor tracks held modifiers and keys, resolving a modifier from the keycode of its own flagsChanged event and consulting the NX device bits only when the event carries them — virtual keyboards re-post events without those bits, where the bits alone can pin the wrong side. A bound regular key is swallowed while held so ⌃⌥D does not also type "d"; that needs Accessibility, and without it the old listen-only tap is used instead. - ShortcutRecorderField records a binding and disarms itself after six idle seconds, so a field left armed can't silently capture the next keystroke as your hotkey. The preset menu keeps the nine old choices and adds a reset. - HotkeyConflictChecker flags a binding that matches an enabled system shortcut (read live from com.apple.symbolichotkeys, so a remapped Spotlight is caught), is reserved above the event tap, duplicates the other slot, uses a repurposed fn, or is a bare character key that would stop typing. A missing Accessibility grant is called out too. Diagnostics land in ~/.fastword/hotkey.log: permission state, event tap mode, the active binding and the first modifier events, so the causes that look identical from outside can be told apart. --- .../Resources/en.lproj/Localizable.strings | 43 +++ .../Resources/ru.lproj/Localizable.strings | 43 +++ .../zh-Hans.lproj/Localizable.strings | 43 +++ FastWord/Sources/AppDelegate.swift | 2 +- FastWord/Sources/HotkeyBinding.swift | 262 +++++++++++++ FastWord/Sources/HotkeyConflicts.swift | 186 +++++++++ FastWord/Sources/HotkeyMonitor.swift | 352 +++++++++++++----- FastWord/Sources/Settings.swift | 63 +++- FastWord/Sources/SettingsView.swift | 112 +++++- FastWord/Sources/ShortcutRecorderField.swift | 174 +++++++++ README.md | 23 +- 11 files changed, 1169 insertions(+), 134 deletions(-) create mode 100644 FastWord/Sources/HotkeyBinding.swift create mode 100644 FastWord/Sources/HotkeyConflicts.swift create mode 100644 FastWord/Sources/ShortcutRecorderField.swift diff --git a/FastWord/Resources/en.lproj/Localizable.strings b/FastWord/Resources/en.lproj/Localizable.strings index b467f0a..b196245 100644 --- a/FastWord/Resources/en.lproj/Localizable.strings +++ b/FastWord/Resources/en.lproj/Localizable.strings @@ -131,3 +131,46 @@ "GigaAM model not downloaded yet (~215 MB)" = "GigaAM model not downloaded yet (~215 MB)"; "GigaAM model installed" = "GigaAM model installed"; "Retry" = "Retry"; + +/* Custom shortcut recorder */ +"hotkey.none" = "Not set"; +"hotkey.press_keys" = "Press keys…"; +"hotkey.record_hint" = "esc to cancel"; +"hotkey.presets" = "Common modifier keys"; +"hotkey.custom_footer" = "Click the field and press any keys — a single modifier (⌥R), a chord (⌃L⌥L), or modifiers plus a key (⌃⌥D, F13). Shortcuts that include a regular key are not passed on to the app you're typing in."; + +/* Hotkey conflicts */ +"conflict.duplicate" = "Same as the other FastWord key — set them to different shortcuts."; +"conflict.reserved" = "macOS handles %@ before any app, so FastWord will never see it."; +"conflict.system" = "Matches a macOS shortcut: %@. The system may take it first."; +"conflict.fn" = "System Settings → Keyboard has the fn key assigned to another action, so fn may not reach FastWord."; +"conflict.typing_key" = "%@ has no modifier, so that character stops typing anywhere while FastWord runs."; +"conflict.app_switcher" = "app switcher"; +"conflict.window_switcher" = "window switcher"; +"conflict.force_quit" = "Force Quit"; +"conflict.name.generic" = "a system shortcut"; +"conflict.name.spotlight" = "Spotlight"; +"conflict.name.spotlight_finder" = "Spotlight Finder search"; +"conflict.name.mission_control" = "Mission Control"; +"conflict.name.app_windows" = "Application windows"; +"conflict.name.previous_input_source" = "Previous input source"; +"conflict.name.next_input_source" = "Next input source"; +"conflict.name.screenshot_file" = "Screenshot to file"; +"conflict.name.screenshot_clipboard" = "Screenshot to clipboard"; +"conflict.name.screenshot_area_file" = "Screenshot of selection to file"; +"conflict.name.screenshot_area_clipboard" = "Screenshot of selection to clipboard"; +"conflict.name.screenshot_options" = "Screenshot and recording options"; +"conflict.name.space_left" = "Move one space left"; +"conflict.name.space_right" = "Move one space right"; +"conflict.name.help_menu" = "Help menu"; +"conflict.name.launchpad" = "Launchpad"; +"conflict.name.notification_center" = "Notification Center"; +"conflict.name.desktop_1" = "Switch to Desktop 1"; +"conflict.name.desktop_2" = "Switch to Desktop 2"; +"conflict.name.desktop_3" = "Switch to Desktop 3"; +"conflict.name.desktop_4" = "Switch to Desktop 4"; + +"hotkey.needs_accessibility" = "Accessibility is not granted, so a hotkey with a regular key will also type that character."; +"hotkey.open_accessibility" = "Open Accessibility settings"; + +"hotkey.reset" = "Reset to Right Option (⌥R)"; diff --git a/FastWord/Resources/ru.lproj/Localizable.strings b/FastWord/Resources/ru.lproj/Localizable.strings index 07029ec..fd060da 100644 --- a/FastWord/Resources/ru.lproj/Localizable.strings +++ b/FastWord/Resources/ru.lproj/Localizable.strings @@ -132,3 +132,46 @@ "GigaAM model not downloaded yet (~215 MB)" = "Модель GigaAM ещё не скачана (~215 МБ)"; "GigaAM model installed" = "Модель GigaAM установлена"; "Retry" = "Повторить"; + +/* Custom shortcut recorder */ +"hotkey.none" = "Не задана"; +"hotkey.press_keys" = "Нажмите клавиши…"; +"hotkey.record_hint" = "esc — отмена"; +"hotkey.presets" = "Частые модификаторы"; +"hotkey.custom_footer" = "Нажмите на поле и нажмите любые клавиши — один модификатор (⌥R), аккорд (⌃L⌥L) или модификаторы с клавишей (⌃⌥D, F13). Сочетания с обычной клавишей не доходят до приложения, в котором вы печатаете."; + +/* Hotkey conflicts */ +"conflict.duplicate" = "Совпадает со второй клавишей FastWord. Задайте разные сочетания."; +"conflict.reserved" = "macOS обрабатывает %@ раньше любого приложения, до FastWord это сочетание не дойдёт."; +"conflict.system" = "Совпадает с системным сочетанием macOS: %@. Система может перехватить его первой."; +"conflict.fn" = "В Системных настройках клавише fn назначено другое действие, поэтому fn может не доходить до FastWord."; +"conflict.typing_key" = "У %@ нет модификатора, поэтому этот символ перестанет печататься везде, пока FastWord запущен."; +"conflict.app_switcher" = "переключение приложений"; +"conflict.window_switcher" = "переключение окон"; +"conflict.force_quit" = "Принудительное завершение"; +"conflict.name.generic" = "системное сочетание"; +"conflict.name.spotlight" = "Spotlight"; +"conflict.name.spotlight_finder" = "Поиск Spotlight в Finder"; +"conflict.name.mission_control" = "Mission Control"; +"conflict.name.app_windows" = "Окна программы"; +"conflict.name.previous_input_source" = "Предыдущий источник ввода"; +"conflict.name.next_input_source" = "Следующий источник ввода"; +"conflict.name.screenshot_file" = "Снимок экрана в файл"; +"conflict.name.screenshot_clipboard" = "Снимок экрана в буфер обмена"; +"conflict.name.screenshot_area_file" = "Снимок выбранной области в файл"; +"conflict.name.screenshot_area_clipboard" = "Снимок выбранной области в буфер"; +"conflict.name.screenshot_options" = "Параметры снимка экрана и записи"; +"conflict.name.space_left" = "Переход на рабочее место влево"; +"conflict.name.space_right" = "Переход на рабочее место вправо"; +"conflict.name.help_menu" = "Меню Справка"; +"conflict.name.launchpad" = "Launchpad"; +"conflict.name.notification_center" = "Центр уведомлений"; +"conflict.name.desktop_1" = "Переход к рабочему столу 1"; +"conflict.name.desktop_2" = "Переход к рабочему столу 2"; +"conflict.name.desktop_3" = "Переход к рабочему столу 3"; +"conflict.name.desktop_4" = "Переход к рабочему столу 4"; + +"hotkey.needs_accessibility" = "Нет разрешения Accessibility, поэтому сочетание с обычной клавишей будет ещё и печатать этот символ."; +"hotkey.open_accessibility" = "Открыть настройки Accessibility"; + +"hotkey.reset" = "Сбросить на правый Option (⌥R)"; diff --git a/FastWord/Resources/zh-Hans.lproj/Localizable.strings b/FastWord/Resources/zh-Hans.lproj/Localizable.strings index 6de116e..5ae9d97 100644 --- a/FastWord/Resources/zh-Hans.lproj/Localizable.strings +++ b/FastWord/Resources/zh-Hans.lproj/Localizable.strings @@ -132,3 +132,46 @@ "GigaAM model not downloaded yet (~215 MB)" = "GigaAM 模型尚未下载(约 215 MB)"; "GigaAM model installed" = "GigaAM 模型已安装"; "Retry" = "重试"; + +/* Custom shortcut recorder */ +"hotkey.none" = "未设置"; +"hotkey.press_keys" = "请按下按键…"; +"hotkey.record_hint" = "按 esc 取消"; +"hotkey.presets" = "常用修饰键"; +"hotkey.custom_footer" = "点击输入框并按下任意按键 — 单个修饰键(⌥R)、组合修饰键(⌃L⌥L),或修饰键加普通键(⌃⌥D、F13)。包含普通键的快捷键不会传递给您正在输入的应用。"; + +/* Hotkey conflicts */ +"conflict.duplicate" = "与 FastWord 的另一个按键相同,请设置不同的快捷键。"; +"conflict.reserved" = "macOS 会在任何应用之前处理 %@,FastWord 无法收到它。"; +"conflict.system" = "与 macOS 系统快捷键冲突:%@。系统可能会先截获它。"; +"conflict.fn" = "系统设置 → 键盘 中 fn 键已分配给其他操作,因此 fn 可能无法到达 FastWord。"; +"conflict.typing_key" = "%@ 没有修饰键,FastWord 运行时该字符将无法输入。"; +"conflict.app_switcher" = "应用切换"; +"conflict.window_switcher" = "窗口切换"; +"conflict.force_quit" = "强制退出"; +"conflict.name.generic" = "系统快捷键"; +"conflict.name.spotlight" = "聚焦搜索"; +"conflict.name.spotlight_finder" = "聚焦访达搜索"; +"conflict.name.mission_control" = "调度中心"; +"conflict.name.app_windows" = "应用程序窗口"; +"conflict.name.previous_input_source" = "上一个输入法"; +"conflict.name.next_input_source" = "下一个输入法"; +"conflict.name.screenshot_file" = "截屏并存为文件"; +"conflict.name.screenshot_clipboard" = "截屏并拷贝到剪贴板"; +"conflict.name.screenshot_area_file" = "截取所选区域并存为文件"; +"conflict.name.screenshot_area_clipboard" = "截取所选区域并拷贝到剪贴板"; +"conflict.name.screenshot_options" = "截屏与录制选项"; +"conflict.name.space_left" = "移到左边一个空间"; +"conflict.name.space_right" = "移到右边一个空间"; +"conflict.name.help_menu" = "帮助菜单"; +"conflict.name.launchpad" = "启动台"; +"conflict.name.notification_center" = "通知中心"; +"conflict.name.desktop_1" = "切换到桌面 1"; +"conflict.name.desktop_2" = "切换到桌面 2"; +"conflict.name.desktop_3" = "切换到桌面 3"; +"conflict.name.desktop_4" = "切换到桌面 4"; + +"hotkey.needs_accessibility" = "未授予辅助功能权限,因此包含普通键的快捷键仍会输入该字符。"; +"hotkey.open_accessibility" = "打开辅助功能设置"; + +"hotkey.reset" = "重置为右 Option (⌥R)"; diff --git a/FastWord/Sources/AppDelegate.swift b/FastWord/Sources/AppDelegate.swift index 48e3a45..817b6a1 100644 --- a/FastWord/Sources/AppDelegate.swift +++ b/FastWord/Sources/AppDelegate.swift @@ -103,7 +103,7 @@ final class AppController: ObservableObject { private func readyStatusText() -> String { let format = NSLocalizedString("status.ready", comment: "") - return String(format: format, AppSettings.hotkey.displayName) + return String(format: format, AppSettings.hotkeyBinding.displayName) } func deleteHistoryEntry(_ id: UUID) { diff --git a/FastWord/Sources/HotkeyBinding.swift b/FastWord/Sources/HotkeyBinding.swift new file mode 100644 index 0000000..d199c86 --- /dev/null +++ b/FastWord/Sources/HotkeyBinding.swift @@ -0,0 +1,262 @@ +import AppKit +import Carbon.HIToolbox + +/// A side-aware modifier key. Unlike `CGEventFlags`, which collapses left and +/// right into a single bit, every modifier here maps onto the device-dependent +/// mask that macOS also sets on the event, so "left ⌥ is down" and "right ⌥ is +/// down" are distinguishable states. +enum ModifierKey: Int64, CaseIterable { + case leftControl = 59 + case rightControl = 62 + case leftOption = 58 + case rightOption = 61 + case leftShift = 56 + case rightShift = 60 + case leftCommand = 55 + case rightCommand = 54 + case fn = 63 + + /// Device-dependent bit from `IOLLEvent.h` (NX_DEVICE*KEYMASK). These are + /// carried in the raw value of both `CGEventFlags` and + /// `NSEvent.ModifierFlags`. + var deviceMask: UInt64 { + switch self { + case .leftControl: return 0x0000_0001 + case .rightControl: return 0x0000_2000 + case .leftShift: return 0x0000_0002 + case .rightShift: return 0x0000_0004 + case .leftCommand: return 0x0000_0008 + case .rightCommand: return 0x0000_0010 + case .leftOption: return 0x0000_0020 + case .rightOption: return 0x0000_0040 + case .fn: return 0x0080_0000 // NX_SECONDARYFNMASK + } + } + + /// The side-agnostic mask (`.maskControl`, `.maskAlternate`, …). + var genericMask: UInt64 { + switch self { + case .leftControl, .rightControl: return CGEventFlags.maskControl.rawValue + case .leftOption, .rightOption: return CGEventFlags.maskAlternate.rawValue + case .leftShift, .rightShift: return CGEventFlags.maskShift.rawValue + case .leftCommand, .rightCommand: return CGEventFlags.maskCommand.rawValue + case .fn: return CGEventFlags.maskSecondaryFn.rawValue + } + } + + /// The same physical modifier on the other side of the keyboard, if any. + var sibling: ModifierKey? { + switch self { + case .leftControl: return .rightControl + case .rightControl: return .leftControl + case .leftOption: return .rightOption + case .rightOption: return .leftOption + case .leftShift: return .rightShift + case .rightShift: return .leftShift + case .leftCommand: return .rightCommand + case .rightCommand: return .leftCommand + case .fn: return nil + } + } + + /// Sort order used when rendering a chord, matching the macOS convention + /// (fn, ⌃, ⌥, ⇧, ⌘). + var displayOrder: Int { + switch self { + case .fn: return 0 + case .leftControl, .rightControl: return 1 + case .leftOption, .rightOption: return 2 + case .leftShift, .rightShift: return 3 + case .leftCommand, .rightCommand: return 4 + } + } + + var symbol: String { + switch self { + case .leftControl, .rightControl: return "⌃" + case .leftOption, .rightOption: return "⌥" + case .leftShift, .rightShift: return "⇧" + case .leftCommand, .rightCommand: return "⌘" + case .fn: return "fn" + } + } + + /// "L" / "R" suffix, empty for keys that exist only once. + var sideSuffix: String { + switch self { + case .leftControl, .leftOption, .leftShift, .leftCommand: return "L" + case .rightControl, .rightOption, .rightShift, .rightCommand: return "R" + case .fn: return "" + } + } + + var displayName: String { symbol + sideSuffix } + + /// Whether this specific key is held, given a flags mask. + /// + /// Prefers the device bit. Some external keyboards (and some remapping + /// utilities) only set the generic bit; in that case we report both sides + /// as held rather than silently never matching. + func isHeld(in rawFlags: UInt64) -> Bool { + if rawFlags & deviceMask != 0 { return true } + guard let sibling else { return rawFlags & genericMask != 0 } + let anySideBit = deviceMask | sibling.deviceMask + return (rawFlags & genericMask != 0) && (rawFlags & anySideBit == 0) + } +} + +/// A user-defined push-to-talk trigger: any number of side-aware modifiers, +/// optionally plus one regular key. +/// +/// Modifier-only bindings (`⌥R`, `⌃L⌥L`) are the classic FastWord behaviour. +/// Bindings with a regular key (`⌃⌥D`, `F13`) are suppressed while held so the +/// character is not typed into the focused field. +struct HotkeyBinding: Codable, Equatable, Hashable { + /// Keycode of the non-modifier key; `nil` for modifier-only bindings. + var keyCode: Int64? + /// Side-aware keycodes of the modifiers that must be held. + var modifierKeyCodes: [Int64] + /// Character captured at record time, used to label layout-dependent keys. + var keyLabel: String? + + init(keyCode: Int64? = nil, modifierKeyCodes: [Int64] = [], keyLabel: String? = nil) { + self.keyCode = keyCode + self.modifierKeyCodes = modifierKeyCodes + self.keyLabel = keyLabel + } + + var isEmpty: Bool { keyCode == nil && modifierKeyCodes.isEmpty } + + var modifiers: [ModifierKey] { + modifierKeyCodes + .compactMap { ModifierKey(rawValue: $0) } + .sorted { ($0.displayOrder, $0.rawValue) < ($1.displayOrder, $1.rawValue) } + } + + /// True when the binding can be triggered by holding modifiers alone. + var isModifierOnly: Bool { keyCode == nil } + + /// Side-agnostic mask of the bound modifiers, for comparing against + /// system shortcut definitions (which don't record left/right). + var genericModifierMask: UInt64 { + modifiers.reduce(0) { $0 | $1.genericMask } + } + + /// Whether the key types a character, as opposed to function keys, + /// navigation keys and the numeric keypad. + static func isTextProducing(keyCode: Int64) -> Bool { + if specialKeyNames[keyCode] != nil { return false } + guard let character = translateToCharacter(keyCode: keyCode) else { return false } + return character.unicodeScalars.contains { !CharacterSet.controlCharacters.contains($0) } + } + + var displayName: String { + if isEmpty { return NSLocalizedString("hotkey.none", comment: "") } + let mods = modifiers.map(\.displayName).joined() + guard let keyCode else { return mods } + let key = HotkeyBinding.keyName(for: keyCode, fallback: keyLabel) + return mods.isEmpty ? key : "\(mods) \(key)" + } + + // MARK: - Presets + + static let rightOption = HotkeyBinding(modifierKeyCodes: [ModifierKey.rightOption.rawValue]) + static let rightControl = HotkeyBinding(modifierKeyCodes: [ModifierKey.rightControl.rawValue]) + + /// The single-modifier bindings offered as one-click presets in Settings — + /// the same set the pre-0.7 picker exposed. + static let presets: [HotkeyBinding] = [ + .rightOption, + HotkeyBinding(modifierKeyCodes: [ModifierKey.leftOption.rawValue]), + HotkeyBinding(modifierKeyCodes: [ModifierKey.rightCommand.rawValue]), + HotkeyBinding(modifierKeyCodes: [ModifierKey.leftCommand.rawValue]), + .rightControl, + HotkeyBinding(modifierKeyCodes: [ModifierKey.leftControl.rawValue]), + HotkeyBinding(modifierKeyCodes: [ModifierKey.rightShift.rawValue]), + HotkeyBinding(modifierKeyCodes: [ModifierKey.leftShift.rawValue]), + HotkeyBinding(modifierKeyCodes: [ModifierKey.fn.rawValue]), + ] + + // MARK: - Serialisation + + func encoded() -> String { + guard let data = try? JSONEncoder().encode(self), + let json = String(data: data, encoding: .utf8) else { return "" } + return json + } + + static func decode(_ json: String) -> HotkeyBinding? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(HotkeyBinding.self, from: data) + } + + // MARK: - Key naming + + /// Keys whose `charactersIgnoringModifiers` is a control character or is + /// otherwise unhelpful as a label. + private static let specialKeyNames: [Int64: String] = [ + 36: "↩", 48: "⇥", 49: "Space", 51: "⌫", 53: "⎋", + 76: "⌤", 71: "⌧", 117: "⌦", 114: "?⃝", + 115: "↖", 119: "↘", 116: "⇞", 121: "⇟", + 123: "←", 124: "→", 125: "↓", 126: "↑", + 122: "F1", 120: "F2", 99: "F3", 118: "F4", 96: "F5", 97: "F6", + 98: "F7", 100: "F8", 101: "F9", 109: "F10", 103: "F11", 111: "F12", + 105: "F13", 107: "F14", 113: "F15", 106: "F16", 64: "F17", + 79: "F18", 80: "F19", 90: "F20", + 82: "num 0", 83: "num 1", 84: "num 2", 85: "num 3", 86: "num 4", + 87: "num 5", 88: "num 6", 89: "num 7", 91: "num 8", 92: "num 9", + 65: "num .", 67: "num *", 69: "num +", 75: "num /", 78: "num −", + 81: "num =", + ] + + /// Live layout lookup takes priority over the recorded label, so a binding + /// captured on one layout is displayed with the character the key produces + /// now — the physical key is what the user actually presses. + static func keyName(for keyCode: Int64, fallback: String?) -> String { + if let name = specialKeyNames[keyCode] { return name } + if let translated = translateToCharacter(keyCode: keyCode)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !translated.isEmpty { + return translated.uppercased() + } + if let label = fallback?.trimmingCharacters(in: .whitespacesAndNewlines), + !label.isEmpty, + label.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) { + return label.uppercased() + } + return "#\(keyCode)" + } + + /// Asks the current keyboard layout what character a keycode produces, so + /// a binding recorded on one layout still reads correctly elsewhere. + private static func translateToCharacter(keyCode: Int64) -> String? { + guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let layoutPtr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) + else { return nil } + let layoutData = Unmanaged.fromOpaque(layoutPtr).takeUnretainedValue() as Data + + var deadKeyState: UInt32 = 0 + var length = 0 + var chars = [UniChar](repeating: 0, count: 4) + + let status = layoutData.withUnsafeBytes { raw -> OSStatus in + guard let base = raw.bindMemory(to: UCKeyboardLayout.self).baseAddress else { + return OSStatus(paramErr) + } + return UCKeyTranslate( + base, + UInt16(keyCode), + UInt16(kUCKeyActionDisplay), + 0, + UInt32(LMGetKbdType()), + OptionBits(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + chars.count, + &length, + &chars + ) + } + guard status == noErr, length > 0 else { return nil } + return String(utf16CodeUnits: chars, count: length) + } +} diff --git a/FastWord/Sources/HotkeyConflicts.swift b/FastWord/Sources/HotkeyConflicts.swift new file mode 100644 index 0000000..bcd3c21 --- /dev/null +++ b/FastWord/Sources/HotkeyConflicts.swift @@ -0,0 +1,186 @@ +import AppKit +import Carbon.HIToolbox + +/// Something that stands between a recorded hotkey and FastWord actually +/// receiving it. Surfaced in Settings the moment a binding is chosen, so a +/// clashing shortcut fails loudly instead of just doing nothing. +struct HotkeyConflict: Equatable { + enum Kind { + /// The same binding is already used by FastWord's other slot. + case duplicate + /// macOS handles this before any event tap; it will never arrive. + case reserved + /// Matches an enabled shortcut in System Settings → Keyboard. + case systemShortcut + /// The `fn` key is configured to do something else system-wide. + case fnRepurposed + /// A character key with no modifiers — bindable, but it stops typing. + case typingKey + } + + let kind: Kind + /// Human-readable name of what it clashes with, e.g. "Spotlight (⌘Space)". + let detail: String + + var isBlocking: Bool { + switch kind { + case .duplicate, .reserved, .systemShortcut, .fnRepurposed: return true + case .typingKey: return false + } + } + + var message: String { + switch kind { + case .duplicate: + return NSLocalizedString("conflict.duplicate", comment: "") + case .reserved: + return String(format: NSLocalizedString("conflict.reserved", comment: ""), detail) + case .systemShortcut: + return String(format: NSLocalizedString("conflict.system", comment: ""), detail) + case .fnRepurposed: + return NSLocalizedString("conflict.fn", comment: "") + case .typingKey: + return String(format: NSLocalizedString("conflict.typing_key", comment: ""), detail) + } + } +} + +enum HotkeyConflictChecker { + + /// Shift + Control + Option + Command. The `fn` bit is deliberately left + /// out of comparisons: arrow and function keys set it on their own, so + /// system shortcut entries carry it spuriously. + private static let comparableModifiers: UInt64 = + CGEventFlags.maskShift.rawValue + | CGEventFlags.maskControl.rawValue + | CGEventFlags.maskAlternate.rawValue + | CGEventFlags.maskCommand.rawValue + + static func conflict(for binding: HotkeyBinding, otherBinding: HotkeyBinding?) -> HotkeyConflict? { + guard !binding.isEmpty else { return nil } + + if let otherBinding, !otherBinding.isEmpty, otherBinding == binding { + return HotkeyConflict(kind: .duplicate, detail: binding.displayName) + } + if let reserved = reservedConflict(for: binding) { return reserved } + if let system = systemShortcutConflict(for: binding) { return system } + if let fnIssue = fnConflict(for: binding) { return fnIssue } + if let typing = typingKeyWarning(for: binding) { return typing } + return nil + } + + // MARK: - Reserved by macOS + + /// Combinations macOS consumes above the session event tap, so no amount + /// of permissions makes them reachable. + private static func reservedConflict(for binding: HotkeyBinding) -> HotkeyConflict? { + guard let keyCode = binding.keyCode else { return nil } + let mods = binding.genericModifierMask & comparableModifiers + let cmd = CGEventFlags.maskCommand.rawValue + let opt = CGEventFlags.maskAlternate.rawValue + let shift = CGEventFlags.maskShift.rawValue + + switch (keyCode, mods) { + case (Int64(kVK_Tab), cmd), (Int64(kVK_Tab), cmd | shift): + return HotkeyConflict(kind: .reserved, detail: "⌘⇥ — " + NSLocalizedString("conflict.app_switcher", comment: "")) + case (Int64(kVK_ANSI_Grave), cmd), (Int64(kVK_ANSI_Grave), cmd | shift): + return HotkeyConflict(kind: .reserved, detail: "⌘` — " + NSLocalizedString("conflict.window_switcher", comment: "")) + case (Int64(kVK_Escape), cmd | opt), (Int64(kVK_Escape), cmd | opt | shift): + return HotkeyConflict(kind: .reserved, detail: "⌥⌘⎋ — " + NSLocalizedString("conflict.force_quit", comment: "")) + default: + return nil + } + } + + // MARK: - System Settings → Keyboard → Shortcuts + + /// Names for the symbolic-hotkey IDs that are stable and worth naming. + /// Anything else falls back to a generic label rather than risking a + /// wrong one. + private static let symbolicHotkeyNames: [Int: String] = [ + 28: "conflict.name.screenshot_file", + 29: "conflict.name.screenshot_clipboard", + 30: "conflict.name.screenshot_area_file", + 31: "conflict.name.screenshot_area_clipboard", + 32: "conflict.name.mission_control", + 33: "conflict.name.app_windows", + 60: "conflict.name.previous_input_source", + 61: "conflict.name.next_input_source", + 64: "conflict.name.spotlight", + 65: "conflict.name.spotlight_finder", + 79: "conflict.name.space_left", + 80: "conflict.name.space_right", + 98: "conflict.name.help_menu", + 118: "conflict.name.desktop_1", + 119: "conflict.name.desktop_2", + 120: "conflict.name.desktop_3", + 121: "conflict.name.desktop_4", + 160: "conflict.name.launchpad", + 163: "conflict.name.notification_center", + 175: "conflict.name.notification_center", + 184: "conflict.name.screenshot_options", + ] + + private static func systemShortcutConflict(for binding: HotkeyBinding) -> HotkeyConflict? { + guard let keyCode = binding.keyCode else { return nil } + let mods = binding.genericModifierMask & comparableModifiers + + guard let raw = CFPreferencesCopyAppValue( + "AppleSymbolicHotKeys" as CFString, + "com.apple.symbolichotkeys" as CFString + ) as? [String: Any] else { return nil } + + for (idString, entry) in raw { + guard let entry = entry as? [String: Any], + (entry["enabled"] as? Bool) == true, + let value = entry["value"] as? [String: Any], + (value["type"] as? String) == "standard", + let parameters = value["parameters"] as? [Any], + parameters.count >= 3, + let otherKey = (parameters[1] as? NSNumber)?.int64Value, + let otherMask = (parameters[2] as? NSNumber)?.uint64Value, + otherKey != 65535, + otherKey == keyCode, + otherMask & comparableModifiers == mods + else { continue } + + let name: String + if let id = Int(idString), let key = symbolicHotkeyNames[id] { + name = NSLocalizedString(key, comment: "") + } else { + name = NSLocalizedString("conflict.name.generic", comment: "") + } + return HotkeyConflict(kind: .systemShortcut, detail: "\(name) (\(binding.displayName))") + } + return nil + } + + // MARK: - fn + + /// `fn` only reaches FastWord when System Settings → Keyboard leaves it on + /// "Do Nothing"; otherwise the system claims the press. + private static func fnConflict(for binding: HotkeyBinding) -> HotkeyConflict? { + guard binding.modifierKeyCodes.contains(ModifierKey.fn.rawValue) else { return nil } + let usage = CFPreferencesCopyAppValue( + "AppleFnUsageType" as CFString, + "com.apple.HIToolbox" as CFString + ) as? NSNumber + // Absent means the macOS default, which is not "Do Nothing" on + // machines with a globe key, so treat a missing value as configured. + guard let usage else { return nil } + return usage.intValue == 0 ? nil : HotkeyConflict(kind: .fnRepurposed, detail: "fn") + } + + // MARK: - Bare typing keys + + /// A character key with no modifiers is legal but swallows that character + /// everywhere while FastWord runs, which is rarely what someone wants. + private static func typingKeyWarning(for binding: HotkeyBinding) -> HotkeyConflict? { + guard let keyCode = binding.keyCode, binding.modifierKeyCodes.isEmpty else { return nil } + let name = HotkeyBinding.keyName(for: keyCode, fallback: binding.keyLabel) + // Function keys, the numeric keypad and navigation keys don't produce + // text, so losing them costs nothing. + guard HotkeyBinding.isTextProducing(keyCode: keyCode) else { return nil } + return HotkeyConflict(kind: .typingKey, detail: name) + } +} diff --git a/FastWord/Sources/HotkeyMonitor.swift b/FastWord/Sources/HotkeyMonitor.swift index 1f813a1..9549c0e 100644 --- a/FastWord/Sources/HotkeyMonitor.swift +++ b/FastWord/Sources/HotkeyMonitor.swift @@ -1,5 +1,6 @@ import AppKit import Carbon.HIToolbox +import IOKit.hid final class HotkeyMonitor { var onPressStart: (() -> Void)? @@ -10,46 +11,51 @@ final class HotkeyMonitor { private(set) var isActive = false private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? + /// False when we had to fall back to a listen-only tap, in which case a + /// binding that includes a regular key still types its character. + private(set) var canSuppressKeys = false - private var primaryDown = false - private var secondaryDown = false - private var combinedActive = false // current "both keys held" state + /// Side-aware modifier keycodes currently held. + private var modifiersDown: Set = [] + /// Non-modifier keycodes currently held. + private var keysDown: Set = [] + /// Keycodes whose key-down we swallowed, so we swallow their key-up too + /// even if a modifier was released first. + private var swallowedKeys: Set = [] + private var triggerActive = false + /// True while Settings is recording a new shortcut. + private var isSuspended = false - private var triggerKeyCode: Int64 = AppSettings.hotkey.keyCode - private var triggerFlag: CGEventFlags = AppSettings.hotkey.modifierFlag - private var secondaryKeyCode: Int64? - private var secondaryFlag: CGEventFlags? + private var primary: HotkeyBinding = AppSettings.hotkeyBinding + private var secondary: HotkeyBinding? private var comboMode: HotkeyComboMode = .either func reloadHotkey() { - let primary = AppSettings.hotkey - triggerKeyCode = primary.keyCode - triggerFlag = primary.modifierFlag + primary = AppSettings.hotkeyBinding comboMode = AppSettings.hotkeyComboMode if AppSettings.useComboHotkey { - let secondary = AppSettings.hotkeySecondary - // Avoid the silly case of "same key twice" — fall back to single. - if secondary.keyCode != primary.keyCode { - secondaryKeyCode = secondary.keyCode - secondaryFlag = secondary.modifierFlag - } else { - secondaryKeyCode = nil - secondaryFlag = nil - } + let candidate = AppSettings.hotkeySecondaryBinding + // Avoid the silly case of "same binding twice" — fall back to single. + secondary = (candidate.isEmpty || candidate == primary) ? nil : candidate } else { - secondaryKeyCode = nil - secondaryFlag = nil + secondary = nil } + HotkeyMonitor.dlog("binding now: primary=\(primary.displayName), secondary=\(secondary?.displayName ?? "none"), mode=\(comboMode.rawValue)") + loggedEvents = 0 // Force release whatever was held under the old config. - primaryDown = false - secondaryDown = false - if combinedActive { - combinedActive = false + modifiersDown.removeAll() + keysDown.removeAll() + swallowedKeys.removeAll() + if triggerActive { + triggerActive = false DispatchQueue.main.async { [weak self] in self?.onPressEnd?() } } } func start() { + reloadHotkey() + HotkeyMonitor.dlog("--- FastWord start ---") + logPermissionState() installEventTap() // After the system wakes from sleep, the event tap is sometimes left // disabled and macOS does not redeliver tap-disabled events. Re-arm or @@ -60,15 +66,28 @@ final class HotkeyMonitor { name: NSWorkspace.didWakeNotification, object: nil ) + NotificationCenter.default.addObserver( + self, selector: #selector(captureBegan), + name: AppSettings.hotkeyCaptureBeganNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, selector: #selector(captureEnded), + name: AppSettings.hotkeyCaptureEndedNotification, object: nil + ) + } + + @objc private func captureBegan() { + isSuspended = true + forceRelease() + } + + @objc private func captureEnded() { + isSuspended = false + forceRelease() } @objc private func systemDidWake(_ note: Notification) { - primaryDown = false - secondaryDown = false - if combinedActive { - combinedActive = false - DispatchQueue.main.async { [weak self] in self?.onPressEnd?() } - } + forceRelease() if let tap = eventTap, CGEvent.tapIsEnabled(tap: tap) == false { CGEvent.tapEnable(tap: tap, enable: true) return @@ -82,41 +101,58 @@ final class HotkeyMonitor { private func installEventTap() { let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) + | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.tapDisabledByTimeout.rawValue) | (1 << CGEventType.tapDisabledByUserInput.rawValue) - guard let tap = CGEvent.tapCreate( + + let callback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return monitor.handle(type: type, event: event) + } + + // A default tap lets us swallow the trigger keystroke so a binding + // like ⌃⌥D doesn't also type "d" into the focused field. It needs + // Accessibility; if that isn't granted yet, fall back to a listen-only + // tap so modifier-only bindings keep working. + var suppressing = true + var tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, - options: .listenOnly, + options: .defaultTap, eventsOfInterest: CGEventMask(mask), - callback: { _, type, event, refcon in - guard let refcon else { return Unmanaged.passUnretained(event) } - let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - if let tap = monitor.eventTap { CGEvent.tapEnable(tap: tap, enable: true) } - monitor.recoverAfterTapReEnable() - } else if type == .keyDown { - let keyCode = event.getIntegerValueField(.keyboardEventKeycode) - if keyCode == 53 /* kVK_Escape */ { - DispatchQueue.main.async { [weak monitor] in monitor?.onCancel?() } - } - } else { - monitor.handleFlags(event) - } - return Unmanaged.passUnretained(event) - }, + callback: callback, userInfo: Unmanaged.passUnretained(self).toOpaque() - ) else { + ) + if tap == nil { + suppressing = false + tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: CGEventMask(mask), + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + if tap != nil { + NSLog("FastWord: no Accessibility permission — hotkeys with a regular key will still type their character.") + } + } + + guard let tap else { NSLog("FastWord: failed to create event tap. Grant Input Monitoring + Accessibility in System Settings.") + HotkeyMonitor.dlog("event tap FAILED to create in both modes — no keys will ever arrive") DispatchQueue.main.async { [weak self] in self?.onPermissionMissing?() } return } + HotkeyMonitor.dlog("event tap created, mode=\(suppressing ? "defaultTap (can suppress)" : "listenOnly (cannot suppress)")") let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) self.eventTap = tap self.runLoopSource = source self.isActive = true + self.canSuppressKeys = suppressing } func stop() { @@ -124,79 +160,209 @@ final class HotkeyMonitor { if let src = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), src, .commonModes) } eventTap = nil runLoopSource = nil + isActive = false } - private static let logQueue = DispatchQueue(label: "fastword.hotkey.log") - private static let logURL: URL = FileManager.default.homeDirectoryForCurrentUser + // MARK: - Diagnostics + + /// Append-only log at ~/.fastword/hotkey.log. A hotkey that doesn't fire + /// has several possible causes that look identical from the outside + /// (missing permission, the event tap never created, another app eating + /// the keys, a binding that doesn't match); this records which one it is. + static let logURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".fastword/hotkey.log") + private static let logQueue = DispatchQueue(label: "fastword.hotkey.log") - private static func dlog(_ msg: String) { - let line = "[\(Date())] \(msg)\n" + static func dlog(_ message: String) { + let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n" logQueue.async { guard let data = line.data(using: .utf8) else { return } - if let h = try? FileHandle(forWritingTo: logURL) { - try? h.seekToEnd() - try? h.write(contentsOf: data) - try? h.close() + let fm = FileManager.default + let dir = logURL.deletingLastPathComponent() + if !fm.fileExists(atPath: dir.path) { + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + if let handle = try? FileHandle(forWritingTo: logURL) { + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + try? handle.close() } else { try? data.write(to: logURL) } } } - private func handleFlags(_ event: CGEvent) { - let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + private func logPermissionState() { + let accessibility = AXIsProcessTrusted() + let listen: String + switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) { + case kIOHIDAccessTypeGranted: listen = "granted" + case kIOHIDAccessTypeDenied: listen = "DENIED" + default: listen = "unknown" + } + HotkeyMonitor.dlog("permissions: accessibility=\(accessibility ? "granted" : "DENIED"), inputMonitoring=\(listen)") + HotkeyMonitor.dlog("binding: primary=\(primary.displayName) \(primary), secondary=\(secondary.map { "\($0.displayName) \($0)" } ?? "none"), mode=\(comboMode.rawValue)") + } + + /// Logs the first few modifier events after start-up so a binding that + /// never matches can be compared against what the keyboard actually sent. + private var loggedEvents = 0 + private func logEvent(_ raw: UInt64, _ changedKey: Int64, matched: Bool) { + guard loggedEvents < 40 else { return } + loggedEvents += 1 + HotkeyMonitor.dlog(String( + format: "flags=0x%llx key=%lld down=%@ matched=%@", + raw, changedKey, + modifiersDown.sorted().map(String.init).joined(separator: ",") as NSString, + matched ? "YES" : "no" + )) + } + + // MARK: - Event handling - // Update per-key down states based on which keycode just changed. - // We can't trust just the global flags mask because two distinct - // modifiers can share a CGEventFlag (e.g. left/right Option both - // set .maskAlternate). Checking keyCode tells us which specific - // key fired the flagsChanged event. - if keyCode == triggerKeyCode { - primaryDown = event.flags.contains(triggerFlag) + private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { + let passthrough = Unmanaged.passUnretained(event) + + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: true) } + forceRelease() + return passthrough + + case _ where isSuspended: + return passthrough + + case .flagsChanged: + updateModifiers(from: event) + evaluate() + logEvent(event.flags.rawValue, event.getIntegerValueField(.keyboardEventKeycode), matched: triggerActive) + return passthrough + + case .keyDown: + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + updateModifiers(from: event) + keysDown.insert(keyCode) + // Escape cancels an in-flight recording, unless the user has + // actually bound Escape as their trigger. + if keyCode == Int64(kVK_Escape), !isBound(keyCode: keyCode) { + DispatchQueue.main.async { [weak self] in self?.onCancel?() } + evaluate() + return passthrough + } + evaluate() + if triggerActive, isBound(keyCode: keyCode) { + swallowedKeys.insert(keyCode) + return nil + } + return passthrough + + case .keyUp: + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + updateModifiers(from: event) + keysDown.remove(keyCode) + evaluate() + if swallowedKeys.remove(keyCode) != nil { return nil } + return passthrough + + default: + return passthrough } - if let secCode = secondaryKeyCode, let secFlag = secondaryFlag, - keyCode == secCode { - secondaryDown = event.flags.contains(secFlag) + } + + /// Rebuilds the held-modifier set from the event's flags. + /// + /// A `flagsChanged` event names the exact key that changed, which is the + /// only signal that always tells left from right: virtual keyboards + /// (Karabiner, remapping tools, some Bluetooth stacks) re-post events + /// without the device-dependent NX bits, and then the bits alone can't + /// distinguish the sides — or, worse, report the wrong one. + /// + /// So: the changed key is resolved from its own keycode, using device + /// bits only when they are actually present. Every other modifier is + /// re-synced conservatively, and left alone when the flags are ambiguous. + private func updateModifiers(from event: CGEvent) { + let raw = event.flags.rawValue + let changedKey = event.getIntegerValueField(.keyboardEventKeycode) + + if event.type == .flagsChanged, let changed = ModifierKey(rawValue: changedKey) { + setDown(changed, isDownForChangedKey(changed, raw: raw)) } - // If a third modifier fires and ours is no longer present in the - // global flags mask, treat as a release of that side. - if keyCode != triggerKeyCode, !event.flags.contains(triggerFlag) { - primaryDown = false + for modifier in ModifierKey.allCases where modifier.rawValue != changedKey { + // fn's generic mask is also set by arrow and function keys, so it + // is only ever updated by its own flagsChanged event above. + guard modifier != .fn else { continue } + if raw & modifier.genericMask == 0 { + setDown(modifier, false) // definitely released + } else if raw & modifier.deviceMask != 0 { + setDown(modifier, true) // definitely held + } + // Otherwise the modifier is held on *some* side but the flags + // don't say which. Leave the existing state alone rather than + // guessing. } - if let secFlag = secondaryFlag, - keyCode != secondaryKeyCode, !event.flags.contains(secFlag) { - secondaryDown = false + } + + /// Down-state of the key whose own flagsChanged event this is. + private func isDownForChangedKey(_ modifier: ModifierKey, raw: UInt64) -> Bool { + let ownBit = raw & modifier.deviceMask != 0 + let siblingBit = modifier.sibling.map { raw & $0.deviceMask != 0 } ?? false + // When the event carries side bits, they settle it — that's what makes + // "hold both ⌥, release one" work. When it carries none, the generic + // mask is all we have, and the keycode already told us which key. + if ownBit || siblingBit { return ownBit } + return raw & modifier.genericMask != 0 + } + + private func setDown(_ modifier: ModifierKey, _ isDown: Bool) { + if isDown { + modifiersDown.insert(modifier.rawValue) + } else { + modifiersDown.remove(modifier.rawValue) } + } + + private func matches(_ binding: HotkeyBinding) -> Bool { + guard !binding.isEmpty else { return false } + guard binding.modifierKeyCodes.allSatisfy({ modifiersDown.contains($0) }) else { return false } + guard let keyCode = binding.keyCode else { return true } + return keysDown.contains(keyCode) + } + + private func isBound(keyCode: Int64) -> Bool { + if primary.keyCode == keyCode { return true } + return secondary?.keyCode == keyCode + } + private func evaluate() { let nowActive: Bool - if secondaryKeyCode == nil { - nowActive = primaryDown - } else { + if let secondary { switch comboMode { - case .either: nowActive = primaryDown || secondaryDown - case .both: nowActive = primaryDown && secondaryDown + case .either: nowActive = matches(primary) || matches(secondary) + case .both: nowActive = matches(primary) && matches(secondary) } + } else { + nowActive = matches(primary) } - if nowActive && !combinedActive { - combinedActive = true + if nowActive, !triggerActive { + triggerActive = true DispatchQueue.main.async { [weak self] in self?.onPressStart?() } - } else if !nowActive && combinedActive { - combinedActive = false + } else if !nowActive, triggerActive { + triggerActive = false DispatchQueue.main.async { [weak self] in self?.onPressEnd?() } } } - fileprivate func recoverAfterTapReEnable() { - // After tap timeout/disable, we may have missed a release. Force release. - if combinedActive { - combinedActive = false - primaryDown = false - secondaryDown = false + /// Drops all held state after an event we may have missed (sleep, tap + /// timeout), ending any recording that was in flight. + private func forceRelease() { + modifiersDown.removeAll() + keysDown.removeAll() + swallowedKeys.removeAll() + if triggerActive { + triggerActive = false DispatchQueue.main.async { [weak self] in self?.onPressEnd?() } } } - } diff --git a/FastWord/Sources/Settings.swift b/FastWord/Sources/Settings.swift index 4a50233..82a2541 100644 --- a/FastWord/Sources/Settings.swift +++ b/FastWord/Sources/Settings.swift @@ -3,9 +3,13 @@ import Foundation enum SettingsKey { static let livePreviewEnabled = "livePreviewEnabled" + /// Legacy (pre-0.7) single-modifier choice, kept only so existing + /// installs can be migrated to `hotkeyBinding` on first read. static let hotkey = "hotkey" static let hotkeySecondary = "hotkeySecondary" static let hotkeyUseCombo = "hotkeyUseCombo" + static let hotkeyBinding = "hotkeyBindingV2" + static let hotkeySecondaryBinding = "hotkeySecondaryBindingV2" static let language = "language" static let transcriptionLanguage = "transcriptionLanguage" static let idleEviction = "idleEviction" @@ -181,6 +185,9 @@ enum HotkeyComboMode: String, CaseIterable, Identifiable { var displayName: String { NSLocalizedString("combo.\(rawValue)", comment: "") } } +/// Legacy fixed list of modifier hotkeys, replaced in 0.7 by the freeform +/// `HotkeyBinding`. Retained so a stored pre-0.7 preference can be read once +/// and converted; do not add cases. enum HotkeyChoice: String, CaseIterable, Identifiable { case rightOption case leftOption @@ -239,37 +246,55 @@ enum AppSettings { static let hotkeyChangedNotification = Notification.Name("FastWord.hotkeyChanged") static let idleEvictionChangedNotification = Notification.Name("FastWord.idleEvictionChanged") static let activeModelChangedNotification = Notification.Name("FastWord.activeModelChanged") + /// Posted while the Settings window is capturing a new shortcut, so the + /// global hotkey doesn't fire dictation on the keys being recorded. + static let hotkeyCaptureBeganNotification = Notification.Name("FastWord.hotkeyCaptureBegan") + static let hotkeyCaptureEndedNotification = Notification.Name("FastWord.hotkeyCaptureEnded") static var livePreviewEnabled: Bool { get { UserDefaults.standard.bool(forKey: SettingsKey.livePreviewEnabled) } set { UserDefaults.standard.set(newValue, forKey: SettingsKey.livePreviewEnabled) } } - static var hotkey: HotkeyChoice { - get { - let raw = UserDefaults.standard.string(forKey: SettingsKey.hotkey) ?? "" - return HotkeyChoice(rawValue: raw) ?? .rightOption - } - set { - UserDefaults.standard.set(newValue.rawValue, forKey: SettingsKey.hotkey) - NotificationCenter.default.post(name: hotkeyChangedNotification, object: nil) - } + /// The trigger the user holds to dictate. Any combination of modifiers, + /// optionally plus one regular key. + static var hotkeyBinding: HotkeyBinding { + get { storedBinding(SettingsKey.hotkeyBinding, legacyKey: SettingsKey.hotkey, fallback: .rightOption) } + set { storeBinding(newValue, forKey: SettingsKey.hotkeyBinding) } } - /// Optional second modifier — when `useComboHotkey` is true, both must - /// be held down simultaneously to trigger recording. - static var hotkeySecondary: HotkeyChoice { - get { - let raw = UserDefaults.standard.string(forKey: SettingsKey.hotkeySecondary) ?? "" - return HotkeyChoice(rawValue: raw) ?? .rightControl + /// Optional second trigger — `hotkeyComboMode` decides whether it acts as + /// an alternative to the primary one or has to be held together with it. + static var hotkeySecondaryBinding: HotkeyBinding { + get { storedBinding(SettingsKey.hotkeySecondaryBinding, legacyKey: SettingsKey.hotkeySecondary, fallback: .rightControl) } + set { storeBinding(newValue, forKey: SettingsKey.hotkeySecondaryBinding) } + } + + /// Reads a binding, falling back to the pre-0.7 `HotkeyChoice` preference + /// so upgrading users keep the key they configured. The conversion is not + /// written back — the legacy key stays untouched until the user saves a + /// new binding, which keeps a downgrade harmless. + private static func storedBinding( + _ key: String, legacyKey: String, fallback: HotkeyBinding + ) -> HotkeyBinding { + if let json = UserDefaults.standard.string(forKey: key), + let binding = HotkeyBinding.decode(json), + !binding.isEmpty { + return binding } - set { - UserDefaults.standard.set(newValue.rawValue, forKey: SettingsKey.hotkeySecondary) - NotificationCenter.default.post(name: hotkeyChangedNotification, object: nil) + if let raw = UserDefaults.standard.string(forKey: legacyKey), + let legacy = HotkeyChoice(rawValue: raw) { + return HotkeyBinding(modifierKeyCodes: [legacy.keyCode]) } + return fallback + } + + private static func storeBinding(_ binding: HotkeyBinding, forKey key: String) { + UserDefaults.standard.set(binding.encoded(), forKey: key) + NotificationCenter.default.post(name: hotkeyChangedNotification, object: nil) } - /// When true, the hotkey takes a second modifier into account + /// When true, the hotkey takes a second binding into account /// (the meaning of which is controlled by `hotkeyComboMode`). static var useComboHotkey: Bool { get { UserDefaults.standard.bool(forKey: SettingsKey.hotkeyUseCombo) } diff --git a/FastWord/Sources/SettingsView.swift b/FastWord/Sources/SettingsView.swift index 62b2882..f0b5dac 100644 --- a/FastWord/Sources/SettingsView.swift +++ b/FastWord/Sources/SettingsView.swift @@ -2,8 +2,8 @@ import SwiftUI struct SettingsView: View { @AppStorage(SettingsKey.livePreviewEnabled) private var livePreview: Bool = false - @AppStorage(SettingsKey.hotkey) private var hotkeyRaw: String = HotkeyChoice.rightOption.rawValue - @AppStorage(SettingsKey.hotkeySecondary) private var hotkeySecondaryRaw: String = HotkeyChoice.rightControl.rawValue + @AppStorage(SettingsKey.hotkeyBinding) private var hotkeyRaw: String = "" + @AppStorage(SettingsKey.hotkeySecondaryBinding) private var hotkeySecondaryRaw: String = "" @AppStorage(SettingsKey.hotkeyUseCombo) private var useCombo: Bool = false @AppStorage("hotkeyComboMode") private var comboModeRaw: String = HotkeyComboMode.either.rawValue @AppStorage(SettingsKey.language) private var languageRaw: String = LanguageChoice.system.rawValue @@ -14,21 +14,21 @@ struct SettingsView: View { @AppStorage(SettingsKey.cleanupEnabled) private var cleanupEnabled: Bool = true @State private var languageChanged = false - private var hotkey: Binding { + private var hotkey: Binding { Binding( - get: { HotkeyChoice(rawValue: hotkeyRaw) ?? .rightOption }, + get: { HotkeyBinding.decode(hotkeyRaw) ?? AppSettings.hotkeyBinding }, set: { newValue in - hotkeyRaw = newValue.rawValue + hotkeyRaw = newValue.encoded() NotificationCenter.default.post(name: AppSettings.hotkeyChangedNotification, object: nil) } ) } - private var hotkeySecondary: Binding { + private var hotkeySecondary: Binding { Binding( - get: { HotkeyChoice(rawValue: hotkeySecondaryRaw) ?? .rightControl }, + get: { HotkeyBinding.decode(hotkeySecondaryRaw) ?? AppSettings.hotkeySecondaryBinding }, set: { newValue in - hotkeySecondaryRaw = newValue.rawValue + hotkeySecondaryRaw = newValue.encoded() NotificationCenter.default.post(name: AppSettings.hotkeyChangedNotification, object: nil) } ) @@ -75,15 +75,79 @@ struct SettingsView: View { ) } + /// Quick access to the single-modifier hotkeys most people want, so the + /// common case stays one click even though any chord is now recordable. + private func presetMenu(for target: Binding) -> some View { + Menu { + ForEach(HotkeyBinding.presets, id: \.self) { preset in + Button(preset.displayName) { target.wrappedValue = preset } + } + Divider() + Button(LocalizedStringKey("hotkey.reset")) { target.wrappedValue = .rightOption } + } label: { + Image(systemName: "list.bullet") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(LocalizedStringKey("hotkey.presets")) + } + + /// A shortcut that clashes with something else fails silently — the keys + /// simply never arrive. Say so where the user chose it. + private func conflictRow(_ conflict: HotkeyConflict) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: conflict.isBlocking ? "exclamationmark.triangle.fill" : "info.circle") + .foregroundStyle(conflict.isBlocking ? .orange : .secondary) + Text(conflict.message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + /// Without Accessibility the tap can't swallow a bound regular key, so the + /// character reaches whatever the user is typing into. + private func accessibilityRow() -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 4) { + Text(LocalizedStringKey("hotkey.needs_accessibility")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(LocalizedStringKey("hotkey.open_accessibility")) { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { return } + NSWorkspace.shared.open(url) + } + .font(.caption) + } + } + } + + private var accessibilityGranted: Bool { AXIsProcessTrusted() } + var body: some View { Form { Section { - Picker(LocalizedStringKey("Hold to dictate"), selection: hotkey) { - ForEach(HotkeyChoice.allCases) { choice in - Text(choice.displayName).tag(choice) + LabeledContent(LocalizedStringKey("Hold to dictate")) { + HStack(spacing: 8) { + ShortcutRecorderField(binding: hotkey) + presetMenu(for: hotkey) } } - .pickerStyle(.menu) + + if let conflict = HotkeyConflictChecker.conflict( + for: hotkey.wrappedValue, + otherBinding: useCombo ? hotkeySecondary.wrappedValue : nil + ) { + conflictRow(conflict) + } + + if !accessibilityGranted, hotkey.wrappedValue.keyCode != nil { + accessibilityRow() + } Toggle(isOn: useComboBinding) { VStack(alignment: .leading, spacing: 2) { @@ -95,12 +159,19 @@ struct SettingsView: View { } if useCombo { - Picker(LocalizedStringKey("Second key"), selection: hotkeySecondary) { - ForEach(HotkeyChoice.allCases) { choice in - Text(choice.displayName).tag(choice) + LabeledContent(LocalizedStringKey("Second key")) { + HStack(spacing: 8) { + ShortcutRecorderField(binding: hotkeySecondary) + presetMenu(for: hotkeySecondary) } } - .pickerStyle(.menu) + + if let conflict = HotkeyConflictChecker.conflict( + for: hotkeySecondary.wrappedValue, + otherBinding: hotkey.wrappedValue + ) { + conflictRow(conflict) + } Picker(LocalizedStringKey("Combo mode"), selection: comboModeBinding) { ForEach(HotkeyComboMode.allCases) { mode in @@ -112,9 +183,12 @@ struct SettingsView: View { } header: { Text(LocalizedStringKey("Hotkey")) } footer: { - Text(LocalizedStringKey("Hold the chosen key (or combo) to record. Release to transcribe and paste.")) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(LocalizedStringKey("Hold the chosen key (or combo) to record. Release to transcribe and paste.")) + Text(LocalizedStringKey("hotkey.custom_footer")) + } + .font(.caption) + .foregroundStyle(.secondary) } Section { diff --git a/FastWord/Sources/ShortcutRecorderField.swift b/FastWord/Sources/ShortcutRecorderField.swift new file mode 100644 index 0000000..17f8770 --- /dev/null +++ b/FastWord/Sources/ShortcutRecorderField.swift @@ -0,0 +1,174 @@ +import AppKit +import Carbon.HIToolbox +import SwiftUI + +/// Click-to-record shortcut field. +/// +/// Captures whatever the user presses: modifiers alone (`⌥R`, `⌃L⌥L`) or +/// modifiers plus one regular key (`⌃⌥D`, `F13`). A modifier-only chord is +/// committed when the last modifier comes back up, so the user can hold the +/// combination and inspect it before releasing. +struct ShortcutRecorderField: View { + @Binding var binding: HotkeyBinding + + @State private var isRecording = false + @State private var held: [Int64] = [] + @State private var lastHeld: [Int64] = [] + @State private var monitor: Any? + @State private var disarm: DispatchWorkItem? + + /// A field left armed silently swallows the next thing typed and saves it + /// as the hotkey, which is how a stray ⇧3 ends up as your trigger. Give up + /// on its own if nothing is pressed. + private let armedTimeout: TimeInterval = 6 + + var body: some View { + Button(action: toggleRecording) { + HStack(spacing: 6) { + Text(fieldText) + .font(.system(size: 13, weight: isRecording ? .regular : .medium)) + .foregroundStyle(isRecording ? .secondary : .primary) + Spacer(minLength: 0) + if isRecording { + Text(LocalizedStringKey("hotkey.record_hint")) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(minWidth: 160, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder( + isRecording ? Color.accentColor : Color(nsColor: .separatorColor), + lineWidth: isRecording ? 2 : 1 + ) + ) + .contentShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .onDisappear(perform: endRecording) + // The local monitor swallows every key event, so never leave it armed + // once the Settings window stops being the one the user is looking at. + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { _ in + endRecording() + } + } + + private var fieldText: String { + if isRecording { + let pending = held.isEmpty ? lastHeld : held + if pending.isEmpty { return NSLocalizedString("hotkey.press_keys", comment: "") } + return HotkeyBinding(modifierKeyCodes: pending).displayName + } + return binding.displayName + } + + // MARK: - Capture + + private func toggleRecording() { + isRecording ? endRecording() : beginRecording() + } + + private func beginRecording() { + guard !isRecording else { return } + held = [] + lastHeld = [] + isRecording = true + // Silence the global hotkey while capturing, otherwise pressing the + // current trigger would start a dictation behind the Settings window. + NotificationCenter.default.post(name: AppSettings.hotkeyCaptureBeganNotification, object: nil) + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { event in + handle(event) + return nil // swallow everything so nothing types or beeps + } + scheduleDisarm() + } + + private func scheduleDisarm() { + disarm?.cancel() + let item = DispatchWorkItem { + if isRecording, held.isEmpty { endRecording() } + } + disarm = item + DispatchQueue.main.asyncAfter(deadline: .now() + armedTimeout, execute: item) + } + + private func endRecording() { + disarm?.cancel() + disarm = nil + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + if isRecording { + NotificationCenter.default.post(name: AppSettings.hotkeyCaptureEndedNotification, object: nil) + } + isRecording = false + held = [] + lastHeld = [] + } + + private func handle(_ event: NSEvent) { + scheduleDisarm() + + switch event.type { + case .flagsChanged: + updateHeld(from: event) + // Releasing the last modifier without ever pressing a regular key + // commits the chord the user was holding. + if held.isEmpty, !lastHeld.isEmpty { + commit(HotkeyBinding(modifierKeyCodes: lastHeld)) + } + + case .keyDown: + let keyCode = Int64(event.keyCode) + if keyCode == Int64(kVK_Escape), held.isEmpty { + endRecording() + return + } + commit( + HotkeyBinding( + keyCode: keyCode, + modifierKeyCodes: held, + keyLabel: event.charactersIgnoringModifiers + ) + ) + + default: + break + } + } + + /// Recomputes which side-aware modifiers are down, preserving press order. + private func updateHeld(from event: NSEvent) { + let raw = UInt64(event.modifierFlags.rawValue) + let changed = Int64(event.keyCode) + + for modifier in ModifierKey.allCases { + // `fn`'s mask is also set by arrow and function keys, so trust it + // only on its own flagsChanged event. + let isDown = modifier == .fn + ? (changed == ModifierKey.fn.rawValue && modifier.isHeld(in: raw)) + : modifier.isHeld(in: raw) + + if isDown { + if !held.contains(modifier.rawValue) { held.append(modifier.rawValue) } + } else if modifier != .fn || changed == ModifierKey.fn.rawValue { + held.removeAll { $0 == modifier.rawValue } + } + } + if !held.isEmpty { lastHeld = held } + } + + private func commit(_ newBinding: HotkeyBinding) { + guard !newBinding.isEmpty else { return } + binding = newBinding + endRecording() + } +} diff --git a/README.md b/README.md index bbbdb5d..67a86b9 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,28 @@ After granting, fully quit and relaunch — TCC permissions only apply on a fres ## Usage -- **Dictate.** Hold the chosen modifier key (default: Right Option ⌥), speak, release. Text is inserted at the cursor and saved to history. +- **Dictate.** Hold your hotkey (default: Right Option ⌥), speak, release. Text is inserted at the cursor and saved to history. - **Cancel.** Press **Escape** during recording or transcription to abort and discard the result. -- **Settings.** Menu-bar icon → **Settings…** (⌘,) — pick your hotkey, transcription language, idle eviction time, and live preview toggle. +- **Settings.** Menu-bar icon → **Settings…** (⌘,) — record your hotkey, pick transcription language, idle eviction time, and live preview toggle. + +### Custom hotkeys + +The hotkey field records whatever you press, so the trigger is not limited to a preset list: + +- a single modifier — `⌥R`, `⌃L`, `fn` +- a chord of modifiers — `⌃L⌥L`, `⇧R⌘R` +- modifiers plus a regular key — `⌃⌥D`, `⌥Space` +- a bare key — `F13`, `F18` + +Left and right modifiers are distinguished, so `⌥R` never fires from the left ⌥. Click the field, press the combination, and it is saved when you release (or as soon as you hit the non-modifier key). Escape while the field is empty cancels the recording. The `☰` button next to the field still offers the nine common single-modifier presets. + +A hotkey that contains a regular key is swallowed while held, so `⌃⌥D` will not also type a `d` into the field you're dictating into. This needs the Accessibility permission — without it, FastWord falls back to a listen-only event tap where modifier-only hotkeys keep working but a regular key still reaches the app underneath. + +**Conflicts.** A shortcut that something else already claims fails silently — the keys simply never reach FastWord. Settings guards against that in two ways: + +- A warning appears under the field when the recorded shortcut matches an enabled macOS shortcut (read live from `com.apple.symbolichotkeys`, so a remapped Spotlight is detected correctly), is reserved above the event tap (⌘⇥, ⌘\`, ⌥⌘⎋), collides with FastWord's own second key, or is a bare character key that would stop typing. A separate warning appears if Accessibility isn't granted and the shortcut contains a regular key. + +**Second key.** Optional. Set it to an alternative trigger (`Either` mode — handy when you switch between keyboards with different modifiers) or to a key that has to be held alongside the first (`Both` mode). - **History.** Menu-bar icon → **Show History**. Searchable, click rows to copy or delete. - **Quit.** Menu-bar icon → **Quit**.