Share verified native Where Am I speech with Remote

This commit is contained in:
ak47 committed 2026-11-05 19:04:09 -05:00
1 parent ee3d91af23
commit 8631c5a601
15 files changed
+473 -12

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ type currentSpeechSource struct {
}
func (state *eventLoopState) setCurrent(snapshot atspi.Snapshot) {
state.currentRevision++
state.current = snapshot
state.currentSpeech = currentSpeechSource{}
}
+50 -5
View File
@@ -7,6 +7,7 @@ package app
import (
"log/slog"
"slices"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
@@ -45,17 +46,57 @@ func (handler *eventReviewHandler) whereAmI() {
state.pending.terminalFocus.clear()
state.pending.terminalDeletion.clear()
state.pending.terminalRepaint.clear()
origin := handler.currentSpeechOrigin()
observer, observing := handler.speech.(interface{ remote_observation_active() bool })
if observing && observer.remote_observation_active() && origin == speechOriginUnknown && handler.objectReview != nil && state.current.ID() == handler.objectReview.Focus() &&
!(handler.browser != nil && handler.browser.ready && handler.browser.mode == review.BrowseMode) {
if err := handler.objectReview.ReadWhereAmI(state.currentRevision); err == nil {
return
}
}
handler.present_where_am_i(origin)
}
func where_am_i_speech(snapshot atspi.Snapshot, options presentation.Options) presentation.Speech {
// Python's Where Am I is speech-only. Do not suppress the role merely
// because ordinary focus presentation replaces it with a sound cue.
reviewOptions := state.presentationOptions
reviewOptions.EnableSound = false
spoken := presentation.FocusSpeechWithOptions(state.current, reviewOptions)
if formula := presentation.LibreOfficeCellFormula(state.current); formula != "" {
options.EnableSound = false
spoken := presentation.FocusSpeechWithOptions(snapshot, options)
if formula := presentation.LibreOfficeCellFormula(snapshot); formula != "" {
spoken = spoken.Append(formula, ", ")
}
return spoken
}
func (handler *eventReviewHandler) present_native_where_am_i(result review.Result) {
state := handler.state
if handler.focusGate.Locked() || handler.focusGate.Suspended() || handler.objectReview == nil ||
!handler.objectReview.CurrentDocumentResult(result) || result.DocumentFocus != handler.objectReview.Focus() ||
result.DocumentFocus != state.current.ID() || result.WhereAmIRevision != state.currentRevision {
return
}
origin := speechOriginUnknown
source, ancestry := result.DocumentPosition.Snapshot, result.DocumentCaretContext
if result.Err == nil && source.ID() == state.current.ID() && len(ancestry) > 0 &&
ancestry[0].ID() == source.ID() && ancestry[len(ancestry)-1].Role == "application" &&
review_speech_origin(ancestry) == speechOriginLocal && accessibility_speech_origin(source) == speechOriginLocal &&
accessibility_speech_origin(state.current) == speechOriginLocal && source.Role == state.current.Role {
// Fresh ancestry alone cannot authorize older cached text. Require the
// complete fresh report, including formula and chunk boundaries, to match.
fresh := where_am_i_speech(source, state.presentationOptions)
cached := where_am_i_speech(state.current, state.presentationOptions)
if fresh.Text == cached.Text && slices.Equal(fresh.Chunks, cached.Chunks) {
origin = speechOriginLocal
}
}
handler.present_where_am_i(origin)
}
func (handler *eventReviewHandler) present_where_am_i(origin speechOrigin) {
state := handler.state
spoken := where_am_i_speech(state.current, state.presentationOptions)
text := spoken.Text
role := state.current.Role
origin := handler.currentSpeechOrigin()
if text == "" {
text = "Could not find current location."
role = "system"
@@ -89,6 +130,10 @@ func (handler *eventReviewHandler) handle(result review.Result) {
if !state.current.ID().Valid() {
return
}
if result.WhereAmI {
handler.present_native_where_am_i(result)
return
}
if result.CurrentLine {
handler.present_current_line(result)
return
+1
View File
@@ -47,6 +47,7 @@ type eventForegroundState struct {
type eventFocusState struct {
libreOfficeSelection libreOfficeSelectionState
current atspi.Snapshot
currentRevision uint64
currentSpeech currentSpeechSource
brailleSnapshot atspi.Snapshot
focusedID atspi.ObjectID
@@ -0,0 +1,195 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func native_where_am_i_handler(t *testing.T) (*eventReviewHandler, *fakeSpeechQueue, review.Result) {
t.Helper()
result := native_current_line_result()
result.WhereAmI, result.DocumentFocus = true, result.DocumentPosition.Snapshot.ID()
result.DocumentPosition.Snapshot.Name = "Native colour."
worker, err := review.NewWorker(&fakeAdapter{}, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(result.DocumentFocus)
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 4), spoken: make(chan string, 4)}
handler := &eventReviewHandler{state: &eventLoopState{}, objectReview: worker, speech: queue,
logger: slog.New(slog.DiscardHandler), focusGate: &focusGate{}, syncForeground: func() {}}
handler.state.setCurrent(result.DocumentPosition.Snapshot)
handler.state.presentationOptions = presentation.DefaultOptions()
result.WhereAmIRevision = handler.state.currentRevision
return handler, queue, result
}
func native_where_am_i_request(t *testing.T, privacy string) speechRequest {
t.Helper()
handler, queue, result := native_where_am_i_handler(t)
switch privacy {
case "password":
result.DocumentPosition.Snapshot.Role = "password text"
case "ancestor", "unknown", "invalid":
role := privacy
if privacy == "ancestor" {
role = "password text"
}
result.DocumentCaretContext[1].Role = role
case "defunct":
result.DocumentCaretContext[1].Defunct = true
case "missing":
result.DocumentCaretContext = nil
case "truncated":
result.DocumentCaretContext = result.DocumentCaretContext[:2]
case "mismatched":
result.DocumentCaretContext[0].Path = "/other"
case "foreign":
result.DocumentPosition.Snapshot.Path = "/other"
case "changed-text":
result.DocumentPosition.Snapshot.Name = "Fresh public text"
case "cached-ancestor":
handler.state.current.SpeechContext = []atspi.Snapshot{{Role: "password text"}}
case "fresh-ancestor":
result.DocumentPosition.Snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}}
case "failure":
result.Err = errors.New("provider unavailable")
}
handler.handle(result)
return <-queue.requests
}
func TestRemoteNativeWhereAmISourcePrivacy(t *testing.T) {
for _, privacy := range []string{"ordinary", "password", "ancestor", "unknown", "invalid", "defunct", "missing", "truncated", "mismatched", "foreign", "changed-text", "cached-ancestor", "fresh-ancestor", "failure"} {
t.Run(privacy, func(t *testing.T) {
request := native_where_am_i_request(t, privacy)
if exportable_speech(request) != (privacy == "ordinary") {
t.Fatalf("privacy %s produced exportable=%v", privacy, exportable_speech(request))
}
if !strings.Contains(request.Text, "Native colour.") || request.Cause != "where-am-i" {
t.Fatal("privacy verification changed the local report", request.Text)
}
})
}
}
func TestRemoteNativeWhereAmIRetiresCapture(t *testing.T) {
for _, change := range []string{"snapshot", "focus", "epoch", "lock", "handoff", "foreground"} {
t.Run(change, func(t *testing.T) {
handler, queue, result := native_where_am_i_handler(t)
switch change {
case "snapshot":
handler.state.setCurrent(handler.state.current)
case "focus":
handler.objectReview.SyncFocus(atspi.NewObjectID(result.DocumentFocus.BusName, "/other"))
case "epoch":
handler.objectReview.ResetDocument(result.DocumentFocus)
case "lock":
handler.focusGate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
case "handoff":
handler.focusGate.SetSuspended(true)
case "foreground":
handler.syncForeground = func() { handler.focusGate.SetSuspended(true) }
}
handler.handle(result)
if len(queue.requests) != 0 {
t.Fatal("retired native Where Am I capture spoke")
}
})
}
}
type whereAmICapturer struct {
*fakeAdapter
content atspi.DocumentContent
}
type whereAmIObservationQueue struct {
*fakeSpeechQueue
active bool
}
func (queue *whereAmIObservationQueue) remote_observation_active() bool { return queue.active }
func (capturer *whereAmICapturer) ReadFocusedSnapshot(context.Context, atspi.ObjectID) (atspi.DocumentContent, error) {
return capturer.content, nil
}
func TestRemoteNativeWhereAmIQueuesCapture(t *testing.T) {
handler, queue, result := native_where_am_i_handler(t)
capturer := &whereAmICapturer{fakeAdapter: &fakeAdapter{}, content: atspi.DocumentContent{
Snapshot: result.DocumentPosition.Snapshot, SourceContext: result.DocumentCaretContext}}
worker, err := review.NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(result.DocumentFocus)
handler.objectReview = worker
observedQueue := &whereAmIObservationQueue{fakeSpeechQueue: queue}
handler.speech = observedQueue
handler.whereAmI()
if request := <-queue.requests; exportable_speech(request) {
t.Fatal("inactive observation authorized native export")
}
<-queue.spoken
observedQueue.active = true
handler.whereAmI()
if len(queue.requests) != 0 {
t.Fatal("native capture bypassed the review worker")
}
if err := worker.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { worker.Stop(context.Background()) })
select {
case captured := <-worker.Results():
handler.handle(captured)
if request := <-queue.requests; !exportable_speech(request) {
t.Fatal("ordinary native report stayed private after fresh verification")
}
case <-time.After(time.Second):
t.Fatal("native report did not complete")
}
}
func TestRemoteNativeWhereAmIResolvedOutput(t *testing.T) {
worker, session, _, _ := observation_fixture(t)
if !worker.remote_observation_active() {
t.Fatal("live software observer did not enable source verification")
}
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
for _, privacy := range []string{"ordinary", "ancestor", "changed-text", "failure"} {
if err := worker.EnqueueAndWait(t.Context(), native_where_am_i_request(t, privacy)); err != nil {
t.Fatal(err)
}
for len(worker.speaker.(*observationSpeaker).spoken) > 0 {
want := <-worker.speaker.(*observationSpeaker).spoken
if privacy == "ordinary" {
value := observed_speech(t, session)
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || !value.Parts[1].End {
t.Fatal("native Where Am I export lost resolved text or chunks", value)
}
}
}
worker.InterruptForUser()
if value := observed_speech(t, session); value.Kind != "cancel" {
t.Fatal("private native report reached the child", value)
}
}
session.RevokeRemote()
if worker.remote_observation_active() {
t.Fatal("revoked observer still enabled native verification")
}
}
+9
View File
@@ -123,6 +123,15 @@ func (worker *speechWorker) observe_remote_output(manager *tentacle.Manager) {
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.
@@ -595,6 +595,11 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal(err)
}
}
for _, privacy := range []string{"ordinary", "ancestor", "changed-text", "failure"} {
if err := worker.EnqueueAndWait(t.Context(), native_where_am_i_request(t, privacy)); err != nil {
t.Fatal(err)
}
}
worker.InterruptForUser()
var received strings.Builder
ends, frames := 0, 0
@@ -630,7 +635,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal("reader output did not reach the relay in order")
}
}
if received.String() != "color "+remoteText+"First color.Second color.Current color.Native color.Review color.staticFlat color. Review color.Review color.static" || frames < 11 || ends != 10 {
if received.String() != "color "+remoteText+"First color.Second color.Current color.Native color.Review color.staticFlat color. Review color.Review color.staticNative color.text" || frames < 13 || ends != 12 {
t.Fatalf("observation leaked private text or lost resolved output/boundaries: frames=%d ends=%d suffix=%q", frames, ends, received.String()[max(0, received.Len()-160):])
}
}