Share verified native Where Am I speech with Remote
This commit is contained in:
1 parent
ee3d91af23
commit
8631c5a601
15 files changed
+473
-12
No files matched your search
@@ -129,7 +129,8 @@ updates and Say All position updates without ancestry. Focus/epoch replacement,
|
||||
missing or mismatched ancestry, password/unknown/invalid roles and defunct
|
||||
sources keep the report local. Foreground synchronization runs before this
|
||||
check. Lock and terminal handoff suppress the command. Missing-location status
|
||||
stays private. Native focus reports and other unreviewed producers remain private.
|
||||
stays private. Native focus reports use the separate fresh-verification path below;
|
||||
other unreviewed producers remain private.
|
||||
Existing text, role chunks, formula reporting, pronunciation, focus, caret and
|
||||
Braille behavior are preserved; the speech worker captures the current lease
|
||||
and generation when the explicit command enters its queue.
|
||||
@@ -156,7 +157,31 @@ application ancestry; the shared classifier rejects password, unknown/invalid
|
||||
and defunct sources or ancestors. Spelling, phonetics, pronunciation and blank
|
||||
line handling use the existing speech owner and original lease/generation.
|
||||
Lock/handoff are rechecked after foreground synchronization and before presenting
|
||||
a completed report. Native Where Am I and other unreviewed producers stay private.
|
||||
a completed report. Other unreviewed producers stay private.
|
||||
|
||||
Native Where Am I now requests a fresh focused snapshot and complete ancestry on
|
||||
the review/GLib owners, only while enabled speech has a live output observer.
|
||||
Idle/disabled Remote retains the immediate cached local path. Capture does not
|
||||
move focus or caret. The existing 64-object/100-millisecond ancestry limits apply;
|
||||
individual synchronous provider calls retain their existing timeout. Unsupported
|
||||
providers or a full request queue fall back to the original private local report.
|
||||
|
||||
The cached local report is preserved. Export additionally requires matching source
|
||||
identities and roles, complete known nonprivate ancestry, and exact equality of
|
||||
the freshly rendered and cached text, formula and speech chunks. Fresh ancestry
|
||||
alone cannot authorize cached text. Missing/truncated ancestry, provider failure,
|
||||
password/unknown/invalid/defunct sources and changed reports keep local output
|
||||
private. A snapshot replacement (even of the same object), focus/epoch change,
|
||||
lock or handoff retires a pending result. Verification stores no reusable native
|
||||
classification or lease. The speech worker captures its ordinary lease/generation
|
||||
ticket only when the verified report enters its queue. Pronunciation, role chunks,
|
||||
local speech, focus, caret and Braille retain their existing presentation paths.
|
||||
|
||||
Private GTK checks exercise the new snapshot operation, fresh private ancestor
|
||||
roles, password text exclusion, unchanged caret and truncated ancestry. Worker,
|
||||
application and actual source-command/private-relay tests cover fresh capture,
|
||||
cached/fresh mismatch, stale-result exclusion and resolved output. Physical
|
||||
keyboard, audible speech and real NVDA/Orca acceptance remain pending.
|
||||
|
||||
Say All exports each resolved chunk immediately before its completion-paced
|
||||
backend call. Sentence boundaries, pronunciation and the original request's
|
||||
|
||||
@@ -18,6 +18,7 @@ type currentSpeechSource struct {
|
||||
}
|
||||
|
||||
func (state *eventLoopState) setCurrent(snapshot atspi.Snapshot) {
|
||||
state.currentRevision++
|
||||
state.current = snapshot
|
||||
state.currentSpeech = currentSpeechSource{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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):])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,8 @@ type treeCaptureResult struct {
|
||||
}
|
||||
|
||||
type treeCaptureRequest struct {
|
||||
readFocusedSnapshot bool
|
||||
|
||||
soundObjects [2]ObjectReference
|
||||
transferItem bool
|
||||
typeText bool
|
||||
|
||||
@@ -445,6 +445,9 @@ func cthulhuAtspiTreeCaptureCallback(userData unsafe.Pointer) {
|
||||
} else if request.setFocusedCaret {
|
||||
err := adapter.setNativeFocusedTextCaret(request.ctx, request.reviewFocus, request.positionOffset)
|
||||
request.result <- treeCaptureResult{err: err}
|
||||
} else if request.readFocusedSnapshot {
|
||||
content, err := adapter.read_native_focused_snapshot(request.ctx, request.reviewFocus)
|
||||
request.result <- treeCaptureResult{content: content, err: err}
|
||||
} else if request.readFocusedLine {
|
||||
content, err := adapter.read_native_focused_line(request.ctx, request.reviewFocus)
|
||||
request.result <- treeCaptureResult{content: content, err: err}
|
||||
|
||||
@@ -22,6 +22,26 @@ func (adapter *Adapter) ReadFocusedLine(ctx context.Context, focus ObjectID) (Do
|
||||
return result.content, err
|
||||
}
|
||||
|
||||
// ReadFocusedSnapshot captures presentation and privacy metadata together on
|
||||
// the GLib owner. It neither moves focus nor changes the native caret.
|
||||
func (adapter *Adapter) ReadFocusedSnapshot(ctx context.Context, focus ObjectID) (DocumentContent, error) {
|
||||
result, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: focus, readFocusedSnapshot: true})
|
||||
return result.content, err
|
||||
}
|
||||
|
||||
func (adapter *Adapter) read_native_focused_snapshot(ctx context.Context, focus ObjectID) (DocumentContent, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return DocumentContent{}, err
|
||||
}
|
||||
if !adapter.nativeFocusIs(focus) {
|
||||
return DocumentContent{}, ErrStaleObject
|
||||
}
|
||||
source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source
|
||||
C.atspi_accessible_clear_cache_single(source)
|
||||
snapshot := snapshotFromNativeWithCaret(source, true, false, false, false)
|
||||
return DocumentContent{Snapshot: snapshot, SourceContext: native_focused_line_context(ctx, source)}, ctx.Err()
|
||||
}
|
||||
|
||||
func (adapter *Adapter) read_native_focused_line(ctx context.Context, focus ObjectID) (content DocumentContent, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return DocumentContent{}, err
|
||||
|
||||
@@ -77,23 +77,34 @@ func TestNativeCurrentLine(t *testing.T) {
|
||||
focused:
|
||||
lastControl = probe.control
|
||||
content, err := adapter.ReadFocusedLine(ctx, focus)
|
||||
focusedSnapshot, snapshotErr := adapter.ReadFocusedSnapshot(ctx, focus)
|
||||
if snapshotErr != nil || focusedSnapshot.Snapshot.ID() != focus {
|
||||
t.Fatalf("focused snapshot: %+v %v", focusedSnapshot, snapshotErr)
|
||||
}
|
||||
if probe.password {
|
||||
if err == nil || content.Text != "" || content.Snapshot.Name != "" {
|
||||
t.Fatalf("password report: %+v %v", content, err)
|
||||
}
|
||||
if focusedSnapshot.Snapshot.Text != "" || focusedSnapshot.Snapshot.Role != "password text" {
|
||||
t.Fatal("focused snapshot exposed password text")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || strings.TrimRight(content.Text, "\r\n") != probe.want {
|
||||
t.Fatalf("%s offset %d: %+v %v, want %q", probe.control, probe.offset, content, err, probe.want)
|
||||
}
|
||||
if probe.control == "deep" {
|
||||
if len(content.SourceContext) != 0 {
|
||||
if len(content.SourceContext) != 0 || len(focusedSnapshot.SourceContext) != 0 {
|
||||
t.Fatal("truncated native ancestry authorized export")
|
||||
}
|
||||
} else if len(content.SourceContext) == 0 || content.SourceContext[0].ID() != focus ||
|
||||
content.SourceContext[len(content.SourceContext)-1].Role != "application" {
|
||||
t.Fatalf("native report lost complete source ancestry: %+v", content.SourceContext)
|
||||
}
|
||||
if probe.control != "deep" && (len(focusedSnapshot.SourceContext) == 0 || focusedSnapshot.SourceContext[0].ID() != focus ||
|
||||
focusedSnapshot.SourceContext[len(focusedSnapshot.SourceContext)-1].Role != "application") {
|
||||
t.Fatal("focused snapshot lost complete source ancestry")
|
||||
}
|
||||
if probe.control == "entry" && probe.want != "" {
|
||||
for _, role := range []struct{ atk, want string }{{"PASSWORD_TEXT", "password text"}, {"UNKNOWN", "unknown"}, {"INVALID", "invalid"}} {
|
||||
fmt.Fprintf(stdin, "{\"action\":\"privacy\",\"role\":%q}\n", role.atk)
|
||||
@@ -109,6 +120,17 @@ func TestNativeCurrentLine(t *testing.T) {
|
||||
if len(private.SourceContext) != 0 && !found {
|
||||
t.Fatal("native capture omitted a private ancestor", role.want)
|
||||
}
|
||||
privateSnapshot, err := adapter.ReadFocusedSnapshot(ctx, focus)
|
||||
if err != nil || privateSnapshot.Snapshot.Text != focusedSnapshot.Snapshot.Text {
|
||||
t.Fatal("ancestor privacy changed the focused snapshot", err)
|
||||
}
|
||||
found = false
|
||||
for _, ancestor := range privateSnapshot.SourceContext {
|
||||
found = found || ancestor.Role == role.want
|
||||
}
|
||||
if len(privateSnapshot.SourceContext) != 0 && !found {
|
||||
t.Fatal("focused snapshot omitted a private ancestor", role.want)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(stdin, "{\"action\":\"privacy\",\"role\":\"FILLER\"}")
|
||||
ready()
|
||||
@@ -124,4 +146,7 @@ func TestNativeCurrentLine(t *testing.T) {
|
||||
if _, err := adapter.ReadFocusedLine(ctx, ObjectID{BusName: focus.BusName, Path: "/stale"}); err == nil {
|
||||
t.Fatal("accepted stale native focus")
|
||||
}
|
||||
if _, err := adapter.ReadFocusedSnapshot(ctx, ObjectID{BusName: focus.BusName, Path: "/stale"}); err == nil {
|
||||
t.Fatal("accepted stale focused snapshot")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
type focusedSnapshotReader interface {
|
||||
ReadFocusedSnapshot(context.Context, atspi.ObjectID) (atspi.DocumentContent, error)
|
||||
}
|
||||
|
||||
// ReadWhereAmI queues privacy verification without blocking the command loop.
|
||||
// Providers without a focused snapshot operation retain their local-only path.
|
||||
func (worker *Worker) ReadWhereAmI(revision uint64) error {
|
||||
focus := worker.Focus()
|
||||
if !focus.Valid() {
|
||||
return ErrNoCurrent
|
||||
}
|
||||
if _, ok := worker.nativeCapturerFor(focus).(focusedSnapshotReader); !ok {
|
||||
return ErrNoCurrent
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request{whereAmI: true, whereAmIRevision: revision, readFocus: focus,
|
||||
documentEpoch: worker.documentEpoch.Load(), queuedAt: time.Now()}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) read_where_am_i(ctx context.Context, request request) Result {
|
||||
result := Result{WhereAmI: true, WhereAmIRevision: request.whereAmIRevision, DocumentFocus: request.readFocus}
|
||||
if !result.DocumentFocus.Valid() || result.DocumentFocus != worker.Focus() {
|
||||
result.Err = ErrNoCurrent
|
||||
return result
|
||||
}
|
||||
reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(focusedSnapshotReader)
|
||||
if !ok {
|
||||
result.Err = ErrNoCurrent
|
||||
return result
|
||||
}
|
||||
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
|
||||
defer cancel()
|
||||
content, err := reader.ReadFocusedSnapshot(deadline, result.DocumentFocus)
|
||||
result.Err = err
|
||||
result.DocumentPosition.Snapshot = content.Snapshot
|
||||
result.DocumentCaretContext = content.SourceContext
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
type whereAmICapturer struct {
|
||||
*readAllCapturer
|
||||
content atspi.DocumentContent
|
||||
reads int
|
||||
}
|
||||
|
||||
func (capturer *whereAmICapturer) ReadFocusedSnapshot(context.Context, atspi.ObjectID) (atspi.DocumentContent, error) {
|
||||
capturer.reads++
|
||||
return capturer.content, nil
|
||||
}
|
||||
|
||||
func TestWhereAmICapturesFreshSnapshotAndAncestry(t *testing.T) {
|
||||
source := atspi.Snapshot{BusName: webID("/entry").BusName, Path: "/entry", Role: "text", Name: "Current item"}
|
||||
capturer := &whereAmICapturer{content: atspi.DocumentContent{Snapshot: source,
|
||||
SourceContext: []atspi.Snapshot{source, {Role: "frame"}, {Role: "application"}}}}
|
||||
worker, cancel := startWorker(t, capturer, time.Second)
|
||||
defer cancel()
|
||||
worker.SyncFocus(source.ID())
|
||||
for revision := uint64(1); revision <= 2; revision++ {
|
||||
if err := worker.ReadWhereAmI(revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := waitResult(t, worker)
|
||||
if !result.WhereAmI || result.WhereAmIRevision != revision || result.DocumentFocus != source.ID() || result.Err != nil ||
|
||||
!reflect.DeepEqual(result.DocumentPosition.Snapshot, source) || !reflect.DeepEqual(result.DocumentCaretContext, capturer.content.SourceContext) {
|
||||
t.Fatal("Where Am I lost its snapshot, source lifetime or ancestry", result)
|
||||
}
|
||||
}
|
||||
if capturer.reads != 2 {
|
||||
t.Fatal("Where Am I reused a cached capture")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereAmIRejectsChangedFocusBeforeCapture(t *testing.T) {
|
||||
capturer := &whereAmICapturer{}
|
||||
worker, err := NewWorker(capturer, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := worker.ReadWhereAmI(1); err == nil {
|
||||
t.Fatal("missing focus queued a native request")
|
||||
}
|
||||
worker.SyncFocus(webID("/new"))
|
||||
result := worker.read_where_am_i(t.Context(), request{readFocus: webID("/old")})
|
||||
if result.Err == nil || capturer.reads != 0 {
|
||||
t.Fatal("retired focus started a native capture")
|
||||
}
|
||||
unsupported, _ := NewWorker(&readAllCapturer{}, time.Second)
|
||||
unsupported.SyncFocus(webID("/entry"))
|
||||
if err := unsupported.ReadWhereAmI(1); err == nil {
|
||||
t.Fatal("provider without snapshot support queued a native request")
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
request.interest.result <- interestReply{interest, err}
|
||||
continue
|
||||
}
|
||||
if (request.currentLine || request.brailleRoute != nil || request.nativeCaret || request.readAll != nil || request.line || request.listSelection != nil || request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != "" || request.unit != 0) && request.documentEpoch != documentEpoch {
|
||||
if (request.whereAmI || request.currentLine || request.brailleRoute != nil || request.nativeCaret || request.readAll != nil || request.line || request.listSelection != nil || request.target != 0 || request.caretCommand != 0 || request.activate || request.fragment != "" || request.unit != 0) && request.documentEpoch != documentEpoch {
|
||||
if request.brailleRoute != nil {
|
||||
request.brailleRoute.done <- atspi.ErrStaleObject
|
||||
}
|
||||
@@ -369,7 +369,9 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
continue
|
||||
}
|
||||
var result Result
|
||||
if request.currentLine {
|
||||
if request.whereAmI {
|
||||
result = worker.read_where_am_i(ctx, request)
|
||||
} else if request.currentLine {
|
||||
result = worker.read_current_line(ctx, document, request.currentLineBrowse)
|
||||
result.FlatSpeech = request.flatSpeech
|
||||
} else if request.listSelection != nil {
|
||||
|
||||
@@ -52,6 +52,8 @@ func (worker *Worker) nativeCapturerFor(id atspi.ObjectID) TreeCapturer {
|
||||
// Result describes one requested navigation step. A boundary has Moved false
|
||||
// and no error; Position remains the current object in that case.
|
||||
type Result struct {
|
||||
WhereAmI bool
|
||||
WhereAmIRevision uint64
|
||||
CurrentLine bool
|
||||
CurrentLineBrowse bool
|
||||
ReadAllContext context.Context
|
||||
@@ -107,11 +109,14 @@ type Result struct {
|
||||
DocumentLineText string
|
||||
}
|
||||
|
||||
// request uses the existing ordered dispatch contract: native caret, Say All
|
||||
// request uses the existing ordered dispatch contract: Where Am I/current line,
|
||||
// native caret, Say All
|
||||
// progress, Say All capture, document line, structural target, caret command,
|
||||
// activation, fragment, flat unit, then object direction. Flags are deliberately
|
||||
// retained; epoch and timeout retirement depend on their distinct combinations.
|
||||
type request struct {
|
||||
whereAmI bool
|
||||
whereAmIRevision uint64
|
||||
warmDocument bool
|
||||
currentLine bool
|
||||
currentLineBrowse bool
|
||||
|
||||
Reference in new issue
Block a user