From 8631c5a601097a40e10c2843d2d867d430b132b1 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Thu, 5 Nov 2026 19:04:09 -0500 Subject: [PATCH] Share verified native Where Am I speech with Remote --- docs/tentacle-remote-speech.md | 29 ++- internal/app/current_speech_source.go | 1 + internal/app/event_review_results.go | 55 ++++- internal/app/event_state.go | 1 + internal/app/speech_native_where_am_i_test.go | 195 ++++++++++++++++++ internal/app/speech_observation.go | 9 + .../app/tentacle_remote_connection_test.go | 7 +- internal/atspi/adapter.go | 2 + internal/atspi/adapter_tree_native.go | 3 + internal/atspi/current_line_native.go | 20 ++ internal/atspi/current_line_native_test.go | 27 ++- internal/review/where_am_i.go | 55 +++++ internal/review/where_am_i_test.go | 68 ++++++ internal/review/worker.go | 6 +- internal/review/worker_contract.go | 7 +- 15 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 internal/app/speech_native_where_am_i_test.go create mode 100644 internal/review/where_am_i.go create mode 100644 internal/review/where_am_i_test.go diff --git a/docs/tentacle-remote-speech.md b/docs/tentacle-remote-speech.md index 024fe57..a03e369 100644 --- a/docs/tentacle-remote-speech.md +++ b/docs/tentacle-remote-speech.md @@ -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 diff --git a/internal/app/current_speech_source.go b/internal/app/current_speech_source.go index f2f236b..59ba77d 100644 --- a/internal/app/current_speech_source.go +++ b/internal/app/current_speech_source.go @@ -18,6 +18,7 @@ type currentSpeechSource struct { } func (state *eventLoopState) setCurrent(snapshot atspi.Snapshot) { + state.currentRevision++ state.current = snapshot state.currentSpeech = currentSpeechSource{} } diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index 0bdd6a9..bebcdf6 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -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 diff --git a/internal/app/event_state.go b/internal/app/event_state.go index 17bee8e..4d03a73 100644 --- a/internal/app/event_state.go +++ b/internal/app/event_state.go @@ -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 diff --git a/internal/app/speech_native_where_am_i_test.go b/internal/app/speech_native_where_am_i_test.go new file mode 100644 index 0000000..de89f30 --- /dev/null +++ b/internal/app/speech_native_where_am_i_test.go @@ -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") + } +} diff --git a/internal/app/speech_observation.go b/internal/app/speech_observation.go index 9523733..a1323e7 100644 --- a/internal/app/speech_observation.go +++ b/internal/app/speech_observation.go @@ -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. diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go index 1c386f7..f57ba0d 100644 --- a/internal/app/tentacle_remote_connection_test.go +++ b/internal/app/tentacle_remote_connection_test.go @@ -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):]) } } diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index b7165e5..91f6a05 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -215,6 +215,8 @@ type treeCaptureResult struct { } type treeCaptureRequest struct { + readFocusedSnapshot bool + soundObjects [2]ObjectReference transferItem bool typeText bool diff --git a/internal/atspi/adapter_tree_native.go b/internal/atspi/adapter_tree_native.go index 701898a..a66eb4d 100644 --- a/internal/atspi/adapter_tree_native.go +++ b/internal/atspi/adapter_tree_native.go @@ -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} diff --git a/internal/atspi/current_line_native.go b/internal/atspi/current_line_native.go index cbf11b8..a80b202 100644 --- a/internal/atspi/current_line_native.go +++ b/internal/atspi/current_line_native.go @@ -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 diff --git a/internal/atspi/current_line_native_test.go b/internal/atspi/current_line_native_test.go index 1ea331b..55764cb 100644 --- a/internal/atspi/current_line_native_test.go +++ b/internal/atspi/current_line_native_test.go @@ -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") + } } diff --git a/internal/review/where_am_i.go b/internal/review/where_am_i.go new file mode 100644 index 0000000..f6b5209 --- /dev/null +++ b/internal/review/where_am_i.go @@ -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 +} diff --git a/internal/review/where_am_i_test.go b/internal/review/where_am_i_test.go new file mode 100644 index 0000000..3e87d98 --- /dev/null +++ b/internal/review/where_am_i_test.go @@ -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") + } +} diff --git a/internal/review/worker.go b/internal/review/worker.go index 3bbeb54..ef49c27 100644 --- a/internal/review/worker.go +++ b/internal/review/worker.go @@ -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 { diff --git a/internal/review/worker_contract.go b/internal/review/worker_contract.go index 71a5798..6f2e07b 100644 --- a/internal/review/worker_contract.go +++ b/internal/review/worker_contract.go @@ -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