feat: warn tahoe users of shortcut conflict with macos game-overlay

closes #5018
This commit is contained in:
lwouis committed 2025-11-29 00:03:27 +01:00
1 parent 36ded28ce4
commit 911aad42cc
4 files changed
+114 -42

No files matched your search

+7 -1
View File
@@ -301,6 +301,9 @@
/* No comment provided by engineer. */
"Open Screen Recording Settings…" = "Open Screen Recording Settings…";
/* No comment provided by engineer. */
"Open System Settings to confirm, and continue" = "Open System Settings to confirm, and continue";
/* No comment provided by engineer. */
"Open Trackpad Settings…" = "Open Trackpad Settings…";
@@ -503,7 +506,10 @@
"The application needs to restart to apply the language change." = "The application needs to restart to apply the language change.";
/* No comment provided by engineer. */
"The ⎋ (escape) key is reserved by some macOS shortcuts (e.g. ⌘⌥⎋ will show the Force Quit Applications window).\n\nIf you want to use ⎋, make sure that Hold modifiers are neither: ⌘⌥, ⌘⌥⇧, or ⌘⌥⇧⌃" = "The ⎋ (escape) key is reserved by some macOS shortcuts (e.g. ⌘⌥⎋ will show the Force Quit Applications window).\n\nIf you want to use ⎋, make sure that Hold modifiers are neither: ⌘⌥, ⌘⌥⇧, or ⌘⌥⇧⌃";
"The ⌘⎋ (command+escape) shortcut may be used by macOS for GameOverlay.\n\nYour change would assign it to: %@.\n\nIf you want to continue, make sure that the GameOverlay shortcut is disabled.\nAlso note that macOS has reported bugs where the shortcut may still interfere while disabled." = "The ⌘⎋ (command+escape) shortcut may be used by macOS for GameOverlay.\n\nYour change would assign it to: %@.\n\nIf you want to continue, make sure that the GameOverlay shortcut is disabled.\nAlso note that macOS has reported bugs where the shortcut may still interfere while disabled.";
/* No comment provided by engineer. */
"The ⎋ (escape) key is reserved by some macOS shortcuts (e.g. ⌘⌥⎋ will show the Force Quit Applications window).\n\nYour change would assign it to: %@.\n\nIf you want to use ⎋, make sure that Hold modifiers are neither: ⌘⌥, ⌘⌥⇧, or ⌘⌥⇧⌃" = "The ⎋ (escape) key is reserved by some macOS shortcuts (e.g. ⌘⌥⎋ will show the Force Quit Applications window).\n\nYour change would assign it to: %@.\n\nIf you want to use ⎋, make sure that Hold modifiers are neither: ⌘⌥, ⌘⌥⇧, or ⌘⌥⇧⌃";
/* No comment provided by engineer. */
"Theme" = "Theme";
@@ -37,7 +37,7 @@ class CustomRecorderControl: RecorderControl {
set(allowedModifierFlags: CustomRecorderControl.allowedModifiers.subtracting(restrictedModifiers), requiredModifierFlags: [], allowsEmptyModifierFlags: true)
}
func alertIfSameShortcutAlreadyAssigned(_ candidateId: String, _ candidateShortcut: Shortcut, _ shortcutAlreadyAssigned: String) {
func alertIfSameShortcutAlreadyAssigned(_ candidateShortcut: Shortcut, _ shortcutAlreadyAssigned: String) {
let isArrowKeys = ["←", "→", "↑", "↓"].contains(shortcutAlreadyAssigned)
let isVimKeys = shortcutAlreadyAssigned.starts(with: "vimCycle")
let existingShortcut = ControlsTab.shortcutControls[shortcutAlreadyAssigned]!
@@ -46,16 +46,16 @@ class CustomRecorderControl: RecorderControl {
alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "")
alert.informativeText = String(format: NSLocalizedString("Shortcut already assigned to another action: %@", comment: ""),
(isArrowKeys ? "Arrow keys" : (isVimKeys ? "Vim keys" : existingShortcut.1)).replacingOccurrences(of: " ", with: "\u{00A0}"))
if !candidateId.starts(with: "holdShortcut") {
if !id.starts(with: "holdShortcut") {
alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: "")).setAccessibilityFocused(true)
}
let cancelButton = alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
cancelButton.keyEquivalent = "\u{1b}"
if candidateId.starts(with: "holdShortcut") {
if id.starts(with: "holdShortcut") {
cancelButton.setAccessibilityFocused(true)
}
let userChoice = alert.runModal()
if !candidateId.starts(with: "holdShortcut") && userChoice == .alertFirstButtonReturn {
if !id.starts(with: "holdShortcut") && userChoice == .alertFirstButtonReturn {
if isArrowKeys {
ControlsTab.arrowKeysCheckbox.state = .off
ControlsTab.arrowKeysEnabledCallback(ControlsTab.arrowKeysCheckbox)
@@ -65,46 +65,70 @@ class CustomRecorderControl: RecorderControl {
ControlsTab.vimKeysEnabledCallback(ControlsTab.vimKeysCheckbox)
LabelAndControl.controlWasChanged(ControlsTab.vimKeysCheckbox, nil)
} else {
existingShortcut.0.objectValue = nil
ControlsTab.shortcutChangedCallback(existingShortcut.0)
LabelAndControl.controlWasChanged(existingShortcut.0, shortcutAlreadyAssigned)
updateShortcut(existingShortcut.0, nil, existingShortcut.0, shortcutAlreadyAssigned)
}
ControlsTab.shortcutControls[candidateId]!.0.objectValue = candidateShortcut
ControlsTab.shortcutChangedCallback(self)
LabelAndControl.controlWasChanged(self, candidateId)
updateShortcut(ControlsTab.shortcutControls[id]!.0, candidateShortcut, self, id)
}
}
func alertIfShortcutReservedByMacos(_ candidateId: String, _ candidateShortcut: Shortcut, _ shortcutUsingEscape: String) {
func updateShortcut(_ control: CustomRecorderControl, _ objectValue: Shortcut?, _ senderControl: NSControl, _ id: String) {
control.objectValue = objectValue
LabelAndControl.controlWasChanged(senderControl, id)
ControlsTab.shortcutChangedCallback(senderControl)
}
func alertIfShortcutReservedByMacos(_ candidateShortcut: Shortcut, _ shortcutReservedByMacos: String) {
let existingShortcutLabel = ControlsTab.shortcutControls[shortcutReservedByMacos]!.1
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "")
alert.informativeText = NSLocalizedString("The ⎋ (escape) key is reserved by some macOS shortcuts (e.g. ⌘⌥⎋ will show the Force Quit Applications window).\n\nIf you want to use ⎋, make sure that Hold modifiers are neither: ⌘⌥, ⌘⌥⇧, or ⌘⌥⇧⌃", comment: "")
alert.informativeText = String(format: NSLocalizedString("The ⎋ (escape) key is reserved by some macOS shortcuts (e.g. ⌘⌥⎋ will show the Force Quit Applications window).\n\nYour change would assign it to: %@.\n\nIf you want to use ⎋, make sure that Hold modifiers are neither: ⌘⌥, ⌘⌥⇧, or ⌘⌥⇧⌃", comment: ""), existingShortcutLabel)
alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: ""))
let cancelButton = alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
cancelButton.keyEquivalent = "\u{1b}"
cancelButton.setAccessibilityFocused(true)
let userChoice = alert.runModal()
if userChoice == .alertFirstButtonReturn {
guard candidateId != shortcutUsingEscape else { return }
let existingShortcut = ControlsTab.shortcutControls[shortcutUsingEscape]!
existingShortcut.0.objectValue = nil
ControlsTab.shortcutChangedCallback(existingShortcut.0)
LabelAndControl.controlWasChanged(existingShortcut.0, shortcutUsingEscape)
ControlsTab.shortcutControls[candidateId]!.0.objectValue = candidateShortcut
ControlsTab.shortcutChangedCallback(self)
LabelAndControl.controlWasChanged(self, candidateId)
guard id != shortcutReservedByMacos else { return }
let existingShortcut = ControlsTab.shortcutControls[shortcutReservedByMacos]!
updateShortcut(existingShortcut.0, nil, existingShortcut.0, shortcutReservedByMacos)
updateShortcut(ControlsTab.shortcutControls[id]!.0, candidateShortcut, self, id)
}
}
// @available(macOS 26.0, *)
func alertIfShortcutUsedByGameOverlay(_ candidateShortcut: Shortcut, _ shortcutUsingGameOverlay: String) {
let existingShortcutLabel = ControlsTab.shortcutControls[shortcutUsingGameOverlay]!.1
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "")
alert.informativeText = String(format: NSLocalizedString("The ⌘⎋ (command+escape) shortcut may be used by macOS for GameOverlay.\n\nYour change would assign it to: %@.\n\nIf you want to continue, make sure that the GameOverlay shortcut is disabled.\nAlso note that macOS has reported bugs where the shortcut may still interfere while disabled.", comment: ""), existingShortcutLabel)
alert.addButton(withTitle: NSLocalizedString("Open System Settings to confirm, and continue", comment: ""))
let cancelButton = alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
cancelButton.keyEquivalent = "\u{1b}"
cancelButton.setAccessibilityFocused(true)
let userChoice = alert.runModal()
if userChoice == .alertFirstButtonReturn {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.keyboard?Shortcuts")!)
updateShortcut(ControlsTab.shortcutControls[id]!.0, candidateShortcut, self, id)
}
}
func save(_ candidateShortcut: Shortcut) {
LabelAndControl.controlWasChanged(self, id)
// shortcutChangedCallback is called automatically here
// setting objectValue also happens automatically
}
}
extension CustomRecorderControl: RecorderControlDelegate {
func recorderControl(_ control: RecorderControl, canRecord shortcut: Shortcut) -> Bool {
switch CustomRecorderControlTestable.isShortcutAcceptable(id, shortcut) {
case .accepted: return true
case .accepted: save(shortcut)
case .modifiersOnlyButContainsKeycode: return false
case .conflictWithExistingShortcut(let s): alertIfSameShortcutAlreadyAssigned(id, shortcut, s)
case .reservedByMacos(let s): alertIfShortcutReservedByMacos(id, shortcut, s)
case .conflictWithExistingShortcut(let s): alertIfSameShortcutAlreadyAssigned(shortcut, s)
case .reservedByMacos(let s): alertIfShortcutReservedByMacos(shortcut, s)
case .usedByGameOverlay(let s): alertIfShortcutUsedByGameOverlay(shortcut, s)
}
return true
}
@@ -4,6 +4,10 @@ import ShortcutRecorder
class CustomRecorderControlTestable {
static func isShortcutAcceptable(_ candidateId: String, _ candidateShortcut: Shortcut) -> ShortcutAcceptance {
if let currentShortcutWithSameId = ControlsTab.shortcuts[candidateId]?.shortcut,
candidateShortcut.carbonKeyCode == currentShortcutWithSameId.carbonKeyCode && candidateShortcut.carbonModifierFlags == currentShortcutWithSameId.carbonModifierFlags {
return .accepted
}
if candidateId.starts(with: "holdShortcut") && candidateShortcut.keyCode != .none {
return .modifiersOnlyButContainsKeycode
}
@@ -17,6 +21,9 @@ class CustomRecorderControlTestable {
if let shortcutUsingEscape = isReservedByMacos(newCombos) {
return .reservedByMacos(shortcutUsingEscape: shortcutUsingEscape)
}
if let shortcutUsingGameOverlay = isUsedByGameOverlay(newCombos) {
return .usedByGameOverlay(shortcutUsingGameOverlay: shortcutUsingGameOverlay)
}
return .accepted
}
@@ -42,8 +49,8 @@ class CustomRecorderControlTestable {
}
// candidate is a local shortcut
} else {
// combine with everything except a nextWindowShortcut
if !atShortcut.id.starts(with: "nextWindowShortcut") {
// combine with every holdShortcut
if atShortcut.id.starts(with: "holdShortcut") {
combos.append((candidateId, Shortcut(code: candidateShortcut.keyCode, modifierFlags: [candidateShortcut.modifierFlags, atShortcut.shortcut.modifierFlags], characters: nil, charactersIgnoringModifiers: nil)))
}
}
@@ -73,6 +80,18 @@ class CustomRecorderControlTestable {
return combos
}
static func isUsedByGameOverlay(_ newCombinationsFromCandidate: [(String, Shortcut)] ) -> String? {
if #available(macOS 26.0, *) {
let go = MacOsShortcuts.gameOverlay
for c in newCombinationsFromCandidate {
if c.1.carbonKeyCode == go.carbonKeyCode && c.1.carbonModifierFlags == go.carbonModifierFlags {
return c.0
}
}
}
return nil
}
static func isReservedByMacos(_ newCombinationsFromCandidate: [(String, Shortcut)] ) -> String? {
for r in (ReservedMacosShortcut.allCases.map { $0.basicShortcut }) {
for c in newCombinationsFromCandidate {
@@ -116,12 +135,14 @@ enum ShortcutAcceptance: Equatable {
case modifiersOnlyButContainsKeycode
case conflictWithExistingShortcut(shortcutAlreadyAssigned: String)
case reservedByMacos(shortcutUsingEscape: String)
case usedByGameOverlay(shortcutUsingGameOverlay: String)
static func == (lhs: ShortcutAcceptance, rhs: ShortcutAcceptance) -> Bool {
switch (lhs, rhs) {
case (.accepted, .accepted),
(.modifiersOnlyButContainsKeycode, .modifiersOnlyButContainsKeycode),
(.reservedByMacos, .reservedByMacos),
(.usedByGameOverlay, .usedByGameOverlay),
(.conflictWithExistingShortcut, .conflictWithExistingShortcut):
return true
default:
@@ -129,6 +150,14 @@ enum ShortcutAcceptance: Equatable {
}
}
}
struct MacOsShortcuts {
// Introduced in macOS 26. Can be toggled in System Settings
static let gameOverlay = Shortcut(code: KeyCode.escape, modifierFlags: [.command], characters: nil, charactersIgnoringModifiers: nil)
// Ancient shortcuts. Hard-set by the OS; can't be toggled by the user
static let forceQuitApplicationsDialog = Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option], characters: nil, charactersIgnoringModifiers: nil)
static let forceQuitActiveApp = Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option, .shift], characters: nil, charactersIgnoringModifiers: nil)
static let reservedForUnknownReason = Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option, .shift, .control], characters: nil, charactersIgnoringModifiers: nil)
}
enum ReservedMacosShortcut: CaseIterable {
case forceQuitApplicationsDialog
@@ -137,9 +166,9 @@ enum ReservedMacosShortcut: CaseIterable {
var basicShortcut: Shortcut {
switch self {
case .forceQuitApplicationsDialog: return Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option], characters: nil, charactersIgnoringModifiers: nil)
case .forceQuitActiveApp: return Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option, .shift], characters: nil, charactersIgnoringModifiers: nil)
case .reservedForUnknownReason: return Shortcut(code: KeyCode.escape, modifierFlags: [.command, .option, .shift, .control], characters: nil, charactersIgnoringModifiers: nil)
case .forceQuitApplicationsDialog: return MacOsShortcuts.forceQuitApplicationsDialog
case .forceQuitActiveApp: return MacOsShortcuts.forceQuitActiveApp
case .reservedForUnknownReason: return MacOsShortcuts.reservedForUnknownReason
}
}
}
+26 -13
View File
@@ -2,20 +2,32 @@ import XCTest
import ShortcutRecorder
final class CustomRecorderControlTests: XCTestCase {
func testIsShortcutAcceptable() {
// .accepted
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘")!), .accepted)
func testIsShortcutAcceptable_accepted() {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⇧⇥")!), .accepted)
ControlsTab.shortcuts["holdShortcut"] = ATShortcut(Shortcut(keyEquivalent: "⌘⌥")!, "holdShortcut", .global, .up)
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌥")!), .accepted)
ControlsTab.shortcuts = ControlsTab.defaultShortcuts
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌥")!), .accepted)
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⇧")!), .accepted)
ControlsTab.shortcuts["previousWindowShortcut"] = nil
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⇧")!), .accepted)
ControlsTab.shortcuts = ControlsTab.defaultShortcuts
}
// .modifiersOnlyButContainsKeycode
func testIsShortcutAcceptable_modifiersOnlyButContainsKeycode() {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘⇧")!), .accepted)
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘e")!), .modifiersOnlyButContainsKeycode)
}
// .reservedByMacos
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘")!), .accepted) // ⌘⎋
func testIsShortcutAcceptable_conflictWithExistingShortcut() {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("vimCycleRight", Shortcut(keyEquivalent: "l")!), .accepted)
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇧⇥")!), .accepted)
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("vimCycleLeft", Shortcut(keyEquivalent: "h")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "hideShowAppShortcut"))
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇥")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "nextWindowShortcut"))
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut", Shortcut(keyEquivalent: "⇧")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "previousWindowShortcut"))
}
func testIsShortcutAcceptable_reservedByMacos() {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⌘⇧")!), .accepted) // ⌘⎋
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("previousWindowShortcut", Shortcut(keyEquivalent: "⌘⌃⇧")!), .accepted) // ⌘⎋
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘⌥")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⎋
@@ -25,13 +37,6 @@ final class CustomRecorderControlTests: XCTestCase {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("cancelShortcut", Shortcut(keyEquivalent: "⌘⇧⌃⎋")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⇧⌃⎋
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("cancelShortcut", Shortcut(keyEquivalent: "⌘⎋")!), .reservedByMacos(shortcutUsingEscape: "cancelShortcut")) // ⌘⌥⎋
// .conflictWithExistingShortcut
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("vimCycleRight", Shortcut(keyEquivalent: "l")!), .accepted)
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇧⇥")!), .accepted)
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("vimCycleLeft", Shortcut(keyEquivalent: "h")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "hideShowAppShortcut"))
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut2", Shortcut(keyEquivalent: "⇥")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "nextWindowShortcut"))
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut", Shortcut(keyEquivalent: "⇧")!), .conflictWithExistingShortcut(shortcutAlreadyAssigned: "previousWindowShortcut"))
// alt + shift+tab / alt+shift + tab => pressing tab is ambiguous which one should trigger
ControlsTab.shortcuts["previousWindowShortcut"] = ATShortcut(Shortcut(keyEquivalent: "p")!, "previousWindowShortcut", .local, .down)
ControlsTab.shortcuts["nextWindowShortcut2"] = ATShortcut(Shortcut(keyEquivalent: "⇧⇥")!, "nextWindowShortcut2", .global, .down)
@@ -60,4 +65,12 @@ final class CustomRecorderControlTests: XCTestCase {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("nextWindowShortcut", Shortcut(keyEquivalent: "⇥")!), .accepted)
ControlsTab.shortcuts = ControlsTab.defaultShortcuts
}
func testIsShortcutAcceptable_usedByGameOverlay() {
if #available(macOS 26.0, *) {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘")!), .usedByGameOverlay(shortcutUsingGameOverlay: "cancelShortcut"))
} else {
XCTAssertEqual(CustomRecorderControlTestable.isShortcutAcceptable("holdShortcut", Shortcut(keyEquivalent: "⌘")!), .accepted)
}
}
}