Fixed a bug with removed or replaced form fields on some websites.

This commit is contained in:
Storm Dragon committed 2026-10-18 17:02:37 -04:00
1 parent 919b9e013b
commit 9e0ff41b16
6 files changed
+191 -7

No files matched your search

+7
View File
@@ -117,6 +117,13 @@ parent, and `saved-object-missing` falls back to native focus. These records hel
distinguish a cursor reset from an intentional wrap without extra native queries. distinguish a cursor reset from an intentional wrap without extra native queries.
Review debug logs before sharing: they also contain spoken page text. Review debug logs before sharing: they also contain spoken page text.
When the browser confirms that a navigation target no longer exists, Cthulhu
retires the cached object and refreshes its parent's child list. It retries
caret placement only if the same target survives the refresh. Otherwise the
move reports a location failure; repeat the navigation command to find a live
target from the retained browser focus. An ordinary native caret refusal still
allows a successful virtual reading move.
## Wine Access ## Wine Access
Install the separate `wine-access` helper for Wine application accessibility. Install the separate `wine-access` helper for Wine application accessibility.
+4 -4
View File
@@ -287,7 +287,7 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob
var nativeErr *C.GError var nativeErr *C.GError
count := int(C.atspi_text_get_character_count(text, &nativeErr)) count := int(C.atspi_text_get_character_count(text, &nativeErr))
if nativeErr != nil { if nativeErr != nil {
err := nativeError("get document text count", nativeErr) err := adapter.document_object_error(position.ID(), nativeError("get document text count", nativeErr))
C.g_error_free(nativeErr) C.g_error_free(nativeErr)
return DocumentCaretText{}, err return DocumentCaretText{}, err
} }
@@ -340,7 +340,7 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob
} }
rangeText := C.atspi_text_get_string_at_offset(text, C.gint(offset), granularity, &nativeErr) rangeText := C.atspi_text_get_string_at_offset(text, C.gint(offset), granularity, &nativeErr)
if nativeErr != nil { if nativeErr != nil {
err := nativeError("read document text", nativeErr) err := adapter.document_object_error(position.ID(), nativeError("read document text", nativeErr))
C.g_error_free(nativeErr) C.g_error_free(nativeErr)
return DocumentCaretText{}, err return DocumentCaretText{}, err
} }
@@ -494,7 +494,7 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position
} else { } else {
caretOffset = C.atspi_text_get_caret_offset(text, &nativeErr) caretOffset = C.atspi_text_get_caret_offset(text, &nativeErr)
if nativeErr != nil { if nativeErr != nil {
err := nativeError("get document caret", nativeErr) err := adapter.document_object_error(position.ID(), nativeError("get document caret", nativeErr))
C.g_error_free(nativeErr) C.g_error_free(nativeErr)
clear(adapter.navigationEcho) clear(adapter.navigationEcho)
return err return err
@@ -522,7 +522,7 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position
} }
moved := C.atspi_text_set_caret_offset(text, caretOffset, &nativeErr) moved := C.atspi_text_set_caret_offset(text, caretOffset, &nativeErr)
if nativeErr != nil { if nativeErr != nil {
err := nativeError("set document caret", nativeErr) err := adapter.document_object_error(position.ID(), nativeError("set document caret", nativeErr))
C.g_error_free(nativeErr) C.g_error_free(nativeErr)
clear(adapter.navigationEcho) clear(adapter.navigationEcho)
return err return err
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"fmt"
"strings"
)
// libatspi forwards Chromium's missing-object response as an untyped GError.
// Match the queried path exactly; a timeout or unsupported caret is not proof
// that the virtual reading position has disappeared.
func (adapter *Adapter) document_object_error(object ObjectID, err error) error {
if err == nil || !object.Valid() || !strings.HasSuffix(strings.TrimSpace(err.Error()),
"could not be processed as object "+object.Path+" does not exist") {
return err
}
adapter.treeMutex.Lock()
trees := []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree}
adapter.treeMutex.Unlock()
for _, tree := range trees {
if tree == nil {
continue
}
tree.mu.RLock()
entry := tree.nodes[object]
tree.mu.RUnlock()
adapter.treeInvalidations.Add(uint64(tree.Invalidate(object)))
if entry.hasParent {
// The parent's cached child list may also predate the removal.
// Refresh it to discover any replacement while retaining siblings.
tree.MarkChildrenChanged(entry.parent)
}
}
clear(adapter.documentLineCache)
return fmt.Errorf("%w: %w", ErrDeadObject, err)
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"errors"
"fmt"
"testing"
)
func TestDocumentMissingObjectRetiresCachedTarget(t *testing.T) {
adapter := New()
adapter.tree, adapter.documentTree, adapter.documentTextTree = testTree(t), testTree(t), testTree(t)
object := testID("/second")
failure := fmt.Errorf("get document caret: Method %q with signature %q on interface %q could not be processed as object %s does not exist\n",
"Get", "ss", "org.freedesktop.DBus.Properties", object.Path)
err := adapter.document_object_error(object, failure)
if !errors.Is(err, ErrDeadObject) || !errors.Is(err, failure) {
t.Fatalf("missing-object error lost classification or details: %v", err)
}
for _, tree := range []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} {
if _, err := tree.Reference(object); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("removed target remains cached: %v", err)
}
if _, err := tree.Reference(testID("/grandchild")); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("removed descendant remains cached: %v", err)
}
if _, err := tree.Reference(testID("/first")); err != nil {
t.Fatalf("live sibling was retired: %v", err)
}
root, _ := tree.Root()
if _, _, err := tree.FirstChild(root); !errors.Is(err, ErrIncompleteTree) {
t.Fatalf("parent child order was not marked for refresh: %v", err)
}
}
}
func TestDocumentCaretFailuresDoNotRetireLiveTargets(t *testing.T) {
for _, message := range []string{
"browser refused document caret",
"get document caret: timeout",
"get document caret: unknown property CaretOffset",
"could not be processed as object /other does not exist",
"could not be processed as object /second_child does not exist",
} {
t.Run(message, func(t *testing.T) {
adapter := New()
adapter.documentTree = testTree(t)
failure := errors.New(message)
if err := adapter.document_object_error(testID("/second"), failure); err != failure {
t.Fatalf("ordinary failure reclassified: %v", err)
}
if _, err := adapter.documentTree.Reference(testID("/second")); err != nil {
t.Fatalf("unconfirmed removal retired live target: %v", err)
}
})
}
}
+12 -2
View File
@@ -157,6 +157,12 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
return worker.placeDocument(ctx, cursor, result) return worker.placeDocument(ctx, cursor, result)
} }
result = place(result) result = place(result)
if errors.Is(result.CaretErr, atspi.ErrDeadObject) {
// A native caret refusal can leave a valid virtual position. A provider
// confirming that the target no longer exists cannot: do not announce
// its cached label as a successful move, even if recapture fails.
result.Err, result.Moved = result.CaretErr, false
}
if !documentCaretNeedsRecapture(result.CaretErr) || !worker.CurrentDocumentResult(result) { if !documentCaretNeedsRecapture(result.CaretErr) || !worker.CurrentDocumentResult(result) {
return result return result
} }
@@ -188,8 +194,12 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
result.DocumentPosition.Object = target result.DocumentPosition.Object = target
result.DocumentPosition.Document = owner.TopLevel result.DocumentPosition.Document = owner.TopLevel
result.DocumentPosition.Snapshot = snapshot result.DocumentPosition.Snapshot = snapshot
result.Err, result.Moved = nil, true
result.CaretErr = nil result.CaretErr = nil
result = place(result) result = place(result)
if errors.Is(result.CaretErr, atspi.ErrDeadObject) {
result.Err, result.Moved = result.CaretErr, false
}
if result.Err == nil && result.CaretErr == nil && cursor != nil { if result.Err == nil && result.CaretErr == nil && cursor != nil {
cursor.rebindPlace(tree, oldPosition, result.DocumentPosition) cursor.rebindPlace(tree, oldPosition, result.DocumentPosition)
} }
@@ -198,7 +208,7 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
func documentCaretNeedsRecapture(err error) bool { func documentCaretNeedsRecapture(err error) bool {
return errors.Is(err, atspi.ErrObjectNotFound) || errors.Is(err, atspi.ErrStaleObject) || return errors.Is(err, atspi.ErrObjectNotFound) || errors.Is(err, atspi.ErrStaleObject) ||
errors.Is(err, atspi.ErrStaleSnapshot) errors.Is(err, atspi.ErrStaleSnapshot) || errors.Is(err, atspi.ErrDeadObject)
} }
func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch uint64) (Result, *DocumentCursor) { func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch uint64) (Result, *DocumentCursor) {
@@ -477,7 +487,7 @@ func (worker *Worker) recoverCapturedDocumentFocus(cursor *DocumentCursor, tree
func documentNeedsCapture(err error) bool { func documentNeedsCapture(err error) bool {
return errors.Is(err, ErrNoCurrent) || errors.Is(err, atspi.ErrObjectNotFound) || return errors.Is(err, ErrNoCurrent) || errors.Is(err, atspi.ErrObjectNotFound) ||
errors.Is(err, atspi.ErrStaleObject) || errors.Is(err, atspi.ErrIncompleteTree) || errors.Is(err, atspi.ErrStaleObject) || errors.Is(err, atspi.ErrDeadObject) || errors.Is(err, atspi.ErrIncompleteTree) ||
errors.Is(err, atspi.ErrStaleText) || errors.Is(err, atspi.ErrStaleGeometry) || errors.Is(err, atspi.ErrStaleSnapshot) errors.Is(err, atspi.ErrStaleText) || errors.Is(err, atspi.ErrStaleGeometry) || errors.Is(err, atspi.ErrStaleSnapshot)
} }
+69 -1
View File
@@ -643,7 +643,7 @@ func (navigator *fakeDocumentNavigator) SetDocumentPosition(ctx context.Context,
} }
func TestWorkerRetriesStaleDocumentCaretOnSameTarget(t *testing.T) { func TestWorkerRetriesStaleDocumentCaretOnSameTarget(t *testing.T) {
for _, failure := range []error{atspi.ErrStaleObject, atspi.ErrObjectNotFound} { for _, failure := range []error{atspi.ErrStaleObject, atspi.ErrObjectNotFound, atspi.ErrDeadObject} {
t.Run(failure.Error(), func(t *testing.T) { t.Run(failure.Error(), func(t *testing.T) {
tree, _ := webCursor(t) tree, _ := webCursor(t)
navigator := newFakeDocumentNavigator() navigator := newFakeDocumentNavigator()
@@ -694,6 +694,74 @@ func TestWorkerRetriesStaleDocumentCaretOnSameTarget(t *testing.T) {
} }
} }
func TestWorkerRemovedEntryIsNotPresentedAndNextCommandRecovers(t *testing.T) {
tree, _ := webCursor(t)
nodes := webNodes()
for index := range nodes {
if nodes[index].Snapshot.Path == "/first" || nodes[index].Snapshot.Path == "/second" {
nodes[index].Snapshot.Role = "combo box"
nodes[index].Snapshot.Editable = true
nodes[index].Snapshot.Focusable = true
}
}
if err := tree.Replace(webID("/root"), nodes); err != nil {
t.Fatal(err)
}
navigator := newFakeDocumentNavigator()
worker, cancel := startWorker(t, navigator, time.Second)
defer worker.Stop(context.Background())
defer cancel()
worker.SyncFocus(webID("/button"))
navigator.placementErr <- atspi.ErrDeadObject
if err := worker.MoveDocument(DocumentEntry, 0, true); err != nil {
t.Fatal(err)
}
initial := <-navigator.fakeCapturer.calls
initial.result <- captureResult{tree: tree}
first := <-navigator.calls
if first.position.ID() != webID("/second") {
t.Fatalf("initial entry target = %+v", first)
}
retry := <-navigator.fakeCapturer.calls
// The native adapter retires a confirmed missing target before recapture.
tree.Invalidate(webID("/second"))
retry.result <- captureResult{tree: tree}
result := waitResult(t, worker)
if result.Moved || !errors.Is(result.Err, atspi.ErrDeadObject) {
t.Fatalf("removed entry reported as a successful move: %+v", result)
}
if err := worker.MoveDocument(DocumentEntry, 0, true); err != nil {
t.Fatal(err)
}
placement := <-navigator.calls
if placement.position.ID() != webID("/first") {
t.Fatalf("next command revisited removed entry: %+v", placement)
}
next := waitResult(t, worker)
if next.Err != nil || next.CaretErr != nil || !next.Moved {
t.Fatalf("next entry navigation failed: %+v", next)
}
}
func TestWorkerCaretRefusalKeepsVirtualMove(t *testing.T) {
_, cursor := webCursor(t)
navigator := newFakeDocumentNavigator()
worker, err := NewWorker(navigator, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(webID("/first"))
failure := errors.New("browser refused document caret")
navigator.placementErr <- failure
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, worker.DocumentEpoch())
if result.Err != nil || result.CaretErr != failure || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") {
t.Fatalf("ordinary caret refusal lost virtual reading position: %+v", result)
}
if len(navigator.fakeCapturer.calls) != 0 {
t.Fatal("ordinary caret refusal unnecessarily recaptured the document")
}
}
func TestWorkerDocumentNavigation(t *testing.T) { func TestWorkerDocumentNavigation(t *testing.T) {
tree, _ := webCursor(t) tree, _ := webCursor(t)
capturer := &fakeCapturer{calls: make(chan captureCall, 2)} capturer := &fakeCapturer{calls: make(chan captureCall, 2)}