From 4c47f2cf08b7cd15c17261699ea9debe67e02abf Mon Sep 17 00:00:00 2001 From: casona Date: Sun, 25 Oct 2026 22:30:44 +0300 Subject: [PATCH] feat: remap Caps directly through in-process IOKit --- README.md | 170 +++--- Sources/Capswitch/HIDMapping.swift | 143 ----- .../CapswitchApp/HIDRemappingController.swift | 198 +++---- .../CapswitchApp/MacOSInputController.swift | 50 +- .../CapswitchApp/StatusBarController.swift | 33 +- Sources/CapswitchApp/main.swift | 23 + Sources/CapswitchHID/CapswitchRemapping.c | 513 ++++++++++++++++++ Sources/CapswitchHID/include/CapswitchHID.h | 45 +- Taskfile.yml | 48 +- .../HIDMappingCodecTests.swift | 58 -- 10 files changed, 802 insertions(+), 479 deletions(-) delete mode 100644 Sources/Capswitch/HIDMapping.swift create mode 100644 Sources/CapswitchHID/CapswitchRemapping.c delete mode 100644 Tests/CapswitchCoreTests/HIDMappingCodecTests.swift diff --git a/README.md b/README.md index 10891a0..3b639ef 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,117 @@ # Capswitch -Small native macOS menu-bar app. Physical Caps Lock is HID-remapped to F19, which Capswitch intercepts to select the next input source **on release**, including during continuous fast typing. Caps+Option retains the system Caps Lock function. +Native macOS menu-bar keyboard layout switcher, targeting macOS 14+. -## Requirements +**Caps**: switch to the next enabled input source **on release**, without +macOS's stock Caps delay or input-source HUD. -- macOS 14 or newer and Apple Command Line Tools (install with xcode-select --install). -- Task (Taskfile runner). -- Grant **Accessibility** under **System Settings -> Privacy & Security** to - let the app suppress F19. On some macOS setups you may also need - **Input Monitoring**. The menu shows both permissions separately and - provides a grant action; active CGEventTap may work with Accessibility alone. -- **Important for users coming from the stock Caps switch:** Disable - **Keyboard -> Text Input -> Edit -> Use Caps Lock to switch to/from ABC** - while using Capswitch. That macOS shortcut must not compete with our own - input-source selector. Capswitch does not change this setting automatically. -- Keep the Caps Lock key set to its normal role in **Keyboard -> Keyboard Shortcuts -> Modifier Keys**, not "No Action". -- No Xcode project, root helper, Karabiner or third-party driver is needed. +**Caps + Option** in either order: toggle the actual system Caps Lock state. -## Build and test on your Mac +## How it works -**Quit the currently running Capswitch from its menu first.** Opening an already running app may activate an old process even if a new bundle was created. The Taskfile refuses to replace the app bundle while a Capswitch process is running. +Capswitch uses **IOKit APIs inside its own process** to remap physical HID +Caps Lock (usage 0x39) to F19 (usage 0x6E). Its active CoreGraphics event tap +intercepts F19 down/up and suppresses them, switching layouts on F19 release. +A small C bridge built into Capswitch contains the IOKit calls required by +Swift. There is **no subprocess**, no hidutil dependency, no external helper, +no driver, and no requirement to give permissions to a system executable. -~~~bash +Apple documents the direct APIs used for remapping in Technical Note TN2450: +https://developer.apple.com/library/archive/technotes/tn2450/ + +## Build on your Mac + +Install Command Line Tools and the Task runner. Full Xcode is not needed. + +**Quit any running Capswitch from the menu before rebuilding.** An already +running process won't be replaced merely by opening a new .app file. + +~~~ git pull origin main task test task bundle open build/Capswitch.app ~~~ -After opening Capswitch, grant **Accessibility** from the status-bar menu. -If the event tap is refused or no F19 events are received, also grant -**Input Monitoring** from the menu. Restart Capswitch after granting -permissions if macOS prompts you to do so. +The bundle is ad-hoc signed with a stable bundle identifier +(dev.local.Capswitch). Keep its installed path stable so macOS can retain +its privacy grants. -### macOS Sonoma: grant Input Monitoring to hidutil itself +## Permissions - Capswitch only -On macOS 14, an Input Monitoring grant for Capswitch.app may not be -sufficient for its child process /usr/bin/hidutil, which is used to set -the actual global HID mapping. If task doctor reports (null), Caps->F19 -is not installed (regardless of whether the app process is running). +At first launch Capswitch requests access via macOS: +- **Accessibility**: needed for an active event tap that suppresses F19. +- **Input Monitoring**, if macOS requires it: needed on some macOS setups + for observing keyboard events or interacting with the HID subsystem. -Open **System Settings -> Privacy & Security -> Input Monitoring**, click -the + button, then press **Command+Shift+G** in the file picker and enter -**/usr/bin/hidutil**. Select hidutil and enable its Input Monitoring -toggle. This is separate from Capswitch.app. Quit and reopen Capswitch, -then run task doctor again. See also the Sonoma notes for -mvp/mac-remap-keys. +Approve Capswitch.app, *never* /usr/bin/hidutil. macOS requires your own +confirmation in System Settings; applications cannot grant themselves access. -If the mapping remains null, inspect the **Capswitch menu** and capture -the Accessibility / Event tap / HID / Error entries. The application may -be running without having created its event tap at all; Input Monitoring -for hidutil cannot fix missing Accessibility permission. +You can check/access those settings through the Capswitch menu. If granting +a permission requires a restart, Quit and reopen Capswitch. -If you previously used macOS's built-in Caps-to-input-source switching, open -System Settings -> Keyboard -> Text Input -> Edit and uncheck -"Use Caps Lock to switch to and from [last used Latin input source]". -Keep Caps assigned to the normal Caps Lock action in Modifier Keys. +To avoid competing with the stock shortcut, disable +**System Settings -> Keyboard -> Text Input -> Edit -> +Use Caps Lock to switch to/from ABC**. Leave Caps Lock with its normal +Caps Lock modifier role (not "No Action") in Keyboard Shortcuts -> Modifier Keys. +Capswitch never secretly changes these system preferences. -Open the Capswitch menu and confirm: -- Accessibility: Granted -- Input Monitoring: Granted (or not granted if the active tap works) -- Event tap: Enabled -- HID Caps -> F19: configured +## Status menu -These confirm permissions and the global configuration, **not** yet that the -physical keyboard is actually sending F19. Use the counters to verify it. +The menu shows: +- Current layout +- Native HID keyboard mapping count (e.g. 2/2 keyboards) +- Whether the keyboard event tap is enabled +- Accessibility and Input Monitoring grants +- F19 down/up and input-source switch counters (no typed text is recorded) +- Errors and a manual "Retry Caps -> F19 mapping" action +- Pause (restores Caps) and Quit (restores Caps) -## Behavior +When testing fast typing, repeatedly tap Caps while typing letters. If F19 +counts increase but the switch counter doesn't, check the enabled TIS sources. +If the F19 counters remain zero, check the event tap, privacy grants, and HID +keyboard service count in the menu. -- Plain physical Caps emits F19 keyDown/keyUp. Capswitch swallows both events and on F19 keyUp selects the next enabled keyboard input source. -- Caps+Option in either order toggles the actual system Caps Lock state through IOKit after Option has been released. -- Pause from the menu disables the event tap and restores the Caps key to its original purpose. -- Quit normally restores only Capswitch's Caps->F19 mapping, leaving unrelated mappings alone. -- On wake or keyboard reconnection, Capswitch checks whether its HID mapping is still present. +## Native diagnostics -## Diagnostics - -Open the status-bar menu while testing quick typing. It displays: - - F19 ↓N ↑N · switches N - -No typed text is recorded. If the F19 counts do not change, the HID remap is not producing F19 events -or the event tap is not receiving them. First check both permissions, the -event tap status and the native Caps switch preference. Then use the -"Reapply Caps -> F19 mapping" menu action and repeat the test. - -If F19 counts increase but switches do not, investigate TIS input-source -selection. If the stock Caps input switch still operates while Capswitch -is active, the HID remap may not be applied to your physical keyboard. - -To gather diagnostics without logging any typed text: - -~~~bash +~~~ task doctor ~~~ -This prints the native Caps preference, global UserKeyMapping and the keyboard -service-specific mapping; these can differ even when hidutil exits successfully. +This executes **Capswitch's own signed executable** in a read-only inspection +mode and reports the number of HID keyboard services and how many have +Caps->F19 configured. It does not launch hidutil and does not change keyboard +configuration. The command requires an existing app bundle. -Caps+Option through IOKit and its LED behavior still require verification on an actual Mac. +## HID safety -## HID remapping safety +Capswitch reads **each keyboard service's existing mappings**, refuses +conflicting mappings involving Caps or F19, and preserves unrelated mappings. +It remembers which services it modified, storing their registry IDs in the +app's own preferences for crash recovery. -HID key mapping is global. Capswitch first reads existing mappings, refuses collisions involving Caps or F19, and preserves unrelated remaps. It removes only its own rule on normal Quit/Pause. A small ownership marker allows a later launch to restore a rule that Capswitch left behind after a crash. +During Pause or normal Quit it removes only the exact Caps->F19 rule from +those services. Newly connected keyboards are handled when the app refreshes +its HID state. If Capswitch is forcibly killed, its mapping may remain until +the next successful Capswitch launch/quit, keyboard service removal, or reboot. +If the IOKit service rejects a mapping, Capswitch shows an error rather than +silently claiming success. -If the app is forcibly terminated, Caps may remain mapped to F19 until the app is relaunched and Quit normally, or until reboot. To inspect mappings: - -~~~bash -hidutil property --get UserKeyMapping -~~~ - -**Do not clear all UserKeyMapping rules if you use other remappers.** Only if the sole entry is the one Capswitch inserted may you reset mappings manually: - -~~~bash -hidutil property --set '{"UserKeyMapping":[]}' -~~~ - -hidutil remapping normally resets after restart or after the last keyboard service disappears. +If you need to recover, use the menu's Pause/Quit action after restarting +Capswitch. In an emergency reboot macOS to clear HID remappings. Do not +blindly clear other programs' mapping tables. ## Development -~~~bash +~~~ +task verify task lint task test task build task bundle -task clean -task verify task doctor +task clean ~~~ -Linux RSB runs host-independent static checks only. SwiftPM builds, XCTest, IOKit and actual keyboard input behavior must be verified on macOS with the Apple SDK. +The Linux RSB host can run static checks but has no Apple AppKit/IOKit SDK. +The final Swift build and real keyboard tests must be run on macOS. diff --git a/Sources/Capswitch/HIDMapping.swift b/Sources/Capswitch/HIDMapping.swift deleted file mode 100644 index 14fc122..0000000 --- a/Sources/Capswitch/HIDMapping.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation - -public struct HIDKeyMapping: Equatable, Hashable { - public let source: UInt64 - public let destination: UInt64 - - public init(source: UInt64, destination: UInt64) { - self.source = source - self.destination = destination - } -} - -public enum HIDMappingCodecError: Error, Equatable { - case unrecognizedOutput - case invalidEntry -} - -// Parse OpenStep-style hidutil --get output. Unknown entries are rejected -// rather than risking deletion of someone else's keyboard remappings. -public enum HIDMappingCodec { - public static let capsUsage: UInt64 = 0x700000039 - public static let f19Usage: UInt64 = 0x70000006E - public static let capsToF19 = HIDKeyMapping( - source: capsUsage, - destination: f19Usage - ) - - public static func parse(_ output: String) throws -> [HIDKeyMapping] { - var value = output.trimmingCharacters(in: .whitespacesAndNewlines) - - if value.hasPrefix("UserKeyMapping:") { - value = String(value.dropFirst("UserKeyMapping:".count)) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - guard value.first == "(", value.last == ")" else { - throw HIDMappingCodecError.unrecognizedOutput - } - - let inner = String(value.dropFirst().dropLast()) - .trimmingCharacters(in: .whitespacesAndNewlines) - - if inner.isEmpty || inner == "null" { - return [] - } - - let entryPattern = try NSRegularExpression(pattern: #"\{([^{}]*)\}"#) - let matches = entryPattern.matches( - in: inner, - range: NSRange(inner.startIndex.. String { - let entries: [[String: NSNumber]] = mappings.map { - [ - "HIDKeyboardModifierMappingSrc": NSNumber(value: $0.source), - "HIDKeyboardModifierMappingDst": NSNumber(value: $0.destination), - ] - } - let data = try JSONSerialization.data( - withJSONObject: ["UserKeyMapping": entries], - options: [.sortedKeys] - ) - return String(decoding: data, as: UTF8.self) - } - - private static func parseEntry(_ body: String) throws -> HIDKeyMapping { - let fieldPattern = try NSRegularExpression( - pattern: #"([A-Za-z][A-Za-z0-9_]*)\s*=\s*([^;]+);"# - ) - let matches = fieldPattern.matches( - in: body, - range: NSRange(body.startIndex.. F19 rule. Other mappings are preserved both when -/// installing and when restoring on quit or Pause. +/// Owns the native IOKit remapping manager inside Capswitch itself. +/// No subprocess, hidutil, privileged helper, driver or third-party library. final class HIDRemappingController { - private let markerKey = "CapswitchOwnsCapsF19Mapping" - private let ourMapping = HIDMappingCodec.capsToF19 + private let manager: UnsafeMutableRawPointer? private(set) var isInstalled = false - private var ownsMapping = false + private(set) var keyboardServiceCount: UInt32 = 0 + private(set) var mappedServiceCount: UInt32 = 0 + + init() { + manager = CapswitchRemapperCreate() + } + + deinit { + if let manager { + CapswitchRemapperDestroy(manager) + } + } func ensureInstalled() throws { - let current = try readMappings() - - if current.contains(ourMapping) { - // Adopt a rule left behind after Capswitch crashed. A pre-existing - // user-managed Caps -> F19 rule has no marker and is never removed. - ownsMapping = ownsMapping || UserDefaults.standard.bool(forKey: markerKey) - if ownsMapping && !isInstalled { - // Reapply once after a crash/relaunch. Global UserKeyMapping - // may be present while a keyboard service still has no mapping. - try writeMappings(current) - } - isInstalled = true - return + guard let manager else { + throw HIDRemapError.failure(1) } - // Do not overwrite Caps remapped to something else, F19 remapped away, - // or a different physical key remapped to F19. - if current.contains(where: { - $0.source == HIDMappingCodec.capsUsage - || $0.source == HIDMappingCodec.f19Usage - || $0.destination == HIDMappingCodec.f19Usage - }) { - throw HIDRemapError.conflictingMapping + var keyboardCount: UInt32 = 0 + var mappedCount: UInt32 = 0 + let status = CapswitchRemapperApply(manager, &keyboardCount, &mappedCount) + + keyboardServiceCount = keyboardCount + mappedServiceCount = mappedCount + isInstalled = status == 0 && keyboardCount > 0 + && mappedCount == keyboardCount + + guard status == 0, isInstalled else { + throw HIDRemapError.failure(status == 0 ? 7 : status) } - - // The marker is written before modifying global HID state so the next - // launch can clean up an interrupted installation after a crash. - UserDefaults.standard.set(true, forKey: markerKey) - try writeMappings(current + [ourMapping]) - - guard try readMappings().contains(ourMapping) else { - throw HIDRemapError.verificationFailed - } - - ownsMapping = true - isInstalled = true } func reapplyIfOwned() throws { - guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else { - throw HIDRemapError.conflictingMapping + // Reconciliation is idempotent: the native layer checks every + // keyboard and only installs missing mappings it can safely own. + try ensureInstalled() + } + + func inspect() throws { + guard let manager else { + throw HIDRemapError.failure(1) } - let current = try readMappings() - guard current.contains(ourMapping) else { - try ensureInstalled() - return - } + var keyboardCount: UInt32 = 0 + var mappedCount: UInt32 = 0 + let status = CapswitchRemapperInspect(manager, &keyboardCount, &mappedCount) + keyboardServiceCount = keyboardCount + mappedServiceCount = mappedCount - // Re-broadcast the complete mapping table, never an empty replacement. - try writeMappings(current) - guard try readMappings().contains(ourMapping) else { - throw HIDRemapError.verificationFailed + guard status == 0 else { + throw HIDRemapError.failure(status) } - - isInstalled = true } func restoreIfOwned() throws { - guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else { - isInstalled = false - return + guard let manager else { + throw HIDRemapError.failure(1) } - let current = try readMappings() - - if current.contains(ourMapping) { - // Remove only our exact pair, preserving any changes made by - // another remapper while Capswitch was running. - try writeMappings(current.filter { $0 != ourMapping }) - - if try readMappings().contains(ourMapping) { - throw HIDRemapError.verificationFailed - } + let status = CapswitchRemapperRestore(manager) + guard status == 0 else { + throw HIDRemapError.failure(status) } - ownsMapping = false isInstalled = false - UserDefaults.standard.removeObject(forKey: markerKey) - } - - private func readMappings() throws -> [HIDKeyMapping] { - let output = try runHidutil(["property", "--get", "UserKeyMapping"]) - - do { - return try HIDMappingCodec.parse(output) - } catch { - throw HIDRemapError.unreadableMapping - } - } - - private func writeMappings(_ mappings: [HIDKeyMapping]) throws { - let argument = try HIDMappingCodec.setArgument(mappings) - _ = try runHidutil(["property", "--set", argument]) - } - - private func runHidutil(_ arguments: [String]) throws -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/hidutil") - process.arguments = arguments - - let output = Pipe() - let errors = Pipe() - process.standardOutput = output - process.standardError = errors - - do { - try process.run() - } catch { - throw HIDRemapError.commandFailed(error.localizedDescription) - } - - process.waitUntilExit() - let data = output.fileHandleForReading.readDataToEndOfFile() - let errorData = errors.fileHandleForReading.readDataToEndOfFile() - let message = String(decoding: errorData, as: UTF8.self) - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard process.terminationStatus == 0 else { - throw HIDRemapError.commandFailed( - message.isEmpty ? "exit code \(process.terminationStatus)" : message - ) - } - - return String(decoding: data, as: UTF8.self) + keyboardServiceCount = 0 + mappedServiceCount = 0 } } diff --git a/Sources/CapswitchApp/MacOSInputController.swift b/Sources/CapswitchApp/MacOSInputController.swift index 7af6cd0..231e0c2 100644 --- a/Sources/CapswitchApp/MacOSInputController.swift +++ b/Sources/CapswitchApp/MacOSInputController.swift @@ -181,8 +181,12 @@ final class MacOSInputController { remapper.isInstalled } - // This key is an undocumented macOS preference; missing means unknown, - // not "off". The UI never changes the user's global keyboard settings. + var mappingStatus: String { + "\(remapper.mappedServiceCount)/\(remapper.keyboardServiceCount) keyboards" + } + + // Undocumented macOS preference: only diagnostic, never modified by us. + // Missing means unknown, not off. var nativeCapsSwitchState: String { let value = UserDefaults.standard.object(forKey: "TISRomanSwitchState") ?? UserDefaults(suiteName: "com.apple.HIToolbox")? @@ -274,7 +278,19 @@ final class MacOSInputController { onStateChanged?() } - func stop() { + @discardableResult + func stop() -> Bool { + // Restore the actual Caps key before disabling the F19 interceptor. + // If restoration fails, refuse a normal Pause/Quit so F19 cannot be + // left unhandled by an otherwise healthy running Capswitch. + do { + try remapper.restoreIfOwned() + } catch { + lastError = "Cannot restore Caps remap: \(error.localizedDescription)" + onStateChanged?() + return false + } + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } @@ -290,13 +306,8 @@ final class MacOSInputController { eventTap = nil runLoopSource = nil resetGestureState() - - do { - try remapper.restoreIfOwned() - } catch { - lastError = "Cannot restore Caps remap: \(error.localizedDescription)" - } onStateChanged?() + return true } func setPaused(_ paused: Bool) { @@ -304,10 +315,12 @@ final class MacOSInputController { return } - isPaused = paused if paused { - stop() + if stop() { + isPaused = true + } } else { + isPaused = false do { try start() } catch { @@ -358,6 +371,21 @@ final class MacOSInputController { onStateChanged?() } + func requestRequiredPermissions() { + if !accessibilityTrusted { + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: + true as CFBoolean + ] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + + if !inputMonitoringTrusted { + // Request Input Monitoring for our own application identity. + _ = CGRequestListenEventAccess() + } + } + func requestInputMonitoringPermission() { _ = CGRequestListenEventAccess() if let url = URL( diff --git a/Sources/CapswitchApp/StatusBarController.swift b/Sources/CapswitchApp/StatusBarController.swift index fc17f5a..c2895c0 100644 --- a/Sources/CapswitchApp/StatusBarController.swift +++ b/Sources/CapswitchApp/StatusBarController.swift @@ -32,7 +32,7 @@ final class StatusBarController: NSObject, NSMenuDelegate { keyEquivalent: "" ) private let reapplyMappingItem = NSMenuItem( - title: "Reapply Caps → F19 mapping", + title: "Retry Caps → F19 mapping", action: #selector(reapplyMapping), keyEquivalent: "" ) @@ -87,6 +87,7 @@ final class StatusBarController: NSObject, NSMenuDelegate { object: nil ) + inputController.requestRequiredPermissions() refreshController() } @@ -151,10 +152,14 @@ final class StatusBarController: NSObject, NSMenuDelegate { now >= nextStartAttempt { nextStartAttempt = now.addingTimeInterval(5) - do { - try inputController.start() - } catch { - inputController.lastError = error.localizedDescription + if inputController.isTapEnabled { + inputController.repairMappingIfNeeded() + } else { + do { + try inputController.start() + } catch { + inputController.lastError = error.localizedDescription + } } } @@ -184,10 +189,10 @@ final class StatusBarController: NSObject, NSMenuDelegate { tapStatusItem.isEnabled = false hidMappingItem.title = inputController.hidMappingConfigured - ? "HID Caps → F19: configured (verify physical keyboard)" - : "HID Caps → F19: not configured" + ? "Native HID: \(inputController.mappingStatus)" + : "Native HID inactive: \(inputController.mappingStatus)" hidMappingItem.isEnabled = false - reapplyMappingItem.isEnabled = inputController.isRunning + reapplyMappingItem.isEnabled = inputController.isTapEnabled errorItem.isHidden = inputController.lastError == nil errorItem.title = "Error: \(inputController.lastError ?? "")" @@ -255,6 +260,16 @@ final class StatusBarController: NSObject, NSMenuDelegate { } @objc private func quit() { - NSApp.terminate(nil) + if inputController.stop() { + NSApp.terminate(nil) + } else { + update() + let alert = NSAlert() + alert.messageText = "Caps Lock could not be restored" + alert.informativeText = inputController.lastError + ?? "The native HID service rejected the restoration." + alert.addButton(withTitle: "Keep Capswitch Running") + alert.runModal() + } } } diff --git a/Sources/CapswitchApp/main.swift b/Sources/CapswitchApp/main.swift index 5dec54f..3daca20 100644 --- a/Sources/CapswitchApp/main.swift +++ b/Sources/CapswitchApp/main.swift @@ -1,4 +1,27 @@ import AppKit +import Darwin +import Foundation + +if CommandLine.arguments.contains("--doctor") { + // Read-only inspection uses the same native IOKit code as the GUI. + // This command never changes mappings or starts an event tap. + let remapper = HIDRemappingController() + do { + try remapper.inspect() + print("Capswitch - native HID diagnostic") + print("Keyboard services: \(remapper.keyboardServiceCount)") + print("Caps → F19 mappings: \(remapper.mappedServiceCount)") + if remapper.mappedServiceCount == remapper.keyboardServiceCount { + print("Result: all detected keyboard services map Caps to F19") + } else { + print("Result: Caps → F19 is missing on at least one keyboard") + } + exit(0) + } catch { + fputs("Capswitch HID check: \(error.localizedDescription)\n", stderr) + exit(1) + } +} let application = NSApplication.shared let delegate = AppDelegate() diff --git a/Sources/CapswitchHID/CapswitchRemapping.c b/Sources/CapswitchHID/CapswitchRemapping.c new file mode 100644 index 0000000..d31861c --- /dev/null +++ b/Sources/CapswitchHID/CapswitchRemapping.c @@ -0,0 +1,513 @@ +#include "CapswitchHID.h" + +#include +#include +#include +#include +#include + +#define CAPSWITCH_CAPS_USAGE UINT64_C(0x700000039) +#define CAPSWITCH_F19_USAGE UINT64_C(0x70000006E) + +// These exact property names are documented in Apple Technical Note TN2450. +#define SOURCE_KEY CFSTR("HIDKeyboardModifierMappingSrc") +#define DEST_KEY CFSTR("HIDKeyboardModifierMappingDst") +#define MAPPING_KEY CFSTR("UserKeyMapping") +#define OWNED_SERVICES_KEY CFSTR("CapswitchOwnedKeyboardServiceIDs") + +typedef struct { + CFMutableSetRef ownedServices; +} CapswitchRemapper; + +static void PersistOwnedServices(CapswitchRemapper *manager) { + CFIndex count = CFSetGetCount(manager->ownedServices); + const void **ids = count > 0 ? calloc((size_t)count, sizeof(void *)) : NULL; + + if (count > 0 && ids == NULL) { + return; + } + + if (count > 0) { + CFSetGetValues(manager->ownedServices, ids); + } + + CFArrayRef array = CFArrayCreate( + kCFAllocatorDefault, ids, count, &kCFTypeArrayCallBacks + ); + free(ids); + + if (array != NULL) { + CFPreferencesSetAppValue( + OWNED_SERVICES_KEY, array, kCFPreferencesCurrentApplication + ); + CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication); + CFRelease(array); + } +} + +CapswitchRemapperRef CapswitchRemapperCreate(void) { + CapswitchRemapper *manager = calloc(1, sizeof(*manager)); + if (manager == NULL) { + return NULL; + } + + manager->ownedServices = CFSetCreateMutable( + kCFAllocatorDefault, 0, &kCFTypeSetCallBacks + ); + if (manager->ownedServices == NULL) { + free(manager); + return NULL; + } + + CFTypeRef saved = CFPreferencesCopyAppValue( + OWNED_SERVICES_KEY, kCFPreferencesCurrentApplication + ); + if (saved != NULL && CFGetTypeID(saved) == CFArrayGetTypeID()) { + CFArrayRef ids = (CFArrayRef)saved; + for (CFIndex i = 0; i < CFArrayGetCount(ids); i++) { + CFTypeRef id = CFArrayGetValueAtIndex(ids, i); + if (CFGetTypeID(id) == CFNumberGetTypeID()) { + CFSetAddValue(manager->ownedServices, id); + } + } + } + if (saved != NULL) { + CFRelease(saved); + } + + return manager; +} + +void CapswitchRemapperDestroy(CapswitchRemapperRef reference) { + CapswitchRemapper *manager = (CapswitchRemapper *)reference; + if (manager == NULL) { + return; + } + if (manager->ownedServices != NULL) { + CFRelease(manager->ownedServices); + } + free(manager); +} + +static bool KeyboardService(IOHIDServiceClientRef service) { + return IOHIDServiceClientConformsTo( + service, kHIDPage_GenericDesktop, kHIDUsage_GD_Keyboard + ) != 0; +} + +static CFNumberRef ServiceID(IOHIDServiceClientRef service) { + CFTypeRef id = IOHIDServiceClientGetRegistryID(service); + if (id == NULL || CFGetTypeID(id) != CFNumberGetTypeID()) { + return NULL; + } + return (CFNumberRef)id; // Get: borrowed, not released by the caller. +} + +static CFArrayRef CopyMappings(IOHIDServiceClientRef service) { + CFTypeRef property = IOHIDServiceClientCopyProperty(service, MAPPING_KEY); + + if (property == NULL) { + // A missing UserKeyMapping property means there are no active pairs. + return CFArrayCreate(kCFAllocatorDefault, NULL, 0, &kCFTypeArrayCallBacks); + } + + if (CFGetTypeID(property) != CFArrayGetTypeID()) { + CFRelease(property); + return NULL; + } + return (CFArrayRef)property; +} + +static bool ReadPair( + CFTypeRef object, uint64_t *source, uint64_t *destination +) { + if (CFGetTypeID(object) != CFDictionaryGetTypeID()) { + return false; + } + + CFDictionaryRef mapping = (CFDictionaryRef)object; + CFTypeRef src = CFDictionaryGetValue(mapping, SOURCE_KEY); + CFTypeRef dst = CFDictionaryGetValue(mapping, DEST_KEY); + + if (src == NULL || dst == NULL + || CFGetTypeID(src) != CFNumberGetTypeID() + || CFGetTypeID(dst) != CFNumberGetTypeID()) { + return false; + } + + int64_t signedSource = 0; + int64_t signedDestination = 0; + if (!CFNumberGetValue((CFNumberRef)src, kCFNumberSInt64Type, &signedSource) + || !CFNumberGetValue((CFNumberRef)dst, kCFNumberSInt64Type, &signedDestination) + || signedSource < 0 || signedDestination < 0) { + return false; + } + + *source = (uint64_t)signedSource; + *destination = (uint64_t)signedDestination; + return true; +} + +// -1: malformed; 0: not mapped; 1: Caps->F19 already mapped; 2: conflict. +static int MappingState(CFArrayRef mappings) { + bool found = false; + + for (CFIndex i = 0; i < CFArrayGetCount(mappings); i++) { + uint64_t source = 0; + uint64_t destination = 0; + if (!ReadPair(CFArrayGetValueAtIndex(mappings, i), &source, &destination)) { + return -1; + } + + if (source == CAPSWITCH_CAPS_USAGE + && destination == CAPSWITCH_F19_USAGE) { + found = true; + continue; + } + + if (source == CAPSWITCH_CAPS_USAGE + || source == CAPSWITCH_F19_USAGE + || destination == CAPSWITCH_CAPS_USAGE + || destination == CAPSWITCH_F19_USAGE) { + return 2; + } + } + + return found ? 1 : 0; +} + +static CFDictionaryRef CreateOurMapping(void) { + int64_t source = (int64_t)CAPSWITCH_CAPS_USAGE; + int64_t destination = (int64_t)CAPSWITCH_F19_USAGE; + CFNumberRef sourceValue = CFNumberCreate( + kCFAllocatorDefault, kCFNumberSInt64Type, &source + ); + CFNumberRef destinationValue = CFNumberCreate( + kCFAllocatorDefault, kCFNumberSInt64Type, &destination + ); + + if (sourceValue == NULL || destinationValue == NULL) { + if (sourceValue != NULL) CFRelease(sourceValue); + if (destinationValue != NULL) CFRelease(destinationValue); + return NULL; + } + + const void *keys[] = { SOURCE_KEY, DEST_KEY }; + const void *values[] = { sourceValue, destinationValue }; + CFDictionaryRef mapping = CFDictionaryCreate( + kCFAllocatorDefault, keys, values, 2, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + CFRelease(sourceValue); + CFRelease(destinationValue); + return mapping; +} + +static int32_t InspectService( + IOHIDServiceClientRef service, bool *mapped +) { + CFArrayRef current = CopyMappings(service); + if (current == NULL) { + return CapswitchRemapInvalidExistingMappings; + } + + int state = MappingState(current); + CFRelease(current); + if (state < 0) { + return CapswitchRemapInvalidExistingMappings; + } + if (state == 2) { + return CapswitchRemapConflict; + } + + *mapped = state == 1; + return CapswitchRemapOK; +} + +// Keeps a separate record of ownership for each HID keyboard service so +// removing our remap will never remove another program's mapping. +static int32_t InstallService( + CapswitchRemapper *manager, IOHIDServiceClientRef service +) { + CFNumberRef id = ServiceID(service); + if (id == NULL) { + return CapswitchRemapMissingRegistryID; + } + + CFArrayRef current = CopyMappings(service); + if (current == NULL) { + return CapswitchRemapInvalidExistingMappings; + } + + int state = MappingState(current); + if (state < 0) { + CFRelease(current); + return CapswitchRemapInvalidExistingMappings; + } + if (state == 2) { + CFRelease(current); + return CapswitchRemapConflict; + } + if (state == 1) { + // Existing Caps->F19 mapping may belong to somebody else. + // Only the per-service ownership record authorizes restoration. + CFRelease(current); + return CapswitchRemapOK; + } + + CFDictionaryRef ourMapping = CreateOurMapping(); + CFMutableArrayRef desired = CFArrayCreateMutableCopy( + kCFAllocatorDefault, 0, current + ); + CFRelease(current); + + if (ourMapping == NULL || desired == NULL) { + if (ourMapping != NULL) CFRelease(ourMapping); + if (desired != NULL) CFRelease(desired); + return CapswitchRemapOutOfMemory; + } + CFArrayAppendValue(desired, ourMapping); + CFRelease(ourMapping); + + // Persist ownership before writing so a crash immediately after writing + // does not leave behind a remap the next launch cannot recognize. + CFSetAddValue(manager->ownedServices, id); + PersistOwnedServices(manager); + + bool set = IOHIDServiceClientSetProperty(service, MAPPING_KEY, desired); + CFRelease(desired); + + if (!set) { + // Keep the ownership record: a partially accepted write can be safely + // removed by Restore after observing the exact pair on this service. + return CapswitchRemapSetFailed; + } + + bool mapped = false; + int32_t result = InspectService(service, &mapped); + if (result != CapswitchRemapOK) { + return result; + } + return mapped ? CapswitchRemapOK : CapswitchRemapVerificationFailed; +} + +static int32_t WithKeyboardServices( + CapswitchRemapper *manager, + bool install, + uint32_t *total, + uint32_t *mapped +) { + if (total != NULL) *total = 0; + if (mapped != NULL) *mapped = 0; + if (manager == NULL) return CapswitchRemapOutOfMemory; + + IOHIDEventSystemClientRef system = + IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault); + if (system == NULL) { + return CapswitchRemapClientUnavailable; + } + + CFArrayRef services = IOHIDEventSystemClientCopyServices(system); + if (services == NULL) { + CFRelease(system); + return CapswitchRemapClientUnavailable; + } + + uint32_t keyboardCount = 0; + uint32_t mappedCount = 0; + int32_t result = CapswitchRemapOK; + + for (CFIndex i = 0; i < CFArrayGetCount(services); i++) { + IOHIDServiceClientRef service = (IOHIDServiceClientRef) + CFArrayGetValueAtIndex(services, i); + if (!KeyboardService(service)) { + continue; + } + + keyboardCount++; + + if (install) { + result = InstallService(manager, service); + } else { + bool isMapped = false; + result = InspectService(service, &isMapped); + } + if (result != CapswitchRemapOK) { + break; + } + + bool isMapped = false; + result = InspectService(service, &isMapped); + if (result != CapswitchRemapOK) { + break; + } + if (isMapped) mappedCount++; + } + + CFRelease(services); + CFRelease(system); + + if (total != NULL) *total = keyboardCount; + if (mapped != NULL) *mapped = mappedCount; + + if (result != CapswitchRemapOK) { + return result; + } + return keyboardCount > 0 ? CapswitchRemapOK : CapswitchRemapNoKeyboards; +} + +int32_t CapswitchRemapperApply( + CapswitchRemapperRef reference, + uint32_t *keyboardCount, + uint32_t *mappedCount +) { + return WithKeyboardServices( + (CapswitchRemapper *)reference, true, keyboardCount, mappedCount + ); +} + +int32_t CapswitchRemapperInspect( + CapswitchRemapperRef reference, + uint32_t *keyboardCount, + uint32_t *mappedCount +) { + return WithKeyboardServices( + (CapswitchRemapper *)reference, false, keyboardCount, mappedCount + ); +} + +int32_t CapswitchRemapperRestore(CapswitchRemapperRef reference) { + CapswitchRemapper *manager = (CapswitchRemapper *)reference; + if (manager == NULL) return CapswitchRemapOutOfMemory; + + IOHIDEventSystemClientRef system = + IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault); + if (system == NULL) return CapswitchRemapClientUnavailable; + + CFArrayRef services = IOHIDEventSystemClientCopyServices(system); + if (services == NULL) { + CFRelease(system); + return CapswitchRemapClientUnavailable; + } + + int32_t result = CapswitchRemapOK; + CFMutableSetRef connected = CFSetCreateMutable( + kCFAllocatorDefault, 0, &kCFTypeSetCallBacks + ); + if (connected == NULL) { + CFRelease(services); + CFRelease(system); + return CapswitchRemapOutOfMemory; + } + + for (CFIndex i = 0; i < CFArrayGetCount(services); i++) { + IOHIDServiceClientRef service = (IOHIDServiceClientRef) + CFArrayGetValueAtIndex(services, i); + if (!KeyboardService(service)) continue; + + CFNumberRef id = ServiceID(service); + if (id == NULL) { + result = CapswitchRemapMissingRegistryID; + break; + } + CFSetAddValue(connected, id); + + if (!CFSetContainsValue(manager->ownedServices, id)) continue; + + CFArrayRef current = CopyMappings(service); + if (current == NULL) { + result = CapswitchRemapInvalidExistingMappings; + break; + } + + CFMutableArrayRef desired = CFArrayCreateMutable( + kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks + ); + if (desired == NULL) { + CFRelease(current); + result = CapswitchRemapOutOfMemory; + break; + } + + bool found = false; + for (CFIndex j = 0; j < CFArrayGetCount(current); j++) { + CFTypeRef entry = CFArrayGetValueAtIndex(current, j); + uint64_t source = 0; + uint64_t destination = 0; + + if (!ReadPair(entry, &source, &destination)) { + result = CapswitchRemapInvalidExistingMappings; + break; + } + if (source == CAPSWITCH_CAPS_USAGE + && destination == CAPSWITCH_F19_USAGE) { + found = true; + } else { + CFArrayAppendValue(desired, entry); + } + } + CFRelease(current); + + if (result == CapswitchRemapOK && found) { + if (!IOHIDServiceClientSetProperty(service, MAPPING_KEY, desired)) { + result = CapswitchRemapSetFailed; + } else { + // Verify only that our exact rule disappeared. A third party + // may have added another rule during our lifetime; it must + // not prevent us from restoring (or erasing that rule). + CFArrayRef verified = CopyMappings(service); + if (verified == NULL) { + result = CapswitchRemapInvalidExistingMappings; + } else { + for (CFIndex k = 0; k < CFArrayGetCount(verified); k++) { + uint64_t source = 0; + uint64_t destination = 0; + CFTypeRef entry = CFArrayGetValueAtIndex(verified, k); + + if (!ReadPair(entry, &source, &destination)) { + result = CapswitchRemapInvalidExistingMappings; + break; + } + if (source == CAPSWITCH_CAPS_USAGE + && destination == CAPSWITCH_F19_USAGE) { + result = CapswitchRemapVerificationFailed; + break; + } + } + CFRelease(verified); + } + } + } + + CFRelease(desired); + if (result != CapswitchRemapOK) break; + + CFSetRemoveValue(manager->ownedServices, id); + PersistOwnedServices(manager); + } + + // IDs for disconnected services can be discarded; macOS discards their + // key mappings when the keyboard service is removed. + if (result == CapswitchRemapOK) { + CFIndex count = CFSetGetCount(manager->ownedServices); + const void **ids = count > 0 ? calloc((size_t)count, sizeof(void *)) : NULL; + if (count > 0 && ids == NULL) { + result = CapswitchRemapOutOfMemory; + } else { + if (count > 0) CFSetGetValues(manager->ownedServices, ids); + for (CFIndex i = 0; i < count; i++) { + if (!CFSetContainsValue(connected, ids[i])) { + CFSetRemoveValue(manager->ownedServices, ids[i]); + } + } + free(ids); + PersistOwnedServices(manager); + } + } + + CFRelease(connected); + CFRelease(services); + CFRelease(system); + return result; +} diff --git a/Sources/CapswitchHID/include/CapswitchHID.h b/Sources/CapswitchHID/include/CapswitchHID.h index ed3f379..8eac284 100644 --- a/Sources/CapswitchHID/include/CapswitchHID.h +++ b/Sources/CapswitchHID/include/CapswitchHID.h @@ -4,8 +4,51 @@ #include #include -// Returns KERN_SUCCESS (0) or an IOKit/Mach error code. +// Caps Lock lock-state APIs return KERN_SUCCESS (0) or a Mach/IOKit code. int32_t CapswitchReadCapsLock(bool *enabled); int32_t CapswitchWriteCapsLock(bool enabled); +// Remaps HID usage 0x700000039 (physical Caps Lock) to 0x70000006E (F19). +// Uses IOHIDEventSystemClient directly in the current process. Does not +// launch hidutil or require root, a privileged helper or a virtual driver. +// +// The opaque manager remembers the registry IDs of keyboard services modified +// by Capswitch; other service mappings are preserved on apply and restoration. +// IDs are persisted in the application's CFPreferences for crash recovery. +typedef void *CapswitchRemapperRef; + +typedef enum { + CapswitchRemapOK = 0, + CapswitchRemapOutOfMemory = 1, + CapswitchRemapClientUnavailable = 2, + CapswitchRemapNoKeyboards = 3, + CapswitchRemapInvalidExistingMappings = 4, + CapswitchRemapConflict = 5, + CapswitchRemapSetFailed = 6, + CapswitchRemapVerificationFailed = 7, + CapswitchRemapMissingRegistryID = 8 +} CapswitchRemapResult; + +CapswitchRemapperRef CapswitchRemapperCreate(void); +void CapswitchRemapperDestroy(CapswitchRemapperRef manager); + +// A return of CapswitchRemapOK means every connected keyboard service has the +// Caps->F19 pair; keyboardCount must also be greater than zero. +int32_t CapswitchRemapperApply( + CapswitchRemapperRef manager, + uint32_t *keyboardCount, + uint32_t *mappedCount +); + +// Remove our exact Caps->F19 pair from services that Capswitch itself changed. +// Re-read each service before altering it to preserve other remappers' changes. +int32_t CapswitchRemapperRestore(CapswitchRemapperRef manager); + +// Read-only diagnostics. No HID mappings are changed. +int32_t CapswitchRemapperInspect( + CapswitchRemapperRef manager, + uint32_t *keyboardCount, + uint32_t *mappedCount +); + #endif diff --git a/Taskfile.yml b/Taskfile.yml index 215d081..52418a7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -63,10 +63,11 @@ tasks: - grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift - test -f Sources/CapswitchApp/HIDRemappingController.swift - - test -f Sources/Capswitch/HIDMapping.swift - test -f Sources/CapswitchHID/CapswitchHID.c + - test -f Sources/CapswitchHID/CapswitchRemapping.c + - grep -q "IOHIDServiceClientSetProperty" Sources/CapswitchHID/CapswitchRemapping.c + - '! grep -R -E "Process\(|/usr/bin/hidutil" Sources' - test -f Tests/CapswitchCoreTests/CapsInputRouterTests.swift - - test -f Tests/CapswitchCoreTests/HIDMappingCodecTests.swift - grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CGPreflightListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift @@ -78,49 +79,18 @@ tasks: - grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift doctor: - desc: Inspect macOS Caps preferences, remapping and running Capswitch processes + desc: Inspect keyboard services through Capswitch's own native IOKit code cmds: - | if [ "$(uname -s)" != "Darwin" ]; then - echo "SKIP: HID diagnostics require macOS." + echo "SKIP: native HID diagnostics require macOS." exit 0 fi - echo "=== macOS ===" - sw_vers - echo "=== Native Caps input-source setting (1=enabled, 0=disabled) ===" - defaults read -g TISRomanSwitchState 2>/dev/null || true - defaults read com.apple.HIToolbox TISRomanSwitchState 2>/dev/null || true - echo "=== Global HID remappings ===" - mapping="$(hidutil property --get UserKeyMapping 2>&1)" || { - echo "$mapping" - echo "ERROR: hidutil could not read UserKeyMapping." >&2 + if [ ! -x "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" ]; then + echo "Build the Capswitch app first: task bundle" >&2 exit 1 - } - echo "$mapping" - echo "=== HID keyboard services (may vary by keyboard) ===" - hidutil property --matching '{"PrimaryUsagePage":1,"PrimaryUsage":6}' --get UserKeyMapping || true - echo "=== Running Capswitch processes ===" - pgrep -fl Capswitch || true - echo "=== Diagnosis ===" - case "$mapping" in - *"(null)"* | *"()"*) - echo "NO HID REMAP ACTIVE: physical Caps cannot arrive as F19." - echo "Capswitch can be running without a working event tap or remap." - echo "Check Capswitch menu: Accessibility, Event tap, HID and Error." - echo "On Sonoma, /usr/bin/hidutil may need its OWN Input Monitoring approval." - echo "Grant it via Privacy & Security -> Input Monitoring -> + -> Cmd+Shift+G -> /usr/bin/hidutil." - echo "That is separate from permission for Capswitch.app." - ;; - *"HIDKeyboardModifierMappingSrc"*) - echo "Mapping entries exist. Verify Caps -> F19 in the output above." - ;; - *) - echo "Unrecognized mapping output. Check the raw values above." - ;; - esac - echo "=== Privacy permissions ===" - echo "The terminal cannot verify the running app's TCC status." - echo "Inspect Accessibility / Input Monitoring / Event tap / Error in its menu." + fi + "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" --doctor clean: desc: Remove build outputs diff --git a/Tests/CapswitchCoreTests/HIDMappingCodecTests.swift b/Tests/CapswitchCoreTests/HIDMappingCodecTests.swift deleted file mode 100644 index 7ce9a69..0000000 --- a/Tests/CapswitchCoreTests/HIDMappingCodecTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation -import XCTest -@testable import CapswitchCore - -final class HIDMappingCodecTests: XCTestCase { - func testMissingMappingsReadAsEmpty() throws { - XCTAssertEqual(try HIDMappingCodec.parse("(null)\n"), []) - XCTAssertEqual(try HIDMappingCodec.parse("UserKeyMapping:(\n)\n"), []) - } - - func testReadsDecimalMappingsAndPreservesTheirOrder() throws { - let output = """ - UserKeyMapping:( - { - HIDKeyboardModifierMappingDst = 30064771125; - HIDKeyboardModifierMappingSrc = 30064771172; - }, - { - HIDKeyboardModifierMappingSrc = 30064771129; - HIDKeyboardModifierMappingDst = 30064771182; - } - ) - """ - let mappings = try HIDMappingCodec.parse(output) - - XCTAssertEqual(mappings, [ - HIDKeyMapping(source: 30064771172, destination: 30064771125), - .init(source: 30064771129, destination: 30064771182), - ]) - XCTAssertEqual(mappings[1], HIDMappingCodec.capsToF19) - } - - func testEncodeMappingPreservesOtherEntries() throws { - let original = [HIDKeyMapping(source: 30064771302, destination: 30064771300)] - let installed = original + [HIDMappingCodec.capsToF19] - let remaining = installed.filter { $0 != HIDMappingCodec.capsToF19 } - XCTAssertEqual(remaining, original) - - let payload = try HIDMappingCodec.setArgument(installed) - let dictionary = try XCTUnwrap( - JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any] - ) - let entries = try XCTUnwrap(dictionary["UserKeyMapping"] as? [[String: NSNumber]]) - XCTAssertEqual(entries.count, 2) - XCTAssertEqual( - entries[1]["HIDKeyboardModifierMappingDst"]?.uint64Value, - HIDMappingCodec.f19Usage - ) - } - - func testUnknownFieldsOrGarbageAreRejected() { - XCTAssertThrowsError(try HIDMappingCodec.parse("hidutil failed")) - XCTAssertThrowsError(try HIDMappingCodec.parse("({ Foo = 123; })")) - XCTAssertThrowsError(try HIDMappingCodec.parse( - "({ HIDKeyboardModifierMappingSrc = 1; HIDKeyboardModifierMappingDst = 2; Mystery = 3; })" - )) - } -}