Enable isolated Remote speech and protect reading exports
Attach incoming software speech to reader sessions with lease checks, local output priority and isolated cancellation. Preserve every document fragment source for privacy checks and export reviewed browse current-line reading, spelling and phonetic output. Verified focused reading and Remote race tests, complete review and external Remote race suites, private relay integration, speech regressions, affected-package vet, reader build and Tentacles make check. Audible speech, physical input/Braille and live interoperability remain unverified.
This commit is contained in:
1 parent
ac67236eca
commit
49be8309fe
27 files changed
+986
-54
No files matched your search
@@ -0,0 +1,105 @@
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user