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.
303 lines
9.9 KiB
Go
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
|
|
}
|
|
}
|
|
}
|