// 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() } } }