159 lines
4.9 KiB
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()
|
|
}
|
|
}
|
|
}
|