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

106 lines
3.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"
"errors"
"slices"
"time"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
// Called under the speech mutex. Cancellation performs no backend I/O. A local
// announcement or interruption discards all older incoming work, including work
// waiting behind an active renderer. Later Remote output needs fresh admission.
func (worker *speechWorker) retire_remote_speech_locked() {
worker.remoteAfter = time.Now()
if worker.remoteCancel != nil {
worker.remoteCancel()
}
worker.remoteLifetime, worker.remoteCancel = nil, nil
}
func (worker *speechWorker) admit_remote_speech(delivery tentacle.RemoteSpeechDelivery) (remoteSpeechPermit, bool) {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if !worker.running || !worker.enabled || worker.secure || worker.mode != speechModeSpeech ||
worker.interrupting || worker.activeDone != nil || len(worker.pending) != 0 ||
!delivery.Received.After(worker.remoteAfter) {
return remoteSpeechPermit{}, false
}
if worker.remoteLifetime == nil {
worker.remoteLifetime, worker.remoteCancel = context.WithCancel(worker.workerContext)
}
ctx, cancel := context.WithCancel(delivery.Context)
local := worker.remoteLifetime
stop := context.AfterFunc(local, cancel)
return remoteSpeechPermit{context: ctx, current: func() bool { return local.Err() == nil },
release: func() { stop(); cancel() }}, true
}
func (worker *speechWorker) remote_speech_available(delivery tentacle.RemoteSpeechDelivery) bool {
worker.mutex.Lock()
defer worker.mutex.Unlock()
return worker.running && worker.enabled && !worker.secure && worker.mode == speechModeSpeech &&
delivery.Received.After(worker.remoteAfter)
}
// start_remote_speech installs the mailbox before the child becomes active.
// One session worker owns it until output revocation or reader shutdown. The
// lifecycle watcher joins cleanup; input/focus callers only cancel contexts.
func (runtime *tentacleRuntime) start_remote_speech(ctx context.Context, session *tentacle.Session) <-chan struct{} {
done := make(chan struct{})
owner := runtime.output
if owner == nil || owner.speech == nil || owner.gate == nil || runtime.remotePolicy == nil ||
!slices.Contains(session.RemoteGrants(), "remote-present") {
close(done)
return done
}
speaker, ok := owner.speech.speaker.(*configuredSpeaker)
if !ok {
close(done)
return done
}
incoming := session.EnableRemoteSpeech()
ctx, cancel := context.WithCancel(ctx)
stop := context.AfterFunc(session.OutputContext(), cancel)
allow := func(delivery tentacle.RemoteSpeechDelivery) bool {
if delivery.Context == nil || delivery.Context.Err() != nil {
return false
}
owner.gate.mutex.Lock()
defer owner.gate.mutex.Unlock()
if owner.gate.Locked() || owner.gate.Suspended() || owner.gate.InternalUIActive() ||
!owner.speech.remote_speech_available(delivery) {
return false
}
lifetime, err := session.RemoteContext(delivery.Lease, "remote-present")
return err == nil && lifetime.Err() == nil
}
go func() {
defer close(done)
defer cancel()
defer stop()
for ctx.Err() == nil {
err := run_remote_speech_playback(ctx, speaker, incoming, allow, owner.speech.admit_remote_speech, runtime.logger)
if ctx.Err() != nil || err == nil {
return
}
var failure *remoteSpeechStreamError
if !errors.As(err, &failure) {
// Internal ownership failure: leave no accepting mailbox without
// a consumer. Normal backend failures retire only their lease.
session.Disable()
return
}
session.RevokeRemoteEpoch(failure.lease.Epoch)
runtime.report("Remote speech stopped. Connect again to retry.")
}
}()
return done
}