diff --git a/resources/l10n/Localizable.strings b/resources/l10n/Localizable.strings index 267e4afe..01d7e9b7 100644 --- a/resources/l10n/Localizable.strings +++ b/resources/l10n/Localizable.strings @@ -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"; diff --git a/src/ui/generic-components/CustomRecorderControl.swift b/src/ui/generic-components/CustomRecorderControl.swift index a11fc35f..3e0fd7b0 100644 --- a/src/ui/generic-components/CustomRecorderControl.swift +++ b/src/ui/generic-components/CustomRecorderControl.swift @@ -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 } diff --git a/src/ui/generic-components/CustomRecorderControlTestable.swift b/src/ui/generic-components/CustomRecorderControlTestable.swift index d279d014..b5ed889f 100644 --- a/src/ui/generic-components/CustomRecorderControlTestable.swift +++ b/src/ui/generic-components/CustomRecorderControlTestable.swift @@ -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 } } } diff --git a/unit-tests/CustomRecorderControlTests.swift b/unit-tests/CustomRecorderControlTests.swift index 16233508..43f318cc 100644 --- a/unit-tests/CustomRecorderControlTests.swift +++ b/unit-tests/CustomRecorderControlTests.swift @@ -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) + } + } }