From 23c8dd55451f995db5e483d925bd4ce5379bcfea Mon Sep 17 00:00:00 2001 From: jrtxio Date: Tue, 13 Oct 2026 13:40:18 +0800 Subject: [PATCH] Complete cross-platform global shortcuts; add the commercial-parity scorecard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold Rivet to the Tauri 2 / Electron feature floor. The audit found three capabilities below it: global shortcuts existed only on Linux (products hand-rolled macOS and Windows), clipboard, and native file dialogs. This change closes the first; the other two become visible, guarded rows on the new scorecard instead of tribal knowledge. - macOS RivetGlobalShortcuts: Carbon RegisterEventHotKey on the main-thread event loop, shared trigger grammar, explicit duplicate-id and duplicate-live-trigger refusal (RegisterHotKey gives Windows this for free; Carbon does not), parse(_:) public for tests and trigger rendering. ANSI virtual-key codes map through their Carbon constants — the codes follow the physical QWERTY layout, not the alphabet. - Windows rivet::system::GlobalShortcuts: RegisterHotKey on a message-only window with WM_HOTKEY dispatch, same callback shape (shortcut id, millisecond timestamp, portal-only activation token kept empty for signature parity). Compile-gated by the WinUI host build in CI. - docs/system-services.md documents all three adapters and the shared contract; docs/commercial-parity.md is the honest above/at/below/roadmap scorecard with rules of engagement, and the docs suite keeps it and its below-floor rows from silently disappearing. --- CHANGELOG.md | 20 ++ docs/commercial-parity.md | 39 +++ docs/system-services.md | 14 + .../Sources/RivetSystem/GlobalShortcuts.swift | 242 ++++++++++++++++++ .../GlobalShortcutsTests.swift | 59 +++++ platform/windows/system/system_services.cpp | 199 ++++++++++++++ platform/windows/system/system_services.hpp | 56 ++++ tests/documentation.rkt | 13 + 8 files changed, 642 insertions(+) create mode 100644 docs/commercial-parity.md create mode 100644 platform/macos/Sources/RivetSystem/GlobalShortcuts.swift create mode 100644 platform/macos/Tests/RivetSystemTests/GlobalShortcutsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index edeaa52..fc40d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +- Complete cross-platform global shortcuts. Linux already binds through the + XDG portal; macOS gains `RivetGlobalShortcuts` (Carbon + `RegisterEventHotKey`, main-thread event loop, explicit duplicate-trigger + refusal — RegisterHotKey provides it on Windows, Carbon does not) and + Windows gains `rivet::system::GlobalShortcuts` (`RegisterHotKey` on a + message-only window with `WM_HOTKEY` dispatch). The three adapters share + one activation-callback shape (shortcut id, millisecond timestamp, + portal-only activation token) and direct-registration platforms share a + case-insensitive trigger grammar; ANSI virtual-key codes map through + their Carbon constants because the codes follow the physical QWERTY + layout, not the alphabet. macOS parse and lifecycle tests are in the + Swift suite; the Windows adapter is compile-gated by the WinUI CI host + build. +- Add `docs/commercial-parity.md`: the honest scorecard against the Tauri 2 + and Electron feature floors (above / at / below / roadmap per + capability), with rules of engagement — a product hand-rolling the same + capability twice is a library gap, below-floor rows outrank new surface + area, and the docs suite keeps the scorecard and its below-floor rows + (clipboard, native file dialogs, mobile hosts) from silently + disappearing. - Close the scribble coverage gap: the RVT1 framing and value-codec surface (`rivet/protocol` — frames, message-type constants, transport limits, read/write/encode/decode), application resources (`rivet/resources`), diff --git a/docs/commercial-parity.md b/docs/commercial-parity.md new file mode 100644 index 0000000..5669081 --- /dev/null +++ b/docs/commercial-parity.md @@ -0,0 +1,39 @@ +# Commercial parity baseline + +Rivet is held to commercial desktop-framework standards: what Tauri 2 and +Electron ship is the floor, never the ceiling. This page is the honest +scorecard, maintained with the same anti-rot discipline as the rest of the +documentation — update it whenever a capability is added, and treat any +"below floor" row as an open work item with priority over new surface area. + +Statuses: **above** (differentiating), **at** (floor met), **below** +(open gap), **roadmap** (deliberately scheduled, not accidental). + +## Scorecard + +| Capability | Tauri 2 / Electron floor | Rivet status | +| --- | --- | --- | +| App foundation | WebView-based UI layer | **above** — native SwiftUI/WinUI/GTK hosts, no WebView: ~30 MB artifacts, native startup and accessibility from the platform toolkits | +| IPC discipline | `invoke` string commands / `ipcRenderer` channels | **above** — RVT1 typed RPC/event/state contracts with generated Swift, C++, and Kotlin clients and a closed schema | +| In-app updates | updater plugin / electron-updater, signed feeds | **at** — Ed25519-signed manifests, verify-before-parse, managed watchdogs, install journal + recovery, AppImage atomic replacement, and a shared macOS host client (`RivetUpdate`); no delta downloads (Tauri 2 has none either) | +| Installers | dmg, NSIS/MSI, deb/rpm/AppImage | **at** — DMG, MSI, deb, rpm, AppImage, portable archives, SBOM and third-party notices per release | +| Single instance / deep links | plugins or manual | **at** — lease per platform, activation routing, `--toggle`-style second-instance handoff | +| Autostart, tray, notifications, secure storage, crash hooks | plugins / Node APIs | **at** — first-party adapters on every desktop platform, capability-reported, fail-closed without a provider | +| Global shortcuts | global-shortcut plugin, direct registration | **at** — Linux through the XDG portal (the compositor owns the trigger), macOS Carbon `RegisterEventHotKey` and Windows `RegisterHotKey` with a shared trigger grammar and activation-callback shape | +| Accessibility | not exercised by their CI | **above** — accessibility-tree end-to-end suites on all three desktop platforms in CI, plus protocol fuzzing | +| Docs discipline | versioned docs site | **partial** — full API-reference coverage with two-way drift guards in CI (exports ↔ manual, guide pins ↔ release version); a versioned docs site and zh-CN pairs for newer pages remain **roadmap** | +| Project templates | create-tauri-app gallery | **partial** — `raco rivet new` starter plus the reference Taskboard; a template gallery is **roadmap** | +| Clipboard read/write/watch | clipboard-manager plugin | **below** — no adapter yet (products hand-roll); open work item | +| Native file dialogs | dialog plugin | **below** — no adapter yet; open work item | +| Mobile hosts | iOS and Android (Tauri 2) | **roadmap** — gated on the family's Rivet Phase 8 mobile-host decision, recorded per product | +| Delta updates | Electron blockmap (Electron only) | **roadmap** — full-artifact updates first; revisit when artifact sizes make deltas worth their complexity | + +## Rules of engagement + +- A product hand-rolling the same capability twice is a library gap by + definition; upstream it (precedents: tray, Wayland shortcuts, the macOS + update client). +- New capabilities enter this table in the same change that adds them, with + an honest status — the docs suite links this page so it cannot silently + disappear. +- Below-floor rows outrank new surface area in planning. diff --git a/docs/system-services.md b/docs/system-services.md index 43791c2..a01830e 100644 --- a/docs/system-services.md +++ b/docs/system-services.md @@ -8,6 +8,10 @@ Commercial desktop features live in `rivet/system` and native platform adapters, Generated native hosts call the first-party Swift/C++ system libraries directly, on the native UI or lifecycle thread that owns the operation. They do not install Racket procedures into the embedded runtime and system services do not extend RVT1. Racket-side providers and headless tests can parameterize `current-system-adapter`; without such a provider, the Racket convenience procedures fail clearly instead of silently using insecure storage or a fake notification. +### Global shortcuts across platforms + +Every adapter reports activations through the same callback shape — shortcut id, platform timestamp in milliseconds, and an activation token (portal platforms only; empty elsewhere) — so hosts share one dispatch path. Direct-registration platforms (macOS, Windows) use a shared trigger grammar: case-insensitive `'+'`-separated modifiers (`ctrl|control`, `alt|option`, `shift`, `cmd|command|meta|super|win`) and exactly one key (`a`–`z`, `0`–`9`, `f1`–`f24`, `space`, `tab`, `enter|return`, `escape|esc`, `backspace`, `delete|del`, `insert`, `home`, `end`, `pageup`, `pagedown`, arrows, and common punctuation names). Both adapters refuse duplicate ids and duplicate live triggers. Linux intentionally differs: Wayland has no global grabs, so binding goes through the compositor's portal prompt and the compositor owns the final trigger. + ## Windows `platform/windows/system` uses first-party Win32 surfaces: @@ -17,6 +21,11 @@ Generated native hosts call the first-party Swift/C++ system libraries directly, - `Shell_NotifyIconW` for tray presence and notification balloons, plus a `TrayController` that owns the window subclass, native popup menu, command routing, and `TaskbarCreated` recovery; +- `GlobalShortcuts` on `RegisterHotKey`: a message-only window per instance + with `WM_HOTKEY` dispatch to the shared activation-callback shape. + `ParseTrigger` implements the cross-platform trigger grammar; register and + unregister on the thread that pumps the window's messages (the WinUI + thread in generated hosts); - the current user's `Run` registry value for opt-in autostart; - Windows Credential Manager (`CredWriteW`/`CredReadW`) for secrets; - `SetUnhandledExceptionFilter` and `RegisterApplicationRestart` as low-level crash/restart hooks. @@ -42,6 +51,11 @@ private endpoint, or no uploader; Rivet does not bind to any provider. - SwiftUI `onOpenURL` plus packaged `CFBundleURLTypes`/`CFBundleDocumentTypes` metadata; - `UNUserNotificationCenter`; - `NSStatusItem` for menu-bar UI; +- `RivetGlobalShortcuts` on Carbon `RegisterEventHotKey`: direct registration + with the shared trigger grammar and callback shape, activations on the + main thread's event loop. `parse(_:)` maps ANSI virtual-key codes through + their Carbon constants — the codes follow the physical QWERTY layout, not + the alphabet — and is public for tests and trigger rendering; - `SMAppService.mainApp` for login-item registration; - Security.framework Keychain generic-password items; - an activation router that stays on the main actor. diff --git a/platform/macos/Sources/RivetSystem/GlobalShortcuts.swift b/platform/macos/Sources/RivetSystem/GlobalShortcuts.swift new file mode 100644 index 0000000..c5aace8 --- /dev/null +++ b/platform/macos/Sources/RivetSystem/GlobalShortcuts.swift @@ -0,0 +1,242 @@ +import Foundation + +#if canImport(Carbon.HIToolbox) +import Carbon.HIToolbox + +/// Global shortcuts on macOS through Carbon's `RegisterEventHotKey` — the +/// mechanism launcher-class applications use, and the macOS counterpart of +/// Rivet's Linux XDG-portal adapter and Windows `RegisterHotKey` adapter. +/// Linux binds shortcuts through a compositor prompt because Wayland has no +/// global grabs; macOS permits direct registration with an explicit trigger. +/// +/// All methods and the activation callback run on the main thread, which +/// must run an event loop for activations to arrive. +public final class RivetGlobalShortcuts: @unchecked Sendable { + public typealias ActivationHandler = + @Sendable (_ shortcutID: String, + _ timestampMilliseconds: UInt64, + _ activationToken: String) -> Void + + /// Identifies a registered shortcut inside the Carbon event metadata. + private static let hotKeySignature: OSType = 0x52565453 // 'RVTS' + + private var eventHandler: EventHandlerRef? + private var hotKeys: [UInt32: (ref: EventHotKeyRef?, + id: String, + modifiers: UInt32, + keyCode: UInt32)] = [:] + private var nextIndex: UInt32 = 1 + private var activationHandler: ActivationHandler? + + public init() { + var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)) + // Carbon does not retain user data; the owner's reference keeps + // this object alive and deinit removes the handler before state + // goes away. + let selfPointer = Unmanaged.passUnretained(self).toOpaque() + let status = InstallEventHandler(GetApplicationEventTarget(), + hotKeyEventRouter, + 1, &spec, + selfPointer, + &eventHandler) + if status != noErr { + eventHandler = nil + } + } + + deinit { + for entry in hotKeys.values { + if let ref = entry.ref { UnregisterEventHotKey(ref) } + } + // Removing the handler guarantees no further callbacks reach the + // object after deinit begins. + if eventHandler != nil { + RemoveEventHandler(eventHandler) + } + } + + /// Carbon hot-key registration is available in every application + /// context; the portal-less counterpart of the Linux support probe. + public static func available() -> Bool { true } + + /// Registers `trigger` under `shortcutID`. The trigger grammar matches + /// the Windows adapter: case-insensitive '+'-separated modifiers + /// (ctrl|control, alt|option, shift, cmd|command|meta|super|win) and + /// exactly one key (a–z, 0–9, f1–f20, space, tab, enter|return, + /// escape|esc, backspace, delete|del, insert, home, end, pageup, + /// pagedown, up, down, left, right, comma, period, slash, backslash, + /// semicolon, quote, minus, equal, bracketleft, bracketright). `win` + /// maps to Command. Returns false for an unparsable trigger or a + /// duplicate id, exactly like the Windows `Register`. + @discardableResult + public func register(shortcutID: String, trigger: String) -> Bool { + guard let parsed = Self.parse(trigger) else { return false } + guard !hotKeys.values.contains(where: { $0.id == shortcutID }) else { + return false + } + // RegisterHotKey refuses a duplicate live trigger on Windows; + // Carbon does not, so this adapter enforces the same rule itself. + guard !hotKeys.values.contains(where: { + $0.modifiers == parsed.modifiers && $0.keyCode == parsed.keyCode + }) else { return false } + var hotKeyRef: EventHotKeyRef? + let hotKeyID = EventHotKeyID(signature: Self.hotKeySignature, + id: nextIndex) + let status = RegisterEventHotKey(parsed.keyCode, + parsed.modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &hotKeyRef) + guard status == noErr else { return false } + hotKeys[nextIndex] = (hotKeyRef, shortcutID, + parsed.modifiers, parsed.keyCode) + nextIndex += 1 + return true + } + + @discardableResult + public func unregister(shortcutID: String) -> Bool { + for (index, entry) in hotKeys where entry.id == shortcutID { + if let ref = entry.ref { UnregisterEventHotKey(ref) } + hotKeys.removeValue(forKey: index) + return true + } + return false + } + + public func setActivationHandler(_ handler: @escaping ActivationHandler) { + activationHandler = handler + } + + /// Parses a trigger into Carbon modifier flags and a virtual key code. + /// Public for tests and for hosts that render trigger strings. + public static func parse(_ trigger: String) + -> (modifiers: UInt32, keyCode: UInt32)? { + // omittingEmptySubsequences must stay false: "+a" and "a+" have an + // empty token and must be rejected, not silently accepted. + let tokens = trigger.lowercased() + .split(separator: "+", omittingEmptySubsequences: false) + .map(String.init) + guard !tokens.isEmpty else { return nil } + var modifiers: UInt32 = 0 + var keyCode: UInt32? + for token in tokens { + switch token { + case "ctrl", "control": modifiers |= UInt32(controlKey) + case "alt", "option": modifiers |= UInt32(optionKey) + case "shift": modifiers |= UInt32(shiftKey) + case "cmd", "command", "meta", "super", "win": + modifiers |= UInt32(cmdKey) + default: + guard keyCode == nil, + let code = Self.keyCode(for: token) else { return nil } + keyCode = code + } + } + guard let code = keyCode else { return nil } + return (modifiers, code) + } + + /// ANSI virtual-key codes follow the physical QWERTY layout, not the + /// alphabet: kVK_ANSI_T is 0x11 while T is the 20th letter. Every key + /// is therefore mapped through its Carbon constant. + private static func keyCode(for token: String) -> UInt32? { + let ansiKeys: [String: UInt32] = [ + "a": UInt32(kVK_ANSI_A), "s": UInt32(kVK_ANSI_S), + "d": UInt32(kVK_ANSI_D), "f": UInt32(kVK_ANSI_F), + "h": UInt32(kVK_ANSI_H), "g": UInt32(kVK_ANSI_G), + "z": UInt32(kVK_ANSI_Z), "x": UInt32(kVK_ANSI_X), + "c": UInt32(kVK_ANSI_C), "v": UInt32(kVK_ANSI_V), + "b": UInt32(kVK_ANSI_B), "q": UInt32(kVK_ANSI_Q), + "w": UInt32(kVK_ANSI_W), "e": UInt32(kVK_ANSI_E), + "r": UInt32(kVK_ANSI_R), "y": UInt32(kVK_ANSI_Y), + "t": UInt32(kVK_ANSI_T), "o": UInt32(kVK_ANSI_O), + "u": UInt32(kVK_ANSI_U), "i": UInt32(kVK_ANSI_I), + "p": UInt32(kVK_ANSI_P), "l": UInt32(kVK_ANSI_L), + "j": UInt32(kVK_ANSI_J), "k": UInt32(kVK_ANSI_K), + "n": UInt32(kVK_ANSI_N), "m": UInt32(kVK_ANSI_M), + "0": UInt32(kVK_ANSI_0), "1": UInt32(kVK_ANSI_1), + "2": UInt32(kVK_ANSI_2), "3": UInt32(kVK_ANSI_3), + "4": UInt32(kVK_ANSI_4), "5": UInt32(kVK_ANSI_5), + "6": UInt32(kVK_ANSI_6), "7": UInt32(kVK_ANSI_7), + "8": UInt32(kVK_ANSI_8), "9": UInt32(kVK_ANSI_9), + "space": UInt32(kVK_Space), + "tab": UInt32(kVK_Tab), + "enter": UInt32(kVK_Return), + "return": UInt32(kVK_Return), + "escape": UInt32(kVK_Escape), + "esc": UInt32(kVK_Escape), + // On Apple keyboards the PC Insert position is Help. + "insert": UInt32(kVK_Help), + "backspace": UInt32(kVK_Delete), + "delete": UInt32(kVK_ForwardDelete), + "del": UInt32(kVK_ForwardDelete), + "home": UInt32(kVK_Home), + "end": UInt32(kVK_End), + "pageup": UInt32(kVK_PageUp), + "pagedown": UInt32(kVK_PageDown), + "up": UInt32(kVK_UpArrow), + "down": UInt32(kVK_DownArrow), + "left": UInt32(kVK_LeftArrow), + "right": UInt32(kVK_RightArrow), + "comma": UInt32(kVK_ANSI_Comma), + "period": UInt32(kVK_ANSI_Period), + "slash": UInt32(kVK_ANSI_Slash), + "backslash": UInt32(kVK_ANSI_Backslash), + "semicolon": UInt32(kVK_ANSI_Semicolon), + "quote": UInt32(kVK_ANSI_Quote), + "minus": UInt32(kVK_ANSI_Minus), + "equal": UInt32(kVK_ANSI_Equal), + "bracketleft": UInt32(kVK_ANSI_LeftBracket), + "bracketright": UInt32(kVK_ANSI_RightBracket), + ] + if let code = ansiKeys[token] { return code } + if token.first == "f", let number = Int(token.dropFirst()), + (1...12).contains(number) { + return UInt32(kVK_F1 + number - 1) + } + if token.first == "f", let number = Int(token.dropFirst()), + (13...20).contains(number) { + return UInt32(kVK_F13 + number - 13) + } + return nil + } + + fileprivate func handleHotKeyEvent(_ event: EventRef?) -> OSStatus { + guard let event else { return OSStatus(eventNotHandledErr) } + var hotKeyID = EventHotKeyID() + var actualSize = 0 + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + &actualSize, + &hotKeyID) + guard status == noErr, hotKeyID.signature == Self.hotKeySignature, + let entry = hotKeys[hotKeyID.id] else { + return OSStatus(eventNotHandledErr) + } + let milliseconds = UInt64(max(0, GetEventTime(event)) * 1000) + if let handler = activationHandler { + handler(entry.id, milliseconds, "") + } + return noErr + } +} + +/// The Carbon callback must be a C function pointer; route through the +/// installed user-data pointer back into the owning instance. +private func hotKeyEventRouter(_ nextHandler: EventHandlerCallRef?, + _ event: EventRef?, + _ userData: UnsafeMutableRawPointer?) -> OSStatus { + guard let event, let userData else { return OSStatus(eventNotHandledErr) } + let shortcuts = Unmanaged + .fromOpaque(userData).takeUnretainedValue() + return shortcuts.handleHotKeyEvent(event) +} + +#endif // canImport(Carbon.HIToolbox) diff --git a/platform/macos/Tests/RivetSystemTests/GlobalShortcutsTests.swift b/platform/macos/Tests/RivetSystemTests/GlobalShortcutsTests.swift new file mode 100644 index 0000000..1e0dfec --- /dev/null +++ b/platform/macos/Tests/RivetSystemTests/GlobalShortcutsTests.swift @@ -0,0 +1,59 @@ +import Carbon.HIToolbox +import Foundation +import Testing +@testable import RivetSystem + +@Suite(.serialized) // shares the Carbon application event target +@MainActor +struct RivetGlobalShortcutsTests { + @Test func parseAcceptsModifiersAndKeys() { + let cmdShiftSpace = RivetGlobalShortcuts.parse("Cmd+Shift+Space") + #expect(cmdShiftSpace?.modifiers == UInt32(cmdKey | shiftKey)) + #expect(cmdShiftSpace?.keyCode == UInt32(kVK_Space)) + + let ctrlAltT = RivetGlobalShortcuts.parse("CTRL+alt+t") + #expect(ctrlAltT?.modifiers == UInt32(controlKey | optionKey)) + #expect(ctrlAltT?.keyCode == UInt32(kVK_ANSI_T)) + + #expect(RivetGlobalShortcuts.parse("cmd+1")?.keyCode == UInt32(kVK_ANSI_1)) + #expect(RivetGlobalShortcuts.parse("super+f13")?.keyCode == UInt32(kVK_F13)) + #expect(RivetGlobalShortcuts.parse("option+delete")? + .keyCode == UInt32(kVK_ForwardDelete)) + #expect(RivetGlobalShortcuts.parse("shift+backspace")? + .keyCode == UInt32(kVK_Delete)) + #expect(RivetGlobalShortcuts.parse("win+left")?.modifiers == UInt32(cmdKey)) + + let bare = RivetGlobalShortcuts.parse("a") + #expect(bare?.modifiers == 0) + #expect(bare?.keyCode == UInt32(kVK_ANSI_A)) + } + + @Test func parseRejectsMalformedTriggers() { + #expect(RivetGlobalShortcuts.parse("") == nil) + #expect(RivetGlobalShortcuts.parse("cmd+") == nil) + #expect(RivetGlobalShortcuts.parse("cmd") == nil) + #expect(RivetGlobalShortcuts.parse("foo+a") == nil) + #expect(RivetGlobalShortcuts.parse("a+b") == nil) + #expect(RivetGlobalShortcuts.parse("+a") == nil) + #expect(RivetGlobalShortcuts.parse("cmd+shift+ctrl+alt+meta+hyper+a") == nil) + #expect(RivetGlobalShortcuts.parse("f25") == nil) + #expect(RivetGlobalShortcuts.parse("f0") == nil) + #expect(RivetGlobalShortcuts.parse("cmd+home+l") == nil) + } + + @Test func registrationLifecycleRoundTrips() { + // Beyond every shipped Apple keyboard; registering it cannot shadow + // a real user shortcut during the test. + let shortcuts = RivetGlobalShortcuts() + #expect(RivetGlobalShortcuts.available()) + #expect(shortcuts.register(shortcutID: "obscure", trigger: "Ctrl+Alt+Shift+F20")) + // Duplicate ids and duplicate live triggers are refused. + #expect(!shortcuts.register(shortcutID: "obscure", trigger: "Ctrl+Alt+Shift+F19")) + #expect(!shortcuts.register(shortcutID: "other", trigger: "ctrl+alt+shift+f20")) + #expect(shortcuts.unregister(shortcutID: "obscure")) + #expect(!shortcuts.unregister(shortcutID: "obscure")) + // The freed trigger is registerable again under another id. + #expect(shortcuts.register(shortcutID: "other", trigger: "ctrl+alt+shift+f20")) + #expect(shortcuts.unregister(shortcutID: "other")) + } +} diff --git a/platform/windows/system/system_services.cpp b/platform/windows/system/system_services.cpp index d5fe6c1..37000a6 100644 --- a/platform/windows/system/system_services.cpp +++ b/platform/windows/system/system_services.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #pragma comment(lib, "Advapi32.lib") #pragma comment(lib, "Comctl32.lib") #pragma comment(lib, "Shell32.lib") +#pragma comment(lib, "User32.lib") namespace rivet::system { namespace { @@ -437,4 +439,201 @@ void InstallShutdownHook(std::function callback) { } } +bool GlobalShortcuts::available() { + // RegisterHotKey needs a message pump on an interactive desktop, which + // every Rivet host already runs; there is no portal or capability gate. + return true; +} + +namespace { + +std::string LowercaseAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { + return static_cast(::tolower(c)); + }); + return value; +} + +constexpr wchar_t const* kShortcutWindowClass = L"RivetGlobalShortcuts"; + +bool ParseModifier(std::string const& token, std::uint32_t& modifiers) { + if (token == "ctrl" || token == "control") { + modifiers |= MOD_CONTROL; + } else if (token == "alt" || token == "option") { + modifiers |= MOD_ALT; + } else if (token == "shift") { + modifiers |= MOD_SHIFT; + } else if (token == "win" || token == "meta" || token == "super" || + token == "cmd" || token == "command") { + modifiers |= MOD_WIN; + } else { + return false; + } + return true; +} + +bool ParseKey(std::string const& token, std::uint32_t& virtual_key) { + if (token.size() == 1) { + char const c = token[0]; + if (c >= 'a' && c <= 'z') { + virtual_key = static_cast('A' + (c - 'a')); + return true; + } + if (c >= '0' && c <= '9') { + virtual_key = static_cast(c); + return true; + } + return false; + } + struct NamedKey { + char const* name; + std::uint32_t key; + }; + static NamedKey const named[] = { + {"space", VK_SPACE}, {"tab", VK_TAB}, + {"enter", VK_RETURN}, {"return", VK_RETURN}, + {"escape", VK_ESCAPE}, {"esc", VK_ESCAPE}, + {"backspace", VK_BACK}, {"delete", VK_DELETE}, + {"del", VK_DELETE}, {"insert", VK_INSERT}, + {"home", VK_HOME}, {"end", VK_END}, + {"pageup", VK_PRIOR}, {"pagedown", VK_NEXT}, + {"up", VK_UP}, {"down", VK_DOWN}, + {"left", VK_LEFT}, {"right", VK_RIGHT}, + {"comma", VK_OEM_COMMA}, {"period", VK_OEM_PERIOD}, + {"slash", VK_OEM_2}, {"backslash", VK_OEM_5}, + {"semicolon", VK_OEM_1}, {"quote", VK_OEM_7}, + {"minus", VK_OEM_MINUS}, {"equal", VK_OEM_PLUS}, + {"bracketleft", VK_OEM_4}, {"bracketright", VK_OEM_6}, + }; + for (auto const& entry : named) { + if (token == entry.name) { + virtual_key = entry.key; + return true; + } + } + if (token.size() >= 2 && token[0] == 'f') { + int value = 0; + for (std::size_t index = 1; index < token.size(); ++index) { + if (token[index] < '0' || token[index] > '9') return false; + value = value * 10 + (token[index] - '0'); + } + if (value >= 1 && value <= 24) { + virtual_key = (value <= 12) ? VK_F1 + (value - 1) : VK_F13 + (value - 13); + return true; + } + } + return false; +} + +} // namespace + +bool GlobalShortcuts::ParseTrigger(std::string const& trigger, + std::uint32_t& modifiers, + std::uint32_t& virtual_key) { + modifiers = 0; + virtual_key = 0; + std::uint32_t parsed_modifiers = 0; + std::uint32_t parsed_key = 0; + bool has_key = false; + std::string const text = LowercaseAscii(trigger); + std::size_t start = 0; + while (start <= text.size()) { + std::size_t const separator = text.find('+', start); + std::string const token = + text.substr(start, separator == std::string::npos + ? std::string::npos + : separator - start); + if (token.empty()) return false; + if (ParseModifier(token, parsed_modifiers)) { + // Modifier consumed; keys accumulate below. + } else if (!has_key && ParseKey(token, parsed_key)) { + has_key = true; + } else { + return false; + } + if (separator == std::string::npos) break; + start = separator + 1; + } + if (!has_key) return false; + modifiers = parsed_modifiers; + virtual_key = parsed_key; + return true; +} + +LRESULT CALLBACK GlobalShortcuts::WndProc(HWND window, UINT message, + WPARAM wparam, + LPARAM lparam) noexcept { + if (message == WM_HOTKEY) { + auto* self = reinterpret_cast( + ::GetWindowLongPtrW(window, GWLP_USERDATA)); + if (self != nullptr) { + auto const hotkey_id = static_cast(wparam); + for (auto const& entry : self->registered_) { + if (entry.first == hotkey_id && self->handler_) { + self->handler_(entry.second, + static_cast( + static_cast(::GetMessageTime())), + std::string()); + } + } + } + return 0; + } + return ::DefWindowProcW(window, message, wparam, lparam); +} + +GlobalShortcuts::GlobalShortcuts() { + WNDCLASSW window_class{}; + window_class.lpfnWndProc = &GlobalShortcuts::WndProc; + window_class.hInstance = ::GetModuleHandleW(nullptr); + window_class.lpszClassName = kShortcutWindowClass; + ::RegisterClassW(&window_class); + window_ = ::CreateWindowExW(0, kShortcutWindowClass, L"", 0, 0, 0, 0, 0, + HWND_MESSAGE, nullptr, window_class.hInstance, + nullptr); + if (window_ == nullptr) ThrowLastError("CreateWindowExW"); + ::SetWindowLongPtrW(window_, GWLP_USERDATA, reinterpret_cast(this)); +} + +GlobalShortcuts::~GlobalShortcuts() { + for (auto const& entry : registered_) { + ::UnregisterHotKey(window_, static_cast(entry.first)); + } + if (window_ != nullptr) ::DestroyWindow(window_); +} + +bool GlobalShortcuts::Register(std::string const& shortcut_id, + std::string const& trigger) { + std::uint32_t modifiers = 0; + std::uint32_t virtual_key = 0; + if (!ParseTrigger(trigger, modifiers, virtual_key)) return false; + if (next_hotkey_id_ > 0xBFFF) return false; + for (auto const& entry : registered_) { + if (entry.second == shortcut_id) return false; + } + if (::RegisterHotKey(window_, static_cast(next_hotkey_id_), modifiers, + virtual_key) == 0) { + return false; + } + registered_.emplace_back(next_hotkey_id_, shortcut_id); + ++next_hotkey_id_; + return true; +} + +bool GlobalShortcuts::Unregister(std::string const& shortcut_id) { + for (auto entry = registered_.begin(); entry != registered_.end(); ++entry) { + if (entry->second == shortcut_id) { + ::UnregisterHotKey(window_, static_cast(entry->first)); + registered_.erase(entry); + return true; + } + } + return false; +} + +void GlobalShortcuts::SetActivationHandler(ActivationHandler handler) { + handler_ = std::move(handler); +} + } // namespace rivet::system diff --git a/platform/windows/system/system_services.hpp b/platform/windows/system/system_services.hpp index 5a28809..4901db4 100644 --- a/platform/windows/system/system_services.hpp +++ b/platform/windows/system/system_services.hpp @@ -170,4 +170,60 @@ void InstallShutdownHook(std::function callback); // installer; this helper normalizes the received payload for application code. std::vector ActivationArguments(); +// Cross-platform global-shortcut parity with the Linux XDG-portal adapter +// and the macOS Carbon adapter: one activation callback carrying the +// shortcut id, a platform timestamp in milliseconds, and an empty +// activation token (portal-only field, kept for signature parity). +struct GlobalShortcutActivation final { + std::string shortcut_id; + std::uint64_t timestamp{}; +}; + +// RegisterHotKey-based global shortcuts on a message-only window. Linux +// binds through a compositor prompt because Wayland has no global grabs; +// Windows permits direct registration, so this class exposes explicit +// Register/Unregister calls. Create, register, unregister, and destroy on +// one thread; that thread must pump messages for activations to arrive. +// The activation handler runs on that same thread. +class GlobalShortcuts final { + public: + using ActivationHandler = + std::function; + + static bool available(); + + GlobalShortcuts(); + ~GlobalShortcuts(); + GlobalShortcuts(GlobalShortcuts const&) = delete; + GlobalShortcuts& operator=(GlobalShortcuts const&) = delete; + + // `trigger` grammar (case-insensitive, '+'-separated, exactly one key): + // modifiers ctrl|control, alt|option, shift, win|meta|super|cmd|command; + // keys a-z, 0-9, f1-f24, space, tab, enter|return, escape|esc, backspace, + // delete|del, insert, home, end, pageup, pagedown, up, down, left, right, + // comma, period, slash, backslash, semicolon, quote, minus, equal, + // bracketleft, bracketright. Returns false for an unknown token, a + // missing key, or more than one key. + [[nodiscard]] static bool ParseTrigger(std::string const& trigger, + std::uint32_t& modifiers, + std::uint32_t& virtual_key); + + [[nodiscard]] bool Register(std::string const& shortcut_id, + std::string const& trigger); + bool Unregister(std::string const& shortcut_id); + + void SetActivationHandler(ActivationHandler handler); + + private: + static LRESULT CALLBACK WndProc(HWND window, UINT message, WPARAM wparam, + LPARAM lparam) noexcept; + + HWND window_{}; + std::uint32_t next_hotkey_id_{1}; + std::vector> registered_; + ActivationHandler handler_; +}; + } // namespace rivet::system diff --git a/tests/documentation.rkt b/tests/documentation.rkt index 7b98b64..e0c602c 100644 --- a/tests/documentation.rkt +++ b/tests/documentation.rkt @@ -190,6 +190,19 @@ "TrackPopupMenu"))]) (check-file-contains system-services-guide expected)) +;; The commercial-parity scorecard cannot silently disappear, and its +;; below-floor rows stay visible until the gap actually closes — closing one +;; means updating this list in the same change. +(define parity-guide (build-path repository-root "docs" "commercial-parity.md")) +(for ([expected (in-list '("Commercial parity baseline" + "Global shortcuts" + "Clipboard read/write/watch" + "Native file dialogs" + "Mobile hosts"))]) + (check-file-contains + parity-guide expected + (format "docs/commercial-parity.md must keep its ~s row; update the scorecard when a gap closes" expected))) + ;; --- Scribble coverage: every public export documented, no phantom docs --- ;; ;; Documentation rots silently when a new export lands without a manual