Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Platform/macOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -169,6 +171,7 @@
}

func applicationDidFinishLaunching(_ notification: Notification) {
CapsLockRemapper.shared.recoverAtLaunch()
if isDockIconHidden {
NSApp.setActivationPolicy(.accessory)
}
Expand Down
108 changes: 108 additions & 0 deletions Platform/macOS/Display/CapsLockRemapper.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 12 additions & 0 deletions Platform/macOS/Display/VMMetalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -310,6 +314,9 @@ extension VMMetalView {
isMouseCaptured = true
NSCursor.tryHide()
CGSSetGlobalHotKeyOperatingMode(CGSMainConnectionID(), .disable)
if isCapsLockKey {
CapsLockRemapper.shared.apply()
}
}

func releaseMouse() {
Expand All @@ -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()
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions UTM.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1716,6 +1717,7 @@
836CA97E28FCC39700EB9EF0 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
83A004B826A8CC95001AC09E /* UTMDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadTask.swift; sourceTree = "<group>"; };
83C15C5E26CC441000ADFD45 /* KeyCodeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeMap.swift; sourceTree = "<group>"; };
A1C4F00130C1200000CA9501 /* CapsLockRemapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsLockRemapper.swift; sourceTree = "<group>"; };
83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "VMDisplayMetalViewController+Pointer.h"; sourceTree = "<group>"; };
83FBDD55242FA7BC00D2C5D7 /* VMDisplayMetalViewController+Pointer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "VMDisplayMetalViewController+Pointer.m"; sourceTree = "<group>"; };
83FE63B628F617CD0047FFEF /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/VMDisplayMetalViewInputAccessory.strings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2628,6 +2630,7 @@
CE1BD9FA24F4825C0022A468 /* Display */ = {
isa = PBXGroup;
children = (
A1C4F00130C1200000CA9501 /* CapsLockRemapper.swift */,
CE061CDD289E6DC30000351C /* VMDisplayWindow.xib */,
CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */,
84F746B8276FF40900A20C87 /* VMDisplayAppleWindowController.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
Loading