Files
cthulhu/internal/app/tentacle_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

138 lines
4.7 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"slices"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
// remoteCaptureEndTimeout bounds waiting for the native owner, which ends a
// capture only between chords.
const remoteCaptureEndTimeout = 2 * time.Second
// remoteCaptureInput is the native exclusive-capture owner (Xorg only).
type remoteCaptureInput interface {
BeginRemoteCapture(context.Context, string) (uint64, <-chan input.RemoteCaptureEvent, error)
EndRemoteCapture(context.Context, uint64) error
}
// start_remote_capture attaches the controller capture owner for a session
// installed with keyboard-capture. Without a native capture owner remote.focus
// stays owner-unavailable. Capture is entered only on a request for the current
// controller lease while the reader is unlocked, not handed off and showing no
// reader UI. Revocation, policy or the core escape end it; ending releases all
// forwarded keys through the stream before the local focus event.
func (runtime *tentacleRuntime) start_remote_capture(ctx context.Context, session *tentacle.Session) <-chan struct{} {
done := make(chan struct{})
capture, ok := runtime.input.(remoteCaptureInput)
owner := runtime.output
if !ok || owner == nil || owner.gate == nil || runtime.remotePolicy == nil ||
!slices.Contains(session.RemoteGrants(), "keyboard-capture") {
close(done)
return done
}
requests := session.EnableRemoteCapture()
allowed := func() bool {
owner.gate.mutex.Lock()
defer owner.gate.mutex.Unlock()
return !owner.gate.Locked() && !owner.gate.Suspended() && !owner.gate.InternalUIActive()
}
prefix := "tentacle/" + session.Identity() + "/"
go func() {
defer close(done)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
stop := context.AfterFunc(session.OutputContext(), cancel)
defer stop()
var active uint64
var lease tentaclewire.RemoteLease
var events <-chan input.RemoteCaptureEvent
var leaseDone <-chan struct{}
end := func() {
if active == 0 {
return
}
// Use a fresh bounded context: ending must not depend on the lease
// or reader context that may already be cancelled.
endCtx, endCancel := context.WithTimeout(context.Background(), remoteCaptureEndTimeout)
_ = capture.EndRemoteCapture(endCtx, active)
endCancel()
}
defer end()
for {
select {
case <-ctx.Done():
return
case <-leaseDone:
leaseDone = nil
end()
case request := <-requests:
current := request.Context != nil && request.Context.Err() == nil
if current {
_, err := session.RemoteContext(request.Lease, "keyboard-capture")
current = err == nil
}
if !request.Remote {
if active != 0 && lease == request.Lease {
end() // The stream reports "requested" when it closes.
} else if current {
_ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "requested")
}
continue
}
if !current {
continue
}
if active != 0 {
if lease == request.Lease {
_ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested")
continue
}
end()
}
if !allowed() {
_ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "local")
continue
}
id, stream, err := capture.BeginRemoteCapture(request.Context, prefix)
if err != nil {
_ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "unavailable")
runtime.report("Remote keyboard control is unavailable.")
continue
}
runtime.report("Keyboard controls the remote computer. Press F11 to return.")
active, lease, events, leaseDone = id, request.Lease, stream, request.Context.Done()
if session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested") != nil {
end()
}
case event, open := <-events:
if !open {
active, events, leaseDone = 0, nil, nil
continue
}
if event.Ended != "" {
runtime.logger.Debug("remote keyboard capture ended", "reason", event.Ended)
_ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusLocal, event.Ended)
runtime.report("Keyboard returned to this computer.")
continue
}
key := tentaclewire.RemoteCapturedKey{Code: int(event.Key.Code), Name: event.Key.Name, Pressed: event.Key.Pressed, Repeat: event.Key.Repeat}
if session.PublishRemoteInput(lease, key) != nil && key.Pressed {
// A retired lease cannot carry keys. Releases continue to
// drain the stream so the native owner never blocks.
end()
}
}
}
}()
return done
}