cmux/Sources/KeyboardShortcutSettings.swift
Lawrence Chen dd54927cb9
Add React Grab inject button to browser toolbar (#2373)
* Add React Grab inject button to browser toolbar

Adds a toolbar button (cursor click icon) that injects the react-grab
script (unpkg.com/react-grab/dist/index.global.js) into the current
page. Hover over React elements and Cmd+C to copy component context
(file, component name, line number) for AI agents.

Button highlights when active, resets on navigation.

* Auto-activate selection mode on React Grab inject

First click: injects the script and auto-activates selection mode via
the react-grab:init event. Subsequent clicks toggle selection mode
on/off via window.__REACT_GRAB__.toggle().

* Bridge React Grab state back to Swift via WKScriptMessageHandler

Register a cmux-bridge plugin after injecting react-grab that posts
state changes back to Swift via webkit.messageHandlers. The button
now highlights accent color only when selection mode is actually
active (not just when the script is loaded), and deactivates when
the user exits selection mode via Escape or the react-grab toolbar.

* Fetch react-grab script via URLSession to bypass CSP

Sites like vercel.com block loading external scripts via CSP headers.
Fetch the script with URLSession (not subject to page CSP), cache it,
and inject inline via evaluateJavaScript. Also guard against duplicate
injection on repeated clicks.

* Prefetch react-grab script on first browser panel init

Kick off a low-priority background fetch of the react-grab script
when the first BrowserPanel is created. The script is cached
statically so clicking the button is instant.

* Eliminate react-grab button and callback lag

Three changes:
1. Fire-and-forget: use evaluateJavaScript with completionHandler
   instead of await, so button taps return immediately.
2. Single JS payload: combine bootstrap listener + script source
   into one evaluateJavaScript call (one IPC round-trip, not two).
3. Dedupe state callbacks: only post webkit message when isActive
   actually changes, not on every hover/drag state update.

* Fix duplicate state callback on react-grab toggle

toggleReactGrab was sending an explicit postMessage AND the plugin's
onStateChange hook was firing too, causing two @Published updates per
toggle. Remove the explicit postMessage since the plugin hook handles
it. Also add dlog instrumentation for debugging.

* Add Cmd+Shift+G shortcut for React Grab (configurable)

- Add toggleReactGrab to KeyboardShortcutSettings with Cmd+Shift+G default
- Add View menu item with customizable shortcut
- Add command palette entry (searchable as "react grab" or "inspect element")
- Simplify button to use toggleOrInjectReactGrab, remove local state tracking

* Fix Codex review findings: pin version, verify hash, fix retry and state

1. Pin react-grab to exact version (0.1.29) with SHA-256 integrity
   check. Script is verified before evaluation to prevent supply-chain
   attacks via compromised CDN responses.
2. Clear prefetchTask on failure so subsequent attempts retry the
   download instead of reusing a permanently failed task.
3. Remove premature isReactGrabActive=true. State is now only set
   by the onStateChange message handler callback after confirmed
   initialization, or explicitly reset on evaluation error.

* Extract React Grab into own file, make version configurable

Move all react-grab logic (settings, script loader, message handler,
BrowserPanel extension) into Sources/Panels/ReactGrab.swift.

Add a "React Grab Version" text field in Settings > Browser that lets
the user pin which npm version is fetched. Only versions with a known
SHA-256 integrity hash in ReactGrabSettings.knownHashes are accepted.
The cache invalidates when the configured version changes.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-30 18:00:45 -07:00

650 lines
28 KiB
Swift

import AppKit
import SwiftUI
/// Stores customizable keyboard shortcuts (definitions + persistence).
enum KeyboardShortcutSettings {
static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange")
static let actionUserInfoKey = "action"
enum Action: String, CaseIterable, Identifiable {
// Titlebar / primary UI
case toggleSidebar
case newTab
case newWindow
case closeWindow
case openFolder
case sendFeedback
case showNotifications
case jumpToUnread
case triggerFlash
// Navigation
case nextSurface
case prevSurface
case selectSurfaceByNumber
case nextSidebarTab
case prevSidebarTab
case selectWorkspaceByNumber
case renameTab
case renameWorkspace
case closeWorkspace
case newSurface
case toggleTerminalCopyMode
// Panes / splits
case focusLeft
case focusRight
case focusUp
case focusDown
case splitRight
case splitDown
case toggleSplitZoom
case splitBrowserRight
case splitBrowserDown
// Panels
case openBrowser
case toggleBrowserDeveloperTools
case showBrowserJavaScriptConsole
case toggleReactGrab
var id: String { rawValue }
var label: String {
switch self {
case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar")
case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace")
case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window")
case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window")
case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder")
case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback")
case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications")
case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread")
case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel")
case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface")
case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface")
case .selectSurfaceByNumber: return String(localized: "shortcut.selectSurfaceByNumber.label", defaultValue: "Select Surface 1…9")
case .nextSidebarTab: return String(localized: "shortcut.nextWorkspace.label", defaultValue: "Next Workspace")
case .prevSidebarTab: return String(localized: "shortcut.previousWorkspace.label", defaultValue: "Previous Workspace")
case .selectWorkspaceByNumber: return String(localized: "shortcut.selectWorkspaceByNumber.label", defaultValue: "Select Workspace 1…9")
case .renameTab: return String(localized: "shortcut.renameTab.label", defaultValue: "Rename Tab")
case .renameWorkspace: return String(localized: "shortcut.renameWorkspace.label", defaultValue: "Rename Workspace")
case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace")
case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface")
case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode")
case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left")
case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right")
case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up")
case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down")
case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right")
case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down")
case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom")
case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right")
case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down")
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools")
case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console")
case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab")
}
}
var defaultsKey: String {
switch self {
case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab"
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .openFolder: return "shortcut.openFolder"
case .sendFeedback: return "shortcut.sendFeedback"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
case .selectWorkspaceByNumber: return "shortcut.selectWorkspaceByNumber"
case .nextSidebarTab: return "shortcut.nextSidebarTab"
case .prevSidebarTab: return "shortcut.prevSidebarTab"
case .renameTab: return "shortcut.renameTab"
case .renameWorkspace: return "shortcut.renameWorkspace"
case .closeWorkspace: return "shortcut.closeWorkspace"
case .focusLeft: return "shortcut.focusLeft"
case .focusRight: return "shortcut.focusRight"
case .focusUp: return "shortcut.focusUp"
case .focusDown: return "shortcut.focusDown"
case .splitRight: return "shortcut.splitRight"
case .splitDown: return "shortcut.splitDown"
case .toggleSplitZoom: return "shortcut.toggleSplitZoom"
case .splitBrowserRight: return "shortcut.splitBrowserRight"
case .splitBrowserDown: return "shortcut.splitBrowserDown"
case .nextSurface: return "shortcut.nextSurface"
case .prevSurface: return "shortcut.prevSurface"
case .selectSurfaceByNumber: return "shortcut.selectSurfaceByNumber"
case .newSurface: return "shortcut.newSurface"
case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode"
case .openBrowser: return "shortcut.openBrowser"
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
case .toggleReactGrab: return "shortcut.toggleReactGrab"
}
}
var defaultShortcut: StoredShortcut {
switch self {
case .toggleSidebar:
return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false)
case .newTab:
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
case .newWindow:
return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
case .closeWindow:
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .openFolder:
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
case .sendFeedback:
return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
return StoredShortcut(key: "u", command: true, shift: true, option: false, control: false)
case .triggerFlash:
return StoredShortcut(key: "h", command: true, shift: true, option: false, control: false)
case .nextSidebarTab:
return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true)
case .prevSidebarTab:
return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true)
case .renameTab:
return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false)
case .renameWorkspace:
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
case .closeWorkspace:
return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false)
case .focusLeft:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusRight:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusUp:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusDown:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .splitRight:
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
case .splitDown:
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
case .toggleSplitZoom:
return StoredShortcut(key: "\r", command: true, shift: true, option: false, control: false)
case .splitBrowserRight:
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
case .splitBrowserDown:
return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false)
case .nextSurface:
return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false)
case .prevSurface:
return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false)
case .selectSurfaceByNumber:
return StoredShortcut(key: "1", command: false, shift: false, option: false, control: true)
case .newSurface:
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
case .toggleTerminalCopyMode:
return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false)
case .selectWorkspaceByNumber:
return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .toggleBrowserDeveloperTools:
// Safari default: Show Web Inspector.
return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false)
case .showBrowserJavaScriptConsole:
// Safari default: Show JavaScript Console.
return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false)
case .toggleReactGrab:
return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false)
}
}
func tooltip(_ base: String) -> String {
"\(base) (\(displayedShortcutString(for: KeyboardShortcutSettings.shortcut(for: self))))"
}
var usesNumberedDigitMatching: Bool {
switch self {
case .selectSurfaceByNumber, .selectWorkspaceByNumber:
return true
default:
return false
}
}
func displayedShortcutString(for shortcut: StoredShortcut) -> String {
if usesNumberedDigitMatching {
return shortcut.modifierDisplayString + "1…9"
}
return shortcut.displayString
}
func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? {
guard usesNumberedDigitMatching else { return shortcut }
guard let digit = Int(shortcut.key), (1...9).contains(digit) else {
return nil
}
return StoredShortcut(
key: "1",
command: shortcut.command,
shift: shortcut.shift,
option: shortcut.option,
control: shortcut.control
)
}
}
static func shortcut(for action: Action) -> StoredShortcut {
guard let data = UserDefaults.standard.data(forKey: action.defaultsKey),
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return action.defaultShortcut
}
return shortcut
}
static func setShortcut(_ shortcut: StoredShortcut, for action: Action) {
let storedShortcut: StoredShortcut
if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) {
storedShortcut = normalizedShortcut
} else if action.usesNumberedDigitMatching {
return
} else {
storedShortcut = shortcut
}
if let data = try? JSONEncoder().encode(storedShortcut) {
UserDefaults.standard.set(data, forKey: action.defaultsKey)
}
postDidChangeNotification(action: action)
}
static func resetShortcut(for action: Action) {
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
postDidChangeNotification(action: action)
}
static func resetAll() {
for action in Action.allCases {
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
}
postDidChangeNotification()
}
private static func postDidChangeNotification(
action: Action? = nil,
center: NotificationCenter = .default
) {
var userInfo: [AnyHashable: Any] = [:]
if let action {
userInfo[actionUserInfoKey] = action.rawValue
}
center.post(
name: didChangeNotification,
object: nil,
userInfo: userInfo.isEmpty ? nil : userInfo
)
}
// MARK: - Backwards-Compatible API (call-sites can migrate gradually)
// Keys (used by debug socket command + UI tests)
static let focusLeftKey = Action.focusLeft.defaultsKey
static let focusRightKey = Action.focusRight.defaultsKey
static let focusUpKey = Action.focusUp.defaultsKey
static let focusDownKey = Action.focusDown.defaultsKey
// Defaults (used by settings reset + recorder button initial title)
static let showNotificationsDefault = Action.showNotifications.defaultShortcut
static let jumpToUnreadDefault = Action.jumpToUnread.defaultShortcut
static func showNotificationsShortcut() -> StoredShortcut { shortcut(for: .showNotifications) }
static func setShowNotificationsShortcut(_ shortcut: StoredShortcut) { setShortcut(shortcut, for: .showNotifications) }
static func jumpToUnreadShortcut() -> StoredShortcut { shortcut(for: .jumpToUnread) }
static func setJumpToUnreadShortcut(_ shortcut: StoredShortcut) { setShortcut(shortcut, for: .jumpToUnread) }
static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) }
static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) }
static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) }
static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) }
static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) }
static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) }
static func focusUpShortcut() -> StoredShortcut { shortcut(for: .focusUp) }
static func focusDownShortcut() -> StoredShortcut { shortcut(for: .focusDown) }
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
static func toggleSplitZoomShortcut() -> StoredShortcut { shortcut(for: .toggleSplitZoom) }
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }
static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) }
static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) }
static func selectSurfaceByNumberShortcut() -> StoredShortcut { shortcut(for: .selectSurfaceByNumber) }
static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) }
static func selectWorkspaceByNumberShortcut() -> StoredShortcut { shortcut(for: .selectWorkspaceByNumber) }
static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) }
static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) }
static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) }
}
/// A keyboard shortcut that can be stored in UserDefaults
struct StoredShortcut: Codable, Equatable {
var key: String
var command: Bool
var shift: Bool
var option: Bool
var control: Bool
var displayString: String {
modifierDisplayString + keyDisplayString
}
var modifierDisplayString: String {
var parts: [String] = []
if control { parts.append("") }
if option { parts.append("") }
if shift { parts.append("") }
if command { parts.append("") }
return parts.joined()
}
var keyDisplayString: String {
switch key {
case "\t":
return "TAB"
case "\r":
return ""
default:
return key.uppercased()
}
}
var modifierFlags: NSEvent.ModifierFlags {
var flags: NSEvent.ModifierFlags = []
if command { flags.insert(.command) }
if shift { flags.insert(.shift) }
if option { flags.insert(.option) }
if control { flags.insert(.control) }
return flags
}
var keyEquivalent: KeyEquivalent? {
switch key {
case "":
return .leftArrow
case "":
return .rightArrow
case "":
return .upArrow
case "":
return .downArrow
case "\t":
return .tab
case "\r":
return KeyEquivalent(Character("\r"))
default:
let lowered = key.lowercased()
guard lowered.count == 1, let character = lowered.first else { return nil }
return KeyEquivalent(character)
}
}
var eventModifiers: EventModifiers {
var modifiers: EventModifiers = []
if command {
modifiers.insert(.command)
}
if shift {
modifiers.insert(.shift)
}
if option {
modifiers.insert(.option)
}
if control {
modifiers.insert(.control)
}
return modifiers
}
var menuItemKeyEquivalent: String? {
switch key {
case "":
guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "\t":
return "\t"
case "\r":
return "\r"
default:
let lowered = key.lowercased()
guard lowered.count == 1 else { return nil }
return lowered
}
}
static func from(event: NSEvent) -> StoredShortcut? {
guard let key = storedKey(from: event) else { return nil }
// Some keys include extra flags depending on the responder chain.
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
let shortcut = StoredShortcut(
key: key,
command: flags.contains(.command),
shift: flags.contains(.shift),
option: flags.contains(.option),
control: flags.contains(.control)
)
// Avoid recording plain typing; require at least one modifier.
if !shortcut.command && !shortcut.shift && !shortcut.option && !shortcut.control {
return nil
}
return shortcut
}
private static func storedKey(from event: NSEvent) -> String? {
// Prefer keyCode mapping so shifted symbol keys (e.g. "}") record as "]".
switch event.keyCode {
case 123: return "" // left arrow
case 124: return "" // right arrow
case 125: return "" // down arrow
case 126: return "" // up arrow
case 48: return "\t" // tab
case 36, 76: return "\r" // return, keypad enter
case 33: return "[" // kVK_ANSI_LeftBracket
case 30: return "]" // kVK_ANSI_RightBracket
case 27: return "-" // kVK_ANSI_Minus
case 24: return "=" // kVK_ANSI_Equal
case 43: return "," // kVK_ANSI_Comma
case 47: return "." // kVK_ANSI_Period
case 44: return "/" // kVK_ANSI_Slash
case 41: return ";" // kVK_ANSI_Semicolon
case 39: return "'" // kVK_ANSI_Quote
case 50: return "`" // kVK_ANSI_Grave
case 42: return "\\" // kVK_ANSI_Backslash
default:
break
}
guard let chars = event.charactersIgnoringModifiers?.lowercased(),
let char = chars.first else {
return nil
}
// Allow letters/numbers; everything else should be handled by keyCode mapping above.
if char.isLetter || char.isNumber {
return String(char)
}
return nil
}
}
/// View for recording a keyboard shortcut
struct KeyboardShortcutRecorder: View {
let label: String
@Binding var shortcut: StoredShortcut
var displayString: (StoredShortcut) -> String = { $0.displayString }
var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 }
@State private var isRecording = false
var body: some View {
HStack {
Text(label)
Spacer()
ShortcutRecorderButton(
shortcut: $shortcut,
isRecording: $isRecording,
displayString: displayString,
transformRecordedShortcut: transformRecordedShortcut
)
.frame(width: 120)
}
}
}
private struct ShortcutRecorderButton: NSViewRepresentable {
@Binding var shortcut: StoredShortcut
@Binding var isRecording: Bool
let displayString: (StoredShortcut) -> String
let transformRecordedShortcut: (StoredShortcut) -> StoredShortcut?
func makeNSView(context: Context) -> ShortcutRecorderNSButton {
let button = ShortcutRecorderNSButton()
button.shortcut = shortcut
button.displayString = displayString
button.transformRecordedShortcut = transformRecordedShortcut
button.onShortcutRecorded = { newShortcut in
shortcut = newShortcut
isRecording = false
}
button.onRecordingChanged = { recording in
isRecording = recording
}
return button
}
func updateNSView(_ nsView: ShortcutRecorderNSButton, context: Context) {
nsView.shortcut = shortcut
nsView.displayString = displayString
nsView.transformRecordedShortcut = transformRecordedShortcut
nsView.updateTitle()
}
}
private class ShortcutRecorderNSButton: NSButton {
var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault
var displayString: (StoredShortcut) -> String = { $0.displayString }
var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 }
var onShortcutRecorded: ((StoredShortcut) -> Void)?
var onRecordingChanged: ((Bool) -> Void)?
private var isRecording = false
private var eventMonitor: Any?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
bezelStyle = .rounded
setButtonType(.momentaryPushIn)
target = self
action = #selector(buttonClicked)
updateTitle()
}
func updateTitle() {
if isRecording {
title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…")
} else {
title = displayString(shortcut)
}
}
@objc private func buttonClicked() {
if isRecording {
stopRecording()
} else {
startRecording()
}
}
private func startRecording() {
isRecording = true
onRecordingChanged?(true)
updateTitle()
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self = self else { return event }
if event.keyCode == 53 { // Escape
self.stopRecording()
return nil
}
if let newShortcut = StoredShortcut.from(event: event) {
guard let transformedShortcut = self.transformRecordedShortcut(newShortcut) else {
NSSound.beep()
return nil
}
self.shortcut = transformedShortcut
self.onShortcutRecorded?(transformedShortcut)
self.stopRecording()
return nil
}
// Consume unsupported keys while recording to avoid triggering app shortcuts.
return nil
}
// Also stop recording if window loses focus
NotificationCenter.default.addObserver(
self,
selector: #selector(windowResigned),
name: NSWindow.didResignKeyNotification,
object: window
)
}
private func stopRecording() {
isRecording = false
onRecordingChanged?(false)
updateTitle()
if let monitor = eventMonitor {
NSEvent.removeMonitor(monitor)
eventMonitor = nil
}
NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: window)
}
@objc private func windowResigned() {
stopRecording()
}
deinit {
stopRecording()
}
}