diff --git a/Hammerspoon 2/Modules/hs.eventtap/HSEventTapEvent.swift b/Hammerspoon 2/Modules/hs.eventtap/HSEventTapEvent.swift index 9f97fb5a..ea752d58 100644 --- a/Hammerspoon 2/Modules/hs.eventtap/HSEventTapEvent.swift +++ b/Hammerspoon 2/Modules/hs.eventtap/HSEventTapEvent.swift @@ -104,6 +104,25 @@ import CoreGraphics /// ``` @objc var characters: String? { get } + /// For a system key event (media, volume, brightness and other special keys), an object + /// describing the key; for any other event, undefined. + /// + /// The object has `key` (a name from `hs.eventtap.systemKeys`, or "unknown"), `keyCode` + /// (the numeric system key code), `isDown` (true for key down, false for key up) and + /// `isRepeat` (true if the key is auto-repeating). Watch for these events with + /// `hs.eventtap.eventTypes.systemDefined`; other kinds of system-defined event, which don't + /// represent a key, also yield undefined. + /// - Example: + /// ```js + /// const tap = hs.eventtap.addWatcher([hs.eventtap.eventTypes.systemDefined], (event) => { + /// const sysKey = event.systemKey + /// if (sysKey && sysKey.key === "PLAY" && sysKey.isDown) console.log("Play pressed") + /// return hs.eventtap.emit + /// }) + /// tap.start() + /// ``` + @objc var systemKey: [String: Any]? { get } + /// Create an independent copy of this event /// - Returns: A new HSEventTapEvent with the same properties, or null if the copy failed /// - Example: @@ -212,6 +231,10 @@ import CoreGraphics return unsafe String(utf16CodeUnits: buffer, count: length) } + @objc var systemKey: [String: Any]? { + return SystemKey.decode(cgEvent) + } + @objc func duplicate() -> HSEventTapEvent? { guard let copy = cgEvent.copy() else { return nil } return HSEventTapEvent(cgEvent: copy) diff --git a/Hammerspoon 2/Modules/hs.eventtap/HSEventTapModule.swift b/Hammerspoon 2/Modules/hs.eventtap/HSEventTapModule.swift index 4c7e4d3f..d317d824 100644 --- a/Hammerspoon 2/Modules/hs.eventtap/HSEventTapModule.swift +++ b/Hammerspoon 2/Modules/hs.eventtap/HSEventTapModule.swift @@ -77,6 +77,20 @@ import AppKit /// ``` @objc var modifierFlags: [String: Int] { get } + /// A dictionary mapping system key names to their numeric codes, for use with + /// `makeSystemKeyEvent()` and `systemKeyStroke()`. + /// + /// System keys are the media, volume, brightness and other special keys that macOS delivers as + /// `systemDefined` events rather than ordinary key events: `SOUND_UP`, `SOUND_DOWN`, `MUTE`, + /// `BRIGHTNESS_UP`, `BRIGHTNESS_DOWN`, `CONTRAST_UP`, `CONTRAST_DOWN`, `POWER`, `LAUNCH_PANEL`, + /// `VIDMIRROR`, `PLAY`, `EJECT`, `NEXT`, `PREVIOUS`, `FAST`, `REWIND`, `ILLUMINATION_UP`, + /// `ILLUMINATION_DOWN`, `ILLUMINATION_TOGGLE`, `CAPS_LOCK`, `HELP` and `NUM_LOCK`. + /// - Example: + /// ```js + /// console.log("PLAY has code " + hs.eventtap.systemKeys.PLAY) + /// ``` + @objc var systemKeys: [String: Int] { get } + /// Return this from an event tap callback to suppress the event (prevent other apps from receiving it). /// - Example: /// ```js @@ -208,6 +222,24 @@ import AppKit /// ``` @objc func makeScrollWheelEvent(_ deltaX: Double, _ deltaY: Double, _ x: Double, _ y: Double) -> HSEventTapEvent? + /// Create a system key event, such as a media, volume or brightness key. + /// + /// Posting these behaves like pressing the key on an Apple keyboard, so media keys are routed + /// to whichever app macOS considers to be playing (including audio in a browser tab). + /// Synthetic brightness keys are ignored by some Macs and external displays; use `hs.screen` + /// to set brightness directly if they have no effect. + /// + /// - Parameters: + /// - key: A system key name from `hs.eventtap.systemKeys` (e.g. "PLAY", "SOUND_UP"), case-insensitive + /// - isDown: true for key down, false for key up + /// - Returns: An HSEventTapEvent, or null if the key name is unknown + /// - Example: + /// ```js + /// hs.eventtap.makeSystemKeyEvent("PLAY", true).post() + /// hs.eventtap.makeSystemKeyEvent("PLAY", false).post() + /// ``` + @objc func makeSystemKeyEvent(_ key: String, _ isDown: Bool) -> HSEventTapEvent? + // MARK: Convenience senders /// Send a key down and key up event with optional modifier keys. @@ -255,6 +287,21 @@ import AppKit /// ``` @objc func keyStrokesAsync(_ text: String) -> JSPromise? + /// Press and release a system key, such as a media, volume or brightness key. + /// + /// A 5 ms pause is inserted between the key-down and key-up events. See + /// `makeSystemKeyEvent()` for details of how these keys behave. + /// + /// Be careful with `POWER` and `EJECT`, which can show a shutdown prompt or eject media. + /// + /// - Parameter key: A system key name from `hs.eventtap.systemKeys` (e.g. "PLAY", "NEXT"), case-insensitive + /// - Example: + /// ```js + /// hs.eventtap.systemKeyStroke("PLAY") // Play/pause whatever is playing + /// hs.eventtap.systemKeyStroke("SOUND_UP") // Raise the volume, showing the system HUD + /// ``` + @objc func systemKeyStroke(_ key: String) + /// Post a left mouse button click at the given position. /// /// Coordinates are in **Hammerspoon screen coordinates** (top-left origin, y increases downward). @@ -550,6 +597,9 @@ import AppKit "otherMouseDown": Int(CGEventType.otherMouseDown.rawValue), "otherMouseUp": Int(CGEventType.otherMouseUp.rawValue), "otherMouseDragged": Int(CGEventType.otherMouseDragged.rawValue), + // Media, volume, brightness and other special keys (see systemKeys). CGEventType has no + // case for this, but CGEventTaps deliver it all the same. + "systemDefined": Int(NSEvent.EventType.systemDefined.rawValue), ] // Device-independent masks (high word of CGEventFlags.rawValue) @@ -584,6 +634,10 @@ import AppKit return _modifierFlags } + @objc var systemKeys: [String: Int] { + return SystemKey.codes + } + @objc var consume: Bool { false } @objc var emit: Bool { true } @@ -663,6 +717,24 @@ import AppKit return HSEventTapEvent(cgEvent: event) } + @objc func makeSystemKeyEvent(_ key: String, _ isDown: Bool) -> HSEventTapEvent? { + guard let code = resolveSystemKeyCode(key, caller: "makeSystemKeyEvent") else { return nil } + guard let event = SystemKey.makeEvent(code: code, isDown: isDown) else { + AKError("hs.eventtap.makeSystemKeyEvent: Failed to create event for \(key)") + return nil + } + return HSEventTapEvent(cgEvent: event) + } + + /// Looks up a system key name, logging an error naming the caller if it isn't a known key. + private func resolveSystemKeyCode(_ key: String, caller: String) -> Int? { + guard let code = SystemKey.code(named: key) else { + AKError("hs.eventtap.\(caller): Unknown system key '\(key)'") + return nil + } + return code + } + /// Resolves a JS key argument (a name/character or a numeric key code) through the current /// keyboard layout, logging an error naming the caller if it isn't a known key. private func resolveKeyCode(_ key: Any?, caller: String) -> CGKeyCode? { @@ -701,6 +773,18 @@ import AppKit } } + @objc func systemKeyStroke(_ key: String) { + guard let code = resolveSystemKeyCode(key, caller: "systemKeyStroke") else { return } + guard let downEvent = SystemKey.makeEvent(code: code, isDown: true), + let upEvent = SystemKey.makeEvent(code: code, isDown: false) else { + AKError("hs.eventtap.systemKeyStroke: Failed to create events for \(key)") + return + } + downEvent.post(tap: .cghidEventTap) + usleep(5000) + upEvent.post(tap: .cghidEventTap) + } + @objc func keyStrokes(_ text: String) { // A private-state source (rather than .hidSystemState) means freshly created events // start with empty flags instead of inheriting whatever real modifier keys are diff --git a/Hammerspoon 2/Modules/hs.eventtap/HSEventTapSystemKey.swift b/Hammerspoon 2/Modules/hs.eventtap/HSEventTapSystemKey.swift new file mode 100644 index 00000000..421dca13 --- /dev/null +++ b/Hammerspoon 2/Modules/hs.eventtap/HSEventTapSystemKey.swift @@ -0,0 +1,90 @@ +// +// HSEventTapSystemKey.swift +// Hammerspoon 2 +// + +import AppKit +import CoreGraphics +import IOKit.hidsystem + +/// Builds and decodes the system-defined (NX_SYSDEFINED) events that macOS uses for media, +/// volume, brightness and other special keys. +/// +/// These events carry the key in `data1`: the NX_KEYTYPE_* code in the high 16 bits, then the +/// key state (0xA = down, 0xB = up) in bits 8-15 and a repeat flag in bit 0. +nonisolated enum SystemKey { + /// NSEvent subtype for auxiliary control button (special key) events, NX_SUBTYPE_AUX_CONTROL_BUTTONS + static let auxControlButtonsSubtype: Int16 = 8 + + private static let keyDownState = 0xA + private static let keyUpState = 0xB + + /// System key names (matching Hammerspoon 1's) mapped to their NX_KEYTYPE_* codes + static let codes: [String: Int] = [ + "SOUND_UP": Int(NX_KEYTYPE_SOUND_UP), + "SOUND_DOWN": Int(NX_KEYTYPE_SOUND_DOWN), + "MUTE": Int(NX_KEYTYPE_MUTE), + "BRIGHTNESS_UP": Int(NX_KEYTYPE_BRIGHTNESS_UP), + "BRIGHTNESS_DOWN": Int(NX_KEYTYPE_BRIGHTNESS_DOWN), + "CONTRAST_UP": Int(NX_KEYTYPE_CONTRAST_UP), + "CONTRAST_DOWN": Int(NX_KEYTYPE_CONTRAST_DOWN), + "POWER": Int(NX_POWER_KEY), + "LAUNCH_PANEL": Int(NX_KEYTYPE_LAUNCH_PANEL), + "VIDMIRROR": Int(NX_KEYTYPE_VIDMIRROR), + "PLAY": Int(NX_KEYTYPE_PLAY), + "EJECT": Int(NX_KEYTYPE_EJECT), + "NEXT": Int(NX_KEYTYPE_NEXT), + "PREVIOUS": Int(NX_KEYTYPE_PREVIOUS), + "FAST": Int(NX_KEYTYPE_FAST), + "REWIND": Int(NX_KEYTYPE_REWIND), + "ILLUMINATION_UP": Int(NX_KEYTYPE_ILLUMINATION_UP), + "ILLUMINATION_DOWN": Int(NX_KEYTYPE_ILLUMINATION_DOWN), + "ILLUMINATION_TOGGLE": Int(NX_KEYTYPE_ILLUMINATION_TOGGLE), + "CAPS_LOCK": Int(NX_KEYTYPE_CAPS_LOCK), + "HELP": Int(NX_KEYTYPE_HELP), + "NUM_LOCK": Int(NX_KEYTYPE_NUM_LOCK), + ] + + private static let names: [Int: String] = Dictionary(uniqueKeysWithValues: codes.map { ($1, $0) }) + + /// Looks up a system key code by name, ignoring case + static func code(named name: String) -> Int? { + codes[name.uppercased()] + } + + /// Creates a system-defined key event, or nil if AppKit can't build one + @MainActor + static func makeEvent(code: Int, isDown: Bool) -> CGEvent? { + let state = isDown ? keyDownState : keyUpState + let nsEvent = NSEvent.otherEvent(with: .systemDefined, + location: .zero, + modifierFlags: NSEvent.ModifierFlags(rawValue: UInt(state << 8)), + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: auxControlButtonsSubtype, + data1: (code << 16) | (state << 8), + data2: -1) + return nsEvent?.cgEvent + } + + /// Decodes a system-defined key event into its key name, code and state, or nil if the + /// event isn't a special key event + @MainActor + static func decode(_ event: CGEvent) -> [String: Any]? { + guard event.type.rawValue == UInt32(NSEvent.EventType.systemDefined.rawValue), + let nsEvent = NSEvent(cgEvent: event), + nsEvent.subtype.rawValue == auxControlButtonsSubtype else { + return nil + } + let data1 = nsEvent.data1 + let code = (data1 & 0xFFFF0000) >> 16 + let state = (data1 & 0xFF00) >> 8 + return [ + "key": names[code] ?? "unknown", + "keyCode": code, + "isDown": state == keyDownState, + "isRepeat": (data1 & 0x1) != 0, + ] + } +} diff --git a/Hammerspoon 2Tests/IntegrationTests/HSEventTapIntegrationTests.swift b/Hammerspoon 2Tests/IntegrationTests/HSEventTapIntegrationTests.swift index 0aeb948d..ec8d9877 100644 --- a/Hammerspoon 2Tests/IntegrationTests/HSEventTapIntegrationTests.swift +++ b/Hammerspoon 2Tests/IntegrationTests/HSEventTapIntegrationTests.swift @@ -7,6 +7,7 @@ import Testing import JavaScriptCore import ApplicationServices import Carbon +import AppKit @testable import Hammerspoon_2 @Suite("hs.eventtap tests") @@ -213,6 +214,26 @@ struct HSEventTapTests { #expect(makeHarness().evalTypeOf("hs.eventtap.keyRepeatInterval") == "function") } + @Test("makeSystemKeyEvent is a function") + func testMakeSystemKeyEventIsFunction() { + #expect(makeHarness().evalTypeOf("hs.eventtap.makeSystemKeyEvent") == "function") + } + + @Test("systemKeyStroke is a function") + func testSystemKeyStrokeIsFunction() { + #expect(makeHarness().evalTypeOf("hs.eventtap.systemKeyStroke") == "function") + } + + @Test("systemKeys is an object") + func testSystemKeysIsObject() { + #expect(makeHarness().evalTypeOf("hs.eventtap.systemKeys") == "object") + } + + @Test("eventTypes.systemDefined is the NX_SYSDEFINED type") + func testEventTypesSystemDefined() { + #expect(makeHarness().evalInt("hs.eventtap.eventTypes.systemDefined") == 14) + } + // MARK: Event object API @Test("event.post is a function") @@ -535,6 +556,134 @@ struct HSEventTapTests { #expect(!harness.hasException) } + // MARK: System keys + // + // These only build and decode events - posting them would play/pause media or change the + // volume on the machine running the tests. + + @Test("systemKeys maps v1 key names to their NX_KEYTYPE codes") + func testSystemKeysCodes() { + let harness = makeHarness() + #expect(harness.evalInt("hs.eventtap.systemKeys.SOUND_UP") == 0) + #expect(harness.evalInt("hs.eventtap.systemKeys.MUTE") == 7) + #expect(harness.evalInt("hs.eventtap.systemKeys.PLAY") == 16) + #expect(harness.evalInt("hs.eventtap.systemKeys.NEXT") == 17) + #expect(harness.evalInt("hs.eventtap.systemKeys.PREVIOUS") == 18) + #expect(harness.evalInt("Object.keys(hs.eventtap.systemKeys).length") == 22) + } + + @Test("makeSystemKeyEvent creates a systemDefined event", arguments: [true, false]) + func testMakeSystemKeyEventType(isDown: Bool) { + let harness = makeHarness() + harness.eval("var evt = hs.eventtap.makeSystemKeyEvent('PLAY', \(isDown))") + #expect(harness.evalInt("evt.type") == harness.evalInt("hs.eventtap.eventTypes.systemDefined")) + #expect(!harness.hasException) + } + + @Test("systemKey decodes every key built by makeSystemKeyEvent", + arguments: SystemKey.codes.keys.sorted(), [true, false]) + func testSystemKeyRoundTrip(key: String, isDown: Bool) { + let harness = makeHarness() + harness.eval("var sk = hs.eventtap.makeSystemKeyEvent('\(key)', \(isDown)).systemKey") + #expect(harness.evalString("sk.key") == key) + #expect(harness.evalInt("sk.keyCode") == SystemKey.codes[key]) + #expect(harness.evalBool("sk.isDown") == isDown) + #expect(harness.evalBool("sk.isRepeat") == false) + #expect(!harness.hasException) + } + + @Test("makeSystemKeyEvent ignores the case of the key name") + func testMakeSystemKeyEventCaseInsensitive() { + let harness = makeHarness() + #expect(harness.evalString("hs.eventtap.makeSystemKeyEvent('sound_down', true).systemKey.key") == "SOUND_DOWN") + #expect(!harness.hasException) + } + + @Test("makeSystemKeyEvent returns null for an unknown key") + func testMakeSystemKeyEventUnknownKey() { + let harness = makeHarness() + harness.eval("var evt = hs.eventtap.makeSystemKeyEvent('NOT_A_KEY', true)") + harness.expectTrue("evt === null || evt === undefined") + #expect(!harness.hasException) + } + + @Test("systemKeyStroke with an unknown key does nothing without throwing") + func testSystemKeyStrokeUnknownKey() { + let harness = makeHarness() + harness.eval("hs.eventtap.systemKeyStroke('NOT_A_KEY')") + #expect(!harness.hasException) + } + + @Test("systemKey is undefined for ordinary events") + func testSystemKeyUndefinedForOtherEvents() { + let harness = makeHarness() + #expect(harness.evalTypeOf("hs.eventtap.makeKeyEvent('a', true).systemKey") == "undefined") + #expect(harness.evalTypeOf("hs.eventtap.makeScrollWheelEvent(0, 3, 500, 400).systemKey") == "undefined") + #expect(!harness.hasException) + } + + @Test("duplicate preserves a system key event") + func testDuplicateSystemKeyEvent() { + let harness = makeHarness() + harness.eval("var copy = hs.eventtap.makeSystemKeyEvent('NEXT', false).duplicate()") + #expect(harness.evalString("copy.systemKey.key") == "NEXT") + #expect(harness.evalBool("copy.systemKey.isDown") == false) + #expect(!harness.hasException) + } + + /// Exposes a hand-built systemDefined event to JS as `evt`, without going through + /// SystemKey, so the decoder is checked against fixed data1 values rather than its builder. + private func makeHarness(systemDefinedSubtype subtype: Int16, data1: Int) throws -> JSTestHarness { + let harness = makeHarness() + let nsEvent = try #require(NSEvent.otherEvent(with: .systemDefined, location: .zero, + modifierFlags: [], timestamp: 0, windowNumber: 0, + context: nil, subtype: subtype, + data1: data1, data2: -1)) + let cgEvent = try #require(nsEvent.cgEvent) + harness.context.setObject(HSEventTapEvent(cgEvent: cgEvent), forKeyedSubscript: "evt" as NSString) + return harness + } + + @Test("systemKey decodes a hand-built key down event") + func testSystemKeyDecodesFixtureKeyDown() throws { + // NX_KEYTYPE_NEXT (17) in the high word, 0xA (down) in bits 8-15 + let harness = try makeHarness(systemDefinedSubtype: 8, data1: 0x0011_0A00) + #expect(harness.evalString("evt.systemKey.key") == "NEXT") + #expect(harness.evalInt("evt.systemKey.keyCode") == 17) + #expect(harness.evalBool("evt.systemKey.isDown") == true) + #expect(harness.evalBool("evt.systemKey.isRepeat") == false) + #expect(!harness.hasException) + } + + @Test("systemKey reports auto-repeat") + func testSystemKeyDecodesRepeat() throws { + // NX_KEYTYPE_SOUND_UP (0), key down, with the repeat bit set + let harness = try makeHarness(systemDefinedSubtype: 8, data1: 0x0000_0A01) + #expect(harness.evalString("evt.systemKey.key") == "SOUND_UP") + #expect(harness.evalBool("evt.systemKey.isDown") == true) + #expect(harness.evalBool("evt.systemKey.isRepeat") == true) + #expect(!harness.hasException) + } + + @Test("systemKey names an unrecognised key code 'unknown'") + func testSystemKeyDecodesUnknownCode() throws { + // Key code 0x7F, key up (0xB) + let harness = try makeHarness(systemDefinedSubtype: 8, data1: 0x007F_0B00) + #expect(harness.evalString("evt.systemKey.key") == "unknown") + #expect(harness.evalInt("evt.systemKey.keyCode") == 0x7F) + #expect(harness.evalBool("evt.systemKey.isDown") == false) + #expect(!harness.hasException) + } + + @Test("systemKey is undefined for systemDefined events of other subtypes") + func testSystemKeyUndefinedForOtherSubtypes() throws { + // Same data1 as a PLAY key down, but not the aux control buttons subtype (8) + let harness = try makeHarness(systemDefinedSubtype: 7, data1: 0x0010_0A00) + #expect(harness.evalInt("evt.type") == harness.evalInt("hs.eventtap.eventTypes.systemDefined")) + #expect(harness.evalTypeOf("evt.systemKey") == "undefined") + #expect(!harness.hasException) + } + // MARK: duplicate @Test("duplicate returns a new event object") diff --git a/docs/migration-guide.md b/docs/migration-guide.md index fba882f8..86c5a642 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -219,7 +219,10 @@ per-row `contextMenu`. Check the styling-property list before assuming appearanc **`hs.eventtap`** — `hs.eventtap.event` folded directly into `hs.eventtap` (build events with `hs.eventtap.makeKeyEvent()` instead of `hs.eventtap.event.newKeyEvent()`; `.post()` is -unchanged). The tap/watcher pattern also changed shape: `hs.eventtap.addWatcher(types, callback, +unchanged). Media/volume/brightness keys likewise use `hs.eventtap.makeSystemKeyEvent(key, +isDown)` in place of `hs.eventtap.event.newSystemKeyEvent()` (same key names, e.g. `"PLAY"`), or +`hs.eventtap.systemKeyStroke(key)` to press and release one; v1's `event:systemKey()` is now the +`event.systemKey` property, with `isDown`/`isRepeat` in place of `down`/`repeat`. The tap/watcher pattern also changed shape: `hs.eventtap.addWatcher(types, callback, listenOnly)` takes an array of event types from `hs.eventtap.eventTypes`, the callback, and an optional listen-only flag, returning a watcher you still need to call `.start()` on. For a modify tap (`listenOnly` omitted/`false`) the callback's return value does the consuming —