Files
cthulhu/internal/app/speech_observation.go

159 lines
4.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
// Unclassified output is private. Origins are core metadata, never inferred
// from network text, a Tentacle's request, or a user-visible cause string.
type speechOrigin uint8
const (
speechOriginUnknown speechOrigin = iota
speechOriginLocal
speechOriginRemote
speechOriginStatus
speechOriginSecret
)
func accessibility_speech_origin(source atspi.Snapshot) speechOrigin {
if source.Role == "password text" {
return speechOriginSecret
}
// Presentation may filter/reset context. Classify the original accessible
// ancestry before doing that, even when its labels will not be spoken.
for _, ancestor := range source.SpeechContext {
if ancestor.Role == "password text" {
return speechOriginSecret
}
}
if !known_speech_source(source) {
return speechOriginUnknown
}
for _, ancestor := range source.SpeechContext {
if !known_speech_source(ancestor) {
return speechOriginUnknown
}
}
return speechOriginLocal
}
func known_speech_source(source atspi.Snapshot) bool {
return !source.Defunct && source.Role != "" && source.Role != "unknown" && source.Role != "invalid"
}
// Document producers may remove the current object from their presentation
// context. Inspect the original sources before that filtering, including every
// content object in an assembled line. A mixed sensitive line stays private.
func document_speech_origin(source atspi.Snapshot, ancestors []atspi.Snapshot) speechOrigin {
if origin := accessibility_speech_origin(source); origin != speechOriginLocal {
return origin
}
for _, ancestor := range ancestors {
if origin := accessibility_speech_origin(ancestor); origin != speechOriginLocal {
return origin
}
}
return speechOriginLocal
}
func document_line_speech_origin(line atspi.DocumentLine) speechOrigin {
if len(line.Contents) == 0 {
return speechOriginUnknown
}
for _, sources := range line.SourceContexts {
if len(sources) == 0 {
return speechOriginUnknown
}
for _, source := range sources {
if origin := document_speech_origin(source, nil); origin != speechOriginLocal {
return origin
}
}
}
for _, content := range line.Contents {
if origin := document_speech_origin(content.Snapshot, line.Context); origin != speechOriginLocal {
return origin
}
}
return speechOriginLocal
}
// Review privacy metadata comes from the same owned capture as its text.
// Missing ancestry and unknown roles deny export, including any joined zone.
func review_speech_origin(contexts ...[]atspi.Snapshot) speechOrigin {
if len(contexts) == 0 {
return speechOriginUnknown
}
for _, sources := range contexts {
if len(sources) == 0 {
return speechOriginUnknown
}
for _, source := range sources {
if origin := document_speech_origin(source, source.SpeechContext); origin != speechOriginLocal {
return origin
}
}
}
return speechOriginLocal
}
func exportable_speech(request speechRequest) bool {
if request.origin != speechOriginLocal || request.Role == "password text" ||
request.ExcludeFromHistory || request.Confirmation || request.Uninterruptible || request.ForceSpeech {
return false
}
for _, ancestor := range request.Context {
if ancestor.Role == "password text" {
return false
}
}
return true
}
func (worker *speechWorker) observe_remote_output(manager *tentacle.Manager) {
worker.mutex.Lock()
defer worker.mutex.Unlock()
worker.remoteOutput = manager.CaptureRemoteSpeechOutput
}
// Optional source verification must not add native reads to ordinary commands
// when no live observer could receive their output.
func (worker *speechWorker) remote_observation_active() bool {
worker.mutex.Lock()
defer worker.mutex.Unlock()
return worker.running && worker.enabled && !worker.secure && worker.mode == speechModeSpeech &&
worker.remoteOutput != nil && worker.remoteOutput() != nil
}
// Serialize observation with user interruption. No event may be published after
// that interruption's cancel using an older delivery generation. This performs
// bounded IPC queue admission only, never pipe, relay or backend I/O.
func (worker *speechWorker) observe_speech(ctx context.Context, request speechRequest, text string, characters bool) {
if request.observation == nil || !exportable_speech(request) || ctx.Err() != nil {
return
}
worker.mutex.Lock()
defer worker.mutex.Unlock()
if !worker.running || !worker.enabled || worker.secure || worker.generation != request.deliveryGeneration ||
worker.mode != speechModeSpeech || ctx.Err() != nil {
return
}
// Failures retire only the remote stream. Local speech remains available.
_ = request.observation.PublishText(text, characters)
}
func (worker *speechWorker) observe_cancel_locked() {
if worker.remoteOutput != nil && !worker.secure {
if output := worker.remoteOutput(); output != nil {
_ = output.Cancel()
}
}
}