Enable isolated Remote speech and protect reading exports

Attach incoming software speech to reader sessions with lease checks, local output priority and isolated cancellation. Preserve every document fragment source for privacy checks and export reviewed browse current-line reading, spelling and phonetic output.

Verified focused reading and Remote race tests, complete review and external Remote race suites, private relay integration, speech regressions, affected-package vet, reader build and Tentacles make check. Audible speech, physical input/Braille and live interoperability remain unverified.
This commit is contained in:
ak47 committed 2026-11-05 15:41:08 -05:00
1 parent ac67236eca
commit 49be8309fe
27 files changed
+986 -54

No files matched your search

+9 -1
View File
@@ -69,10 +69,18 @@ func (worker *Worker) read_current_line(ctx context.Context, cursor *DocumentCur
if result.Err != nil {
return result
}
movement, _, err := worker.readAllMove(deadline, cursor, reader, CaretReadLine, result.DocumentFocus)
movement, cursor, err := worker.readAllMove(deadline, cursor, reader, CaretReadLine, result.DocumentFocus)
result.Err = err
result.DocumentPosition, result.DocumentCaretText = movement.Position, movement.Text
result.DocumentCaretRawText, result.DocumentCaretLine = movement.RawText, movement.Line
if err == nil {
// Retain the unfiltered ancestry from the tree that produced this
// movement, including after recapture. Missing metadata keeps export
// private without preventing the local current-line announcement.
if owner, contextErr := cursor.tree.DocumentContext(movement.Position.Object); contextErr == nil {
result.DocumentCaretContext = owner.Ancestors
}
}
if result.DocumentCaretText == "" && wholeCaretNode(movement.Position.Snapshot) {
result.DocumentCaretText = movement.Position.Snapshot.Name
}
+47
View File
@@ -56,6 +56,10 @@ func TestCurrentLineReadsWholeBrowseLineWithoutMoving(t *testing.T) {
if result.Err != nil || strings.TrimSpace(result.DocumentCaretText) != "Second café 猫" {
t.Fatalf("whole browse line from middle: %+v", result)
}
owner, err := cursor.tree.DocumentContext(result.DocumentPosition.Object)
if err != nil || len(result.DocumentCaretContext) == 0 || !reflect.DeepEqual(result.DocumentCaretContext, owner.Ancestors) {
t.Fatal("current-line report lost its captured source ancestry", err)
}
if !reflect.DeepEqual(cursor.places, before) || capturer.nativePlacements != 0 {
t.Fatal("report changed browse or native caret")
}
@@ -76,6 +80,9 @@ func TestCurrentLineJoinsBrowseFragmentsWithoutMoving(t *testing.T) {
if result.Err != nil || result.DocumentCaretText != "One two." || !reflect.DeepEqual(cursor.places, before) {
t.Fatalf("fragmented current line: %+v", result)
}
if result.DocumentCaretLine == nil || len(result.DocumentCaretLine.SourceContexts) != 2 || len(result.DocumentCaretContext) == 0 {
t.Fatal("fragmented report lost source ancestry")
}
}
func TestCurrentLineReadsBrowseControlLabel(t *testing.T) {
@@ -99,6 +106,46 @@ func TestCurrentLineReadsBrowseControlLabel(t *testing.T) {
}
}
func TestCurrentLineUsesRecapturedSourceContext(t *testing.T) {
text := caretTextReader{webID("/text"): "First\nSecond", webID("/next"): "Last"}
cursor := newCaretTextCursor(t, text)
fresh := newCaretTextCursor(t, text).tree
capturer := &sequenceReadAllCapturer{caretTextReader: text, trees: []*atspi.AccessibleTree{fresh},
invalidateTree: cursor.tree, staleAt: 2}
worker, _ := NewWorker(capturer, time.Second)
worker.SyncFocus(webID("/text"))
result := worker.read_current_line(t.Context(), cursor, true)
if result.Err != nil || strings.TrimSpace(result.DocumentCaretText) != "First" || capturer.index != 1 {
t.Fatalf("current line did not recover capture: %+v", result)
}
owner, err := fresh.DocumentContext(result.DocumentPosition.Object)
if err != nil || len(result.DocumentCaretContext) == 0 || !reflect.DeepEqual(result.DocumentCaretContext, owner.Ancestors) {
t.Fatal("report did not retain the replacement tree's source context", err)
}
}
type currentLineStaleContextCapturer struct{ *readAllCapturer }
func (capturer *currentLineStaleContextCapturer) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
text, err := capturer.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit)
if unit == atspi.DocumentCaretLineRemainder {
capturer.tree.InvalidateSnapshot(webID("/doc"))
}
return text, err
}
func TestCurrentLinePreservesLocalTextWithStaleContext(t *testing.T) {
text := caretTextReader{webID("/text"): "Local line", webID("/next"): "Last"}
cursor := newCaretTextCursor(t, text)
capturer := &currentLineStaleContextCapturer{&readAllCapturer{caretTextReader: text, tree: cursor.tree}}
worker, _ := NewWorker(capturer, time.Second)
worker.SyncFocus(webID("/text"))
result := worker.read_current_line(t.Context(), cursor, true)
if result.Err != nil || result.DocumentCaretText != "Local line" || len(result.DocumentCaretContext) != 0 {
t.Fatalf("stale ancestry must leave local reading available without export metadata: %+v", result)
}
}
type currentLineFragmentCapturer struct {
*readAllCapturer
fragmentTextReader
+3 -3
View File
@@ -272,9 +272,9 @@ func assemble_visual_line(tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot
raw.WriteString(separator)
raw.WriteString(value)
snapshot := doc.Nodes[part.index].Snapshot
if context, err := tree.DocumentContext(doc.Nodes[part.index].Reference); err == nil {
snapshot.BrailleContext = context.Ancestors
}
owner, _ := tree.DocumentContext(doc.Nodes[part.index].Reference)
snapshot.BrailleContext = owner.Ancestors
line.SourceContexts = append(line.SourceContexts, owner.Ancestors)
snapshot.Misspelled = part.text.Misspelled
misspelled = misspelled || part.text.Misspelled
line.Contents = append(line.Contents, atspi.DocumentContent{Reference: doc.Nodes[part.index].Reference, Snapshot: snapshot,
@@ -167,6 +167,13 @@ func TestReadAllFragmentLinesAndRemainder(t *testing.T) {
t.Fatalf("line %d: %q, want %q", index, got, want)
}
}
// Say All flattens the fragments into one text value, but must retain
// every original source and ancestry for Remote's privacy decision.
contexts := result.ReadAllLines[0].SourceContexts
if len(contexts) != 2 || len(contexts[0]) < 2 || len(contexts[1]) < 2 ||
contexts[0][0].ID() != webID("/one") || contexts[1][0].ID() != webID("/two") || contexts[1][1].ID() != webID("/span") {
t.Fatal("flattened Say All line lost fragment ancestry")
}
if cursor.places[0].offset != 1 {
t.Fatal("assembly moved the user's cursor")
}
@@ -182,6 +189,31 @@ func TestReadAllFragmentLinesAndRemainder(t *testing.T) {
}
}
func TestDocumentFragmentSourceLookupFailureKeepsLocalText(t *testing.T) {
cursor, reader := fragment_cursor(t, "paragraph")
doc, err := cursor.tree.Document(t.Context(), cursor.places[0].object)
if err != nil {
t.Fatal(err)
}
var parts []visualLinePart
for index, node := range doc.Nodes {
if node.Reference.ID() == webID("/one") || node.Reference.ID() == webID("/two") {
text, err := reader.ReadDocumentCaret(t.Context(), node.Reference, 0, atspi.DocumentCaretLineRemainder)
if err != nil {
t.Fatal(err)
}
parts = append(parts, visualLinePart{index: index, text: text})
}
}
// Metadata can become stale after the successful text read. The local
// text stays usable; missing source evidence must remain explicit.
cursor.tree.InvalidateSnapshot(webID("/span"))
line, raw, _ := assemble_visual_line(cursor.tree, doc, parts)
if raw != "One two." || len(line.SourceContexts) != 2 || len(line.SourceContexts[0]) == 0 || line.SourceContexts[1] != nil {
t.Fatal("stale source context changed local text or looked classified")
}
}
func TestDocumentFragmentBoundaries(t *testing.T) {
for _, boundary := range []string{"heading", "link", "table cell", "pre", "code", "entry"} {
t.Run(boundary, func(t *testing.T) {
+6
View File
@@ -114,6 +114,12 @@ func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Resul
previous = movement.Position
movement.Position.Snapshot.Misspelled = movement.Misspelled
line := atspi.DocumentLine{Document: movement.Position.Document, Contents: []atspi.DocumentContent{{Snapshot: movement.Position.Snapshot, Text: movement.Text, RawText: movement.RawText}}}
if movement.Line != nil {
// Presentation deliberately flattens a visual line into one spoken
// string. Keep every contributing source for independent privacy
// checks, including sources outside the caret object's ancestry.
line.SourceContexts = movement.Line.SourceContexts
}
owner, err := cursor.tree.DocumentContext(movement.Position.Object)
if err != nil {
if !documentNeedsCapture(err) {