From 8f7a663054c274eb974130d940be1848c793f4a9 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 28 Aug 2026 18:48:54 -0700 Subject: [PATCH] input(macOS): preserve Caps Lock key releases macOS treats Caps Lock as a locking modifier and never delivers its physical release, so once "Caps Lock is treated as a key" is enabled a guest that uses it as a held modifier (e.g. a screen reader's NVDA key) never sees it let go. While VM input is captured, remap Caps Lock to F20 on each attached keyboard through IOHIDEventSystemClient (Apple TN2450) so macOS delivers a normal key down/up pair, which VMMetalView translates back to the Caps Lock scan code. The keyboard's previous mapping is put back when capture ends, and a remap left behind by a crash is removed at the next launch. F20 is the highest function key macOS delivers and is on no Apple keyboard, so no physical key is shadowed. Fixes #7838 Assisted-by: Codex:gpt-5 Assisted-by: Claude:claude-fable-5-1 --- Platform/macOS/AppDelegate.swift | 3 + Platform/macOS/Display/CapsLockRemapper.swift | 108 ++++++++++++++++++ Platform/macOS/Display/VMMetalView.swift | 12 ++ UTM.xcodeproj/project.pbxproj | 4 + 4 files changed, 127 insertions(+) create mode 100644 Platform/macOS/Display/CapsLockRemapper.swift diff --git a/Platform/macOS/AppDelegate.swift b/Platform/macOS/AppDelegate.swift index 140d56a7dc..963b28ba28 100644 --- a/Platform/macOS/AppDelegate.swift +++ b/Platform/macOS/AppDelegate.swift @@ -152,6 +152,8 @@ func applicationWillTerminate(_ notification: Notification) { /// Synchronize registry UTMRegistry.shared.sync() + /// Give the host its Caps Lock back if a VM still has input captured + CapsLockRemapper.shared.restore() /// Clean up caches let fileManager = FileManager.default guard let cacheUrl = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else { @@ -169,6 +171,7 @@ } func applicationDidFinishLaunching(_ notification: Notification) { + CapsLockRemapper.shared.recoverAtLaunch() if isDockIconHidden { NSApp.setActivationPolicy(.accessory) } diff --git a/Platform/macOS/Display/CapsLockRemapper.swift b/Platform/macOS/Display/CapsLockRemapper.swift new file mode 100644 index 0000000000..be3f74daf5 --- /dev/null +++ b/Platform/macOS/Display/CapsLockRemapper.swift @@ -0,0 +1,108 @@ +// +// Copyright © 2026 UTM contributors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Carbon.HIToolbox +import IOKit.hid +import IOKit.hidsystem + +/// Temporarily turns Caps Lock into an ordinary key while VM input is captured. +/// +/// macOS treats Caps Lock as a locking modifier and never delivers its physical release, so a +/// guest that uses it as a held modifier (e.g. a screen reader) never sees it let go. Remapping +/// it to F20 at the HID layer (Apple TN2450) makes macOS deliver a normal down/up pair, which +/// `VMMetalView` translates back to the Caps Lock scan code. F20 is the highest function key +/// macOS delivers and is not on any Apple keyboard, so no real key is shadowed. The remap +/// cannot outlive a reboot or the keyboard being unplugged. +@MainActor +final class CapsLockRemapper { + static let shared = CapsLockRemapper() + + /// Key code macOS delivers for the remapped Caps Lock. + static let hostKeyCode = kVK_F20 + + private static let capsLockUsage = usage(kHIDUsage_KeyboardCapsLock) + private static let f20Usage = usage(kHIDUsage_KeyboardF20) + private static let remap: [String: UInt64] = [kIOHIDKeyboardModifierMappingSrcKey: capsLockUsage, + kIOHIDKeyboardModifierMappingDstKey: f20Usage] + + private lazy var client = IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault) + /// Each remapped keyboard with the mapping it had before. + private var originals: [(keyboard: IOHIDServiceClient, mapping: [[String: Any]])] = [] + + var isActive: Bool { + !originals.isEmpty + } + + private init() { + } + + /// Remaps Caps Lock on every attached keyboard, or does nothing if already remapped. + func apply() { + guard !isActive else { + return + } + for keyboard in keyboards { + let original = IOHIDServiceClientCopyProperty(keyboard, kIOHIDUserKeyUsageMapKey as CFString) as? [[String: Any]] ?? [] + let remapped = original.filter { !Self.isCapsLock($0) } + [Self.remap] + guard IOHIDServiceClientSetProperty(keyboard, kIOHIDUserKeyUsageMapKey as CFString, remapped as CFArray) else { + continue + } + originals.append((keyboard, original)) + } + logger.debug("remapped Caps Lock on \(originals.count) keyboard(s)") + } + + /// Puts back each keyboard's previous mapping, or does nothing if not remapped. + func restore() { + for (keyboard, mapping) in originals { + IOHIDServiceClientSetProperty(keyboard, kIOHIDUserKeyUsageMapKey as CFString, mapping as CFArray) + } + originals = [] + } + + /// If UTM died while remapped, strips the remap so the host's Caps Lock works again. + /// The previous mapping died with the process, so only the Caps Lock → F20 entry is + /// removed; a user's own identical mapping cannot be told apart and is removed too. + func recoverAtLaunch() { + for keyboard in keyboards { + guard let current = IOHIDServiceClientCopyProperty(keyboard, kIOHIDUserKeyUsageMapKey as CFString) as? [[String: Any]], + current.contains(where: Self.isRemap) else { + continue + } + IOHIDServiceClientSetProperty(keyboard, kIOHIDUserKeyUsageMapKey as CFString, current.filter { !Self.isRemap($0) } as CFArray) + logger.debug("removed stale Caps Lock remap") + } + } + + /// Packs a keyboard usage the way `UserKeyMapping` expects it: page in the high word, usage in the low. + private static func usage(_ usage: Int) -> UInt64 { + UInt64(kHIDPage_KeyboardOrKeypad) << 32 | UInt64(usage) + } + + private var keyboards: [IOHIDServiceClient] { + let services = IOHIDEventSystemClientCopyServices(client) as? [IOHIDServiceClient] ?? [] + return services.filter { IOHIDServiceClientConformsTo($0, UInt32(kHIDPage_GenericDesktop), UInt32(kHIDUsage_GD_Keyboard)) != 0 } + } + + private static func isCapsLock(_ entry: [String: Any]) -> Bool { + entry[kIOHIDKeyboardModifierMappingSrcKey] as? UInt64 == capsLockUsage + } + + private static func isRemap(_ entry: [String: Any]) -> Bool { + isCapsLock(entry) && entry[kIOHIDKeyboardModifierMappingDstKey] as? UInt64 == f20Usage + } +} diff --git a/Platform/macOS/Display/VMMetalView.swift b/Platform/macOS/Display/VMMetalView.swift index 74c55d22c6..da6f99cad5 100644 --- a/Platform/macOS/Display/VMMetalView.swift +++ b/Platform/macOS/Display/VMMetalView.swift @@ -27,6 +27,7 @@ class VMMetalView: MTKView { @Setting("HandleInitialClick") private var isHandleInitialClick: Bool = false @Setting("IsCtrlCmdSwapped") private var isCtrlCmdSwapped = false @Setting("IsISOKeySwapped") private var isISOKeySwapped = false + @Setting("IsCapsLockKey") private var isCapsLockKey: Bool = false /// On ISO keyboards we have to switch `kVK_ISO_Section` and `kVK_ANSI_Grave` /// from: https://chromium.googlesource.com/chromium/src/+/lkgr/ui/events/keycodes/keyboard_code_conversion_mac.mm @@ -48,6 +49,9 @@ class VMMetalView: MTKView { private func getScanCodeForEvent(_ event: NSEvent) -> Int { if event.type == .keyDown || event.type == .keyUp { let keycode = convertToCurrentLayout(for: Int(event.keyCode)) + if keycode == CapsLockRemapper.hostKeyCode && CapsLockRemapper.shared.isActive { + return Int(KeyCodeMap.keyCodeToScanCodes[kVK_CapsLock]!.down) + } /// see KeyCodeMap file for explaination why the .down scan code is used for both key down and up return Int(KeyCodeMap.keyCodeToScanCodes[keycode]?.down ?? 0) } else { @@ -310,6 +314,9 @@ extension VMMetalView { isMouseCaptured = true NSCursor.tryHide() CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), .disable) + if isCapsLockKey { + CapsLockRemapper.shared.apply() + } } func releaseMouse() { @@ -320,6 +327,11 @@ extension VMMetalView { NSCursor.tryUnhide() } CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), .enable) + if CapsLockRemapper.shared.isActive { + // the physical release can arrive after the remap is gone, so never leave the guest holding Caps Lock + inputDelegate?.keyUp(scanCode: Int(KeyCodeMap.keyCodeToScanCodes[kVK_CapsLock]!.down)) + CapsLockRemapper.shared.restore() + } } } diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 9b93eff2bb..356efbded2 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 83A004BA26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A004B826A8CC95001AC09E /* UTMDownloadTask.swift */; }; 83A004BB26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A004B826A8CC95001AC09E /* UTMDownloadTask.swift */; }; 83C15C5F26CC441500ADFD45 /* KeyCodeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C15C5E26CC441000ADFD45 /* KeyCodeMap.swift */; }; + A1C4F00230C1200000CA9501 /* CapsLockRemapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C4F00130C1200000CA9501 /* CapsLockRemapper.swift */; }; 8401865A2887AFD50050AC51 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 840186592887AFD50050AC51 /* SwiftTerm */; }; 8401865C2887AFDC0050AC51 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 8401865B2887AFDC0050AC51 /* SwiftTerm */; }; 8401865E2887B1620050AC51 /* VMDisplayTerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401865D2887B1620050AC51 /* VMDisplayTerminalViewController.swift */; }; @@ -1716,6 +1717,7 @@ 836CA97E28FCC39700EB9EF0 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; 83A004B826A8CC95001AC09E /* UTMDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadTask.swift; sourceTree = ""; }; 83C15C5E26CC441000ADFD45 /* KeyCodeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeMap.swift; sourceTree = ""; }; + A1C4F00130C1200000CA9501 /* CapsLockRemapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsLockRemapper.swift; sourceTree = ""; }; 83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "VMDisplayMetalViewController+Pointer.h"; sourceTree = ""; }; 83FBDD55242FA7BC00D2C5D7 /* VMDisplayMetalViewController+Pointer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "VMDisplayMetalViewController+Pointer.m"; sourceTree = ""; }; 83FE63B628F617CD0047FFEF /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/VMDisplayMetalViewInputAccessory.strings; sourceTree = ""; }; @@ -2628,6 +2630,7 @@ CE1BD9FA24F4825C0022A468 /* Display */ = { isa = PBXGroup; children = ( + A1C4F00130C1200000CA9501 /* CapsLockRemapper.swift */, CE061CDD289E6DC30000351C /* VMDisplayWindow.xib */, CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */, 84F746B8276FF40900A20C87 /* VMDisplayAppleWindowController.swift */, @@ -3939,6 +3942,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1C4F00230C1200000CA9501 /* CapsLockRemapper.swift in Sources */, CEE06B272B2FC89400A811AE /* UTMServerView.swift in Sources */, CEB63A7724F4654400CAF323 /* Main.swift in Sources */, 84E3A91B2946D2590024A740 /* UTMMenuBarExtraScene.swift in Sources */,