Share native current-line speech with Remote

This commit is contained in:
ak47 committed 2026-11-05 17:55:11 -05:00
1 parent 103fbfd81a
commit ee3d91af23
11 files changed
+289 -16

No files matched your search

+63 -2
View File
@@ -13,6 +13,7 @@ import (
"context"
"errors"
"strings"
"time"
)
// ReadFocusedLine reads only the line containing the current native caret.
@@ -21,7 +22,7 @@ func (adapter *Adapter) ReadFocusedLine(ctx context.Context, focus ObjectID) (Do
return result.content, err
}
func (adapter *Adapter) read_native_focused_line(ctx context.Context, focus ObjectID) (DocumentContent, error) {
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
}
@@ -29,11 +30,17 @@ func (adapter *Adapter) read_native_focused_line(ctx context.Context, focus Obje
return DocumentContent{}, ErrStaleObject
}
source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source
C.atspi_accessible_clear_cache_single(source)
snapshot := snapshotFromNativeWithCaret(source, false, false, false, false)
if snapshot.Role == "password text" {
return DocumentContent{}, ErrNoDocumentCaret
}
content := DocumentContent{Snapshot: snapshot, Text: snapshot.Name}
content = DocumentContent{Snapshot: snapshot, Text: snapshot.Name}
defer func() {
if err == nil {
content.SourceContext = native_focused_line_context(ctx, source)
}
}()
text := C.atspi_accessible_get_text_iface(source)
if text == nil {
return content, ctx.Err()
@@ -84,3 +91,57 @@ func (adapter *Adapter) read_native_focused_line(ctx context.Context, focus Obje
content.Start, content.End, content.Text, content.RawText = line.Start, line.End, line.Text, line.Text
return content, ctx.Err()
}
// Privacy capture is separate from presentation context: include every parent,
// even unnamed containers and parents outside the window. Keep it on the GLib
// owner and bound the walk without scanning children or retaining native refs.
// Failed/truncated capture must not turn a successful local read into an error.
func native_focused_line_context(ctx context.Context, source *C.AtspiAccessible) []Snapshot {
deadline, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
defer func() {
if current != nil {
C.g_object_unref(C.gpointer(current))
}
}()
var result []Snapshot
seen := make(map[ObjectID]bool)
for current != nil && len(result) < 64 && deadline.Err() == nil {
C.atspi_accessible_clear_cache_single(current)
id := nativeObjectID(current)
if !id.Valid() || seen[id] {
return nil
}
seen[id] = true
var nativeErr *C.GError
roleName := C.atspi_accessible_get_role_name(current, &nativeErr)
role := C.GoString(roleName)
C.g_free(C.gpointer(roleName))
if nativeErr != nil {
C.g_error_free(nativeErr)
return nil
}
states := C.atspi_accessible_get_state_set(current)
if states == nil {
return nil
}
defunct := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0
C.g_object_unref(C.gpointer(states))
result = append(result, Snapshot{BusName: id.BusName, Path: id.Path, Role: role, Defunct: defunct})
if role == "application" {
if deadline.Err() != nil {
return nil
}
return result
}
parent := C.atspi_accessible_get_parent(current, &nativeErr)
C.g_object_unref(C.gpointer(current))
current = parent
if nativeErr != nil {
C.g_error_free(nativeErr)
return nil
}
}
return nil
}
+29 -1
View File
@@ -55,6 +55,7 @@ func TestNativeCurrentLine(t *testing.T) {
{"text", "Final\n", 6, "", false},
{"password", "private test text", 5, "", true},
{"button", "", 0, "Example button", false},
{"deep", "Deep local line", 3, "Deep local line", false},
} {
command, _ := json.Marshal(map[string]any{"action": "focus", "control": probe.control, "text": probe.text, "offset": probe.offset})
fmt.Fprintln(stdin, string(command))
@@ -64,7 +65,7 @@ func TestNativeCurrentLine(t *testing.T) {
select {
case event := <-adapter.Events():
if event.Type == "object:state-changed:focused" && event.Detail1 == 1 &&
(event.Source.Name == map[string]string{"entry": "Entry", "text": "Text", "password": "Password", "button": "Example button"}[probe.control]) {
(event.Source.Name == map[string]string{"entry": "Entry", "text": "Text", "password": "Password", "button": "Example button", "deep": "Deep entry"}[probe.control]) {
focus = event.Source.ID()
goto focused
}
@@ -85,6 +86,33 @@ func TestNativeCurrentLine(t *testing.T) {
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 {
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 == "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)
ready()
private, err := adapter.ReadFocusedLine(ctx, focus)
if err != nil || private.Text != content.Text {
t.Fatal("ancestor privacy changed local text", err)
}
found := false
for _, ancestor := range private.SourceContext {
found = found || ancestor.Role == role.want
}
if len(private.SourceContext) != 0 && !found {
t.Fatal("native capture omitted a private ancestor", role.want)
}
}
fmt.Fprintln(stdin, "{\"action\":\"privacy\",\"role\":\"FILLER\"}")
ready()
}
if probe.control != "button" {
fmt.Fprintf(stdin, "{\"action\":\"caret\",\"control\":%q}\n", probe.control)
if !scanner.Scan() || scanner.Text() != fmt.Sprint(probe.offset) {
+3
View File
@@ -15,6 +15,9 @@ type DocumentContent struct {
End int
Text string
RawText string
// SourceContext is the focused-line source followed by its ancestors through
// the application. Nil means privacy capture failed; local text stays usable.
SourceContext []Snapshot
// Separator is display spacing before this object, outside its native range.
Separator string
Bounds Rectangle