Files
cthulhu/internal/input/remote_capture.go
ak47 64f60fe4e8 Honor Num Lock during Remote keyboard capture
Forward numeric keypad identities with Num Lock enabled and preserve held-key pairing across lock changes. Suppress local lock feedback during remote capture so it cannot interrupt incoming speech. Add debug speech and capture-end diagnostics, expand private keypad checks, and repair the suspension probe source list.

Validated affected Go packages, focused remote speech race checks, the private X11 Remote input harness, and a reader build. Live physical keyboard, audible PowerShell speech, Braille, and disconnect-cause verification remain pending; the active reader session was left running.
2026-11-07 22:37:39 -05:00

303 lines
9.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package input
import "errors"
var ErrRemoteCaptureChord = errors.New("remote capture local shortcut is invalid")
// RemoteCaptureKey is one normalized captured transition. Symbol is the
// group-zero, unshifted keysym the native owner resolved for a press, with
// Num Lock selecting numeric keypad identities; releases and repeats are
// matched by keycode, so a later layout change cannot alter ownership.
type RemoteCaptureKey struct {
Keycode uint8
Symbol uint32
Pressed bool
Repeat bool
// Unsupported marks a fresh press the Remote key vocabulary cannot name.
// It is consumed locally with its repeats and release, never forwarded.
Unsupported bool
}
// RemoteCaptureEvent is one item from a native capture owner to its reader
// consumer. Exactly one of Key or Ended is set. Name is present only on a
// fresh press. Ended reports why capture stopped: "escape", "requested" or
// "local" (reader policy, overflow or an inconsistent native stream); no
// further events follow it.
type RemoteCaptureEvent struct {
Key *RemoteCaptureTransition
Ended string
}
type RemoteCaptureTransition struct {
Code uint8
Name string
Pressed bool
Repeat bool
}
// RemoteCaptureEscape is the core escape chord: F11 with no modifiers. It is
// handled by the native owner without waiting for the network or a Tentacle.
var RemoteCaptureEscape = CommandChord{Symbol: 0xffc8}
// RemoteCaptureKeyName returns the canonical Remote key name for an unshifted
// keysym name (with Num Lock selecting keypad identities), or "" when it has none.
func RemoteCaptureKeyName(keysym string) string {
switch keysym {
case "Prior":
keysym = "Page_Up"
case "Next":
keysym = "Page_Down"
case "KP_Prior":
keysym = "KP_Page_Up"
case "KP_Next":
keysym = "KP_Page_Down"
}
if !ValidRemoteKeyName(keysym) {
return ""
}
return keysym
}
// RemoteCaptureResult is the routing decision for one captured transition.
// Remote transitions must be delivered in order before Local is dispatched.
// Escape is set only for the core escape chord; Local names a matched
// explicit local command. At most one of them is set.
type RemoteCaptureResult struct {
Remote []RemoteRepeatTransition
Local Command
Escape bool
}
// RemoteCaptureRetirement transfers cleanup. Remote releases every key
// forwarded and still owned, in reverse down order. Swallow lists every key
// still physically held: the local application never received those presses,
// so the native owner must consume their releases instead of ungrabbing first.
type RemoteCaptureRetirement struct {
Remote []RemoteRepeatTransition
Swallow []uint8
}
type remoteCaptureState uint8
const (
remoteCaptureIdle remoteCaptureState = iota
remoteCaptureForwarded // Down sent to the remote.
remoteCaptureWithdrawn // Modifier released remotely after a local chord.
remoteCaptureLocal // Press consumed by a local command.
)
// RemoteCaptureRouter decides, for a controller capture lifetime, which keys
// go to the remote and which stay local. Ordinary keys and Cthulhu/NVDA
// modifier chords are forwarded. Only the core escape and the supplied local
// chords stay local; callers derive those from resolved bindings so remaps
// are honored rather than a hard-coded list.
//
// Modifiers cannot be classified when pressed, so they are forwarded at once.
// When a later key completes a local chord, forwarded modifiers are released
// remotely first, so no prefix stays held on the remote. While still held they
// are withdrawn; a later forwarded key presses them again before itself, so the
// remote sees the physical chord. Releases of local or withdrawn keys are
// consumed and never reach the remote.
//
// Input must come from RemoteRepeatNormalizer. One serial owner drives an
// instance; it performs no I/O and grants no consent or provenance.
type RemoteCaptureRouter struct {
escape CommandChord
local CommandBindings
cthulhu map[uint32]bool
state [256]remoteCaptureState
modifier [256]bool
mask [256]uint16
isCthulhu [256]bool
order []uint8
stopped bool
retired bool
}
// NewRemoteCaptureRouter requires a non-modifier escape chord. Local chords
// equal to the escape are ignored: the escape always wins.
func NewRemoteCaptureRouter(escape CommandChord, local CommandBindings, cthulhuModifiers []uint32) (*RemoteCaptureRouter, error) {
router := &RemoteCaptureRouter{
escape: escape,
local: make(CommandBindings, len(local)),
cthulhu: make(map[uint32]bool, len(cthulhuModifiers)),
order: make([]uint8, 0, RemoteHeldKeyLimit),
}
for _, symbol := range cthulhuModifiers {
router.cthulhu[symbol] = true
}
if escape.Symbol == 0 || router.isModifierSymbol(escape.Symbol) {
return nil, ErrRemoteCaptureChord
}
for chord, command := range local {
if chord == escape || command == "" {
continue
}
if chord.Symbol == 0 || router.isModifierSymbol(chord.Symbol) {
return nil, ErrRemoteCaptureChord
}
router.local[chord] = command
}
return router, nil
}
func remoteModifierMask(symbol uint32) (uint16, bool) {
switch symbol {
case leftShiftKeySymbol, rightShiftKeySymbol:
return 1, true
case leftControlKeySymbol, rightControlKeySymbol:
return controlModifier, true
case 0xffe7, 0xffe8, 0xffe9, 0xffea: // Meta, Alt.
return 8, true
case 0xffeb, 0xffec: // Super.
return 64, true
case 0xffed, 0xffee: // Hyper: a modifier with no chord bit.
return 0, true
}
return 0, false
}
func (router *RemoteCaptureRouter) isModifierSymbol(symbol uint32) bool {
_, modifier := remoteModifierMask(symbol)
return modifier || router.cthulhu[symbol]
}
func (router *RemoteCaptureRouter) fail(err error) (RemoteCaptureResult, error) {
router.stopped = true
return RemoteCaptureResult{}, err
}
// Route handles one transition. An error stops routing permanently while
// retaining ownership for Retire.
func (router *RemoteCaptureRouter) Route(key RemoteCaptureKey) (RemoteCaptureResult, error) {
if router == nil || router.stopped {
return RemoteCaptureResult{}, ErrRemoteKeyState
}
code := key.Keycode
if code < remoteRepeatMinKeycode {
return router.fail(ErrRemoteKeyUnavailable)
}
state := router.state[code]
if !key.Pressed {
if state == remoteCaptureIdle {
return RemoteCaptureResult{}, nil
}
router.forget(code)
if state == remoteCaptureForwarded {
return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code}}}, nil
}
return RemoteCaptureResult{}, nil
}
if key.Repeat {
switch state {
case remoteCaptureIdle:
return router.fail(ErrRemoteKeyCollision)
case remoteCaptureForwarded:
return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code, Pressed: true, Repeat: true}}}, nil
}
return RemoteCaptureResult{}, nil
}
if state != remoteCaptureIdle {
return router.fail(ErrRemoteKeyCollision)
}
if len(router.order) >= RemoteHeldKeyLimit {
return router.fail(ErrRemoteKeyLimit)
}
if key.Unsupported {
router.hold(code, remoteCaptureLocal)
return RemoteCaptureResult{}, nil
}
if mask, modifier := remoteModifierMask(key.Symbol); modifier || router.cthulhu[key.Symbol] {
router.hold(code, remoteCaptureForwarded)
router.modifier[code] = true
router.mask[code] = mask
router.isCthulhu[code] = router.cthulhu[key.Symbol]
return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code, Pressed: true}}}, nil
}
chord := router.chord(key.Symbol)
escape := chord == router.escape
command := Command("")
if !escape {
command = router.local[chord]
}
if escape || command != "" {
var result RemoteCaptureResult
for index := len(router.order) - 1; index >= 0; index-- {
held := router.order[index]
if router.modifier[held] && router.state[held] == remoteCaptureForwarded {
router.state[held] = remoteCaptureWithdrawn
result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: held})
}
}
router.hold(code, remoteCaptureLocal)
result.Local, result.Escape = command, escape
return result, nil
}
var result RemoteCaptureResult
for _, held := range router.order {
if router.state[held] == remoteCaptureWithdrawn {
router.state[held] = remoteCaptureForwarded
result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: held, Pressed: true})
}
}
router.hold(code, remoteCaptureForwarded)
result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: code, Pressed: true})
return result, nil
}
// Retire stops routing and transfers cleanup once.
func (router *RemoteCaptureRouter) Retire() RemoteCaptureRetirement {
if router == nil || router.retired {
return RemoteCaptureRetirement{}
}
router.stopped = true
router.retired = true
var out RemoteCaptureRetirement
for index := len(router.order) - 1; index >= 0; index-- {
code := router.order[index]
if router.state[code] == remoteCaptureForwarded {
out.Remote = append(out.Remote, RemoteRepeatTransition{Keycode: code})
}
out.Swallow = append(out.Swallow, code)
}
router.state = [256]remoteCaptureState{}
router.modifier = [256]bool{}
router.order = router.order[:0]
return out
}
func (router *RemoteCaptureRouter) chord(symbol uint32) CommandChord {
chord := CommandChord{Symbol: symbol}
for _, code := range router.order {
if !router.modifier[code] {
continue
}
chord.Modifiers |= router.mask[code]
chord.CthulhuModifier = chord.CthulhuModifier || router.isCthulhu[code]
}
return chord
}
func (router *RemoteCaptureRouter) hold(code uint8, state remoteCaptureState) {
router.state[code] = state
router.order = append(router.order, code)
}
func (router *RemoteCaptureRouter) forget(code uint8) {
router.state[code] = remoteCaptureIdle
router.modifier[code] = false
router.mask[code] = 0
router.isCthulhu[code] = false
for index, held := range router.order {
if held == code {
copy(router.order[index:], router.order[index+1:])
router.order = router.order[:len(router.order)-1]
return
}
}
}