diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 97a7193..e2b6b0d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -43,7 +43,9 @@ jobs: run: swift run oigo-issue10-contract-tests - name: Issue eleven performance and lifecycle contract harness run: swift run oigo-issue11-performance-check - - name: Issue seventy-eight transcription lifecycle contract harness - run: swift run oigo-issue78-contract-tests - name: Issue seventy-six input selection contract harness run: swift run oigo-issue76-contract-tests + - name: Issue seventy-eight transcription lifecycle contract harness + run: swift run oigo-issue78-contract-tests + - name: Issue eighty-two global hotkey contract harness + run: swift run oigo-issue82-contract-tests diff --git a/Oigo.xcodeproj/project.pbxproj b/Oigo.xcodeproj/project.pbxproj index 542ac4e..10a9436 100644 --- a/Oigo.xcodeproj/project.pbxproj +++ b/Oigo.xcodeproj/project.pbxproj @@ -49,6 +49,20 @@ A00000010000000000000028 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000025 /* OnboardingWindowController.swift */; }; A00000010000000000000029 /* ProductContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000026 /* ProductContracts.swift */; }; A00000010000000000000030 /* PerformanceContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000027 /* PerformanceContracts.swift */; }; + A00000010000000000000031 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B00000010000000000000008 /* AppKit.framework */; }; + A00000010000000000000032 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B00000010000000000000009 /* Carbon.framework */; }; + A00000010000000000000033 /* libOigoCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B0000001000000000000000B /* libOigoCore.a */; }; + A00000010000000000000034 /* libOigoHotKey.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B00000010000000000000028 /* libOigoHotKey.a */; }; + A00000010000000000000035 /* GlobalShortcutRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000029 /* GlobalShortcutRegistration.swift */; }; + A00000010000000000000036 /* GlobalShortcutIntentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000030 /* GlobalShortcutIntentController.swift */; }; + A00000010000000000000037 /* ShortcutPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000031 /* ShortcutPresentation.swift */; }; + A00000010000000000000038 /* ShortcutFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000032 /* ShortcutFormatter.swift */; }; + A00000010000000000000039 /* ShortcutRecorderControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000033 /* ShortcutRecorderControl.swift */; }; + A00000010000000000000040 /* GlobalShortcutOperationBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000034 /* GlobalShortcutOperationBridge.swift */; }; + A00000010000000000000041 /* ShortcutConfigurationTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000035 /* ShortcutConfigurationTransaction.swift */; }; + A00000010000000000000042 /* AudioRecordingOperationFence.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000036 /* AudioRecordingOperationFence.swift */; }; + A00000010000000000000043 /* OperationTaskRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000037 /* OperationTaskRegistry.swift */; }; + A00000010000000000000044 /* TranscriptionOperationLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000010000000000000038 /* TranscriptionOperationLifecycle.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -94,6 +108,20 @@ remoteGlobalIDString = E00000010000000000000005; remoteInfo = OigoInsertion; }; + K00000010000000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F00000010000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E00000010000000000000006; + remoteInfo = OigoHotKey; + }; + K00000010000000000000008 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F00000010000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E00000010000000000000002; + remoteInfo = OigoCore; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -136,6 +164,17 @@ B00000010000000000000025 /* OnboardingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindowController.swift; sourceTree = ""; }; B00000010000000000000026 /* ProductContracts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductContracts.swift; sourceTree = ""; }; B00000010000000000000027 /* PerformanceContracts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceContracts.swift; sourceTree = ""; }; + B00000010000000000000028 /* libOigoHotKey.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOigoHotKey.a; sourceTree = BUILT_PRODUCTS_DIR; }; + B00000010000000000000029 /* GlobalShortcutRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalShortcutRegistration.swift; sourceTree = ""; }; + B00000010000000000000030 /* GlobalShortcutIntentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalShortcutIntentController.swift; sourceTree = ""; }; + B00000010000000000000031 /* ShortcutPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutPresentation.swift; sourceTree = ""; }; + B00000010000000000000032 /* ShortcutFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutFormatter.swift; sourceTree = ""; }; + B00000010000000000000033 /* ShortcutRecorderControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorderControl.swift; sourceTree = ""; }; + B00000010000000000000034 /* GlobalShortcutOperationBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalShortcutOperationBridge.swift; sourceTree = ""; }; + B00000010000000000000035 /* ShortcutConfigurationTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutConfigurationTransaction.swift; sourceTree = ""; }; + B00000010000000000000036 /* AudioRecordingOperationFence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordingOperationFence.swift; sourceTree = ""; }; + B00000010000000000000037 /* OperationTaskRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationTaskRegistry.swift; sourceTree = ""; }; + B00000010000000000000038 /* TranscriptionOperationLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionOperationLifecycle.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -151,6 +190,7 @@ A00000010000000000000011 /* libOigoCapture.a in Frameworks */, A00000010000000000000013 /* libOigoTranscription.a in Frameworks */, A00000010000000000000021 /* libOigoInsertion.a in Frameworks */, + A00000010000000000000034 /* libOigoHotKey.a in Frameworks */, A00000010000000000000026 /* ServiceManagement.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -196,6 +236,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D00000010000000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000010000000000000031 /* AppKit.framework in Frameworks */, + A00000010000000000000032 /* Carbon.framework in Frameworks */, + A00000010000000000000033 /* libOigoCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -207,6 +257,7 @@ C00000010000000000000006 /* OigoCapture */, C00000010000000000000007 /* OigoTranscription */, C00000010000000000000008 /* OigoInsertion */, + C00000010000000000000009 /* OigoHotKey */, B0000001000000000000000C /* Info.plist */, C00000010000000000000004 /* Frameworks */, C00000010000000000000005 /* Products */, @@ -219,7 +270,6 @@ B00000010000000000000001 /* main.swift */, B00000010000000000000002 /* OigoAppDelegate.swift */, B00000010000000000000003 /* StatusSurfaceController.swift */, - B00000010000000000000004 /* GlobalHotKeyRegistrar.swift */, B00000010000000000000005 /* SettingsWindowController.swift */, B00000010000000000000020 /* HistoryWindowController.swift */, B00000010000000000000024 /* LaunchAtLoginService.swift */, @@ -228,6 +278,18 @@ path = Sources/Oigo; sourceTree = ""; }; + C00000010000000000000009 /* OigoHotKey */ = { + isa = PBXGroup; + children = ( + B00000010000000000000004 /* GlobalHotKeyRegistrar.swift */, + B00000010000000000000029 /* GlobalShortcutRegistration.swift */, + B00000010000000000000032 /* ShortcutFormatter.swift */, + B00000010000000000000033 /* ShortcutRecorderControl.swift */, + B00000010000000000000035 /* ShortcutConfigurationTransaction.swift */, + ); + path = Sources/OigoHotKey; + sourceTree = ""; + }; C00000010000000000000003 /* OigoCore */ = { isa = PBXGroup; children = ( @@ -237,6 +299,11 @@ B00000010000000000000016 /* TranscriptionContracts.swift */, B00000010000000000000026 /* ProductContracts.swift */, B00000010000000000000027 /* PerformanceContracts.swift */, + B00000010000000000000030 /* GlobalShortcutIntentController.swift */, + B00000010000000000000031 /* ShortcutPresentation.swift */, + B00000010000000000000034 /* GlobalShortcutOperationBridge.swift */, + B00000010000000000000037 /* OperationTaskRegistry.swift */, + B00000010000000000000038 /* TranscriptionOperationLifecycle.swift */, ); path = Sources/OigoCore; sourceTree = ""; @@ -245,6 +312,7 @@ isa = PBXGroup; children = ( B0000001000000000000000F /* AudioRecorder.swift */, + B00000010000000000000036 /* AudioRecordingOperationFence.swift */, B00000010000000000000010 /* AudioPlayback.swift */, ); path = Sources/OigoCapture; @@ -293,6 +361,7 @@ B0000001000000000000000E /* libOigoCapture.a */, B00000010000000000000013 /* libOigoTranscription.a */, B0000001000000000000001B /* libOigoInsertion.a */, + B00000010000000000000028 /* libOigoHotKey.a */, ); name = Products; sourceTree = ""; @@ -315,6 +384,7 @@ J00000010000000000000002 /* PBXTargetDependency */, J00000010000000000000004 /* PBXTargetDependency */, J00000010000000000000007 /* PBXTargetDependency */, + J00000010000000000000008 /* PBXTargetDependency */, ); name = Oigo; productName = Oigo; @@ -388,6 +458,23 @@ productReference = B0000001000000000000001B /* libOigoInsertion.a */; productType = "com.apple.product-type.library.static"; }; + E00000010000000000000006 /* OigoHotKey */ = { + isa = PBXNativeTarget; + buildConfigurationList = O00000010000000000000011 /* Build configuration list for PBXNativeTarget "OigoHotKey" */; + buildPhases = ( + D00000010000000000000013 /* Sources */, + D00000010000000000000012 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + J00000010000000000000009 /* PBXTargetDependency */, + ); + name = OigoHotKey; + productName = OigoHotKey; + productReference = B00000010000000000000028 /* libOigoHotKey.a */; + productType = "com.apple.product-type.library.static"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -412,6 +499,9 @@ E00000010000000000000005 = { CreatedOnToolsVersion = 16.0; }; + E00000010000000000000006 = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = G00000010000000000000011 /* Build configuration list for PBXProject "Oigo" */; @@ -432,6 +522,7 @@ E00000010000000000000003 /* OigoCapture */, E00000010000000000000004 /* OigoTranscription */, E00000010000000000000005 /* OigoInsertion */, + E00000010000000000000006 /* OigoHotKey */, ); }; /* End PBXProject section */ @@ -457,7 +548,6 @@ A00000010000000000000001 /* main.swift in Sources */, A00000010000000000000002 /* OigoAppDelegate.swift in Sources */, A00000010000000000000003 /* StatusSurfaceController.swift in Sources */, - A00000010000000000000004 /* GlobalHotKeyRegistrar.swift in Sources */, A00000010000000000000005 /* SettingsWindowController.swift in Sources */, A00000010000000000000023 /* HistoryWindowController.swift in Sources */, A00000010000000000000027 /* LaunchAtLoginService.swift in Sources */, @@ -465,6 +555,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D00000010000000000000013 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000010000000000000004 /* GlobalHotKeyRegistrar.swift in Sources */, + A00000010000000000000035 /* GlobalShortcutRegistration.swift in Sources */, + A00000010000000000000038 /* ShortcutFormatter.swift in Sources */, + A00000010000000000000039 /* ShortcutRecorderControl.swift in Sources */, + A00000010000000000000041 /* ShortcutConfigurationTransaction.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D00000010000000000000003 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -475,6 +577,11 @@ A00000010000000000000016 /* TranscriptionContracts.swift in Sources */, A00000010000000000000029 /* ProductContracts.swift in Sources */, A00000010000000000000030 /* PerformanceContracts.swift in Sources */, + A00000010000000000000036 /* GlobalShortcutIntentController.swift in Sources */, + A00000010000000000000037 /* ShortcutPresentation.swift in Sources */, + A00000010000000000000040 /* GlobalShortcutOperationBridge.swift in Sources */, + A00000010000000000000043 /* OperationTaskRegistry.swift in Sources */, + A00000010000000000000044 /* TranscriptionOperationLifecycle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -483,6 +590,7 @@ buildActionMask = 2147483647; files = ( A0000001000000000000000C /* AudioRecorder.swift in Sources */, + A00000010000000000000042 /* AudioRecordingOperationFence.swift in Sources */, A0000001000000000000000D /* AudioPlayback.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -547,6 +655,16 @@ target = E00000010000000000000005 /* OigoInsertion */; targetProxy = K00000010000000000000006 /* PBXContainerItemProxy */; }; + J00000010000000000000008 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E00000010000000000000006 /* OigoHotKey */; + targetProxy = K00000010000000000000007 /* PBXContainerItemProxy */; + }; + J00000010000000000000009 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E00000010000000000000002 /* OigoCore */; + targetProxy = K00000010000000000000008 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -686,6 +804,28 @@ }; name = Release; }; + O00000010000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_NAME = OigoHotKey; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Debug; + }; + O00000010000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_NAME = OigoHotKey; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -743,6 +883,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + O00000010000000000000011 /* Build configuration list for PBXNativeTarget "OigoHotKey" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + O00000010000000000000001 /* Debug */, + O00000010000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = F00000010000000000000001 /* Project object */; diff --git a/Package.swift b/Package.swift index 7141dee..542d464 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( .library(name: "OigoCapture", targets: ["OigoCapture"]), .library(name: "OigoTranscription", targets: ["OigoTranscription"]), .library(name: "OigoInsertion", targets: ["OigoInsertion"]), + .library(name: "OigoHotKey", targets: ["OigoHotKey"]), .library(name: "OigoSpike", targets: ["OigoSpike"]), .executable(name: "Oigo", targets: ["Oigo"]), .executable(name: "oigo-spike", targets: ["OigoSpikeCLI"]), @@ -59,6 +60,10 @@ let package = Package( .executable( name: "oigo-issue78-contract-tests", targets: ["OigoIssue78ContractTests"] + ), + .executable( + name: "oigo-issue82-contract-tests", + targets: ["OigoIssue82ContractTests"] ) ], targets: [ @@ -93,6 +98,15 @@ let package = Package( .linkedFramework("CoreGraphics") ] ), + .target( + name: "OigoHotKey", + dependencies: ["OigoCore"], + path: "Sources/OigoHotKey", + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("Carbon") + ] + ), .target( name: "OigoSpike", linkerSettings: [ @@ -113,7 +127,7 @@ let package = Package( ), .executableTarget( name: "Oigo", - dependencies: ["OigoCore", "OigoCapture", "OigoTranscription", "OigoInsertion"], + dependencies: ["OigoCore", "OigoCapture", "OigoTranscription", "OigoInsertion", "OigoHotKey"], path: "Sources/Oigo", linkerSettings: [ .linkedFramework("AppKit"), @@ -175,6 +189,11 @@ let package = Package( name: "OigoIssue78ContractTests", dependencies: ["OigoCore", "OigoTranscription"], path: "Tests/OigoIssue78ContractTests" + ), + .executableTarget( + name: "OigoIssue82ContractTests", + dependencies: ["OigoCore", "OigoHotKey"], + path: "Tests/OigoIssue82ContractTests" ) ] ) diff --git a/Sources/Oigo/GlobalHotKeyRegistrar.swift b/Sources/Oigo/GlobalHotKeyRegistrar.swift deleted file mode 100644 index c21b815..0000000 --- a/Sources/Oigo/GlobalHotKeyRegistrar.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Carbon.HIToolbox -import OigoCore - -enum GlobalShortcutRegistrationError: Error, CustomStringConvertible { - case installHandler(OSStatus) - case registerHotKey(OSStatus) - - var description: String { - switch self { - case .installHandler(let status): - return "could not install global shortcut handler: \(status)" - case .registerHotKey(let status): - return "could not register global shortcut: \(status)" - } - } -} - -@MainActor -final class CarbonGlobalShortcutRegistrar { - private var hotKey: EventHotKeyRef? - private var eventHandler: EventHandlerRef? - private var action: (@MainActor () -> Void)? - private var hotKeyID = EventHotKeyID(signature: 0x4F49474F, id: 1) - - func register( - shortcut: ToggleShortcut, - action: @escaping @MainActor () -> Void - ) throws { - unregister() - self.action = action - - var eventType = EventTypeSpec( - eventClass: UInt32(kEventClassKeyboard), - eventKind: UInt32(kEventHotKeyPressed) - ) - let userData = Unmanaged.passUnretained(self).toOpaque() - let handlerStatus = InstallEventHandler( - GetApplicationEventTarget(), - { _, event, userData in - guard let userData else { - return noErr - } - let registrar = Unmanaged - .fromOpaque(userData) - .takeUnretainedValue() - return registrar.handle(event) - }, - 1, - &eventType, - userData, - &eventHandler - ) - guard handlerStatus == noErr else { - throw GlobalShortcutRegistrationError.installHandler(handlerStatus) - } - - let hotKeyStatus = RegisterEventHotKey( - shortcut.keyCode, - shortcut.modifiers, - hotKeyID, - GetApplicationEventTarget(), - 0, - &hotKey - ) - guard hotKeyStatus == noErr else { - unregister() - throw GlobalShortcutRegistrationError.registerHotKey(hotKeyStatus) - } - } - - func unregister() { - if let hotKey { - UnregisterEventHotKey(hotKey) - self.hotKey = nil - } - if let eventHandler { - RemoveEventHandler(eventHandler) - self.eventHandler = nil - } - action = nil - } - - private func handle(_ event: EventRef?) -> OSStatus { - guard let event else { - return noErr - } - - var pressedID = EventHotKeyID() - let status = GetEventParameter( - event, - EventParamName(kEventParamDirectObject), - EventParamType(typeEventHotKeyID), - nil, - MemoryLayout.size, - nil, - &pressedID - ) - guard status == noErr, - pressedID.signature == hotKeyID.signature, - pressedID.id == hotKeyID.id else { - return noErr - } - - Task { @MainActor [action] in - action?() - } - return noErr - } -} diff --git a/Sources/Oigo/OigoAppDelegate.swift b/Sources/Oigo/OigoAppDelegate.swift index c83d8ee..c8d0c44 100644 --- a/Sources/Oigo/OigoAppDelegate.swift +++ b/Sources/Oigo/OigoAppDelegate.swift @@ -6,6 +6,7 @@ import OigoCore import OigoCapture import OigoTranscription import OigoInsertion +import OigoHotKey @available(macOS 26.0, *) @MainActor @@ -19,6 +20,17 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { private let insertion = InsertionService() private let playback = AudioPlayback() private let shortcutRegistrar = CarbonGlobalShortcutRegistrar() + private lazy var shortcutBridge = GlobalShortcutOperationBridge( + state: { [weak self] in self?.coordinator.state ?? .failed }, + start: { [weak self] in self?.startKeyboardDictation() }, + stop: { [weak self] in self?.requestKeyboardStop() }, + feedback: { [weak self] result in self?.showShortcutFeedback(result) } + ) + private lazy var shortcutConfiguration = ShortcutConfigurationTransaction( + committedShortcut: settings.globalShortcut, + registrar: shortcutRegistrar, + onEvent: { [weak self] event in self?.handleGlobalShortcut(event) } + ) private let statusSurface = StatusSurfaceController() private let settingsStore = OigoSettingsStore() private let onboardingStore = OigoOnboardingStore() @@ -40,6 +52,7 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { private var historyWindow: HistoryWindowController? private var statusItem: NSStatusItem? private var toggleItem: NSMenuItem? + private var shortcutStatusItem: NSMenuItem? private var modeMenuItem: NSMenuItem? private var instantModeItem: NSMenuItem? private var cleanModeItem: NSMenuItem? @@ -52,6 +65,8 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { private var recordingStartedAt: Date? private var previewThrottle = OigoHUDPreviewThrottle() private var toggleTask: Task? + private var finishRequestedAfterStart = false + private var shortcutFeedbackDetail: String? private var cleanAgainTask: Task? private var retryTask: Task? private var workspaceInterruptionTask: Task? @@ -83,6 +98,7 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { _ = sender shortcutRegistrar.unregister() + shortcutBridge.reset() deviceInventoryMonitor.stop() removeWorkspaceInterruptionObservers() statusSurface.hide() @@ -183,7 +199,7 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { } @objc private func toggleDictation() { - handleToggle() + handleMouseToggle() } @objc private func openSettings() { @@ -206,6 +222,12 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { supportedLocales: supportedLocales, microphoneState: microphonePermissionState(), accessibilityState: accessibilityPermissionState(), + registrationStatus: { [weak self] in + self?.shortcutRegistrar.status ?? .inactive("Global shortcut is not registered") + }, + registrationError: { [weak self] in + self?.shortcutConfiguration.lastError ?? self?.shortcutRegistrar.lastError + }, save: { [weak self] settings in self?.applySettings(settings) ?? "Oigo is no longer available." }, @@ -276,18 +298,28 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { }, saveLanguage: { [weak self] identifier in guard let self else { return } - self.settings = self.settings.with(localeIdentifier: identifier) - self.settingsStore.save(self.settings) - self.transcription = nil + let updatedSettings = self.settings.with(localeIdentifier: identifier) + do { + try self.settingsStore.save(updatedSettings) + self.settings = updatedSettings + self.transcription = nil + } catch { + self.showSettingsPersistenceFailure(error) + } }, saveStep: { [weak self] step in self?.onboardingStore.save(OigoOnboardingState(step: step)) }, saveInputSelection: { [weak self] selection in guard let self else { return } - self.settings = self.settings.with(selectedInput: selection) - self.settingsStore.save(self.settings) - self.recorder.setInputSelection(selection) + let updatedSettings = self.settings.with(selectedInput: selection) + do { + try self.settingsStore.save(updatedSettings) + self.settings = updatedSettings + self.recorder.setInputSelection(selection) + } catch { + self.showSettingsPersistenceFailure(error) + } }, requestMicrophone: { _ = await AudioRecorder.requestMicrophonePermission() @@ -296,6 +328,12 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { openMicrophoneSettings: { [weak self] in self?.openSystemSettings(OigoPermissionPresentation.microphone(.denied).settingsURL) }, + registrationStatus: { [weak self] in + self?.shortcutRegistrar.status ?? .inactive("Global shortcut is not registered") + }, + registrationError: { [weak self] in + self?.shortcutConfiguration.lastError ?? self?.shortcutRegistrar.lastError + }, validateShortcut: { [weak self] candidate in self?.validateShortcut(candidate) ?? .invalid("Oigo is no longer available") }, @@ -310,7 +348,7 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { }, startTest: { [weak self] in self?.onboardingWindow?.focusTestField() - self?.handleToggle(allowBeforeSetup: true) + self?.handleMouseToggle(allowBeforeSetup: true) }, stopTest: { [weak self] in self?.finishTestDictation() @@ -323,6 +361,13 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { }, onComplete: { [weak self] in guard let self else { return } + guard self.shortcutRegistrar.status.isActive else { + self.onboardingWindow?.showRegistrationFailure( + self.shortcutRegistrar.lastError ?? self.shortcutRegistrar.status.message + ) + self.updateSurface() + return + } self.onboardingStore.markCompleted() self.onboardingWindow = nil self.registerShortcut() @@ -397,6 +442,14 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { toggle.target = self menu.addItem(toggle) + let shortcutStatus = NSMenuItem( + title: "Global Shortcut Inactive - Open Settings…", + action: #selector(openSettings), + keyEquivalent: "" + ) + shortcutStatus.target = self + menu.addItem(shortcutStatus) + let mode = NSMenuItem( title: "Mode", action: nil, @@ -457,6 +510,7 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { item.menu = menu statusItem = item toggleItem = toggle + shortcutStatusItem = shortcutStatus modeMenuItem = mode instantModeItem = instant cleanModeItem = clean @@ -464,13 +518,16 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { } private func registerShortcut() { - shortcutRegistrar.unregister() do { - try shortcutRegistrar.register(shortcut: settings.globalShortcut) { [weak self] in - self?.handleToggle() + try shortcutRegistrar.register(shortcut: settings.globalShortcut) { [weak self] event in + self?.handleGlobalShortcut(event) } + shortcutConfiguration.clearError() + shortcutBridge.reset() } catch { - NSLog("Oigo could not register the global toggle shortcut: %@", String(describing: error)) + NSLog("Oigo could not register the global shortcut: %@", String(describing: error)) + shortcutBridge.reset() + updateSurface() } } @@ -542,29 +599,99 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { guard self.workspaceInterruptionOperationID == operationID else { return } + self.shortcutBridge.reset() self.updateSurface() self.workspaceInterruptionTask = nil self.workspaceInterruptionOperationID = nil } } - private func handleToggle(allowBeforeSetup: Bool = false) { + private func handleMouseToggle(allowBeforeSetup: Bool = false) { performanceInstrumentation.mark(.shortcutReceived) guard allowBeforeSetup || onboardingStore.load().isComplete else { showOnboarding(OigoSystemSupportEvaluator.current()) return } - if let toggleTask { - toggleTask.cancel() + + switch coordinator.state { + case .idle, .complete, .failed, .cancelled, .interrupted: + startDictation() + case .recording: + finishDictation() + case .preparing, .finalizing, .cleaning, .inserting: + showShortcutFeedback(.ignoredProcessing(coordinator.state)) + } + } + + private func handleGlobalShortcut(_ event: GlobalShortcutEvent) { + performanceInstrumentation.mark(.shortcutReceived) + let edge: GlobalShortcutIntentEdge = switch event.edge { + case .pressed: + .pressed + case .released: + .released + } + _ = shortcutBridge.receive(edge) + } + + private func startKeyboardDictation() { + guard onboardingStore.load().isComplete else { + shortcutBridge.reset() + return + } + startDictation() + } + + private func requestKeyboardStop() { + if toggleTask != nil { + finishRequestedAfterStart = true + return + } + finishDictation() + } + + private func startDictation() { + guard toggleTask == nil else { + showShortcutFeedback(.ignoredBusy(coordinator.state)) return } do { toggleTask = try coordinator.startTask { @MainActor [weak self] in - defer { self?.toggleTask = nil } - await self?.performToggle() + guard let self else { return } + defer { + let shouldFinish = self.finishRequestedAfterStart + self.finishRequestedAfterStart = false + self.toggleTask = nil + if shouldFinish { + self.scheduleFinishAfterCurrentTask() + } + } + await self.performStartDictation() + if self.coordinator.state == .recording { + _ = self.shortcutBridge.observeState() + } else { + self.shortcutBridge.reset() + } } } catch { - NSLog("Oigo could not start its coordinator-owned toggle task: %@", String(describing: error)) + shortcutBridge.reset() + NSLog("Oigo could not start its coordinator-owned dictation task: %@", String(describing: error)) + } + } + + private func finishDictation() { + guard toggleTask == nil else { + showShortcutFeedback(.ignoredBusy(coordinator.state)) + return + } + do { + toggleTask = try coordinator.startTask { @MainActor [weak self] in + guard let self else { return } + defer { self.toggleTask = nil } + await self.performFinishDictation() + } + } catch { + NSLog("Oigo could not start its coordinator-owned finish task: %@", String(describing: error)) } } @@ -602,114 +729,154 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { guard self.coordinator.state == .recording else { return } - await self.performToggle() + self.finishDictation() } } - private func performToggle() async { + private func performStartDictation() async { do { - switch coordinator.state { - case .idle, .complete, .failed, .cancelled, .interrupted: - guard let sessionStore else { - throw SessionStoreError.invalidSessionDirectory( - SessionStore.defaultRootDirectory() - ) - } - failureDetail = nil - insertionDisplayStatus = nil - try await ensureMicrophonePermission() - try Task.checkCancellation() - targetSnapshot = insertion.captureTarget() - recorder.setInputSelection(settings.selectedInput) - let format = try recorder.captureFormat() - try Task.checkCancellation() - let service = transcriptionService() - recordingStartedAt = Date() - previewThrottle = OigoHUDPreviewThrottle() - lastSession = try await coordinator.startRecordingWithTranscription( - using: recorder, - store: sessionStore, - transcription: service, - format: format, - onUpdate: { [weak self] update in - Task { @MainActor [weak self] in - self?.applyTranscriptionUpdate(update) - } - } + guard let sessionStore else { + throw SessionStoreError.invalidSessionDirectory( + SessionStore.defaultRootDirectory() ) - case .recording: - let terminalMode = transcriptCleanupMode(for: settings.defaultMode) - insertionDisplayStatus = .finalizing - updateSurface() - _ = try await coordinator.stopRecordingWithTranscription() - recordingStartedAt = nil - guard let snapshot = targetSnapshot, - let store = sessionStore else { - throw DictationCoordinatorError.recordingNotActive + } + failureDetail = nil + insertionDisplayStatus = nil + shortcutFeedbackDetail = nil + try await ensureMicrophonePermission() + try Task.checkCancellation() + targetSnapshot = insertion.captureTarget() + recorder.setInputSelection(settings.selectedInput) + let format = try recorder.captureFormat() + try Task.checkCancellation() + let service = transcriptionService() + recordingStartedAt = Date() + previewThrottle = OigoHUDPreviewThrottle() + lastSession = try await coordinator.startRecordingWithTranscription( + using: recorder, + store: sessionStore, + transcription: service, + format: format, + onUpdate: { [weak self] update in + Task { @MainActor [weak self] in + self?.applyTranscriptionUpdate(update) + } } - let insertionSession = try coordinator.beginInsertion( - using: store, - requiresCleanup: settings.defaultMode == .clean + ) + updateSurface() + } catch is CancellationError { + await coordinator.cancelActiveWork() + shortcutBridge.reset() + lastSession = coordinator.currentSession ?? lastSession + recordingStartedAt = nil + targetSnapshot = nil + livePreview = "" + if !coordinator.hasActiveWork { + insertionDisplayStatus = nil + } + failureDetail = nil + updateSurface() + } catch { + if coordinator.hasActiveWork { + await coordinator.cancelActiveWork(reason: String(describing: error)) + } + shortcutBridge.reset() + lastSession = coordinator.currentSession ?? lastSession + targetSnapshot = nil + recordingStartedAt = nil + insertionDisplayStatus = .failed + failureDetail = Self.friendlyError("Dictation failed", error) + if let session = coordinator.currentSession, + [.failed, .interrupted].contains(session.metadata.state) { + lastSession = session + } + historyWindow?.showMessage(failureDetail ?? Self.friendlyError("Dictation failed", error)) + onboardingWindow?.setTestResult( + transcript: "", + mode: settings.defaultMode, + copied: false + ) + NSLog("Oigo rejected the dictation start command: %@", String(describing: error)) + updateSurface() + } + } + + private func performFinishDictation() async { + do { + guard coordinator.state == .recording else { + throw DictationTransitionError.illegal( + from: coordinator.state, + event: .stop ) - if terminalMode == .clean { - insertionDisplayStatus = .cleaning - } - updateSurface() - let decision = try await resolveCleanup( + } + let terminalMode = transcriptCleanupMode(for: settings.defaultMode) + insertionDisplayStatus = .finalizing + shortcutFeedbackDetail = nil + updateSurface() + _ = try await coordinator.stopRecordingWithTranscription() + recordingStartedAt = nil + guard let snapshot = targetSnapshot, + let store = sessionStore else { + throw DictationCoordinatorError.recordingNotActive + } + let insertionSession = try coordinator.beginInsertion( + using: store, + requiresCleanup: settings.defaultMode == .clean + ) + if terminalMode == .clean { + insertionDisplayStatus = .cleaning + } + updateSurface() + let decision = try await resolveCleanup( + for: insertionSession, + store: store, + mode: terminalMode + ) + try Task.checkCancellation() + if terminalMode == .clean { + _ = try coordinator.finishCleanup() + } + try Task.checkCancellation() + insertionDisplayStatus = .pasting + updateSurface() + explainAccessibilityBeforePaste() + let result: InsertionResult = { + performanceInstrumentation.mark(.insertionStart) + defer { performanceInstrumentation.mark(.insertionEnd) } + return insertion.insertText( for: insertionSession, + source: decision.insertionSource, store: store, - mode: terminalMode - ) - try Task.checkCancellation() - if terminalMode == .clean { - _ = try coordinator.finishCleanup() - } - try Task.checkCancellation() - insertionDisplayStatus = .pasting - updateSurface() - explainAccessibilityBeforePaste() - let result: InsertionResult = { - performanceInstrumentation.mark(.insertionStart) - defer { performanceInstrumentation.mark(.insertionEnd) } - return insertion.insertText( - for: insertionSession, - source: decision.insertionSource, - store: store, - target: snapshot - ) - }() - lastSession = try coordinator.finishInsertion( - outcome: result.outcome, - reason: result.reason, - insertionSource: decision.insertionSource, - cleanupFallbackReason: decision.fallbackReason?.description + target: snapshot ) - let rawText = (try? store.readRawText(for: insertionSession)) ?? "" - onboardingWindow?.setTestResult( - transcript: rawText, - mode: settings.defaultMode, - copied: result.outcome.clipboardOutputAvailable - ) - if let fallbackReason = decision.fallbackReason { - historyWindow?.showMessage( - "Clean unavailable. Inserted the raw transcript: " + fallbackReason.description - ) - } - insertionDisplayStatus = Self.displayStatus(for: result.outcome) - targetSnapshot = nil - livePreview = "" - case .preparing, .finalizing, .cleaning, .inserting: - throw DictationTransitionError.illegal( - from: coordinator.state, - event: .start + }() + lastSession = try coordinator.finishInsertion( + outcome: result.outcome, + reason: result.reason, + insertionSource: decision.insertionSource, + cleanupFallbackReason: decision.fallbackReason?.description + ) + let rawText = (try? store.readRawText(for: insertionSession)) ?? "" + onboardingWindow?.setTestResult( + transcript: rawText, + mode: settings.defaultMode, + copied: result.outcome.clipboardOutputAvailable + ) + if let fallbackReason = decision.fallbackReason { + historyWindow?.showMessage( + "Clean unavailable. Inserted the raw transcript: " + fallbackReason.description ) } + insertionDisplayStatus = Self.displayStatus(for: result.outcome) + targetSnapshot = nil + livePreview = "" if historyWindow != nil { refreshHistory() } updateSurface() } catch is CancellationError { await coordinator.cancelActiveWork() + shortcutBridge.reset() lastSession = coordinator.currentSession ?? lastSession recordingStartedAt = nil targetSnapshot = nil @@ -717,30 +884,65 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { if !coordinator.hasActiveWork { insertionDisplayStatus = nil } - failureDetail = nil updateSurface() - return } catch { if coordinator.hasActiveWork { await coordinator.cancelActiveWork(reason: String(describing: error)) } + shortcutBridge.reset() lastSession = coordinator.currentSession ?? lastSession targetSnapshot = nil recordingStartedAt = nil insertionDisplayStatus = .failed - failureDetail = Self.friendlyError("Dictation failed", error) if let session = coordinator.currentSession, [.failed, .interrupted].contains(session.metadata.state) { lastSession = session } - historyWindow?.showMessage(failureDetail ?? Self.friendlyError("Dictation failed", error)) + historyWindow?.showMessage(Self.friendlyError("Dictation failed", error)) onboardingWindow?.setTestResult( transcript: "", mode: settings.defaultMode, copied: false ) - NSLog("Oigo rejected the toggle command: %@", String(describing: error)) + NSLog("Oigo rejected the dictation finish command: %@", String(describing: error)) + updateSurface() + } + } + + private func scheduleFinishAfterCurrentTask() { + Task { @MainActor [weak self] in + guard let self else { return } + while self.coordinator.activeTaskCount != 0 { + await Task.yield() + } + self.finishDictation() + } + } + + private func showShortcutFeedback(_ result: GlobalShortcutIntentResult) { + switch result { + case .ignoredProcessing(let state): + let displayState: OigoHUDProcessingState? + switch state { + case .finalizing: + displayState = .finalizing + case .cleaning: + displayState = .cleaning + case .inserting: + displayState = .pasting + default: + displayState = nil + } + guard let displayState else { return } + insertionDisplayStatus = displayState + shortcutFeedbackDetail = "Shortcut ignored while \(state.rawValue.capitalized) is running" updateSurface() + case .ignoredRecordingNotOwned: + statusItem?.button?.toolTip = "Shortcut ignored: recording was started from the menu" + case .ignoredBusy(let state): + statusItem?.button?.toolTip = "Shortcut ignored while \(state.rawValue.capitalized) is running" + default: + break } } @@ -1232,6 +1434,21 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { modeMenuItem?.isEnabled = setupComplete && !isRecording launchAtLoginItem?.state = launchAtLoginController.isEnabled ? .on : .off statusItem?.button?.title = "Oigo" + switch shortcutRegistrar.status { + case .active(let shortcut, _): + if let error = shortcutConfiguration.lastError { + shortcutStatusItem?.title = "Global Shortcut Active - Open Settings…" + statusItem?.button?.toolTip = "Global shortcut active: \(shortcut.displayName). Last registration error: \(error)" + } else { + shortcutStatusItem?.title = "Global Shortcut: " + shortcut.displayName + statusItem?.button?.toolTip = shortcutFeedbackDetail + ?? "Global shortcut active: " + shortcut.displayName + } + case .inactive(let message): + let error = shortcutConfiguration.lastError ?? message + shortcutStatusItem?.title = "Global Shortcut Inactive - Open Settings…" + statusItem?.button?.toolTip = "Global Shortcut Inactive: " + error + } if isRecording { statusSurface.showRecording( @@ -1242,7 +1459,9 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { } else if let insertionDisplayStatus { statusSurface.showProcessing( insertionDisplayStatus, - detail: failureDetail ?? Self.hudDetail(for: insertionDisplayStatus), + detail: shortcutFeedbackDetail + ?? failureDetail + ?? Self.hudDetail(for: insertionDisplayStatus), anchoredTo: statusItem?.button ) } else { @@ -1251,29 +1470,52 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { } @objc private func selectInstantMode() { - settings = settings.with(defaultMode: .instant) - settingsStore.save(settings) - updateSurface() + let updatedSettings = settings.with(defaultMode: .instant) + do { + try settingsStore.save(updatedSettings) + settings = updatedSettings + updateSurface() + } catch { + showSettingsPersistenceFailure(error) + } } @objc private func selectCleanMode() { - settings = settings.with(defaultMode: .clean) - settingsStore.save(settings) - updateSurface() + let updatedSettings = settings.with(defaultMode: .clean) + do { + try settingsStore.save(updatedSettings) + settings = updatedSettings + updateSurface() + } catch { + showSettingsPersistenceFailure(error) + } } @objc private func toggleLaunchAtLogin() { let enabled = !launchAtLoginController.isEnabled + let previousSettings = settings + let updatedSettings = settings.with(launchAtLogin: enabled) do { try launchAtLoginController.setEnabled(enabled) - settings = settings.with(launchAtLogin: enabled) - settingsStore.save(settings) + do { + try settingsStore.save(updatedSettings) + } catch { + var message = error.localizedDescription + do { + try launchAtLoginController.setEnabled(previousSettings.launchAtLogin) + } catch let restoreError { + message += "; Launch at Login could not be restored: \(restoreError)" + } + showSettingsPersistenceFailure(message: message) + return + } + settings = updatedSettings updateSurface() } catch { - let alert = NSAlert() - alert.messageText = "Launch at Login could not be changed" - alert.informativeText = String(describing: error) - alert.runModal() + showSettingsPersistenceFailure( + title: "Launch at Login could not be changed", + message: String(describing: error) + ) } } @@ -1327,32 +1569,72 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { } private func applySettings(_ newSettings: OigoSettings) -> String? { - let shortcutValidation = validateShortcut(newSettings.globalShortcut) - if !shortcutValidation.isAvailable { - switch shortcutValidation { - case .conflict(let reason), .invalid(let reason): - return reason - case .available: - return nil - } - } - let previousSettings = settings - if previousSettings.launchAtLogin != newSettings.launchAtLogin { + let shortcutChanged = previousSettings.globalShortcut != newSettings.globalShortcut + let launchAtLoginChanged = previousSettings.launchAtLogin != newSettings.launchAtLogin + + if launchAtLoginChanged { do { try launchAtLoginController.setEnabled(newSettings.launchAtLogin) } catch { - registerShortcut() return "Launch at Login could not be changed: " + String(describing: error) } } + + if shortcutChanged { + let shortcutValidation = shortcutConfiguration.save( + newSettings.globalShortcut, + persist: { [weak self] shortcut in + guard let self else { throw OigoSettingsStoreError.storeUnavailable } + try self.settingsStore.save(newSettings.with(globalShortcut: shortcut)) + }, + restore: { [weak self] in + guard let self else { throw OigoSettingsStoreError.storeUnavailable } + try self.settingsStore.save(previousSettings) + } + ) + guard shortcutValidation.isAvailable else { + let shortcutError = Self.shortcutValidationMessage(shortcutValidation) + guard launchAtLoginChanged else { + updateSurface() + return shortcutError + } + do { + try launchAtLoginController.setEnabled(previousSettings.launchAtLogin) + } catch { + updateSurface() + return shortcutError + "; Launch at Login could not be restored: " + String(describing: error) + } + updateSurface() + return shortcutError + } + } else { + do { + try settingsStore.save(newSettings) + } catch { + var message = "Settings could not be saved: \(error)" + guard launchAtLoginChanged else { + updateSurface() + return message + } + do { + try launchAtLoginController.setEnabled(previousSettings.launchAtLogin) + } catch let restoreError { + message += "; Launch at Login could not be restored: \(restoreError)" + } + updateSurface() + return message + } + } + settings = newSettings - settingsStore.save(settings) recorder.setInputSelection(settings.selectedInput) if previousSettings.localeIdentifier != settings.localeIdentifier { transcription = nil } - registerShortcut() + if !shortcutRegistrar.status.isActive { + registerShortcut() + } updateSurface() return nil } @@ -1372,34 +1654,57 @@ final class OigoAppDelegate: NSObject, NSApplicationDelegate { } private func validateShortcut(_ candidate: ToggleShortcut) -> OigoShortcutValidation { - let basicValidation = OigoShortcutValidator.validate(candidate, occupied: []) - guard basicValidation.isAvailable else { - return basicValidation - } - shortcutRegistrar.unregister() - do { - try shortcutRegistrar.register(shortcut: candidate) { } - shortcutRegistrar.unregister() - return .available - } catch { - shortcutRegistrar.unregister() - registerShortcut() - return .conflict(String(describing: error)) - } + shortcutConfiguration.setCandidate(candidate) + return shortcutConfiguration.validate(candidate) } private func saveShortcut(_ candidate: ToggleShortcut) -> OigoShortcutValidation { - let validation = validateShortcut(candidate) + let previousSettings = settings + let validation = shortcutConfiguration.save( + candidate, + persist: { [weak self] shortcut in + guard let self else { throw OigoSettingsStoreError.storeUnavailable } + try self.settingsStore.save(previousSettings.with(globalShortcut: shortcut)) + }, + restore: { [weak self] in + guard let self else { throw OigoSettingsStoreError.storeUnavailable } + try self.settingsStore.save(previousSettings) + } + ) guard validation.isAvailable else { + updateSurface() return validation } - settings = settings.with(globalShortcut: candidate) - settingsStore.save(settings) - registerShortcut() + settings = previousSettings.with(globalShortcut: candidate) updateSurface() return .available } + private static func shortcutValidationMessage(_ validation: OigoShortcutValidation) -> String { + switch validation { + case .available: + "" + case .conflict(let reason), .invalid(let reason): + reason + } + } + + private func showSettingsPersistenceFailure(_ error: Error) { + showSettingsPersistenceFailure(message: error.localizedDescription) + } + + private func showSettingsPersistenceFailure(message: String) { + showSettingsPersistenceFailure(title: "Settings could not be saved", message: message) + } + + private func showSettingsPersistenceFailure(title: String, message: String) { + NSLog("Oigo settings persistence failed: %@", message) + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.runModal() + } + private func rerunOnboarding() { onboardingStore.rerun() showOnboarding(OigoSystemSupportEvaluator.current()) diff --git a/Sources/Oigo/OnboardingWindowController.swift b/Sources/Oigo/OnboardingWindowController.swift index 118c97d..d530ddf 100644 --- a/Sources/Oigo/OnboardingWindowController.swift +++ b/Sources/Oigo/OnboardingWindowController.swift @@ -1,6 +1,7 @@ import AppKit import OigoCore import OigoTranscription +import OigoHotKey @available(macOS 26.0, *) @MainActor @@ -13,6 +14,8 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { private let saveInputSelection: (OigoInputSelection) -> Void private let requestMicrophone: () async -> OigoPermissionState private let openMicrophoneSettings: () -> Void + private let registrationStatus: () -> GlobalShortcutRegistrationStatus + private let registrationError: () -> String? private let validateShortcut: (ToggleShortcut) -> OigoShortcutValidation private let saveShortcut: (ToggleShortcut) -> OigoShortcutValidation private let requestAccessibility: () -> OigoPermissionState @@ -32,6 +35,7 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { private var testOutcome: OigoOnboardingTestOutcome = .pending private var testRunning = false private var completed = false + private var committedShortcut: ToggleShortcut private var inputMenuSelections: [OigoInputSelection] = [] private var selectedInput: OigoInputSelection @@ -43,8 +47,7 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { private let inputLabel = NSTextField(labelWithString: "Microphone input") private let inputRow = NSStackView() private let languagePopup = NSPopUpButton() - private let keyCodeField = NSTextField(string: "") - private let modifiersField = NSTextField(string: "") + private let shortcutRecorder: ShortcutRecorderControl private let testField = NSTextField(string: "") private let actionButton = NSButton(title: "", target: nil, action: nil) private let skipButton = NSButton(title: "Skip test", target: nil, action: nil) @@ -67,6 +70,8 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { saveInputSelection: @escaping (OigoInputSelection) -> Void, requestMicrophone: @escaping () async -> OigoPermissionState, openMicrophoneSettings: @escaping () -> Void, + registrationStatus: @escaping () -> GlobalShortcutRegistrationStatus, + registrationError: @escaping () -> String?, validateShortcut: @escaping (ToggleShortcut) -> OigoShortcutValidation, saveShortcut: @escaping (ToggleShortcut) -> OigoShortcutValidation, requestAccessibility: @escaping () -> OigoPermissionState, @@ -88,6 +93,8 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { self.saveInputSelection = saveInputSelection self.requestMicrophone = requestMicrophone self.openMicrophoneSettings = openMicrophoneSettings + self.registrationStatus = registrationStatus + self.registrationError = registrationError self.validateShortcut = validateShortcut self.saveShortcut = saveShortcut self.requestAccessibility = requestAccessibility @@ -100,6 +107,8 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { self.onClose = onClose self.microphoneState = microphoneState self.accessibilityState = accessibilityState + committedShortcut = globalShortcut + shortcutRecorder = ShortcutRecorderControl(shortcut: globalShortcut) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 620, height: 480), @@ -111,8 +120,6 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { window.isReleasedWhenClosed = false super.init(window: window) window.delegate = self - keyCodeField.stringValue = String(globalShortcut.keyCode) - modifiersField.stringValue = String(globalShortcut.modifiers) configureInputMenu(devices: inputDevices, selected: selectedInput) configureWindow() render() @@ -125,6 +132,7 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { func windowWillClose(_ notification: Notification) { _ = notification + discardShortcutCandidate() if testRunning { testRunning = false cancelTest() @@ -136,6 +144,12 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { onClose() } + func showRegistrationFailure(_ message: String) { + currentStep = .shortcut + statusLabel.stringValue = "Global shortcut inactive: " + message + render() + } + func showAndFocus() { showWindow(nil) window?.center() @@ -172,6 +186,9 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { guard let contentView = window?.contentView else { return } + shortcutRecorder.onValidationError = { [weak self] message in + self?.statusLabel.stringValue = message + } progressLabel.textColor = .secondaryLabelColor titleLabel.font = .boldSystemFont(ofSize: 22) bodyLabel.maximumNumberOfLines = 8 @@ -180,8 +197,7 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { testField.placeholderString = "Your test transcript appears here" testField.isEditable = false testField.isSelectable = true - keyCodeField.placeholderString = "49" - modifiersField.placeholderString = "2304" + shortcutRecorder.translatesAutoresizingMaskIntoConstraints = false historyButton.target = self historyButton.action = #selector(openHistoryAction) actionButton.target = self @@ -193,26 +209,19 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { nextButton.target = self nextButton.action = #selector(goForward) - let shortcutRow = NSStackView(views: [ - NSTextField(labelWithString: "Key code"), keyCodeField, - NSTextField(labelWithString: "Modifiers"), modifiersField - ]) - shortcutRow.orientation = .horizontal - shortcutRow.spacing = 8 inputRow.addArrangedSubview(inputLabel) inputRow.addArrangedSubview(inputPopup) inputRow.orientation = .horizontal inputRow.alignment = .centerY inputRow.spacing = 8 inputLabel.setContentHuggingPriority(.required, for: .horizontal) - let stack = NSStackView(views: [ progressLabel, titleLabel, bodyLabel, inputRow, languagePopup, - shortcutRow, + shortcutRecorder, testField, statusLabel, historyButton, @@ -238,11 +247,9 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { statusLabel.widthAnchor.constraint(equalTo: stack.widthAnchor), inputRow.widthAnchor.constraint(equalTo: stack.widthAnchor), languagePopup.widthAnchor.constraint(equalToConstant: 280), + shortcutRecorder.widthAnchor.constraint(equalToConstant: 280), inputPopup.widthAnchor.constraint(equalToConstant: 280), testField.widthAnchor.constraint(equalTo: stack.widthAnchor), - shortcutRow.widthAnchor.constraint(equalTo: stack.widthAnchor), - keyCodeField.widthAnchor.constraint(equalToConstant: 80), - modifiersField.widthAnchor.constraint(equalToConstant: 90), actionButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), historyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140), buttons.widthAnchor.constraint(equalTo: stack.widthAnchor) @@ -257,8 +264,8 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { titleLabel.stringValue = isSupported ? currentStep.title : "This Mac cannot run Oigo" bodyLabel.stringValue = isSupported ? body(for: currentStep) : support.reason languagePopup.isHidden = currentStep != .language + shortcutRecorder.isHidden = currentStep != .shortcut inputRow.isHidden = currentStep != .microphone - keyCodeField.superview?.isHidden = currentStep != .shortcut testField.isHidden = !isSupported || currentStep != .testDictation historyButton.isHidden = currentStep != .recovery skipButton.isHidden = currentStep != .testDictation @@ -299,6 +306,9 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { if currentStep == .testDictation { nextButton.isEnabled = testOutcome.allowsContinue } + if currentStep == .recovery { + nextButton.isEnabled = registrationStatus().isActive + } } private func body(for step: OigoOnboardingStep) -> String { @@ -310,7 +320,7 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { case .microphone: "Oigo records audio so every dictation can be retried from History. We ask for microphone access only after this explanation." case .shortcut: - "Option-Space is the default when available. Oigo tests the choice and will not silently accept a shortcut conflict." + "Choose a readable global shortcut. The shipped default is \(ToggleShortcut.default.displayName), and Oigo keeps the previous working choice when registration fails." case .insertion: "Accessibility lets Oigo paste one completed transcript into the field you were using. If you decline, Copy and History still work." case .testDictation: @@ -328,6 +338,14 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { return "Current microphone state: " + microphoneState.rawValue.capitalized case .insertion: return "Current Accessibility state: " + accessibilityState.rawValue.capitalized + case .shortcut: + switch registrationStatus() { + case .active(let shortcut, _): + let suffix = registrationError().map { ". Last error: " + $0 } ?? "" + return "Registered: " + shortcut.displayName + suffix + ". Candidate: " + shortcutRecorder.displayValue + case .inactive(let message): + return "Global shortcut inactive: " + (registrationError() ?? message) + } case .testDictation where testOutcome == .passed: return "Test complete." case .testDictation where testOutcome == .skipped: @@ -455,23 +473,25 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { return } if currentStep == .shortcut { - guard let keyCode = UInt32(keyCodeField.stringValue), - let modifiers = UInt32(modifiersField.stringValue) else { - statusLabel.stringValue = "Enter a working key code and modifier combination." - return - } - let result = validateShortcut(ToggleShortcut(keyCode: keyCode, modifiers: modifiers)) + let candidate = shortcutRecorder.shortcut + let result = validateShortcut(candidate) guard result.isAvailable else { - statusLabel.stringValue = result.isConflict - ? "That shortcut conflicts with another application. Choose a working combination." - : "That shortcut is not valid. Choose a key and modifier." + statusLabel.stringValue = "Global shortcut inactive: " + Self.validationMessage(result) + renderButtons() return } - let saved = saveShortcut(ToggleShortcut(keyCode: keyCode, modifiers: modifiers)) + let saved = saveShortcut(candidate) guard saved.isAvailable else { - statusLabel.stringValue = "Oigo could not register that shortcut. Choose another working combination." + statusLabel.stringValue = "Global shortcut inactive: " + Self.validationMessage(saved) + renderButtons() + return + } + guard registrationStatus().isActive else { + statusLabel.stringValue = "Global shortcut inactive: " + (registrationError() ?? "Registration is not active") + renderButtons() return } + committedShortcut = candidate } if currentStep == .language, let selectedLanguage = languagePopup.selectedItem?.title { @@ -493,6 +513,7 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { } @objc private func goBack() { + discardShortcutCandidate() guard let previous = previousStep(before: currentStep) else { return } currentStep = previous saveStep(previous) @@ -526,4 +547,18 @@ final class OnboardingWindowController: NSWindowController, NSWindowDelegate { case .recovery: .testDictation } } + + private func discardShortcutCandidate() { + shortcutRecorder.cancelRecording() + shortcutRecorder.restoreCandidate(committedShortcut) + } + + private static func validationMessage(_ validation: OigoShortcutValidation) -> String { + switch validation { + case .available: + "Registration is active" + case .conflict(let reason), .invalid(let reason): + reason + } + } } diff --git a/Sources/Oigo/SettingsWindowController.swift b/Sources/Oigo/SettingsWindowController.swift index 3501706..382eb3e 100644 --- a/Sources/Oigo/SettingsWindowController.swift +++ b/Sources/Oigo/SettingsWindowController.swift @@ -1,10 +1,10 @@ import AppKit import OigoCore +import OigoHotKey @MainActor final class SettingsWindowController: NSWindowController, NSWindowDelegate { - private let keyCodeField = NSTextField(string: "") - private let modifiersField = NSTextField(string: "") + private let shortcutRecorder: ShortcutRecorderControl private let inputPopup = NSPopUpButton() private let localePopup = NSPopUpButton() private let modePopup = NSPopUpButton() @@ -14,7 +14,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch Oigo at login", target: nil, action: nil) private let microphoneStatus = NSTextField(labelWithString: "") private let accessibilityStatus = NSTextField(labelWithString: "") + private let shortcutStatus = NSTextField(wrappingLabelWithString: "") private let messageLabel = NSTextField(labelWithString: "") + private let registrationStatus: () -> GlobalShortcutRegistrationStatus + private let registrationError: () -> String? private let save: (OigoSettings) -> String? private let refreshPermissions: () -> (OigoPermissionState, OigoPermissionState) private let openMicrophoneSettings: () -> Void @@ -23,6 +26,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private let openHistory: () -> Void private let openDataFolder: () -> Void private let deleteAllHistory: () -> Void + private var committedShortcut: ToggleShortcut private var inputMenuSelections: [OigoInputSelection] = [] private var selectedInput: OigoInputSelection @@ -32,6 +36,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { supportedLocales: [String], microphoneState: OigoPermissionState, accessibilityState: OigoPermissionState, + registrationStatus: @escaping () -> GlobalShortcutRegistrationStatus, + registrationError: @escaping () -> String?, save: @escaping (OigoSettings) -> String?, refreshPermissions: @escaping () -> (OigoPermissionState, OigoPermissionState), openMicrophoneSettings: @escaping () -> Void, @@ -41,6 +47,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { openDataFolder: @escaping () -> Void, deleteAllHistory: @escaping () -> Void ) { + self.registrationStatus = registrationStatus + self.registrationError = registrationError selectedInput = settings.selectedInput self.save = save self.refreshPermissions = refreshPermissions @@ -50,6 +58,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { self.openHistory = openHistory self.openDataFolder = openDataFolder self.deleteAllHistory = deleteAllHistory + committedShortcut = settings.globalShortcut + shortcutRecorder = ShortcutRecorderControl(shortcut: settings.globalShortcut) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 620, height: 560), @@ -62,9 +72,6 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.isReleasedWhenClosed = false super.init(window: window) window.delegate = self - - keyCodeField.stringValue = String(settings.globalShortcut.keyCode) - modifiersField.stringValue = String(settings.globalShortcut.modifiers) configureInputMenu(devices: inputDevices, selected: selectedInput) localePopup.addItems(withTitles: supportedLocales) if let selectedIndex = supportedLocales.firstIndex(where: { @@ -94,6 +101,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { _ = notification let states = refreshPermissions() updatePermissionLabels(microphone: states.0, accessibility: states.1) + updateShortcutStatus() + } + + func windowWillClose(_ notification: Notification) { + _ = notification + shortcutRecorder.cancelRecording() + shortcutRecorder.restoreCandidate(committedShortcut) } func updateInputDevices(_ devices: [OigoInputDevice]) { @@ -105,6 +119,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { guard let contentView = window?.contentView else { return } + shortcutRecorder.onValidationError = { [weak self] message in + self?.messageLabel.stringValue = message + } let title = NSTextField(labelWithString: "Minimal settings") title.font = .boldSystemFont(ofSize: 18) @@ -114,9 +131,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { description.textColor = .secondaryLabelColor let shortcutTitle = NSTextField(labelWithString: "Global shortcut") - let keyCodeLabel = NSTextField(labelWithString: "Key code") - let modifiersLabel = NSTextField(labelWithString: "Carbon modifiers") - let shortcutHelp = NSTextField(wrappingLabelWithString: "Option-Space uses key code 49 and modifiers 2304. A conflicting shortcut is rejected and the previous working choice is kept.") + let shortcutHelp = NSTextField( + wrappingLabelWithString: "Click the recorder and press a shortcut. The default is \(ToggleShortcut.default.displayName). Validation never displaces the current working registration." + ) shortcutHelp.textColor = .secondaryLabelColor let modeLabel = NSTextField(labelWithString: "Default mode") let localeLabel = NSTextField(labelWithString: "Dictation language") @@ -140,16 +157,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { messageLabel.textColor = .secondaryLabelColor messageLabel.maximumNumberOfLines = 2 - let shortcutGrid = NSGridView(views: [ - [shortcutTitle, NSGridCell.emptyContentView], - [keyCodeLabel, keyCodeField], - [modifiersLabel, modifiersField] - ]) - shortcutGrid.rowSpacing = 8 - shortcutGrid.columnSpacing = 12 - shortcutGrid.translatesAutoresizingMaskIntoConstraints = false - keyCodeField.placeholderString = "49" - modifiersField.placeholderString = "2304" + let shortcutRow = NSStackView(views: [shortcutTitle, shortcutRecorder]) + shortcutRow.orientation = .horizontal + shortcutRow.alignment = .centerY + shortcutRow.spacing = 12 + shortcutRow.translatesAutoresizingMaskIntoConstraints = false + shortcutRecorder.widthAnchor.constraint(equalToConstant: 280).isActive = true let modeRow = row(label: modeLabel, control: modePopup) let localeRow = row(label: localeLabel, control: localePopup) @@ -170,8 +183,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { let stack = NSStackView(views: [ title, description, - shortcutGrid, + shortcutRow, shortcutHelp, + shortcutStatus, inputRow, modeRow, localeRow, @@ -202,11 +216,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { retentionRow.widthAnchor.constraint(equalTo: stack.widthAnchor), inputRow.widthAnchor.constraint(equalTo: stack.widthAnchor), saveButton.trailingAnchor.constraint(equalTo: stack.trailingAnchor), - keyCodeField.widthAnchor.constraint(equalToConstant: 180), - modifiersField.widthAnchor.constraint(equalTo: keyCodeField.widthAnchor), localePopup.widthAnchor.constraint(equalToConstant: 260), inputPopup.widthAnchor.constraint(equalTo: localePopup.widthAnchor) ]) + updateShortcutStatus() } private func configureInputMenu( @@ -247,9 +260,20 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { accessibilityStatus.stringValue = "Accessibility: " + accessibility.rawValue.capitalized + " (Copy and History remain available)" } + private func updateShortcutStatus() { + switch registrationStatus() { + case .active(let shortcut, _): + let suffix = registrationError().map { ". Last error: " + $0 } ?? "" + shortcutStatus.stringValue = "Registration active: " + shortcut.displayName + suffix + case .inactive(let message): + shortcutStatus.stringValue = "Registration inactive: " + (registrationError() ?? message) + } + } + @objc private func refreshPermissionStates() { let states = refreshPermissions() updatePermissionLabels(microphone: states.0, accessibility: states.1) + updateShortcutStatus() messageLabel.stringValue = "Permission states refreshed." } @@ -293,9 +317,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } @objc private func saveSettings() { - guard let keyCode = UInt32(keyCodeField.stringValue), - let modifiers = UInt32(modifiersField.stringValue), - let localeIdentifier = localePopup.selectedItem?.title, + guard let localeIdentifier = localePopup.selectedItem?.title, let modeTitle = modePopup.selectedItem?.title, let mode = OigoProcessingMode.allCases.first(where: { $0.displayName == modeTitle }), let retentionTitle = retentionPopup.selectedItem?.title, @@ -304,8 +326,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { NSSound.beep() return } + let candidate = shortcutRecorder.shortcut let result = save(OigoSettings( - globalShortcut: ToggleShortcut(keyCode: keyCode, modifiers: modifiers), + globalShortcut: candidate, localeIdentifier: localeIdentifier, defaultMode: mode, showVolatilePreview: previewCheckbox.state == .on, @@ -316,9 +339,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { )) if let result { messageLabel.stringValue = result + updateShortcutStatus() NSSound.beep() return } + committedShortcut = candidate window?.close() } } diff --git a/Sources/OigoCore/DictationCoordinator.swift b/Sources/OigoCore/DictationCoordinator.swift index 14aed50..ac01130 100644 --- a/Sources/OigoCore/DictationCoordinator.swift +++ b/Sources/OigoCore/DictationCoordinator.swift @@ -1769,61 +1769,10 @@ public struct ToggleShortcut: Codable, Equatable, Hashable, Sendable { public static let `default` = ToggleShortcut( keyCode: 49, - modifiers: 0x900 + modifiers: 0x300 ) } -public struct ShortcutInput: Equatable, Sendable { - public let keyCode: UInt32 - public let modifiers: UInt32 - - public init(keyCode: UInt32, modifiers: UInt32) { - self.keyCode = keyCode - self.modifiers = modifiers - } -} - -public enum ToggleShortcutError: Error, Equatable, CustomStringConvertible, Sendable { - case notMatching - - public var description: String { - switch self { - case .notMatching: - return "shortcut input does not match configured toggle" - } - } -} - -@MainActor -public final class ToggleShortcutController { - public private(set) var shortcut: ToggleShortcut - private let coordinator: DictationCoordinator - - public init( - shortcut: ToggleShortcut = .default, - coordinator: DictationCoordinator - ) { - self.shortcut = shortcut - self.coordinator = coordinator - } - - public func update(shortcut: ToggleShortcut) { - self.shortcut = shortcut - } - - @discardableResult - public func handle(_ input: ShortcutInput) throws -> DictationState { - guard input == ShortcutInput( - keyCode: shortcut.keyCode, - modifiers: shortcut.modifiers - ) else { - throw ToggleShortcutError.notMatching - } - try coordinator.toggle() - return coordinator.state - } -} - public enum IdlePolicy { public static let maxIdleCPUPercent = PerformanceBudgetCatalog.idleCPUHardLimit public static let maxIdlePhysicalFootprintBytes = PerformanceBudgetCatalog.idlePhysicalFootprintHardLimitBytes diff --git a/Sources/OigoCore/GlobalShortcutIntentController.swift b/Sources/OigoCore/GlobalShortcutIntentController.swift new file mode 100644 index 0000000..46d66c0 --- /dev/null +++ b/Sources/OigoCore/GlobalShortcutIntentController.swift @@ -0,0 +1,171 @@ +public enum GlobalShortcutIntentEdge: Equatable, Sendable { + case pressed + case released +} + +public enum GlobalShortcutIntentResult: Equatable, Sendable { + case start + case stop + case releaseLatched + case ignoredRepeat + case ignoredDuplicatePress + case ignoredDuplicateRelease + case ignoredProcessing(DictationState) + case ignoredRecordingNotOwned + case ignoredBusy(DictationState) + case reset +} + +public struct GlobalShortcutIntentController: Sendable { + private var physicalDown = false + private var keyboardOwnsOperation = false + private var releaseLatched = false + + public init() {} + + public var isPhysicalDown: Bool { + physicalDown + } + + public var ownsKeyboardOperation: Bool { + keyboardOwnsOperation + } + + public var hasLatchedRelease: Bool { + releaseLatched + } + + public mutating func receive( + _ edge: GlobalShortcutIntentEdge, + state: DictationState, + isRepeat: Bool = false + ) -> GlobalShortcutIntentResult { + if state.isIssue82Processing { + return .ignoredProcessing(state) + } + + switch edge { + case .pressed: + return receivePress(state: state, isRepeat: isRepeat) + case .released: + return receiveRelease(state: state, isRepeat: isRepeat) + } + } + + public mutating func observe(_ state: DictationState) -> GlobalShortcutIntentResult? { + if state == .recording, keyboardOwnsOperation, releaseLatched { + releaseLatched = false + physicalDown = false + keyboardOwnsOperation = false + return .stop + } + + guard state.isIssue82Terminal else { + return nil + } + + guard physicalDown || keyboardOwnsOperation || releaseLatched else { + return nil + } + + clearOwnership() + return .reset + } + + public mutating func reset() -> GlobalShortcutIntentResult { + clearOwnership() + return .reset + } + + private mutating func receivePress( + state: DictationState, + isRepeat: Bool + ) -> GlobalShortcutIntentResult { + if isRepeat { + return .ignoredRepeat + } + + guard !physicalDown else { + return .ignoredDuplicatePress + } + + guard state.isIssue82Startable else { + if state == .recording { + return .ignoredRecordingNotOwned + } + return .ignoredBusy(state) + } + + physicalDown = true + keyboardOwnsOperation = true + releaseLatched = false + return .start + } + + private mutating func receiveRelease( + state: DictationState, + isRepeat: Bool + ) -> GlobalShortcutIntentResult { + if isRepeat { + return .ignoredRepeat + } + + guard physicalDown else { + return state == .recording ? .ignoredRecordingNotOwned : .ignoredDuplicateRelease + } + + physicalDown = false + + guard keyboardOwnsOperation else { + return .ignoredRecordingNotOwned + } + + switch state { + case .preparing: + releaseLatched = true + return .releaseLatched + case .recording: + releaseLatched = false + keyboardOwnsOperation = false + return .stop + default: + clearOwnership() + return .ignoredDuplicateRelease + } + } + + private mutating func clearOwnership() { + physicalDown = false + keyboardOwnsOperation = false + releaseLatched = false + } +} + +private extension DictationState { + var isIssue82Processing: Bool { + switch self { + case .finalizing, .cleaning, .inserting: + true + default: + false + } + } + + var isIssue82Startable: Bool { + switch self { + case .idle, .complete, .failed, .cancelled, .interrupted: + true + default: + false + } + } + + var isIssue82Terminal: Bool { + switch self { + case .complete, .failed, .cancelled, .interrupted: + true + default: + false + } + } +} diff --git a/Sources/OigoCore/GlobalShortcutOperationBridge.swift b/Sources/OigoCore/GlobalShortcutOperationBridge.swift new file mode 100644 index 0000000..fb632ca --- /dev/null +++ b/Sources/OigoCore/GlobalShortcutOperationBridge.swift @@ -0,0 +1,88 @@ +@MainActor +public final class GlobalShortcutOperationBridge { + public typealias StateProvider = () -> DictationState + public typealias Operation = () -> Void + public typealias Feedback = (GlobalShortcutIntentResult) -> Void + + private let stateProvider: StateProvider + private let start: Operation + private let stop: Operation + private let feedback: Feedback + private var intent = GlobalShortcutIntentController() + private var keyboardStartupInFlight = false + + public private(set) var lastResult: GlobalShortcutIntentResult? + + public init( + state: @escaping StateProvider, + start: @escaping Operation, + stop: @escaping Operation, + feedback: @escaping Feedback = { _ in } + ) { + stateProvider = state + self.start = start + self.stop = stop + self.feedback = feedback + } + + @discardableResult + public func receive( + _ edge: GlobalShortcutIntentEdge, + isRepeat: Bool = false + ) -> GlobalShortcutIntentResult { + let state = keyboardStartupInFlight ? .preparing : stateProvider() + let result = intent.receive(edge, state: state, isRepeat: isRepeat) + lastResult = result + switch result { + case .start: + keyboardStartupInFlight = true + start() + case .stop: + stop() + case .ignoredBusy, .ignoredProcessing, .ignoredRecordingNotOwned: + feedback(result) + default: + break + } + return result + } + + @discardableResult + public func observeState() -> GlobalShortcutIntentResult? { + let state = stateProvider() + let result = intent.observe(state) + if state == .recording || state.isIssue82TerminalForBridge { + keyboardStartupInFlight = false + } + guard let result else { + return nil + } + + lastResult = result + if result == .stop { + stop() + } else { + feedback(result) + } + return result + } + + @discardableResult + public func reset() -> GlobalShortcutIntentResult { + keyboardStartupInFlight = false + let result = intent.reset() + lastResult = result + return result + } +} + +private extension DictationState { + var isIssue82TerminalForBridge: Bool { + switch self { + case .complete, .failed, .cancelled, .interrupted: + true + default: + false + } + } +} diff --git a/Sources/OigoCore/ProductContracts.swift b/Sources/OigoCore/ProductContracts.swift index f94dfd4..c0b28c5 100644 --- a/Sources/OigoCore/ProductContracts.swift +++ b/Sources/OigoCore/ProductContracts.swift @@ -318,37 +318,120 @@ public struct OigoSettings: Codable, Equatable, Sendable { } } +public enum OigoSettingsStoreError: Error, Equatable, LocalizedError, Sendable { + case encodingFailed(String) + case writeFailed(String) + case writeRejected + case storeUnavailable + + public var errorDescription: String? { + switch self { + case .encodingFailed(let reason): + "Settings could not be encoded: \(reason)" + case .writeFailed(let reason): + "Settings could not be persisted: \(reason)" + case .writeRejected: + "Settings storage rejected the new value" + case .storeUnavailable: + "Settings storage is no longer available" + } + } +} + public final class OigoSettingsStore { private static let key = "oigo.settings.v1" + private static let legacyShortcutDefault = ToggleShortcut(keyCode: 49, modifiers: 0x900) private let defaults: UserDefaults + private let writeData: (Data) throws -> Void - public init(defaults: UserDefaults = .standard) { + public init( + defaults: UserDefaults = .standard, + writeData: ((Data) throws -> Void)? = nil + ) { self.defaults = defaults + self.writeData = writeData ?? { [defaults] data in + defaults.set(data, forKey: Self.key) + guard defaults.data(forKey: Self.key) == data else { + throw OigoSettingsStoreError.writeRejected + } + } } public func load() -> OigoSettings { if let data = defaults.data(forKey: Self.key), let settings = try? JSONDecoder().decode(OigoSettings.self, from: data) { - return settings + let migrated = migrate(settings) + if migrated != settings { + try? save(migrated) + } + return migrated } var settings = OigoSettings.default + var loadedLegacyShortcut = false if let data = defaults.data(forKey: "globalToggleShortcut"), let shortcut = try? JSONDecoder().decode(ToggleShortcut.self, from: data) { - settings.globalShortcut = shortcut + settings.globalShortcut = migrate(shortcut) + loadedLegacyShortcut = true } if let rawMode = defaults.string(forKey: "transcriptCleanupMode"), let mode = OigoProcessingMode(rawValue: rawMode) { settings.defaultMode = mode } + if loadedLegacyShortcut { + try? save(settings) + } return settings } - public func save(_ settings: OigoSettings) { - guard let data = try? JSONEncoder().encode(settings) else { - return + private func migrate(_ settings: OigoSettings) -> OigoSettings { + settings.with(globalShortcut: migrate(settings.globalShortcut)) + } + + private func migrate(_ shortcut: ToggleShortcut) -> ToggleShortcut { + shortcut == Self.legacyShortcutDefault ? .default : shortcut + } + + public func save(_ settings: OigoSettings) throws { + let data: Data + let previousData = defaults.data(forKey: Self.key) + do { + data = try JSONEncoder().encode(settings) + } catch { + throw OigoSettingsStoreError.encodingFailed(String(describing: error)) + } + do { + try writeData(data) + } catch let error as OigoSettingsStoreError { + do { + try restore(previousData) + } catch let restoreError { + throw OigoSettingsStoreError.writeFailed( + "\(error.localizedDescription); settings rollback failed: \(restoreError.localizedDescription)" + ) + } + throw error + } catch { + do { + try restore(previousData) + } catch let restoreError { + throw OigoSettingsStoreError.writeFailed( + "\(error); settings rollback failed: \(restoreError.localizedDescription)" + ) + } + throw OigoSettingsStoreError.writeFailed(String(describing: error)) + } + } + + private func restore(_ data: Data?) throws { + if let data { + defaults.set(data, forKey: Self.key) + } else { + defaults.removeObject(forKey: Self.key) + } + guard defaults.data(forKey: Self.key) == data else { + throw OigoSettingsStoreError.writeRejected } - defaults.set(data, forKey: Self.key) } } @@ -617,16 +700,24 @@ public enum OigoShortcutValidation: Equatable, Sendable { } } +public enum ToggleShortcutModifiers { + public static let command: UInt32 = 0x100 + public static let shift: UInt32 = 0x200 + public static let option: UInt32 = 0x800 + public static let control: UInt32 = 0x1000 + public static let supportedMask: UInt32 = command | shift | option | control +} + public enum OigoShortcutValidator { public static func validate( _ shortcut: ToggleShortcut, occupied: [ToggleShortcut] ) -> OigoShortcutValidation { - guard shortcut.keyCode > 0 else { - return .invalid("Choose a keyboard key for the global shortcut") + guard shortcut.modifiers & ToggleShortcutModifiers.supportedMask != 0 else { + return .invalid("Choose at least one supported modifier for the global shortcut") } - guard shortcut.modifiers != 0 else { - return .invalid("Choose at least one modifier for the global shortcut") + guard shortcut.modifiers & ~ToggleShortcutModifiers.supportedMask == 0 else { + return .invalid("Choose only supported modifiers for the global shortcut") } guard !occupied.contains(shortcut) else { return .conflict("That shortcut is already registered by another application") diff --git a/Sources/OigoCore/ShortcutPresentation.swift b/Sources/OigoCore/ShortcutPresentation.swift new file mode 100644 index 0000000..8fdf7cc --- /dev/null +++ b/Sources/OigoCore/ShortcutPresentation.swift @@ -0,0 +1,100 @@ +public enum OigoShortcutPresentation { + public static func displayName(for shortcut: ToggleShortcut) -> String { + var components: [String] = [] + if shortcut.modifiers & ToggleShortcutModifiers.shift != 0 { + components.append("Shift") + } + if shortcut.modifiers & ToggleShortcutModifiers.control != 0 { + components.append("Control") + } + if shortcut.modifiers & ToggleShortcutModifiers.option != 0 { + components.append("Option") + } + if shortcut.modifiers & ToggleShortcutModifiers.command != 0 { + components.append("Command") + } + components.append(keyName(for: shortcut.keyCode)) + return components.joined(separator: "-") + } + + public static func keyName(for keyCode: UInt32) -> String { + switch keyCode { + case 0: + "A" + case 1: + "S" + case 2: + "D" + case 3: + "F" + case 4: + "H" + case 5: + "G" + case 6: + "Z" + case 7: + "X" + case 8: + "C" + case 9: + "V" + case 11: + "B" + case 12: + "Q" + case 13: + "W" + case 14: + "E" + case 15: + "R" + case 16: + "Y" + case 17: + "T" + case 18: + "1" + case 19: + "2" + case 20: + "3" + case 21: + "4" + case 22: + "6" + case 23: + "5" + case 24: + "=" + case 25: + "9" + case 26: + "7" + case 27: + "-" + case 28: + "8" + case 29: + "0" + case 36: + "Return" + case 48: + "Tab" + case 49: + "Space" + case 51: + "Delete" + case 53: + "Escape" + default: + "Key \(keyCode)" + } + } +} + +public extension ToggleShortcut { + var displayName: String { + OigoShortcutPresentation.displayName(for: self) + } +} diff --git a/Sources/OigoHotKey/GlobalHotKeyRegistrar.swift b/Sources/OigoHotKey/GlobalHotKeyRegistrar.swift new file mode 100644 index 0000000..029971c --- /dev/null +++ b/Sources/OigoHotKey/GlobalHotKeyRegistrar.swift @@ -0,0 +1,267 @@ +import Carbon.HIToolbox +import OigoCore + +public enum GlobalShortcutRegistrationError: Error, Equatable, CustomStringConvertible, Sendable { + case installHandler(OSStatus) + case registerHotKey(OSStatus) + + public var description: String { + switch self { + case .installHandler(let status): + return "could not install the global shortcut handler (OSStatus \(status))" + case .registerHotKey(let status): + return "could not register the global shortcut (OSStatus \(status)); choose another shortcut" + } + } +} + +@MainActor +public final class CarbonGlobalShortcutBackend: GlobalShortcutRegistrationBackend { + private final class RegistrationHandle: GlobalShortcutRegistrationHandle { + let generation: UInt64 + let hotKeyID: EventHotKeyID + let receive: @MainActor (GlobalShortcutEvent) -> Void + var hotKey: EventHotKeyRef? + var eventHandler: EventHandlerRef? + + init( + generation: UInt64, + hotKeyID: EventHotKeyID, + receive: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) { + self.generation = generation + self.hotKeyID = hotKeyID + self.receive = receive + } + + func handle(_ event: EventRef?) -> OSStatus { + guard let event else { + return noErr + } + + var eventHotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &eventHotKeyID + ) + guard status == noErr, + eventHotKeyID.signature == hotKeyID.signature, + eventHotKeyID.id == hotKeyID.id else { + return noErr + } + + let edge: GlobalShortcutEdge + switch UInt32(GetEventKind(event)) { + case UInt32(kEventHotKeyPressed): + edge = .pressed + case UInt32(kEventHotKeyReleased): + edge = .released + default: + return noErr + } + let shortcutEvent = GlobalShortcutEvent(edge: edge, generation: generation) + Task { @MainActor [receive] in + receive(shortcutEvent) + } + return noErr + } + + func close() { + if let hotKey { + UnregisterEventHotKey(hotKey) + self.hotKey = nil + } + if let eventHandler { + RemoveEventHandler(eventHandler) + self.eventHandler = nil + } + } + } + + private var nextEventID: UInt32 = 0 + + public init() {} + + public func register( + shortcut: ToggleShortcut, + generation: UInt64, + receive: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) throws -> any GlobalShortcutRegistrationHandle { + nextEventID = nextEventID == UInt32.max ? 1 : nextEventID + 1 + let handle = RegistrationHandle( + generation: generation, + hotKeyID: EventHotKeyID(signature: 0x4F49474F, id: nextEventID), + receive: receive + ) + var eventTypes = [ + EventTypeSpec( + eventClass: UInt32(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ), + EventTypeSpec( + eventClass: UInt32(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyReleased) + ) + ] + let userData = Unmanaged.passUnretained(handle).toOpaque() + let handlerStatus = InstallEventHandler( + GetApplicationEventTarget(), + { _, event, userData in + guard let userData else { + return noErr + } + let handle = Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + return handle.handle(event) + }, + eventTypes.count, + &eventTypes, + userData, + &handle.eventHandler + ) + guard handlerStatus == noErr else { + throw GlobalShortcutRegistrationError.installHandler(handlerStatus) + } + + let hotKeyStatus = RegisterEventHotKey( + shortcut.keyCode, + shortcut.modifiers, + handle.hotKeyID, + GetApplicationEventTarget(), + 0, + &handle.hotKey + ) + guard hotKeyStatus == noErr else { + handle.close() + throw GlobalShortcutRegistrationError.registerHotKey(hotKeyStatus) + } + return handle + } + + public func unregister(_ handle: any GlobalShortcutRegistrationHandle) { + (handle as? RegistrationHandle)?.close() + } +} + +@MainActor +public final class CarbonGlobalShortcutRegistrar { + private struct ActiveRegistration { + let shortcut: ToggleShortcut + let generation: UInt64 + let handle: any GlobalShortcutRegistrationHandle + let action: @MainActor (GlobalShortcutEvent) -> Void + } + + private let backend: any GlobalShortcutRegistrationBackend + private var activeRegistration: ActiveRegistration? + private var nextGenerationValue: UInt64 = 0 + + public private(set) var status = GlobalShortcutRegistrationStatus.inactive( + "Global shortcut is not registered" + ) + public private(set) var lastError: String? + + public init(backend: any GlobalShortcutRegistrationBackend = CarbonGlobalShortcutBackend()) { + self.backend = backend + } + + public func register( + shortcut: ToggleShortcut, + onEvent: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) throws { + if let activeRegistration, + activeRegistration.shortcut == shortcut { + self.activeRegistration = ActiveRegistration( + shortcut: shortcut, + generation: activeRegistration.generation, + handle: activeRegistration.handle, + action: onEvent + ) + lastError = nil + return + } + + let generation = nextGeneration() + let eventAction: @MainActor (GlobalShortcutEvent) -> Void = { [weak self] event in + self?.deliver(event) + } + do { + let handle = try backend.register( + shortcut: shortcut, + generation: generation, + receive: eventAction + ) + let previous = activeRegistration + activeRegistration = ActiveRegistration( + shortcut: shortcut, + generation: generation, + handle: handle, + action: onEvent + ) + status = .active(shortcut, generation: generation) + lastError = nil + if let previous { + backend.unregister(previous.handle) + } + } catch { + let reason = String(describing: error) + lastError = reason + if activeRegistration == nil { + status = .inactive(reason) + } + throw error + } + } + + public func probe(shortcut: ToggleShortcut) throws { + if activeRegistration?.shortcut == shortcut { + lastError = nil + return + } + let generation = nextGeneration() + let eventAction: @MainActor (GlobalShortcutEvent) -> Void = { [weak self] event in + self?.deliver(event) + } + do { + let handle = try backend.register( + shortcut: shortcut, + generation: generation, + receive: eventAction + ) + backend.unregister(handle) + lastError = nil + } catch { + lastError = String(describing: error) + throw error + } + } + + public func unregister() { + if let activeRegistration { + backend.unregister(activeRegistration.handle) + } + activeRegistration = nil + _ = nextGeneration() + status = .inactive("Global shortcut is not registered") + lastError = nil + } + + private func nextGeneration() -> UInt64 { + nextGenerationValue = nextGenerationValue == UInt64.max ? 1 : nextGenerationValue + 1 + return nextGenerationValue + } + + private func deliver(_ event: GlobalShortcutEvent) { + guard let activeRegistration, + activeRegistration.generation == event.generation else { + return + } + activeRegistration.action(event) + } +} diff --git a/Sources/OigoHotKey/GlobalShortcutRegistration.swift b/Sources/OigoHotKey/GlobalShortcutRegistration.swift new file mode 100644 index 0000000..1a7f043 --- /dev/null +++ b/Sources/OigoHotKey/GlobalShortcutRegistration.swift @@ -0,0 +1,50 @@ +import OigoCore + +public enum GlobalShortcutEdge: Equatable, Sendable { + case pressed + case released +} + +public struct GlobalShortcutEvent: Equatable, Sendable { + public let edge: GlobalShortcutEdge + public let generation: UInt64 + + public init(edge: GlobalShortcutEdge, generation: UInt64) { + self.edge = edge + self.generation = generation + } +} + +public enum GlobalShortcutRegistrationStatus: Equatable, Sendable { + case active(ToggleShortcut, generation: UInt64) + case inactive(String) + + public var isActive: Bool { + if case .active = self { + return true + } + return false + } + + public var message: String { + switch self { + case .active: + return "Global shortcut is active" + case .inactive(let reason): + return reason + } + } +} + +public protocol GlobalShortcutRegistrationHandle: AnyObject {} + +@MainActor +public protocol GlobalShortcutRegistrationBackend: AnyObject { + func register( + shortcut: ToggleShortcut, + generation: UInt64, + receive: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) throws -> any GlobalShortcutRegistrationHandle + + func unregister(_ handle: any GlobalShortcutRegistrationHandle) +} diff --git a/Sources/OigoHotKey/ShortcutConfigurationTransaction.swift b/Sources/OigoHotKey/ShortcutConfigurationTransaction.swift new file mode 100644 index 0000000..368df76 --- /dev/null +++ b/Sources/OigoHotKey/ShortcutConfigurationTransaction.swift @@ -0,0 +1,135 @@ +import OigoCore + +@MainActor +public protocol GlobalShortcutRegistrationClient: AnyObject { + var status: GlobalShortcutRegistrationStatus { get } + var lastError: String? { get } + + func register( + shortcut: ToggleShortcut, + onEvent: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) throws + func probe(shortcut: ToggleShortcut) throws + func unregister() +} + +@MainActor +public final class ShortcutConfigurationTransaction { + private let registrar: any GlobalShortcutRegistrationClient + private let onEvent: @MainActor (GlobalShortcutEvent) -> Void + private var configurationError: String? + + public private(set) var committedShortcut: ToggleShortcut + public private(set) var candidateShortcut: ToggleShortcut + + public var registrationStatus: GlobalShortcutRegistrationStatus { + registrar.status + } + + public var lastError: String? { + configurationError ?? registrar.lastError + } + + public init( + committedShortcut: ToggleShortcut, + registrar: any GlobalShortcutRegistrationClient, + onEvent: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) { + self.committedShortcut = committedShortcut + candidateShortcut = committedShortcut + self.registrar = registrar + self.onEvent = onEvent + } + + public func setCandidate(_ candidate: ToggleShortcut) { + candidateShortcut = candidate + } + + public func validate(_ candidate: ToggleShortcut) -> OigoShortcutValidation { + let basicValidation = OigoShortcutValidator.validate(candidate, occupied: []) + guard basicValidation.isAvailable else { + configurationError = Self.message(for: basicValidation) + return basicValidation + } + do { + try registrar.probe(shortcut: candidate) + configurationError = nil + return .available + } catch { + let validation = OigoShortcutValidation.conflict(String(describing: error)) + configurationError = Self.message(for: validation) + return validation + } + } + + public func save( + _ candidate: ToggleShortcut, + persist: (ToggleShortcut) throws -> Void, + restore: () throws -> Void + ) -> OigoShortcutValidation { + let basicValidation = OigoShortcutValidator.validate(candidate, occupied: []) + guard basicValidation.isAvailable else { + configurationError = Self.message(for: basicValidation) + return basicValidation + } + + let previous = committedShortcut + do { + try registrar.register(shortcut: candidate, onEvent: onEvent) + } catch { + let validation = OigoShortcutValidation.conflict(String(describing: error)) + configurationError = Self.message(for: validation) + return validation + } + + do { + try persist(candidate) + } catch { + var failure = "Shortcut save failed: \(error)" + var compensationFailed = false + do { + try restore() + } catch let restorePersistenceError { + compensationFailed = true + failure += ". Previous settings could not be restored: \(restorePersistenceError)" + } + do { + try registrar.register(shortcut: previous, onEvent: onEvent) + } catch let restoreError { + compensationFailed = true + failure += ". Previous registration could not be restored: \(restoreError)" + } + if compensationFailed { + registrar.unregister() + failure += ". Shortcut registration was disabled until the prior settings can be restored" + } + let validation = OigoShortcutValidation.conflict(failure) + configurationError = Self.message(for: validation) + return validation + } + + committedShortcut = candidate + candidateShortcut = candidate + configurationError = nil + return .available + } + + public func cancel() { + candidateShortcut = committedShortcut + } + + public func clearError() { + configurationError = nil + } + + private static func message(for validation: OigoShortcutValidation) -> String? { + switch validation { + case .available: + nil + case .conflict(let reason), .invalid(let reason): + reason + } + } +} + +extension CarbonGlobalShortcutRegistrar: GlobalShortcutRegistrationClient {} diff --git a/Sources/OigoHotKey/ShortcutFormatter.swift b/Sources/OigoHotKey/ShortcutFormatter.swift new file mode 100644 index 0000000..5ec004e --- /dev/null +++ b/Sources/OigoHotKey/ShortcutFormatter.swift @@ -0,0 +1,24 @@ +import OigoCore + +public enum ShortcutFormatter { + public static func displayName(for shortcut: ToggleShortcut) -> String { + modifierGlyphs(for: shortcut.modifiers) + OigoShortcutPresentation.keyName(for: shortcut.keyCode) + } + + public static func modifierGlyphs(for modifiers: UInt32) -> String { + var result = "" + if modifiers & ToggleShortcutModifiers.control != 0 { + result += "⌃" + } + if modifiers & ToggleShortcutModifiers.option != 0 { + result += "⌥" + } + if modifiers & ToggleShortcutModifiers.shift != 0 { + result += "⇧" + } + if modifiers & ToggleShortcutModifiers.command != 0 { + result += "⌘" + } + return result + } +} diff --git a/Sources/OigoHotKey/ShortcutRecorderControl.swift b/Sources/OigoHotKey/ShortcutRecorderControl.swift new file mode 100644 index 0000000..dff3e2f --- /dev/null +++ b/Sources/OigoHotKey/ShortcutRecorderControl.swift @@ -0,0 +1,161 @@ +import AppKit +import OigoCore + +@MainActor +public final class ShortcutRecorderControl: NSControl { + public private(set) var shortcut: ToggleShortcut + public private(set) var isRecording = false + public private(set) var validationError: String? + + public var onCandidateChange: ((ToggleShortcut) -> Void)? + public var onValidationError: ((String) -> Void)? + + private var shortcutBeforeRecording: ToggleShortcut + + public var displayValue: String { + if isRecording { + return "Press a shortcut" + } + return ShortcutFormatter.displayName(for: shortcut) + } + + public override var acceptsFirstResponder: Bool { + true + } + + public init(shortcut: ToggleShortcut) { + self.shortcut = shortcut + self.shortcutBeforeRecording = shortcut + super.init(frame: .zero) + configureAppearance() + } + + public required init?(coder: NSCoder) { + self.shortcut = .default + self.shortcutBeforeRecording = .default + super.init(coder: coder) + configureAppearance() + } + + public func beginRecording() { + shortcutBeforeRecording = shortcut + validationError = nil + isRecording = true + needsDisplay = true + } + + public func cancelRecording() { + guard isRecording else { + return + } + shortcut = shortcutBeforeRecording + validationError = nil + isRecording = false + onCandidateChange?(shortcut) + needsDisplay = true + } + + public func restoreCandidate(_ shortcut: ToggleShortcut) { + shortcutBeforeRecording = shortcut + self.shortcut = shortcut + validationError = nil + isRecording = false + needsDisplay = true + } + + public override func mouseDown(with event: NSEvent) { + _ = event + if let window, !window.makeFirstResponder(self) { + return + } + beginRecording() + } + + public override func keyDown(with event: NSEvent) { + guard isRecording else { + return + } + + guard !event.isARepeat else { + return + } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let supportedFlags: NSEvent.ModifierFlags = [.command, .control, .option, .shift] + guard flags.subtracting(supportedFlags).isEmpty else { + reject("Choose only supported modifiers: Command, Shift, Option, or Control") + return + } + + if event.keyCode == 53, flags.isEmpty { + cancelRecording() + return + } + + let modifiers = carbonModifiers(for: flags) + let candidate = ToggleShortcut(keyCode: UInt32(event.keyCode), modifiers: modifiers) + switch OigoShortcutValidator.validate(candidate, occupied: []) { + case .available: + shortcut = candidate + validationError = nil + isRecording = false + onCandidateChange?(candidate) + needsDisplay = true + case .conflict(let message), .invalid(let message): + reject(message) + } + } + + public override func draw(_ dirtyRect: NSRect) { + _ = dirtyRect + let background = isRecording ? NSColor.selectedControlColor : NSColor.controlBackgroundColor + background.setFill() + bounds.insetBy(dx: 1, dy: 1).fill() + + NSColor.separatorColor.setStroke() + let border = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 5, yRadius: 5) + border.lineWidth = 1 + border.stroke() + + let textColor = isRecording ? NSColor.selectedControlTextColor : NSColor.labelColor + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: NSFont.systemFontSize), + .foregroundColor: textColor + ] + let text = NSString(string: displayValue) + let size = text.size(withAttributes: attributes) + let origin = NSPoint( + x: bounds.midX - size.width / 2, + y: bounds.midY - size.height / 2 + ) + text.draw(at: origin, withAttributes: attributes) + } + + private func configureAppearance() { + wantsLayer = true + toolTip = "Click to record a global shortcut" + } + + private func carbonModifiers(for flags: NSEvent.ModifierFlags) -> UInt32 { + var modifiers: UInt32 = 0 + if flags.contains(.command) { + modifiers |= ToggleShortcutModifiers.command + } + if flags.contains(.control) { + modifiers |= ToggleShortcutModifiers.control + } + if flags.contains(.option) { + modifiers |= ToggleShortcutModifiers.option + } + if flags.contains(.shift) { + modifiers |= ToggleShortcutModifiers.shift + } + return modifiers + } + + private func reject(_ message: String) { + validationError = message + onValidationError?(message) + needsDisplay = true + } +} diff --git a/Tests/OigoIssue3ContractTests/main.swift b/Tests/OigoIssue3ContractTests/main.swift index e0b3938..fa6cb78 100644 --- a/Tests/OigoIssue3ContractTests/main.swift +++ b/Tests/OigoIssue3ContractTests/main.swift @@ -16,7 +16,7 @@ private struct OigoIssue3ContractTests { let tests: [(String, () async throws -> Void)] = [ ("all legal state transitions", testAllLegalTransitions), ("illegal and duplicate transitions", testIllegalTransitions), - ("toggle shortcut starts and stops", testToggleShortcut), + ("mouse menu toggle starts and stops", testMouseMenuToggle), ("one active task and quit cleanup", testTaskCleanup), ("quit cleanup from every state", testShutdownFromEveryState), ("idle policy", testIdlePolicy) @@ -90,35 +90,16 @@ private struct OigoIssue3ContractTests { } } - private static func testToggleShortcut() throws { - let shortcut = ToggleShortcut(keyCode: 49, modifiers: 0x100000) + private static func testMouseMenuToggle() throws { let coordinator = DictationCoordinator() - let controller = ToggleShortcutController( - shortcut: shortcut, - coordinator: coordinator - ) - - let first = try controller.handle( - ShortcutInput(keyCode: shortcut.keyCode, modifiers: shortcut.modifiers) - ) - guard first == .recording, coordinator.state == .recording else { - throw ContractFailure(message: "first toggle did not enter recording") - } - - let second = try controller.handle( - ShortcutInput(keyCode: shortcut.keyCode, modifiers: shortcut.modifiers) - ) - guard second == .finalizing, coordinator.state == .finalizing else { - throw ContractFailure(message: "second toggle did not enter finalizing") + try coordinator.toggle() + guard coordinator.state == .recording else { + throw ContractFailure(message: "mouse menu start did not enter recording") } - do { - _ = try controller.handle(ShortcutInput(keyCode: 36, modifiers: 0)) - throw ContractFailure(message: "unconfigured shortcut was accepted") - } catch let error as ToggleShortcutError { - guard error == .notMatching else { - throw ContractFailure(message: "unexpected shortcut error: (error)") - } + try coordinator.toggle() + guard coordinator.state == .finalizing else { + throw ContractFailure(message: "mouse menu stop did not enter finalizing") } } diff --git a/Tests/OigoIssue82ContractTests/ConfigurationContractScenarios.swift b/Tests/OigoIssue82ContractTests/ConfigurationContractScenarios.swift new file mode 100644 index 0000000..a1f62cc --- /dev/null +++ b/Tests/OigoIssue82ContractTests/ConfigurationContractScenarios.swift @@ -0,0 +1,175 @@ +import Foundation +import OigoCore +import OigoHotKey + +@MainActor +extension OigoIssue82ContractTests { + static func testConfigurationAtomicSave() throws { + let oldShortcut = ToggleShortcut.default + let newShortcut = ToggleShortcut(keyCode: 0, modifiers: ToggleShortcutModifiers.command) + let registrar = RecordingConfigurationRegistrationClient(active: oldShortcut) + let transaction = ShortcutConfigurationTransaction( + committedShortcut: oldShortcut, + registrar: registrar, + onEvent: { _ in } + ) + transaction.setCandidate(newShortcut) + + guard transaction.validate(newShortcut).isAvailable, + registrar.status == .active(oldShortcut, generation: 1) else { + throw ContractFailure(message: "validation-only changed or dropped the working registration") + } + + var persisted = oldShortcut + guard transaction.save( + newShortcut, + persist: { persisted = $0 }, + restore: { persisted = oldShortcut } + ).isAvailable, + persisted == newShortcut, + transaction.committedShortcut == newShortcut, + transaction.candidateShortcut == newShortcut, + registrar.status == .active(newShortcut, generation: 2), + registrar.calls == [ + "probe:0/256", + "register:0/256", + "unregister:49/768" + ] else { + throw ContractFailure(message: "shortcut save did not atomically commit registration and persistence") + } + + transaction.setCandidate(oldShortcut) + transaction.cancel() + guard transaction.candidateShortcut == newShortcut, + registrar.status == .active(newShortcut, generation: 2), + persisted == newShortcut else { + throw ContractFailure(message: "cancel changed the committed shortcut") + } + } + + static func testConfigurationFailureRestoration() throws { + let oldShortcut = ToggleShortcut.default + let newShortcut = ToggleShortcut(keyCode: 12, modifiers: ToggleShortcutModifiers.command) + let registrar = RecordingConfigurationRegistrationClient(active: oldShortcut) + registrar.failFor = newShortcut + let transaction = ShortcutConfigurationTransaction( + committedShortcut: oldShortcut, + registrar: registrar, + onEvent: { _ in } + ) + var persisted = oldShortcut + guard transaction.save( + newShortcut, + persist: { persisted = $0 }, + restore: { persisted = oldShortcut } + ).isConflict, + registrar.status == .active(oldShortcut, generation: 1), + persisted == oldShortcut, + registrar.calls == ["register:12/256"] else { + throw ContractFailure(message: "failed replacement displaced the prior registration or persistence") + } + + registrar.failFor = nil + let persistenceFailure = transaction.save( + newShortcut, + persist: { _ in + throw ContractFailure(message: "simulated persistence failure") + }, + restore: { persisted = oldShortcut } + ) + guard persistenceFailure.isConflict, + registrar.status == .active(oldShortcut, generation: 3), + transaction.committedShortcut == oldShortcut, + persisted == oldShortcut, + registrar.calls.suffix(4) == [ + "register:12/256", + "unregister:49/768", + "register:49/768", + "unregister:12/256" + ] else { + throw ContractFailure(message: "persistence failure did not restore the previous registration") + } + + transaction.setCandidate(newShortcut) + transaction.cancel() + guard transaction.candidateShortcut == oldShortcut else { + throw ContractFailure(message: "close or cancel did not discard only the uncommitted candidate") + } + } + + static func testSettingsStorePersistenceFailureRestoration() throws { + let suiteName = "oigo-issue82-settings-failure-" + UUID().uuidString + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let oldSettings = OigoSettings.default + let newShortcut = ToggleShortcut(keyCode: 0, modifiers: ToggleShortcutModifiers.command) + let workingStore = OigoSettingsStore(defaults: defaults) + try workingStore.save(oldSettings) + + let failingStore = OigoSettingsStore(defaults: defaults, writeData: { data in + defaults.set(data, forKey: "oigo.settings.v1") + throw SettingsWriteFailure.diskFull + }) + let registrar = RecordingConfigurationRegistrationClient(active: oldSettings.globalShortcut) + let transaction = ShortcutConfigurationTransaction( + committedShortcut: oldSettings.globalShortcut, + registrar: registrar, + onEvent: { _ in } + ) + + let result = transaction.save( + newShortcut, + persist: { shortcut in + try failingStore.save(oldSettings.with(globalShortcut: shortcut)) + }, + restore: { + try workingStore.save(oldSettings) + } + ) + let persistedAfterFailure = OigoSettingsStore(defaults: defaults).load() + guard result.isConflict, + transaction.lastError?.contains("disk full") == true, + registrar.status == .active(oldSettings.globalShortcut, generation: 3), + transaction.committedShortcut == oldSettings.globalShortcut, + persistedAfterFailure == oldSettings else { + throw ContractFailure(message: "production settings persistence failure did not restore live and durable shortcut state") + } + } + + static func testCompoundRollbackFailureFailsClosed() throws { + let oldShortcut = ToggleShortcut.default + let newShortcut = ToggleShortcut(keyCode: 0, modifiers: ToggleShortcutModifiers.command) + let registrar = RecordingConfigurationRegistrationClient(active: oldShortcut) + registrar.failFor = oldShortcut + let transaction = ShortcutConfigurationTransaction( + committedShortcut: oldShortcut, + registrar: registrar, + onEvent: { _ in } + ) + + let result = transaction.save( + newShortcut, + persist: { _ in + throw SettingsWriteFailure.diskFull + }, + restore: { + throw SettingsWriteFailure.diskFull + } + ) + guard result.isConflict, + registrar.status == .inactive("Global shortcut is not registered"), + transaction.committedShortcut == oldShortcut, + transaction.lastError?.contains("Shortcut registration was disabled") == true, + registrar.calls == [ + "register:0/256", + "unregister:49/768", + "register:49/768", + "unregister:0/256" + ] else { + throw ContractFailure(message: "compound rollback failure did not fail closed with actionable state") + } + } + + +} diff --git a/Tests/OigoIssue82ContractTests/ContractTestSupport.swift b/Tests/OigoIssue82ContractTests/ContractTestSupport.swift new file mode 100644 index 0000000..a813584 --- /dev/null +++ b/Tests/OigoIssue82ContractTests/ContractTestSupport.swift @@ -0,0 +1,69 @@ +import Foundation +import OigoCore +import OigoHotKey + +struct ContractFailure: Error, CustomStringConvertible { + let message: String + + var description: String { + message + } +} +enum SettingsWriteFailure: Error, CustomStringConvertible { + case diskFull + + var description: String { + "disk full" + } +} + + +@MainActor +final class RecordingConfigurationRegistrationClient: GlobalShortcutRegistrationClient { + private(set) var status: GlobalShortcutRegistrationStatus + private(set) var lastError: String? + private(set) var calls: [String] = [] + private var generation: UInt64 + var failFor: ToggleShortcut? + + init(active shortcut: ToggleShortcut) { + generation = 1 + status = .active(shortcut, generation: generation) + } + + func register( + shortcut: ToggleShortcut, + onEvent: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) throws { + _ = onEvent + calls.append("register:\(shortcut.keyCode)/\(shortcut.modifiers)") + if failFor == shortcut { + lastError = TestRegistrationError(shortcut: shortcut).description + throw TestRegistrationError(shortcut: shortcut) + } + generation += 1 + if case .active(let previous, _) = status { + calls.append("unregister:\(previous.keyCode)/\(previous.modifiers)") + } + status = .active(shortcut, generation: generation) + lastError = nil + } + + func probe(shortcut: ToggleShortcut) throws { + calls.append("probe:\(shortcut.keyCode)/\(shortcut.modifiers)") + if failFor == shortcut { + lastError = TestRegistrationError(shortcut: shortcut).description + throw TestRegistrationError(shortcut: shortcut) + } + lastError = nil + } + + func unregister() { + if case .active(let shortcut, _) = status { + calls.append("unregister:\(shortcut.keyCode)/\(shortcut.modifiers)") + } + generation += 1 + status = .inactive("Global shortcut is not registered") + lastError = nil + } +} diff --git a/Tests/OigoIssue82ContractTests/IntentContractScenarios.swift b/Tests/OigoIssue82ContractTests/IntentContractScenarios.swift new file mode 100644 index 0000000..74e530e --- /dev/null +++ b/Tests/OigoIssue82ContractTests/IntentContractScenarios.swift @@ -0,0 +1,101 @@ +import OigoCore +import OigoHotKey + +@MainActor +extension OigoIssue82ContractTests { + static func testIntentRapidTap() throws { + var controller = GlobalShortcutIntentController() + guard controller.receive(.pressed, state: .idle) == .start, + controller.receive(.pressed, state: .preparing, isRepeat: true) == .ignoredRepeat, + controller.receive(.released, state: .preparing) == .releaseLatched, + controller.observe(.recording) == .stop, + controller.receive(.released, state: .finalizing) == .ignoredProcessing(.finalizing) else { + throw ContractFailure(message: "rapid press/release did not produce one latched stop without cancellation") + } + } + + static func testIntentDuplicatesAndProcessing() throws { + var controller = GlobalShortcutIntentController() + guard controller.receive(.pressed, state: .idle) == .start, + controller.receive(.pressed, state: .preparing) == .ignoredDuplicatePress, + controller.receive(.released, state: .preparing) == .releaseLatched, + controller.receive(.released, state: .preparing) == .ignoredDuplicateRelease, + controller.observe(.recording) == .stop else { + throw ContractFailure(message: "duplicate shortcut edges changed ownership or stop count") + } + + var mouseOwnedRecording = GlobalShortcutIntentController() + guard mouseOwnedRecording.receive(.pressed, state: .recording) == .ignoredRecordingNotOwned, + mouseOwnedRecording.receive(.released, state: .recording) == .ignoredRecordingNotOwned, + controller.receive(.pressed, state: .cleaning) == .ignoredProcessing(.cleaning), + controller.receive(.released, state: .inserting) == .ignoredProcessing(.inserting) else { + throw ContractFailure(message: "processing or mouse-owned recording input was not ignored explicitly") + } + } + + static func testAppBridgeReleaseDuringStartup() throws { + var state = DictationState.idle + var starts = 0 + var stops = 0 + var feedback: [GlobalShortcutIntentResult] = [] + let bridge = GlobalShortcutOperationBridge( + state: { state }, + start: { starts += 1 }, + stop: { stops += 1 }, + feedback: { feedback.append($0) } + ) + + guard bridge.receive(.pressed) == .start, + bridge.receive(.pressed, isRepeat: true) == .ignoredRepeat, + bridge.receive(.released) == .releaseLatched, + starts == 1, + stops == 0 else { + throw ContractFailure(message: "bridge did not preserve one keyboard start and latch release during startup") + } + + state = .recording + guard bridge.observeState() == .stop, + stops == 1, + !feedback.contains(.ignoredProcessing(.finalizing)) else { + throw ContractFailure(message: "latched release did not stop exactly once at recording") + } + + state = .finalizing + guard bridge.receive(.released) == .ignoredProcessing(.finalizing), + starts == 1, + stops == 1 else { + throw ContractFailure(message: "processing release changed the active operation") + } + } + + static func testAppBridgeProcessingFeedback() throws { + var state = DictationState.finalizing + var starts = 0 + var stops = 0 + var feedback: [GlobalShortcutIntentResult] = [] + let bridge = GlobalShortcutOperationBridge( + state: { state }, + start: { starts += 1 }, + stop: { stops += 1 }, + feedback: { feedback.append($0) } + ) + + guard bridge.receive(.pressed) == .ignoredProcessing(.finalizing), + bridge.receive(.released) == .ignoredProcessing(.finalizing), + feedback == [.ignoredProcessing(.finalizing), .ignoredProcessing(.finalizing)], + starts == 0, + stops == 0 else { + throw ContractFailure(message: "processing input did not produce explicit feedback without commands") + } + + state = .recording + guard bridge.receive(.pressed) == .ignoredRecordingNotOwned, + bridge.receive(.released) == .ignoredRecordingNotOwned, + starts == 0, + stops == 0 else { + throw ContractFailure(message: "keyboard input claimed a mouse-owned recording") + } + } + + +} diff --git a/Tests/OigoIssue82ContractTests/Issue82ContractRunner.swift b/Tests/OigoIssue82ContractTests/Issue82ContractRunner.swift new file mode 100644 index 0000000..f6557c2 --- /dev/null +++ b/Tests/OigoIssue82ContractTests/Issue82ContractRunner.swift @@ -0,0 +1,58 @@ +import Darwin +import Foundation +import OigoCore +import OigoHotKey + +@main +@available(macOS 13.0, *) +@MainActor +struct OigoIssue82ContractTests { + static func main() { + let arguments = Array(CommandLine.arguments.dropFirst()) + let filter: String? = if let index = arguments.firstIndex(of: "--filter"), + arguments.indices.contains(index + 1) { + arguments[index + 1] + } else { + nil + } + let normalizedFilter = filter?.replacingOccurrences(of: "-", with: " ") + let scenarios: [(String, () throws -> Void)] = [ + ("registrar atomic replacement", testRegistrarAtomicReplacement), + ("registrar failure and generation", testRegistrarFailureAndGeneration), + ("intent rapid tap", testIntentRapidTap), + ("intent duplicates and processing", testIntentDuplicatesAndProcessing), + ("shortcut contract default and migration", testShortcutContractDefaultAndMigration), + ("shortcut keycode zero", testShortcutKeyCodeZero), + ("recorder keycode zero", testRecorderKeyCodeZero), + ("recorder rejection", testRecorderRejection), + ("app bridge release during startup", testAppBridgeReleaseDuringStartup), + ("app bridge processing feedback", testAppBridgeProcessingFeedback), + ("configuration atomic save", testConfigurationAtomicSave), + ("configuration failure restoration", testConfigurationFailureRestoration), + ("settings store persistence failure restoration", testSettingsStorePersistenceFailureRestoration), + ("compound rollback failure fails closed", testCompoundRollbackFailureFailsClosed) + ] + let selected = scenarios.filter { normalizedFilter == nil || $0.0.contains(normalizedFilter ?? "") } + guard !selected.isEmpty else { + print("FAIL: no issue #82 contract scenarios matched filter") + exit(1) + } + + var failures = 0 + for (name, test) in selected { + do { + try test() + print("GREEN: " + name) + } catch { + failures += 1 + print("FAIL: " + name + ": " + String(describing: error)) + } + } + guard failures == 0 else { + print("FAILURES=" + String(failures)) + exit(1) + } + print("GREEN: all issue #82 contract scenarios") + } + +} diff --git a/Tests/OigoIssue82ContractTests/RegistrarContractScenarios.swift b/Tests/OigoIssue82ContractTests/RegistrarContractScenarios.swift new file mode 100644 index 0000000..cf0bc03 --- /dev/null +++ b/Tests/OigoIssue82ContractTests/RegistrarContractScenarios.swift @@ -0,0 +1,91 @@ +import OigoCore +import OigoHotKey + +@MainActor +extension OigoIssue82ContractTests { + static func testRegistrarAtomicReplacement() throws { + let backend = RecordingRegistrationBackend() + let registrar = CarbonGlobalShortcutRegistrar(backend: backend) + let first = ToggleShortcut(keyCode: 49, modifiers: 0x300) + let second = ToggleShortcut(keyCode: 0, modifiers: 0x100) + var events: [GlobalShortcutEvent] = [] + + try registrar.register(shortcut: first, onEvent: { event in + events.append(event) + }) + let firstGeneration = try activeGeneration(of: registrar) + backend.emit(.pressed, generation: firstGeneration) + backend.emit(.released, generation: firstGeneration) + guard events.map(\.edge) == [.pressed, .released] else { + throw ContractFailure(message: "registrar did not deliver both press and release edges") + } + + let callsBeforeProbe = backend.calls + try registrar.probe(shortcut: second) + guard backend.calls == callsBeforeProbe + ["register:0/256", "unregister:0/256"], + try activeShortcut(of: registrar) == first else { + throw ContractFailure(message: "successful validation probe displaced the working registration") + } + + try registrar.register(shortcut: second, onEvent: { event in + events.append(event) + }) + guard backend.calls.suffix(2) == ["register:0/256", "unregister:49/768"], + try activeShortcut(of: registrar) == second else { + throw ContractFailure(message: "replacement did not register the candidate before removing the prior shortcut") + } + } + + static func testRegistrarFailureAndGeneration() throws { + let backend = RecordingRegistrationBackend() + let registrar = CarbonGlobalShortcutRegistrar(backend: backend) + let first = ToggleShortcut(keyCode: 49, modifiers: 0x300) + let second = ToggleShortcut(keyCode: 12, modifiers: 0x100) + var events: [GlobalShortcutEvent] = [] + try registrar.register(shortcut: first, onEvent: { event in + events.append(event) + }) + let firstGeneration = try activeGeneration(of: registrar) + backend.failFor = second + + do { + try registrar.register(shortcut: second, onEvent: { event in + events.append(event) + }) + throw ContractFailure(message: "failed candidate registration was accepted") + } catch let error as TestRegistrationError { + guard error.description.contains("12/256") else { + throw ContractFailure(message: "candidate failure was not actionable") + } + } + + guard try activeShortcut(of: registrar) == first, + backend.calls.suffix(1) == ["register:12/256"], + registrar.lastError?.contains("12/256") == true else { + throw ContractFailure(message: "failed replacement removed or hid the prior working registration") + } + + backend.failFor = nil + try registrar.register(shortcut: second, onEvent: { event in + events.append(event) + }) + backend.emit(.pressed, generation: firstGeneration) + guard events.isEmpty else { + throw ContractFailure(message: "stale callback from a replaced generation was delivered") + } + } + + static func activeGeneration(of registrar: CarbonGlobalShortcutRegistrar) throws -> UInt64 { + guard case .active(_, let generation) = registrar.status else { + throw ContractFailure(message: "registrar was not active") + } + return generation + } + + static func activeShortcut(of registrar: CarbonGlobalShortcutRegistrar) throws -> ToggleShortcut { + guard case .active(let shortcut, _) = registrar.status else { + throw ContractFailure(message: "registrar was not active") + } + return shortcut + } +} diff --git a/Tests/OigoIssue82ContractTests/RegistrarScenarios.swift b/Tests/OigoIssue82ContractTests/RegistrarScenarios.swift new file mode 100644 index 0000000..500fafe --- /dev/null +++ b/Tests/OigoIssue82ContractTests/RegistrarScenarios.swift @@ -0,0 +1,71 @@ +import OigoCore +import OigoHotKey + +@MainActor +final class RecordingRegistrationBackend: GlobalShortcutRegistrationBackend { + private final class Handle: GlobalShortcutRegistrationHandle { + let id: UInt64 + + init(id: UInt64) { + self.id = id + } + } + + private struct Registration { + let shortcut: ToggleShortcut + let generation: UInt64 + let handle: Handle + let receive: @MainActor (GlobalShortcutEvent) -> Void + } + + private var nextID: UInt64 = 0 + private var registrations: [Registration] = [] + private(set) var calls: [String] = [] + var failFor: ToggleShortcut? + + func register( + shortcut: ToggleShortcut, + generation: UInt64, + receive: @escaping @MainActor (GlobalShortcutEvent) -> Void + ) throws -> any GlobalShortcutRegistrationHandle { + calls.append("register:\(shortcut.keyCode)/\(shortcut.modifiers)") + if failFor == shortcut { + throw TestRegistrationError(shortcut: shortcut) + } + nextID += 1 + let handle = Handle(id: nextID) + registrations.append( + Registration( + shortcut: shortcut, + generation: generation, + handle: handle, + receive: receive + ) + ) + return handle + } + + func unregister(_ handle: any GlobalShortcutRegistrationHandle) { + guard let handle = handle as? Handle else { + return + } + if let registration = registrations.first(where: { $0.handle.id == handle.id }) { + calls.append("unregister:\(registration.shortcut.keyCode)/\(registration.shortcut.modifiers)") + } + registrations.removeAll { $0.handle.id == handle.id } + } + + func emit(_ edge: GlobalShortcutEdge, generation: UInt64) { + for registration in registrations where registration.generation == generation { + registration.receive(GlobalShortcutEvent(edge: edge, generation: generation)) + } + } +} + +struct TestRegistrationError: Error, CustomStringConvertible { + let shortcut: ToggleShortcut + + var description: String { + "could not register shortcut \(shortcut.keyCode)/\(shortcut.modifiers) because it is occupied" + } +} diff --git a/Tests/OigoIssue82ContractTests/ShortcutContractScenarios.swift b/Tests/OigoIssue82ContractTests/ShortcutContractScenarios.swift new file mode 100644 index 0000000..d129925 --- /dev/null +++ b/Tests/OigoIssue82ContractTests/ShortcutContractScenarios.swift @@ -0,0 +1,241 @@ +import Foundation +import AppKit +import OigoCore +import OigoHotKey + +@MainActor +extension OigoIssue82ContractTests { + static func testShortcutContractDefaultAndMigration() throws { + let legacy = ToggleShortcut(keyCode: 49, modifiers: 0x900) + let canonical = ToggleShortcut(keyCode: 49, modifiers: 0x300) + guard ToggleShortcut.default == canonical, + canonical.displayName == "Shift-Command-Space" else { + throw ContractFailure(message: "shortcut default did not use Shift-Command-Space") + } + + let suiteName = "oigo-issue82-shortcut-migration-" + UUID().uuidString + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let stored = OigoSettings( + globalShortcut: legacy, + localeIdentifier: "en-US", + defaultMode: .clean, + showVolatilePreview: false, + audioRetention: .oneWeek, + keepSuccessfulAudioIndefinitely: true, + launchAtLogin: true + ) + defaults.set(try JSONEncoder().encode(stored), forKey: "oigo.settings.v1") + let loaded = OigoSettingsStore(defaults: defaults).load() + guard loaded.globalShortcut == canonical, + loaded.localeIdentifier == stored.localeIdentifier, + loaded.defaultMode == stored.defaultMode, + loaded.showVolatilePreview == stored.showVolatilePreview, + loaded.audioRetention == stored.audioRetention, + loaded.keepSuccessfulAudioIndefinitely == stored.keepSuccessfulAudioIndefinitely, + loaded.launchAtLogin == stored.launchAtLogin else { + throw ContractFailure(message: "legacy v1 shortcut did not migrate without changing other settings") + } + + let persisted = try JSONDecoder().decode( + OigoSettings.self, + from: defaults.data(forKey: "oigo.settings.v1")! + ) + guard persisted.globalShortcut == canonical, + OigoSettingsStore(defaults: defaults).load() == loaded else { + throw ContractFailure(message: "shortcut migration was not persisted idempotently") + } + + defaults.removeObject(forKey: "oigo.settings.v1") + defaults.set(try JSONEncoder().encode(legacy), forKey: "globalToggleShortcut") + guard OigoSettingsStore(defaults: defaults).load().globalShortcut == canonical else { + throw ContractFailure(message: "legacy globalToggleShortcut key did not migrate") + } + + defaults.removeObject(forKey: "oigo.settings.v1") + defaults.set( + try JSONEncoder().encode(OigoSettings(globalShortcut: ToggleShortcut(keyCode: 0, modifiers: 0x100))), + forKey: "oigo.settings.v1" + ) + guard OigoSettingsStore(defaults: defaults).load().globalShortcut == ToggleShortcut(keyCode: 0, modifiers: 0x100) else { + throw ContractFailure(message: "custom key-code-zero shortcut was changed during migration") + } + } + + static func testShortcutKeyCodeZero() throws { + let keyCodeZero = ToggleShortcut(keyCode: 0, modifiers: 0x100) + guard OigoShortcutValidator.validate(keyCodeZero, occupied: []).isAvailable, + keyCodeZero.displayName == "Command-A" else { + throw ContractFailure(message: "key code zero with Command was rejected") + } + guard !OigoShortcutValidator.validate( + ToggleShortcut(keyCode: 0, modifiers: 0), + occupied: [] + ).isAvailable else { + throw ContractFailure(message: "modifier-free shortcut was accepted") + } + } + + static func testRecorderKeyCodeZero() throws { + let original = ToggleShortcut.default + let recorder = ShortcutRecorderControl(shortcut: original) + var candidates: [ToggleShortcut] = [] + recorder.onCandidateChange = { candidates.append($0) } + + recorder.keyDown(with: try keyEvent( + keyCode: 0, + modifiers: [.command], + characters: "a", + isARepeat: false + )) + guard candidates.isEmpty, recorder.shortcut == original else { + throw ContractFailure(message: "recorder accepted a key while it was not active") + } + + recorder.beginRecording() + recorder.keyDown(with: try keyEvent( + keyCode: 0, + modifiers: [.command], + characters: "a", + isARepeat: true + )) + guard candidates.isEmpty, recorder.isRecording else { + throw ContractFailure(message: "recorder accepted a repeated key event") + } + + recorder.keyDown(with: try keyEvent( + keyCode: 0, + modifiers: [.command], + characters: "a", + isARepeat: false + )) + guard candidates == [ToggleShortcut(keyCode: 0, modifiers: ToggleShortcutModifiers.command)], + recorder.shortcut == candidates[0], + recorder.displayValue == "⌘A", + !recorder.isRecording else { + throw ContractFailure(message: "recorder did not accept and display key code zero") + } + } + + static func testRecorderRejection() throws { + let original = ToggleShortcut.default + let recorder = ShortcutRecorderControl(shortcut: original) + var errors: [String] = [] + recorder.onValidationError = { errors.append($0) } + + recorder.beginRecording() + recorder.keyDown(with: try keyEvent( + keyCode: 0, + modifiers: [], + characters: "a", + isARepeat: false + )) + guard recorder.shortcut == original, + recorder.isRecording, + errors.last?.contains("modifier") == true else { + throw ContractFailure(message: "recorder did not reject a modifier-free shortcut") + } + + recorder.beginRecording() + recorder.keyDown(with: try keyEvent( + keyCode: 12, + modifiers: [.capsLock], + characters: "q", + isARepeat: false + )) + guard recorder.shortcut == original, + recorder.isRecording, + errors.last?.contains("supported") == true else { + throw ContractFailure(message: "recorder did not reject an unsupported modifier") + } + + guard ShortcutFormatter.displayName( + for: ToggleShortcut(keyCode: 36, modifiers: ToggleShortcutModifiers.command) + ) == "⌘Return" else { + throw ContractFailure(message: "special key was not formatted readably") + } + + let expectedNumericKeyNames: [(UInt32, String)] = [ + (22, "6"), + (23, "5"), + (24, "="), + (25, "9"), + (26, "7"), + (27, "-"), + (28, "8") + ] + for (keyCode, expectedName) in expectedNumericKeyNames { + guard OigoShortcutPresentation.keyName(for: keyCode) == expectedName else { + throw ContractFailure(message: "key code \(keyCode) was not formatted as \(expectedName)") + } + } + + recorder.keyDown(with: try keyEvent( + keyCode: 12, + modifiers: [.command], + characters: "q", + isARepeat: false + )) + let accepted = recorder.shortcut + guard accepted == ToggleShortcut(keyCode: 12, modifiers: ToggleShortcutModifiers.command) else { + throw ContractFailure(message: "recorder did not retain the accepted candidate") + } + + let commandEscape = ToggleShortcut(keyCode: 53, modifiers: ToggleShortcutModifiers.command) + recorder.beginRecording() + recorder.keyDown(with: try keyEvent( + keyCode: 53, + modifiers: [.command], + characters: "\u{1b}", + isARepeat: false + )) + guard recorder.shortcut == commandEscape, + !recorder.isRecording else { + throw ContractFailure(message: "recorder treated modified Escape as cancellation instead of a valid shortcut") + } + + recorder.beginRecording() + recorder.keyDown(with: try keyEvent( + keyCode: 13, + modifiers: [.command], + characters: "w", + isARepeat: false + )) + recorder.beginRecording() + recorder.keyDown(with: try keyEvent( + keyCode: 53, + modifiers: [], + characters: "\u{1b}", + isARepeat: false + )) + guard recorder.shortcut == ToggleShortcut(keyCode: 13, modifiers: ToggleShortcutModifiers.command) else { + throw ContractFailure(message: "Escape did not restore the original recorder candidate") + } + } + + static func keyEvent( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + characters: String, + isARepeat: Bool + ) throws -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: isARepeat, + keyCode: keyCode + ) else { + throw ContractFailure(message: "could not construct deterministic key event") + } + return event + } + + +} diff --git a/Tests/OigoIssue9ContractTests/main.swift b/Tests/OigoIssue9ContractTests/main.swift index 9dec916..f9a35cd 100644 --- a/Tests/OigoIssue9ContractTests/main.swift +++ b/Tests/OigoIssue9ContractTests/main.swift @@ -80,7 +80,7 @@ private struct OigoIssue9ContractTests { defer { defaults.removePersistentDomain(forName: suiteName) } let settings = OigoSettingsStore(defaults: defaults) let before = settings.load() - settings.save(before.with(showVolatilePreview: false)) + try settings.save(before.with(showVolatilePreview: false)) let reloaded = OigoSettingsStore(defaults: defaults).load() guard reloaded.showVolatilePreview == false, reloaded.globalShortcut == before.globalShortcut else { @@ -198,7 +198,7 @@ private struct OigoIssue9ContractTests { keepSuccessfulAudioIndefinitely: true, launchAtLogin: true ) - store.save(updated) + try store.save(updated) guard OigoSettingsStore(defaults: defaults).load() == updated else { throw ContractFailure(message: "minimal settings did not round-trip") } diff --git a/docs/issue-11-performance-matrix.md b/docs/issue-11-performance-matrix.md index f33cb91..06cf36a 100644 --- a/docs/issue-11-performance-matrix.md +++ b/docs/issue-11-performance-matrix.md @@ -162,10 +162,10 @@ Record whole-system memory pressure from the trace and the host memory-pressure For every dictation scenario, keep the spoken fixture synthetic and discard the recording after the measurement is extracted. -Use the default shortcut `Command-Option-Space` for a fresh profile by sending: +Use the canonical default shortcut `Shift-Command-Space` for a fresh profile by sending: ```zsh -osascript -e 'tell application "System Events" to key code 49 using {command down, option down}' +osascript -e 'tell application "System Events" to key code 49 using {command down, shift down}' ``` If the profile has a different shortcut, record the configured key action in the operator log without recording transcript content. diff --git a/docs/issue-12-global-shortcut-handoff-matrix.md b/docs/issue-12-global-shortcut-handoff-matrix.md new file mode 100644 index 0000000..6b31743 --- /dev/null +++ b/docs/issue-12-global-shortcut-handoff-matrix.md @@ -0,0 +1,31 @@ +# Issue #12 Global Shortcut Handoff Matrix + +This matrix hands the issue #82 global shortcut scenarios to issue #12's exact-release compatibility and dogfood run. + +It is a handoff artifact, not a claim that issue #12 native acceptance is complete. + +The issue #12 operator must pin one reviewed source SHA and one exact Release `Oigo.app` bundle before running the native rows. + +The operator must record only non-content results and must classify unavailable app-bundle, permission, Speech, input-monitoring, focus, or separate key-edge surfaces as `INCONCLUSIVE`. + +| Handoff row | Deterministic contract | Native issue #12 run | Evidence to carry forward | Required native observation | +| --- | --- | --- | --- | --- | +| H82-01 Press and hold | PASS: one keyboard-owned start | INCONCLUSIVE until exact bundle receives separate key-down and key-up edges | `.omo/evidence/issue-82/task-18-final-verification.log`, `.omo/evidence/issue-82/task-18-native-launch.log` | Another application remains frontmost before, during, and after the operation. | +| H82-02 Release after startup | PASS: permission/Speech/audio startup release latches and stops at recording | INCONCLUSIVE until permission-backed native startup is available | `.omo/evidence/issue-82/task-18-final-verification.log` | No cancellation or discarded recoverable audio occurs. | +| H82-03 Repeat, duplicate, and rapid tap | PASS: no second start, duplicate stop, or stuck preparing state | INCONCLUSIVE until physical repeat and rapid tap can be sent to the exact bundle | `.omo/evidence/issue-82/task-18-final-verification.log` | Exactly one session reaches a terminal result. | +| H82-04 Processing input | PASS: finalizing, cleaning, and inserting produce explicit ignored feedback | INCONCLUSIVE until the processing HUD is observed on the native surface | `.omo/evidence/issue-82/task-18-final-verification.log` | Shortcut input does not cancel or alter finalization, cleanup, or insertion. | +| H82-05 Mouse menu toggle | PASS: issue #3 mouse start and stop remain a separate toggle | INCONCLUSIVE until the exact status menu is exercised | `.omo/evidence/issue-82/task-18-final-verification.log` | Menu Start Dictation and Stop Dictation remain mouse-driven and do not claim keyboard ownership. | +| H82-06 Registration conflict and restoration | PASS: conflict is actionable and prior registration/persistence remain working | INCONCLUSIVE until a real occupied Carbon shortcut is observed in onboarding, Settings, and the menu | `.omo/evidence/issue-82/task-18-final-verification.log` | Validation-only, close, cancellation, failed save, and replacement failure never leave the configured shortcut inactive. | +| H82-07 Recorder key code zero and modified Escape | PASS: key code `0` and modified key code `53` are valid candidates; unmodified Escape cancels | INCONCLUSIVE until the recorder is driven in the exact onboarding and Settings windows | `.omo/evidence/issue-82/task-18-final-verification.log` | Readable native recorder output matches the stored candidate. | +| H82-08 Canonical default | PASS: Shift-Command-Space is `49/0x300` everywhere; exact `49/0x900` migration is isolated | INCONCLUSIVE for a host-level standard-shortcut conflict check | `.omo/evidence/issue-82/task-18-final-verification.log`, `docs/issue-82-global-hotkey-matrix.md` | Registration, stored settings, onboarding, Settings, tests, and docs agree. | +| H82-09 Focus and nonactivation | PASS by static and deterministic path constraints; no keyboard callback activates Oigo | INCONCLUSIVE until frontmost bundle identity is recorded through a native press/release run | `.omo/evidence/issue-82/task-18-native-launch.log` | Oigo never becomes frontmost and the selected target remains the other application. | + +## Issue #12 execution boundary + +The exact Release bundle build passed at the issue #82 final SHA, and an isolated launch observed TextEdit frontmost. + +Computer Use Accessibility and Screen Recording permissions were unavailable during this handoff, so global key edges, microphone, Speech, input-monitoring, native focus, recording, and insertion rows remain `INCONCLUSIVE`. + +The issue #12 run must not convert those rows to `PASS` using SwiftPM callbacks, unsigned-binary launch, deterministic fakes, or static source inspection. + +The issue #12 operator must not mutate TCC, Accessibility, Automation, Input Monitoring, shared defaults, system shortcut state, or private dogfood content to make a row pass. diff --git a/docs/issue-82-global-hotkey-matrix.md b/docs/issue-82-global-hotkey-matrix.md new file mode 100644 index 0000000..d0a54d9 --- /dev/null +++ b/docs/issue-82-global-hotkey-matrix.md @@ -0,0 +1,56 @@ +# Issue 82 Global Hotkey Matrix + +This matrix records the deterministic press-and-hold and configuration contract for issue #82. + +The canonical shortcut is Shift-Command-Space with hardware key code `49` and Carbon modifiers `0x300`. + +The readable word form is `Shift-Command-Space`. + +The native recorder displays modifier glyphs and accepts every valid hardware key code, including key code `0`. + +The former shipped `49/0x900` value is retained only as an exact migration fixture. + +## Deterministic contract rows + +| Scenario | Result | Evidence | Boundary | +| --- | --- | --- | --- | +| Carbon press and release edges | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Strict registration fake delivers both typed edges. | +| Atomic replacement ordering | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Candidate registration precedes prior-registration teardown. | +| Failed replacement and stale generation | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Prior registration remains active and retired callbacks are ignored. | +| Rapid tap and startup release latch | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | One start, one latched release, one safe-boundary stop, and zero cancellation commands. | +| Duplicate and repeat edges | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Duplicate presses, duplicate releases, and repeat presses do not create a second operation. | +| Processing-state input | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Finalizing, cleaning, and inserting return explicit ignored results. | +| Keyboard-owned startup bridge | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Release during startup finishes normally after recording begins. | +| Processing feedback bridge | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Feedback names the exact processing state and counters remain unchanged. | +| Mouse-owned recording isolation | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Keyboard input cannot stop a recording started by the menu command. | +| Canonical default and readable presentation | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Default is `49/0x300` and displays as `Shift-Command-Space`. | +| Canonical default against Apple's documented standard shortcut list | PASS_PUBLIC_MATRIX | `.omo/evidence/issue-82/task-18-final-verification.log`, `docs/issue-82-supported-shortcut-conflict-evidence.md` | `Shift-Command-Space` is absent from the cited Apple standard list; host-customized shortcuts remain an explicit native boundary. | +| Exact legacy default migration | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Only `49/0x900` migrates, including the legacy `globalToggleShortcut` key. | +| Custom key-code-zero preservation | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | `0/0x100` remains unchanged and validates successfully. | +| Native recorder key-code-zero capture | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Non-repeat Command-A emits one candidate and displays `⌘A`. | +| Recorder repeat, unsupported modifier, modified Escape, and unmodified Escape behavior | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Repeats and invalid modifiers are rejected, modified Escape is saved as a valid candidate, and unmodified Escape restores the candidate. | +| Validation-only registration | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Probe leaves the active shortcut and generation unchanged. | +| Atomic save and persistence | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Registration commits before persistence and both values converge. | +| Failed save restoration | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Registration and persistence remain on the prior working shortcut. | +| Cancel and close candidate discard | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Candidate changes are discarded without changing the committed registration. | +| Existing issue-3 mouse toggle contract | PASS | `.omo/evidence/issue-82/task-18-final-verification.log` | Menu-owned start and stop remain separate from keyboard ownership. | + +## Native acceptance rows + +| Scenario | Result | Evidence | Exact limitation | +| --- | --- | --- | --- | +| Exact Release `Oigo.app` bundle build | PASS | `.omo/evidence/issue-82/task-18-native-release-build.log` | Xcode 26.6 on macOS 26.6.1 produced the exact unsigned Release bundle. | +| macOS 26+ exact-bundle launch | PASS | `.omo/evidence/issue-82/task-18-native-launch.log` | The exact bundle launched with isolated defaults and was closed by the harness. | +| Separate global key-down and key-up with another app frontmost | INCONCLUSIVE | `.omo/evidence/issue-82/task-18-native-launch.log` | Computer Use Accessibility and Screen Recording permissions remained unavailable, so OS-level edge automation was not run. | +| Microphone, Speech, and Input Monitoring acceptance | INCONCLUSIVE | `.omo/evidence/issue-82/task-18-native-launch.log` | Permission-backed live capture was not attempted and no TCC or Accessibility state was changed. | +| Focus preservation through recording and insertion | INCONCLUSIVE | `.omo/evidence/issue-82/task-18-native-launch.log` | TextEdit was launched as a disposable frontmost target, but the exact bundle could not be driven through global edges without native permissions. | + +Unavailable native rows are not substituted with SwiftPM, direct callback, unsigned-binary, or frontmost-Oigo evidence. + +Native acceptance must be rerun against the exact Release `Oigo.app` on macOS 26+ with another application frontmost. + +The issue #12 compatibility and dogfood handoff is tracked in `docs/issue-12-global-shortcut-handoff-matrix.md`. + +The native run must record frontmost bundle identity before press, during recording, after release, and after terminal insertion or copy-only completion. + +The native run must not grant, revoke, reset, or otherwise mutate Microphone, Accessibility, Automation, Input Monitoring, shared defaults, or system shortcut state. diff --git a/docs/issue-82-supported-shortcut-conflict-evidence.md b/docs/issue-82-supported-shortcut-conflict-evidence.md new file mode 100644 index 0000000..68e7886 --- /dev/null +++ b/docs/issue-82-supported-shortcut-conflict-evidence.md @@ -0,0 +1,21 @@ +# Issue 82 Supported Shortcut Conflict Evidence + +The canonical default is `Shift-Command-Space`, represented by hardware key code `49` and Carbon modifiers `0x300`. + +The deterministic public matrix compares that exact value with the standard shortcuts listed by Apple Support for macOS 26. + +Apple documents `Command-Space` for Spotlight, `Option-Command-Space` for a Finder search field, `Control-Command-Space` for Character Viewer, `Control-Space` and `Control-Option-Space` for input-source selection, and `Shift-Command-3`, `Shift-Command-4`, and `Shift-Command-5` for screenshots and recording. + +The matrix contains those documented combinations and does not contain `Shift-Command-Space`. + +This is a public-documentation PASS for the cited standard list, not a claim that every application-specific or user-customized shortcut is unused. + +No supported-host System Settings conflict probe was run because the required native accessibility surface was unavailable. + +The native host-level conflict row therefore remains `INCONCLUSIVE`, and the test does not read, write, reset, or otherwise mutate TCC, Accessibility, shared defaults, or system shortcut state. + +Sources: + +- [Apple Support: Mac keyboard shortcuts](https://support.apple.com/en-ie/102650) +- [Apple Support: Spotlight keyboard shortcuts on Mac](https://support.apple.com/en-ca/guide/mac-help/mh26783/mac) +- [Apple Support: Change a conflicting keyboard shortcut on Mac](https://support.apple.com/en-ie/guide/mac-help/mchlp2864/26/mac/26) diff --git a/docs/native-menu-bar.md b/docs/native-menu-bar.md index 468ba2d..5bd19e6 100644 --- a/docs/native-menu-bar.md +++ b/docs/native-menu-bar.md @@ -2,9 +2,15 @@ Oigo is a single arm64 macOS 26 application process built from AppKit. The application uses `NSApplication` with accessory activation policy and `LSUIElement` so it does not appear in the Dock during normal operation. -The status item menu contains only Start Dictation or Stop Dictation, Settings…, and Quit Oigo. -The global toggle is registered with the public Carbon `RegisterEventHotKey` API. -Oigo does not install a global event monitor and does not implement hold-to-talk behavior. +The status item menu contains Start Dictation or Stop Dictation, the current global shortcut status, Settings…, and Quit Oigo. +The global shortcut is registered with the public Carbon `RegisterEventHotKey` API for both pressed and released edges. +The keyboard path starts one operation on the first press and finishes that same operation on release. +Repeats and duplicate edges are ignored by the intent controller. +Release during microphone, Speech, or audio startup is latched until the coordinator reaches recording. +Keyboard release never cancels or discards the operation. +The Start Dictation and Stop Dictation menu item remains a mouse-driven toggle and does not claim keyboard ownership. +The keyboard callback updates a nonactivating status surface and does not make Oigo frontmost. +Oigo does not install a global or local event monitor, use private APIs, or launch a helper process for shortcut input. ## Coordinator