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:
1 parent
ac67236eca
commit
49be8309fe
27 files changed
+986
-54
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := ¤tLineStaleContextCapturer{&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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user