diff --git a/docs/tentacle-remote-speech.md b/docs/tentacle-remote-speech.md index b0df489..67dc128 100644 --- a/docs/tentacle-remote-speech.md +++ b/docs/tentacle-remote-speech.md @@ -121,7 +121,18 @@ without changing its local text. Layout text and privacy context remain from the same capture across tree replacement. Boundaries and errors remain private. Existing role chunks, pronunciation, spelling, phonetic expansion and character delivery retain their normal ordering and original lease/generation admission. -Where Am I and other unreviewed producers remain private. +Where Am I at a document position now exports only when that position retains +complete captured source ancestry. The event loop stores classification with +the current snapshot, document focus and epoch; it stores no lease authority. +Every snapshot replacement clears classification, including same-object native +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. +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. Browse-mode current-line reporting, spelling and phonetic spelling now export only with the command's captured source ancestry. The review worker retains @@ -478,6 +489,20 @@ exclusion and separate role utterance boundaries. An overlay restoring the previous unclassified producers reproduces missing ordinary export. No full reader suite, native desktop, hardware or audible checks were performed. +The document Where Am I increment passed focused application reading/focus/ +speech/Remote race regressions, complete input/review/AT-SPI and external Remote +race suites, ten repetitions of the new source/privacy cases, the speech +regression harness, application vet, a separate reader build and Tentacles +`make check`. The ordinary-source regression failed before implementation. +Owned-tree cases check private/missing/stale ancestry, same-object native updates, +Say All position replacement, focus/epoch retirement and lock/handoff, including +handoff discovered by foreground synchronization. The source-command/private-relay +fixture verifies ordinary exports, private exclusion and separate role boundaries. +The full application race suite timed out after 180 seconds in package-upgrade +tests; it is not a pass. The source-command race run needed approval for local +fixture sockets; its approved rerun passed. No native desktop, hardware or audible +checks were performed. See the [live Where Am I steps](../../cthulhu-tentacles/remote/command.md#physical-keyboard-acceptance-still-pending). + Next: verify real Speech Dispatcher scheduling and audible local/remote arbitration. Physical input capture/release and ordinary reading while connected still need acceptance. diff --git a/internal/app/application_behavior.go b/internal/app/application_behavior.go index db52251..6d75746 100644 --- a/internal/app/application_behavior.go +++ b/internal/app/application_behavior.go @@ -36,7 +36,7 @@ func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Ev } state.pending.caret.clear() state.focusedID = result.event.Source.ID() - state.current = result.event.Source + state.setCurrent(result.event.Source) state.managedContainerID, state.managedDescendantID = atspi.ObjectID{}, atspi.ObjectID{} if result.event.Source.Role != "table" && result.event.Source.Role != "table cell" { // A late-created editor has no ordinary focus event to reset diff --git a/internal/app/current_speech_source.go b/internal/app/current_speech_source.go new file mode 100644 index 0000000..f2f236b --- /dev/null +++ b/internal/app/current_speech_source.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// The event loop retains classification, not authority, alongside its current +// snapshot. Every replacement clears it, including a native update of the same +// object. Only complete owned document ancestry can opt Where Am I into export. +type currentSpeechSource struct { + origin speechOrigin + focus atspi.ObjectID + epoch uint64 +} + +func (state *eventLoopState) setCurrent(snapshot atspi.Snapshot) { + state.current = snapshot + state.currentSpeech = currentSpeechSource{} +} + +func (state *eventLoopState) setCurrentDocument(result review.Result) { + state.setCurrent(result.DocumentPosition.Snapshot) + sources := result.DocumentCaretContext + if result.Err != nil || !state.current.ID().Valid() || len(sources) == 0 || sources[0].ID() != state.current.ID() { + return + } + origin := review_speech_origin(sources) + if source := accessibility_speech_origin(state.current); source != speechOriginLocal { + origin = source + } + state.currentSpeech = currentSpeechSource{origin: origin, focus: result.DocumentFocus, epoch: result.DocumentEpoch} +} + +func (handler *eventReviewHandler) currentSpeechOrigin() speechOrigin { + source := handler.state.currentSpeech + if handler.objectReview == nil || !source.focus.Valid() || source.focus != handler.objectReview.Focus() || + !handler.objectReview.CurrentDocumentResult(review.Result{DocumentEpoch: source.epoch}) { + return speechOriginUnknown + } + return source.origin +} diff --git a/internal/app/event_caret.go b/internal/app/event_caret.go index a6454c1..ef9ca61 100644 --- a/internal/app/event_caret.go +++ b/internal/app/event_caret.go @@ -35,7 +35,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit if !state.focusedID.Valid() { state.owner.follow(event) state.focusedID = event.Source.ID() - state.current = event.Source + state.setCurrent(event.Source) } } if event.Source.ReadOnlyCombo.Valid() && event.Source.ReadOnlyCombo == state.focusedID && @@ -64,7 +64,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit return true } if browser != nil && browser.ready && !event.Source.Editable { - state.current = event.Source + state.setCurrent(event.Source) if objectReview != nil { objectReview.SyncFocus(event.Source.ID()) if err := objectReview.SyncDocumentCaret(event.Source.ID(), event.Detail1); err != nil { @@ -98,7 +98,7 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit // document; focus mode still assembles visual lines across objects. if browser != nil && browser.ready && browser.mode == review.FocusMode && intent == input.LineIntent && !event.Source.Editable && objectReview != nil { - state.current = event.Source + state.setCurrent(event.Source) objectReview.SyncFocus(event.Source.ID()) if request, err := objectReview.ReadDocumentLine(event.Source.ID(), event.Detail1); err == nil { state.documentLineFocus = event.Source.ID() diff --git a/internal/app/event_focus.go b/internal/app/event_focus.go index 40e792a..f0d6dca 100644 --- a/internal/app/event_focus.go +++ b/internal/app/event_focus.go @@ -117,13 +117,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, // this relationship; no extra mutable ownership state is retained. if belongsToFocusedEditor(event.Source, state.focusedID) && (isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent || event.Type == atspi.TextSelectionChangedEvent) { - state.current = event.Source + state.setCurrent(event.Source) } if isFocusEvent(event.Type) || isManagedDescendantEvent(event.Type) || event.Type == atspi.AccessibleNameEvent && presentsReusedRow(event.Source) && event.Source.ID() == state.managedDescendantID || (event.Source.Path != "" && event.Source.ID() == state.focusedID) { - state.current = event.Source + state.setCurrent(event.Source) } if isTextChangeEvent(event.Type) { if !speaksTextChanges(event.Source) && !event.Source.LiveRegion { @@ -135,7 +135,7 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, } } if event.Type == atspi.CaretEvent && browser != nil && browser.ready && !event.Source.Editable { - state.current = event.Source + state.setCurrent(event.Source) } if brailleOutput != nil && (isFocusGainEvent(event) || isManagedDescendantEvent(event.Type) || event.Type == atspi.CaretEvent || diff --git a/internal/app/event_focus_ownership.go b/internal/app/event_focus_ownership.go index e0e111e..dbc9323 100644 --- a/internal/app/event_focus_ownership.go +++ b/internal/app/event_focus_ownership.go @@ -72,7 +72,7 @@ func (handler *accessibilityEventHandler) acceptFocusOwnership(event atspi.Event state.pending.clear() focusGate.SetSuspended(true) state.focusedID = event.Source.ID() - state.current = event.Source + state.setCurrent(event.Source) if objectReview != nil { objectReview.SyncFocus(event.Source.ID()) } diff --git a/internal/app/event_lifecycle.go b/internal/app/event_lifecycle.go index e012135..318fc1e 100644 --- a/internal/app/event_lifecycle.go +++ b/internal/app/event_lifecycle.go @@ -85,7 +85,7 @@ func (handler *accessibilityEventHandler) handleLifecycle(event atspi.Event) boo state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" } state.owner.follow(event) - state.current = event.Source + state.setCurrent(event.Source) state.focusedID = event.Source.ID() if event.Type == atspi.BusyStateEvent && state.loadedDocument != event.Source.ID() { speech.Enqueue(speechRequest{Text: finishedLoadingText(event.Source.Name), Role: "system", Cause: "document-loaded", Append: true}) @@ -126,7 +126,7 @@ func (handler *accessibilityEventHandler) handleLifecycle(event atspi.Event) boo state.chromeLoadedDocument = atspi.ObjectID{} state.chromeLoadedWindow = atspi.ObjectID{} state.owner.follow(event) - state.current = event.Source + state.setCurrent(event.Source) state.focusedID = event.Source.ID() browser.loaded(event.Source.ID()) if objectReview != nil { diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index b8b14e1..0bdd6a9 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -33,10 +33,13 @@ type eventReviewHandler struct { } func (handler *eventReviewHandler) whereAmI() { - if handler.focusGate.Locked() { + if handler.focusGate.Locked() || handler.focusGate.Suspended() { return } handler.syncForeground() + if handler.focusGate.Locked() || handler.focusGate.Suspended() { + return + } handler.refreshPresentation() state := handler.state state.pending.terminalFocus.clear() @@ -52,11 +55,13 @@ func (handler *eventReviewHandler) whereAmI() { } text := spoken.Text role := state.current.Role + origin := handler.currentSpeechOrigin() if text == "" { text = "Could not find current location." role = "system" + origin = speechOriginStatus } - handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"}) + handler.speech.Enqueue(speechRequest{origin: origin, Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"}) handler.logger.Debug("queued where am I presentation", "role", role) } @@ -90,7 +95,7 @@ func (handler *eventReviewHandler) handle(result review.Result) { } if result.ReadAllPosition { if objectReview.CurrentDocumentResult(result) && result.DocumentFocus == objectReview.Focus() { - state.current = result.DocumentPosition.Snapshot + state.setCurrentDocument(result) state.focusedID = state.current.ID() } return @@ -173,7 +178,7 @@ func (handler *eventReviewHandler) handle(result review.Result) { if result.DocumentCaretCommand != review.CaretReadLine || result.DocumentBrailleRoute { state.documentSelection = textSelection{} } - state.current = result.DocumentPosition.Snapshot + state.setCurrentDocument(result) if brailleOutput != nil { state.brailleSnapshot = state.current brailleOptions := config.DefaultSettings().Braille diff --git a/internal/app/event_state.go b/internal/app/event_state.go index 77cb2d4..17bee8e 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 + currentSpeech currentSpeechSource brailleSnapshot atspi.Snapshot focusedID atspi.ObjectID managedContainerID atspi.ObjectID @@ -165,7 +166,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer state.tabFocusWindow, state.tabFocusSequence = atspi.ObjectID{}, 0 state.managedContainerID = atspi.ObjectID{} state.managedDescendantID = atspi.ObjectID{} - state.current = atspi.Snapshot{} + state.setCurrent(atspi.Snapshot{}) state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0 state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" state.lastLiveText, state.lastLiveAt = "", time.Time{} diff --git a/internal/app/progress_bars.go b/internal/app/progress_bars.go index b59d80b..945de27 100644 --- a/internal/app/progress_bars.go +++ b/internal/app/progress_bars.go @@ -96,7 +96,7 @@ func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event) } // Like NVDA, refresh braille only if this is already the displayed object. if event.Source.ID() == state.current.ID() { - state.current = event.Source + state.setCurrent(event.Source) state.brailleSnapshot = event.Source if handler.brailleOutput != nil { handler.brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, settings.Braille, settings.TextAttributes)) diff --git a/internal/app/speech_where_am_i_observation_test.go b/internal/app/speech_where_am_i_observation_test.go new file mode 100644 index 0000000..593ecef --- /dev/null +++ b/internal/app/speech_where_am_i_observation_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "log/slog" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func where_am_i_observation_handler(t *testing.T, privacy string) (*eventReviewHandler, *fakeSpeechQueue) { + t.Helper() + // Use owned-tree ancestry, as document movement does, rather than treating + // the presentation's filtered SpeechContext as a complete source capture. + position := review_observation_result(t, "object", privacy).Position + focus := atspi.NewObjectID(":1.42", "/document") + worker, err := review.NewWorker(&fakeAdapter{}, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(focus) + 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.current = atspi.Snapshot{BusName: focus.BusName, Path: focus.Path, Role: "document web"} + handler.state.presentationOptions = presentation.DefaultOptions() + handler.handle(review.Result{Moved: true, DocumentCaretCommand: review.CaretNextLine, DocumentFocus: focus, + DocumentPosition: review.DocumentPosition{Snapshot: position.Current}, DocumentCaretContext: position.SourceContext, + DocumentCaretText: position.Current.Name}) + <-queue.requests + <-queue.spoken + return handler, queue +} + +func TestRemoteWhereAmIInvalidatesSource(t *testing.T) { + for _, change := range []string{"same-object-update", "say-all-position", "focus", "epoch", "foreground", "missing-owner", "no-location"} { + t.Run(change, func(t *testing.T) { + handler, queue := where_am_i_observation_handler(t, "ordinary") + switch change { + case "same-object-update": + // Even identical native text cannot reuse older capture ancestry. + events := accessibilityEventHandler{state: handler.state, objectReview: handler.objectReview, logger: handler.logger} + events.acceptFocusedEvent(atspi.NewEvent(atspi.FocusEvent, handler.state.current), input.Activity{}) + case "say-all-position": + handler.handle(review.Result{ReadAllPosition: true, DocumentFocus: handler.objectReview.Focus(), + DocumentPosition: review.DocumentPosition{Snapshot: handler.state.current}}) + case "focus": + handler.objectReview.SyncFocus(atspi.NewObjectID(":1.42", "/other")) + case "epoch": + handler.objectReview.ResetDocument(handler.objectReview.Focus()) + case "foreground": + handler.syncForeground = func() { handler.state.setCurrent(handler.state.current) } + case "missing-owner": + handler.objectReview = nil + case "no-location": + handler.state.current.Name, handler.state.current.Role = "", "" + } + handler.whereAmI() + request := <-queue.requests + if exportable_speech(request) { + t.Fatal("Where Am I reused retired source classification") + } + if change != "no-location" && !strings.Contains(request.Text, "Review colour.") { + t.Fatal("source invalidation changed local reading", request.Text) + } + }) + } +} + +func TestRemoteWhereAmIRejectsMismatchedSource(t *testing.T) { + handler, queue := where_am_i_observation_handler(t, "ordinary") + current := handler.state.current + handler.state.setCurrentDocument(review.Result{DocumentFocus: handler.objectReview.Focus(), + DocumentPosition: review.DocumentPosition{Snapshot: current}, + DocumentCaretContext: []atspi.Snapshot{{BusName: current.BusName, Path: "/other", Role: "static"}}}) + handler.whereAmI() + if exportable_speech(<-queue.requests) { + t.Fatal("unrelated ancestry authorized Where Am I export") + } +} + +func TestRemoteWhereAmISecureFocus(t *testing.T) { + for _, mode := range []string{"lock", "handoff", "foreground-handoff"} { + handler, queue := where_am_i_observation_handler(t, "ordinary") + if mode == "lock" { + handler.focusGate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) + } else if mode == "foreground-handoff" { + handler.syncForeground = func() { handler.focusGate.SetSuspended(true) } + } else { + handler.focusGate.SetSuspended(true) + } + handler.whereAmI() + if len(queue.requests) != 0 { + t.Fatal("Where Am I spoke during lock or handoff") + } + } +} + +func TestRemoteWhereAmIResolvedOutput(t *testing.T) { + worker, session, _, _ := observation_fixture(t) + worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } + for _, privacy := range []string{"ordinary", "snapshot-ancestor", "missing", "stale"} { + request := where_am_i_observation_request(t, privacy) + if err := worker.EnqueueAndWait(t.Context(), request); 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("Where Am I export differed from resolved local chunks", value, want) + } + } + } + worker.InterruptForUser() + if value := observed_speech(t, session); value.Kind != "cancel" { + t.Fatal("private Where Am I reached the child", value) + } + } +} + +func where_am_i_observation_request(t *testing.T, privacy string) speechRequest { + t.Helper() + handler, queue := where_am_i_observation_handler(t, privacy) + handler.whereAmI() + return <-queue.requests +} + +func TestRemoteWhereAmISourcePrivacy(t *testing.T) { + for _, privacy := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-role", "invalid", "defunct", "unknown-ancestor", "unknown-snapshot-ancestor", "missing", "stale"} { + t.Run(privacy, func(t *testing.T) { + request := where_am_i_observation_request(t, privacy) + if exportable_speech(request) != (privacy == "ordinary") { + t.Fatalf("privacy %s produced exportable=%v", privacy, exportable_speech(request)) + } + if privacy != "password" && !strings.Contains(request.Text, "Review colour.") { + t.Fatal("source privacy changed local Where Am I", request.Text) + } + }) + } +} diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go index 6baa134..6a3a965 100644 --- a/internal/app/tentacle_remote_connection_test.go +++ b/internal/app/tentacle_remote_connection_test.go @@ -580,6 +580,11 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { } } } + for _, privacy := range []string{"ordinary", "snapshot-ancestor", "missing", "stale"} { + if err := worker.EnqueueAndWait(t.Context(), where_am_i_observation_request(t, privacy)); err != nil { + t.Fatal(err) + } + } worker.InterruptForUser() var received strings.Builder ends, frames := 0, 0 @@ -615,7 +620,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.Review color.staticFlat color. Review color." || frames < 8 || ends != 7 { + if received.String() != "color "+remoteText+"First color.Second color.Current color.Review color.staticFlat color. Review color.Review color.static" || frames < 10 || ends != 9 { 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/app/wine_review_focus.go b/internal/app/wine_review_focus.go index 315cd04..08f0806 100644 --- a/internal/app/wine_review_focus.go +++ b/internal/app/wine_review_focus.go @@ -91,7 +91,7 @@ func (c *wineReviewFocusController) changed() { c.worker.SyncFocus(selected.ID()) } c.active, c.epoch = selected, fg.Epoch - c.state.current = atspi.Snapshot{BusName: selected.Sender, Path: selected.ID().Path, Role: "text"} + c.state.setCurrent(atspi.Snapshot{BusName: selected.Sender, Path: selected.ID().Path, Role: "text"}) if selected.Protected { c.state.current.Role = "password text" }