Normalize captured autorepeat for quarantined Remote

This commit is contained in:
Chase Cichorz committed 2026-11-06 14:30:43 -05:00
1 parent ee1c32f049
commit 6b3aa80138
6 files changed
+561 -3

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package input
import (
"errors"
"time"
)
const (
// RemoteRepeatReleaseDelay is the initial bound for holding a release
// while waiting for its classic X11 autorepeat press. It is a tunable
// latency limit for a private capture owner, not a measured hardware value.
RemoteRepeatReleaseDelay = 25 * time.Millisecond
RemoteRepeatReleaseDelayMax = 250 * time.Millisecond
remoteRepeatMinKeycode = 8
)
var ErrRemoteRepeatDelay = errors.New("remote repeat release delay is invalid")
// RemoteRepeatTransition is one normalized source transition. Repeat is set
// only on a press for a key that is already owned; no synthetic release is
// emitted between repeats.
type RemoteRepeatTransition struct {
Keycode uint8
Pressed bool
Repeat bool
}
// RemoteRepeatNormalizer converts native key observations into the stream
// required by Remote's outgoing encoder: one fresh down, repeated downs and
// one final up. Classic X11 autorepeat reports release/press pairs with the
// same server timestamp; a nonzero-timestamp release is therefore held until
// the next observation or its deadline. Detectable autorepeat already reports
// repeated downs and passes through unchanged.
//
// One instance belongs to one capture lifetime and one serial native owner.
// It owns only keys whose downs it observed: releases for keys held before
// capture are suppressed and never reported as Remote-owned. Admission stops
// on an invalid keycode or more than RemoteHeldKeyLimit held keys; Retire still
// returns releases for every owned key. The normalizer performs no I/O, grants
// no consent and does not establish physical-source provenance.
type RemoteRepeatNormalizer struct {
delay time.Duration
held [256]bool
order []uint8
pending bool
pendingCode uint8
pendingStamp uint32
pendingDeadline time.Time
stopped bool
retired bool
}
func NewRemoteRepeatNormalizer(delay time.Duration) (*RemoteRepeatNormalizer, error) {
if delay <= 0 || delay > RemoteRepeatReleaseDelayMax {
return nil, ErrRemoteRepeatDelay
}
return &RemoteRepeatNormalizer{delay: delay, order: make([]uint8, 0, RemoteHeldKeyLimit)}, nil
}
// Observe admits one native transition. A successful call can return zero,
// one or two transitions; a returned error stops admission permanently while
// retaining ownership for Retire. The caller must not drop returned work.
func (normalizer *RemoteRepeatNormalizer) Observe(event Event, now time.Time) ([]RemoteRepeatTransition, error) {
if normalizer == nil || normalizer.stopped {
return nil, ErrRemoteKeyState
}
if event.Keycode < remoteRepeatMinKeycode {
normalizer.stopped = true
return nil, ErrRemoteKeyUnavailable
}
var out []RemoteRepeatTransition
if normalizer.pending {
if event.Pressed && event.Keycode == normalizer.pendingCode &&
event.Timestamp == normalizer.pendingStamp {
normalizer.pending = false
return []RemoteRepeatTransition{{Keycode: event.Keycode, Pressed: true, Repeat: true}}, nil
}
out = append(out, normalizer.releasePending())
}
code := event.Keycode
if event.Pressed {
if normalizer.held[code] {
return append(out, RemoteRepeatTransition{Keycode: code, Pressed: true, Repeat: true}), nil
}
if len(normalizer.order) >= RemoteHeldKeyLimit {
normalizer.stopped = true
return out, ErrRemoteKeyLimit
}
normalizer.held[code] = true
normalizer.order = append(normalizer.order, code)
return append(out, RemoteRepeatTransition{Keycode: code, Pressed: true}), nil
}
if !normalizer.held[code] {
return out, nil
}
if event.Timestamp == 0 {
// Unknown timestamps cannot prove a classic pair; release now.
normalizer.forget(code)
return append(out, RemoteRepeatTransition{Keycode: code}), nil
}
normalizer.pending = true
normalizer.pendingCode = code
normalizer.pendingStamp = event.Timestamp
normalizer.pendingDeadline = now.Add(normalizer.delay)
return out, nil
}
// Deadline reports when a held release must be flushed. The owner should arm
// a timer for it rather than poll.
func (normalizer *RemoteRepeatNormalizer) Deadline() (time.Time, bool) {
if normalizer == nil || normalizer.retired || !normalizer.pending {
return time.Time{}, false
}
return normalizer.pendingDeadline, true
}
// Flush emits a held release once its deadline has been reached. Pairing is
// decided by event content, not by timer accuracy: a late flush only adds
// latency, and a press arriving after a flush is a fresh down.
func (normalizer *RemoteRepeatNormalizer) Flush(now time.Time) []RemoteRepeatTransition {
if normalizer == nil || normalizer.retired || !normalizer.pending ||
now.Before(normalizer.pendingDeadline) {
return nil
}
return []RemoteRepeatTransition{normalizer.releasePending()}
}
// Retire stops the normalizer and transfers releases for every owned key in
// reverse down order, including a held pending release. It returns the plan
// once; repeated retirement returns nothing.
func (normalizer *RemoteRepeatNormalizer) Retire() []RemoteRepeatTransition {
if normalizer == nil || normalizer.retired {
return nil
}
normalizer.stopped = true
normalizer.retired = true
normalizer.pending = false
out := make([]RemoteRepeatTransition, 0, len(normalizer.order))
for index := len(normalizer.order) - 1; index >= 0; index-- {
out = append(out, RemoteRepeatTransition{Keycode: normalizer.order[index]})
}
normalizer.held = [256]bool{}
normalizer.order = normalizer.order[:0]
return out
}
func (normalizer *RemoteRepeatNormalizer) releasePending() RemoteRepeatTransition {
code := normalizer.pendingCode
normalizer.pending = false
normalizer.forget(code)
return RemoteRepeatTransition{Keycode: code}
}
func (normalizer *RemoteRepeatNormalizer) forget(code uint8) {
normalizer.held[code] = false
for index, held := range normalizer.order {
if held == code {
copy(normalizer.order[index:], normalizer.order[index+1:])
normalizer.order = normalizer.order[:len(normalizer.order)-1]
return
}
}
}
+218
View File
@@ -0,0 +1,218 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package input
import (
"errors"
"reflect"
"testing"
"time"
)
type repeatStep struct {
code uint8
pressed bool
stamp uint32
want []RemoteRepeatTransition
}
func down(code uint8) RemoteRepeatTransition {
return RemoteRepeatTransition{Keycode: code, Pressed: true}
}
func repeat(code uint8) RemoteRepeatTransition {
return RemoteRepeatTransition{Keycode: code, Pressed: true, Repeat: true}
}
func up(code uint8) RemoteRepeatTransition { return RemoteRepeatTransition{Keycode: code} }
func newTestNormalizer(t *testing.T) *RemoteRepeatNormalizer {
t.Helper()
normalizer, err := NewRemoteRepeatNormalizer(RemoteRepeatReleaseDelay)
if err != nil {
t.Fatal(err)
}
return normalizer
}
func runRepeatSteps(t *testing.T, normalizer *RemoteRepeatNormalizer, now time.Time, steps []repeatStep) {
t.Helper()
for index, step := range steps {
got, err := normalizer.Observe(Event{Keycode: step.code, Pressed: step.pressed, Timestamp: step.stamp}, now)
if err != nil {
t.Fatalf("step %d: %v", index, err)
}
if len(got) == 0 && len(step.want) == 0 {
continue
}
if !reflect.DeepEqual(got, step.want) {
t.Fatalf("step %d: transitions = %+v, want %+v", index, got, step.want)
}
}
}
func TestBehaviorRemoteRepeatNormalizesClassicPairs(t *testing.T) {
normalizer := newTestNormalizer(t)
now := time.Unix(100, 0)
runRepeatSteps(t, normalizer, now, []repeatStep{
{38, true, 100, []RemoteRepeatTransition{down(38)}},
{38, false, 660, nil}, // Classic pair: release held.
{38, true, 660, []RemoteRepeatTransition{repeat(38)}},
{38, false, 700, nil},
{38, true, 700, []RemoteRepeatTransition{repeat(38)}},
{38, false, 750, nil}, // Real release: no matching press follows.
{39, true, 760, []RemoteRepeatTransition{up(38), down(39)}},
{39, false, 770, nil},
{39, true, 780, []RemoteRepeatTransition{up(39), down(39)}}, // Different stamp is a fresh press.
})
if _, pending := normalizer.Deadline(); pending {
t.Fatal("fresh press left a pending release")
}
}
func TestBehaviorRemoteRepeatPassesDetectableRepeat(t *testing.T) {
normalizer := newTestNormalizer(t)
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
{50, true, 10, []RemoteRepeatTransition{down(50)}},
{50, true, 20, []RemoteRepeatTransition{repeat(50)}},
{50, true, 30, []RemoteRepeatTransition{repeat(50)}},
{38, true, 40, []RemoteRepeatTransition{down(38)}},
{38, false, 0, []RemoteRepeatTransition{up(38)}}, // Unknown stamp releases immediately.
{38, true, 0, []RemoteRepeatTransition{down(38)}},
})
}
func TestBehaviorRemoteRepeatPendingReleaseFlushesAtDeadline(t *testing.T) {
normalizer := newTestNormalizer(t)
start := time.Unix(5, 0)
runRepeatSteps(t, normalizer, start, []repeatStep{
{38, true, 100, []RemoteRepeatTransition{down(38)}},
{38, false, 200, nil},
})
deadline, pending := normalizer.Deadline()
if !pending || !deadline.Equal(start.Add(RemoteRepeatReleaseDelay)) {
t.Fatalf("deadline = %v %t", deadline, pending)
}
if got := normalizer.Flush(deadline.Add(-time.Nanosecond)); got != nil {
t.Fatalf("early flush = %+v", got)
}
if got := normalizer.Flush(deadline); !reflect.DeepEqual(got, []RemoteRepeatTransition{up(38)}) {
t.Fatalf("flush = %+v", got)
}
if got := normalizer.Flush(deadline.Add(time.Second)); got != nil {
t.Fatalf("repeated flush = %+v", got)
}
// A press with the old stamp after the flush is a fresh, owned press.
runRepeatSteps(t, normalizer, deadline, []repeatStep{
{38, true, 200, []RemoteRepeatTransition{down(38)}},
})
if got := normalizer.Retire(); !reflect.DeepEqual(got, []RemoteRepeatTransition{up(38)}) {
t.Fatalf("retire = %+v", got)
}
}
func TestBehaviorRemoteRepeatSuppressesUnownedReleases(t *testing.T) {
normalizer := newTestNormalizer(t)
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
{64, false, 10, nil}, // Held before capture began.
{64, false, 0, nil},
{38, true, 20, []RemoteRepeatTransition{down(38)}},
{38, false, 30, nil},
{64, false, 31, []RemoteRepeatTransition{up(38)}}, // Pending release flushed; unowned up suppressed.
})
if _, pending := normalizer.Deadline(); pending {
t.Fatal("unowned release became pending")
}
if got := normalizer.Retire(); len(got) != 0 {
t.Fatalf("retire released unowned keys: %+v", got)
}
}
func TestBehaviorRemoteRepeatRetireReleasesReverseOrderOnce(t *testing.T) {
normalizer := newTestNormalizer(t)
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
{50, true, 1, []RemoteRepeatTransition{down(50)}},
{37, true, 2, []RemoteRepeatTransition{down(37)}},
{38, true, 3, []RemoteRepeatTransition{down(38)}},
{37, false, 4, nil}, // Pending release is still owned at retirement.
})
want := []RemoteRepeatTransition{up(38), up(37), up(50)}
if got := normalizer.Retire(); !reflect.DeepEqual(got, want) {
t.Fatalf("retire = %+v, want %+v", got, want)
}
if got := normalizer.Retire(); got != nil {
t.Fatalf("second retire = %+v", got)
}
if _, pending := normalizer.Deadline(); pending {
t.Fatal("retired normalizer kept a deadline")
}
if got := normalizer.Flush(time.Unix(100, 0)); got != nil {
t.Fatalf("retired flush = %+v", got)
}
if _, err := normalizer.Observe(Event{Keycode: 38, Pressed: true, Timestamp: 9}, time.Unix(2, 0)); !errors.Is(err, ErrRemoteKeyState) {
t.Fatalf("observe after retire = %v", err)
}
}
func TestBehaviorRemoteRepeatStopsOnInvalidOrExcessKeys(t *testing.T) {
normalizer := newTestNormalizer(t)
now := time.Unix(1, 0)
runRepeatSteps(t, normalizer, now, []repeatStep{{38, true, 1, []RemoteRepeatTransition{down(38)}}})
if _, err := normalizer.Observe(Event{Keycode: 7, Pressed: true, Timestamp: 2}, now); !errors.Is(err, ErrRemoteKeyUnavailable) {
t.Fatalf("invalid keycode = %v", err)
}
if _, err := normalizer.Observe(Event{Keycode: 39, Pressed: true, Timestamp: 3}, now); !errors.Is(err, ErrRemoteKeyState) {
t.Fatalf("stopped admission = %v", err)
}
if got := normalizer.Retire(); !reflect.DeepEqual(got, []RemoteRepeatTransition{up(38)}) {
t.Fatalf("retire after invalid = %+v", got)
}
normalizer = newTestNormalizer(t)
for index := 0; index < RemoteHeldKeyLimit; index++ {
code := uint8(remoteRepeatMinKeycode + index)
runRepeatSteps(t, normalizer, now, []repeatStep{{code, true, uint32(index + 1), []RemoteRepeatTransition{down(code)}}})
}
// Repeats of owned keys remain admissible at the limit.
runRepeatSteps(t, normalizer, now, []repeatStep{{8, true, 100, []RemoteRepeatTransition{repeat(8)}}})
// A release followed by a different fresh key emits the release before the
// limit is checked again, so the new key fits.
runRepeatSteps(t, normalizer, now, []repeatStep{
{8, false, 101, nil},
{200, true, 102, []RemoteRepeatTransition{up(8), down(200)}},
})
if _, err := normalizer.Observe(Event{Keycode: 201, Pressed: true, Timestamp: 103}, now); !errors.Is(err, ErrRemoteKeyLimit) {
t.Fatalf("overflow = %v", err)
}
got := normalizer.Retire()
if len(got) != RemoteHeldKeyLimit || got[0] != up(200) || got[len(got)-1] != up(9) {
t.Fatalf("retire after overflow = %+v", got)
}
}
func TestBehaviorRemoteRepeatPendingDoesNotPairOtherKeysOrReleases(t *testing.T) {
normalizer := newTestNormalizer(t)
runRepeatSteps(t, normalizer, time.Unix(1, 0), []repeatStep{
{38, true, 1, []RemoteRepeatTransition{down(38)}},
{39, true, 2, []RemoteRepeatTransition{down(39)}},
{38, false, 10, nil},
{39, true, 10, []RemoteRepeatTransition{up(38), repeat(39)}}, // Same stamp, other key.
{39, false, 11, nil},
{39, false, 11, []RemoteRepeatTransition{up(39)}}, // Duplicate release is unowned once flushed.
})
}
func TestBehaviorRemoteRepeatRejectsInvalidDelay(t *testing.T) {
for _, delay := range []time.Duration{0, -time.Millisecond, RemoteRepeatReleaseDelayMax + time.Nanosecond} {
if _, err := NewRemoteRepeatNormalizer(delay); !errors.Is(err, ErrRemoteRepeatDelay) {
t.Fatalf("delay %v: %v", delay, err)
}
}
var normalizer *RemoteRepeatNormalizer
if _, err := normalizer.Observe(Event{Keycode: 38, Pressed: true}, time.Time{}); !errors.Is(err, ErrRemoteKeyState) {
t.Fatalf("nil observe = %v", err)
}
if normalizer.Retire() != nil || normalizer.Flush(time.Time{}) != nil {
t.Fatal("nil normalizer emitted transitions")
}
}