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

+27 -2
View File
@@ -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
+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):])
}
}
+2
View File
@@ -215,6 +215,8 @@ type treeCaptureResult struct {
}
type treeCaptureRequest struct {
readFocusedSnapshot bool
soundObjects [2]ObjectReference
transferItem bool
typeText bool
+3
View File
@@ -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}
+20
View File
@@ -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
+26 -1
View File
@@ -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")
}
}
+55
View File
@@ -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
}
+68
View File
@@ -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")
}
}
+4 -2
View File
@@ -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 {
+6 -1
View File
@@ -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