macos: enforce keyboard layout actor isolation

Text Input Sources APIs are not thread-safe, but shortcut translation could
be called outside a declared main-actor context.

Mark keyboard layout and shortcut conversion as main-actor isolated, update
their tests, and dispatch key-sequence UI notifications to the main queue
before translating their shortcuts.
This commit is contained in:
Jon Parise
2026-08-18 12:34:59 -04:00
parent 761696c349
commit 9886f4817c
6 changed files with 26 additions and 24 deletions

View File

@@ -1985,19 +1985,21 @@ extension Ghostty {
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
if v.active {
NotificationCenter.default.post(
name: Notification.didContinueKeySequence,
object: surfaceView,
userInfo: [
Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any
]
)
} else {
NotificationCenter.default.post(
name: Notification.didEndKeySequence,
object: surfaceView
)
DispatchQueue.main.async {
if v.active {
NotificationCenter.default.post(
name: Notification.didContinueKeySequence,
object: surfaceView,
userInfo: [
Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any
]
)
} else {
NotificationCenter.default.post(
name: Notification.didEndKeySequence,
object: surfaceView
)
}
}
default:

View File

@@ -111,7 +111,7 @@ extension Ghostty {
/// configuration would be "quit" action.
///
/// Returns nil if there is no key equivalent for the given action.
func keyboardShortcut(for action: String) -> KeyboardShortcut? {
@MainActor func keyboardShortcut(for action: String) -> KeyboardShortcut? {
guard let trigger = keybindTrigger(for: action) else { return nil }
return Ghostty.keyboardShortcut(for: trigger)
}

View File

@@ -16,7 +16,7 @@ extension Ghostty {
/// (F1, F2, ...) with a KeyboardShortcut. This doesn't represent a practical issue because input
/// handling for Ghostty is handled at a lower level (usually). This function should generally only
/// be used for things like NSMenu that only support keyboard shortcuts anyways.
static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? {
@MainActor static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? {
let modifierFlags = Self.eventModifierFlags(mods: trigger.mods)
let key: KeyEquivalent
switch trigger.tag {

View File

@@ -16,7 +16,7 @@ class KeyboardLayout {
/// Translate a physical keycode for use as a menu key equivalent.
///
/// Must be called on the main thread because Text Input Sources APIs are not thread-safe.
static func character(
@MainActor static func character(
for keyCode: UInt16,
modifiers: NSEvent.ModifierFlags
) -> Character? {

View File

@@ -223,8 +223,8 @@ struct ConfigTests {
// MARK: - Keybind
@Test
func uppercasedLetterShouldBeNormalized() async throws {
@MainActor @Test
func uppercasedLetterShouldBeNormalized() throws {
let config = try TemporaryConfig("""
keybind=cmd+L=goto_split:left
""")
@@ -238,8 +238,8 @@ struct ConfigTests {
#expect(shortcut2 == .init("ä", modifiers: [.command]))
}
@Test
func emptyConfigShouldBeHaveDefaultShortcut() async throws {
@MainActor @Test
func emptyConfigShouldBeHaveDefaultShortcut() throws {
let config = try TemporaryConfig("")
let newWindow = try #require(config.keyboardShortcut(for: "new_window"))
#expect(newWindow == .init("n", modifiers: [.command]))

View File

@@ -26,14 +26,14 @@ struct MenuShortcutManagerTests {
#expect(item.keyEquivalentModifierMask == .command)
}
@Test func physicalBackquoteUsesCurrentKeyboardLayout() async throws {
@MainActor @Test func physicalBackquoteUsesCurrentKeyboardLayout() throws {
let config = try TemporaryConfig("keybind=super+backquote=toggle_quick_terminal")
let expected = try #require(KeyboardLayout.character(for: 0x32, modifiers: .command))
let item = NSMenuItem(title: "Quick Terminal", action: nil, keyEquivalent: "")
let manager = await Ghostty.MenuShortcutManager()
let manager = Ghostty.MenuShortcutManager()
await manager.reset()
await manager.syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: item)
manager.reset()
manager.syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: item)
#expect(item.keyEquivalent == String(expected))
#expect(item.keyEquivalentModifierMask == .command)