Share native current-line speech with Remote
This commit is contained in:
1 parent
103fbfd81a
commit
ee3d91af23
11 files changed
+289
-16
No files matched your search
@@ -98,8 +98,8 @@ in an assembled line must have a known role and no password ancestry. Say All
|
|||||||
classifies all spoken lines before admission; a sensitive or unclassified line
|
classifies all spoken lines before admission; a sensitive or unclassified line
|
||||||
keeps the entire batch private, including sentence-mode continuations. Local
|
keeps the entire batch private, including sentence-mode continuations. Local
|
||||||
navigation failures stay private. Derived phonetic hints retain their parent's
|
navigation failures stay private. Derived phonetic hints retain their parent's
|
||||||
origin and original observation ticket. Native current-line commands,
|
origin and original observation ticket. Reading-context announcements,
|
||||||
reading-context announcements, separate key-name echo and notification paths
|
separate key-name echo and notification paths
|
||||||
still need explicit source and sensitivity review.
|
still need explicit source and sensitivity review.
|
||||||
|
|
||||||
Visual-line assembly now retains each contributing object's complete captured
|
Visual-line assembly now retains each contributing object's complete captured
|
||||||
@@ -142,9 +142,21 @@ or stale ancestry and sensitive neighboring fragments keep the report private.
|
|||||||
Classification does not add context announcements or change local reading,
|
Classification does not add context announcements or change local reading,
|
||||||
caret position, focus or Braille. Spelling retains repeated letters, character
|
caret position, focus or Braille. Spelling retains repeated letters, character
|
||||||
state and phonetic expansion through the existing speech owner, with its original
|
state and phonetic expansion through the existing speech owner, with its original
|
||||||
lease/generation ticket and pronunciation policy. Errors remain private. Native
|
lease/generation ticket and pronunciation policy. Errors remain private.
|
||||||
focused-line reads lack complete ancestry and remain private even when a supplied
|
|
||||||
snapshot appears ordinary.
|
Native focused-line reports now capture the focused source and every parent
|
||||||
|
through its application on the GLib owner, without scanning children or changing
|
||||||
|
the caret. Capture refreshes role/state metadata and detects cycles, missing
|
||||||
|
parents, invalid identities, provider errors and truncated walks. It has a
|
||||||
|
64-object bound and checks a 100-millisecond context between provider calls;
|
||||||
|
an individual synchronous AT-SPI call still has its existing provider timeout.
|
||||||
|
Failure leaves privacy metadata empty while preserving successful local text.
|
||||||
|
Export requires the original focus/epoch, matching source identities and complete
|
||||||
|
application ancestry; the shared classifier rejects password, unknown/invalid
|
||||||
|
and defunct sources or ancestors. Spelling, phonetics, pronunciation and blank
|
||||||
|
line handling use the existing speech owner and original lease/generation.
|
||||||
|
Lock/handoff are rechecked after foreground synchronization and before presenting
|
||||||
|
a completed report. Native Where Am I and other unreviewed producers stay private.
|
||||||
|
|
||||||
Say All exports each resolved chunk immediately before its completion-paced
|
Say All exports each resolved chunk immediately before its completion-paced
|
||||||
backend call. Sentence boundaries, pronunciation and the original request's
|
backend call. Sentence boundaries, pronunciation and the original request's
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (handler *eventReviewHandler) current_line(command input.Command) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.syncForeground()
|
handler.syncForeground()
|
||||||
if handler.objectReview == nil || !handler.state.current.ID().Valid() {
|
if handler.focusGate.Locked() || handler.focusGate.Suspended() || handler.objectReview == nil || !handler.state.current.ID().Valid() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.state.pending.terminalFocus.clear()
|
handler.state.pending.terminalFocus.clear()
|
||||||
@@ -34,7 +34,7 @@ func (handler *eventReviewHandler) current_line(command input.Command) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (handler *eventReviewHandler) present_current_line(result review.Result) {
|
func (handler *eventReviewHandler) present_current_line(result review.Result) {
|
||||||
if !handler.objectReview.CurrentDocumentResult(result) || result.DocumentFocus != handler.objectReview.Focus() || handler.focusGate.Suspended() {
|
if !handler.objectReview.CurrentDocumentResult(result) || result.DocumentFocus != handler.objectReview.Focus() || handler.focusGate.Locked() || handler.focusGate.Suspended() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if result.CurrentLineBrowse && (handler.browser == nil || !handler.browser.acceptsDocumentResult(review.Result{
|
if result.CurrentLineBrowse && (handler.browser == nil || !handler.browser.acceptsDocumentResult(review.Result{
|
||||||
@@ -44,9 +44,13 @@ func (handler *eventReviewHandler) present_current_line(result review.Result) {
|
|||||||
}
|
}
|
||||||
request := speechRequest{Text: "Could not read current line.", Role: "system", Cause: "current-line"}
|
request := speechRequest{Text: "Could not read current line.", Role: "system", Cause: "current-line"}
|
||||||
if result.Err == nil {
|
if result.Err == nil {
|
||||||
// Native focused-line reads do not carry complete ancestry. Only
|
// Native reports require the matching focused source and an unbroken
|
||||||
// browse reports with captured source metadata opt into observation.
|
// capture through the application. Missing privacy data stays local.
|
||||||
if result.CurrentLineBrowse {
|
context := result.DocumentCaretContext
|
||||||
|
nativeSource := len(context) > 0 && result.DocumentFocus.Valid() &&
|
||||||
|
result.DocumentPosition.Snapshot.ID() == result.DocumentFocus && context[0].ID() == result.DocumentFocus &&
|
||||||
|
context[len(context)-1].Role == "application"
|
||||||
|
if result.CurrentLineBrowse || nativeSource {
|
||||||
sources := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: result.DocumentPosition.Snapshot}},
|
sources := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: result.DocumentPosition.Snapshot}},
|
||||||
SourceContexts: [][]atspi.Snapshot{result.DocumentCaretContext}}
|
SourceContexts: [][]atspi.Snapshot{result.DocumentCaretContext}}
|
||||||
request.origin = document_line_speech_origin(sources)
|
request.origin = document_line_speech_origin(sources)
|
||||||
|
|||||||
@@ -98,3 +98,38 @@ func TestCurrentLinePresentationAndStaleResults(t *testing.T) {
|
|||||||
t.Fatal("report from previous focus was spoken")
|
t.Fatal("report from previous focus was spoken")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentLineSecureFocus(t *testing.T) {
|
||||||
|
for _, mode := range []string{"lock", "handoff", "foreground-lock", "foreground-handoff"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
focus := atspi.NewObjectID(":1.42", "/focus")
|
||||||
|
worker, _ := review.NewWorker(&fakeAdapter{}, time.Second)
|
||||||
|
worker.SyncFocus(focus)
|
||||||
|
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 1), spoken: make(chan string, 1)}
|
||||||
|
handler := eventReviewHandler{state: &eventLoopState{}, objectReview: worker, speech: queue,
|
||||||
|
logger: slog.New(slog.DiscardHandler), focusGate: &focusGate{}}
|
||||||
|
handler.state.current = atspi.Snapshot{BusName: focus.BusName, Path: focus.Path, Role: "text"}
|
||||||
|
secure := func() {
|
||||||
|
if mode == "lock" || mode == "foreground-lock" {
|
||||||
|
handler.focusGate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
|
||||||
|
} else {
|
||||||
|
handler.focusGate.SetSuspended(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "lock" || mode == "handoff" {
|
||||||
|
secure()
|
||||||
|
handler.syncForeground = func() { t.Fatal("secure command synchronized foreground") }
|
||||||
|
} else {
|
||||||
|
handler.syncForeground = secure
|
||||||
|
}
|
||||||
|
handler.current_line(input.PresentCurrentLine)
|
||||||
|
// An already completed read must also be rejected on secure input.
|
||||||
|
result := native_current_line_result()
|
||||||
|
result.DocumentFocus = focus
|
||||||
|
handler.present_current_line(result)
|
||||||
|
if len(queue.requests) != 0 {
|
||||||
|
t.Fatal("current line spoke during lock or handoff")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ func TestRemoteCurrentLineSourcePrivacy(t *testing.T) {
|
|||||||
SourceContexts: [][]atspi.Snapshot{ancestors, neighbor}}
|
SourceContexts: [][]atspi.Snapshot{ancestors, neighbor}}
|
||||||
case "native":
|
case "native":
|
||||||
result.CurrentLineBrowse = false
|
result.CurrentLineBrowse = false
|
||||||
|
result.DocumentCaretContext = nil
|
||||||
case "failure":
|
case "failure":
|
||||||
result.Err = errors.New("fixture unavailable")
|
result.Err = errors.New("fixture unavailable")
|
||||||
}
|
}
|
||||||
@@ -132,3 +133,91 @@ func TestRemoteCurrentLineResolvedOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func native_current_line_result() review.Result {
|
||||||
|
source := atspi.Snapshot{BusName: ":1.42", Path: "/focus", Role: "text"}
|
||||||
|
return review.Result{DocumentCaretText: "Native colour.", DocumentPosition: review.DocumentPosition{Snapshot: source},
|
||||||
|
DocumentCaretContext: []atspi.Snapshot{source, {BusName: source.BusName, Path: "/window", Role: "frame"},
|
||||||
|
{BusName: source.BusName, Path: "/app", Role: "application"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteNativeCurrentLineSourcePrivacy(t *testing.T) {
|
||||||
|
for _, privacy := range []string{"ordinary", "password", "ancestor", "unknown", "invalid", "defunct", "missing", "truncated", "mismatched", "foreign", "failure"} {
|
||||||
|
t.Run(privacy, func(t *testing.T) {
|
||||||
|
result := native_current_line_result()
|
||||||
|
switch privacy {
|
||||||
|
case "password":
|
||||||
|
result.DocumentPosition.Snapshot.Role = "password text"
|
||||||
|
case "ancestor":
|
||||||
|
result.DocumentCaretContext[1].Role = "password text"
|
||||||
|
case "unknown":
|
||||||
|
result.DocumentCaretContext[1].Role = "unknown"
|
||||||
|
case "invalid":
|
||||||
|
result.DocumentCaretContext[1].Role = "invalid"
|
||||||
|
case "defunct":
|
||||||
|
result.DocumentCaretContext[1].Defunct = true
|
||||||
|
case "missing":
|
||||||
|
result.DocumentCaretContext = nil
|
||||||
|
case "truncated":
|
||||||
|
result.DocumentCaretContext = result.DocumentCaretContext[:2]
|
||||||
|
case "mismatched":
|
||||||
|
result.DocumentCaretContext[0].Path = "/other"
|
||||||
|
case "foreign":
|
||||||
|
result.DocumentPosition.Snapshot.Path = "/other"
|
||||||
|
result.DocumentCaretContext[0].Path = "/other"
|
||||||
|
case "failure":
|
||||||
|
result.Err = errors.New("fixture unavailable")
|
||||||
|
}
|
||||||
|
request := current_line_observation_request(t, result)
|
||||||
|
if exportable_speech(request) != (privacy == "ordinary") {
|
||||||
|
t.Fatalf("native source %s produced exportable=%v", privacy, exportable_speech(request))
|
||||||
|
}
|
||||||
|
if privacy != "failure" && request.Text != result.DocumentCaretText {
|
||||||
|
t.Fatal("privacy classification changed local native reading")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteNativeCurrentLineResolvedOutput(t *testing.T) {
|
||||||
|
worker, session, _, _ := observation_fixture(t)
|
||||||
|
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
||||||
|
for _, probe := range []struct {
|
||||||
|
style review.FlatSpeech
|
||||||
|
text string
|
||||||
|
want []string
|
||||||
|
characters bool
|
||||||
|
}{
|
||||||
|
{review.FlatSpeak, "Native colour.", []string{"Native color."}, false},
|
||||||
|
{review.FlatSpell, "book", []string{"b", "o", "o", "k"}, true},
|
||||||
|
{review.FlatPhonetic, "book", []string{"bravo", "oscar", "oscar", "kilo"}, false},
|
||||||
|
{review.FlatSpeak, "\n", []string{"blank"}, false},
|
||||||
|
} {
|
||||||
|
result := native_current_line_result()
|
||||||
|
result.FlatSpeech, result.DocumentCaretText = probe.style, probe.text
|
||||||
|
for _, private := range []bool{false, true} {
|
||||||
|
if private {
|
||||||
|
result.DocumentCaretContext[1].Role = "password text"
|
||||||
|
}
|
||||||
|
if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range probe.want {
|
||||||
|
if !private {
|
||||||
|
value := observed_speech(t, session)
|
||||||
|
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want ||
|
||||||
|
value.Parts[0].Characters != probe.characters || !value.Parts[1].End {
|
||||||
|
t.Fatal("native report lost resolved output or spelling boundaries", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := <-worker.speaker.(*observationSpeaker).spoken; got != want {
|
||||||
|
t.Fatalf("native local reading %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
worker.InterruptForUser()
|
||||||
|
if value := observed_speech(t, session); value.Kind != "cancel" {
|
||||||
|
t.Fatal("private native output reached the child", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -572,6 +572,16 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, private := range []bool{false, true} {
|
||||||
|
result := native_current_line_result()
|
||||||
|
if private {
|
||||||
|
result.DocumentCaretText = "private native text"
|
||||||
|
result.DocumentCaretContext[1].Role = "password text"
|
||||||
|
}
|
||||||
|
if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, kind := range []string{"object", "line"} {
|
for _, kind := range []string{"object", "line"} {
|
||||||
for _, privacy := range []string{"ordinary", "snapshot-ancestor", "stale"} {
|
for _, privacy := range []string{"ordinary", "snapshot-ancestor", "stale"} {
|
||||||
request := review_observation_request(t, kind, review_observation_result(t, kind, privacy))
|
request := review_observation_request(t, kind, review_observation_result(t, kind, privacy))
|
||||||
@@ -620,7 +630,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
|||||||
t.Fatal("reader output did not reach the relay in order")
|
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.Review color.static" || frames < 10 || ends != 9 {
|
if received.String() != "color "+remoteText+"First color.Second color.Current color.Native color.Review color.staticFlat color. Review color.Review color.static" || frames < 11 || ends != 10 {
|
||||||
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):])
|
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):])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReadFocusedLine reads only the line containing the current native caret.
|
// 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
|
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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return DocumentContent{}, err
|
return DocumentContent{}, err
|
||||||
}
|
}
|
||||||
@@ -29,11 +30,17 @@ func (adapter *Adapter) read_native_focused_line(ctx context.Context, focus Obje
|
|||||||
return DocumentContent{}, ErrStaleObject
|
return DocumentContent{}, ErrStaleObject
|
||||||
}
|
}
|
||||||
source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source
|
source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source
|
||||||
|
C.atspi_accessible_clear_cache_single(source)
|
||||||
snapshot := snapshotFromNativeWithCaret(source, false, false, false, false)
|
snapshot := snapshotFromNativeWithCaret(source, false, false, false, false)
|
||||||
if snapshot.Role == "password text" {
|
if snapshot.Role == "password text" {
|
||||||
return DocumentContent{}, ErrNoDocumentCaret
|
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)
|
text := C.atspi_accessible_get_text_iface(source)
|
||||||
if text == nil {
|
if text == nil {
|
||||||
return content, ctx.Err()
|
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
|
content.Start, content.End, content.Text, content.RawText = line.Start, line.End, line.Text, line.Text
|
||||||
return content, ctx.Err()
|
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
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ func TestNativeCurrentLine(t *testing.T) {
|
|||||||
{"text", "Final\n", 6, "", false},
|
{"text", "Final\n", 6, "", false},
|
||||||
{"password", "private test text", 5, "", true},
|
{"password", "private test text", 5, "", true},
|
||||||
{"button", "", 0, "Example button", false},
|
{"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})
|
command, _ := json.Marshal(map[string]any{"action": "focus", "control": probe.control, "text": probe.text, "offset": probe.offset})
|
||||||
fmt.Fprintln(stdin, string(command))
|
fmt.Fprintln(stdin, string(command))
|
||||||
@@ -64,7 +65,7 @@ func TestNativeCurrentLine(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case event := <-adapter.Events():
|
case event := <-adapter.Events():
|
||||||
if event.Type == "object:state-changed:focused" && event.Detail1 == 1 &&
|
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()
|
focus = event.Source.ID()
|
||||||
goto focused
|
goto focused
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,33 @@ func TestNativeCurrentLine(t *testing.T) {
|
|||||||
if err != nil || strings.TrimRight(content.Text, "\r\n") != probe.want {
|
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)
|
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" {
|
if probe.control != "button" {
|
||||||
fmt.Fprintf(stdin, "{\"action\":\"caret\",\"control\":%q}\n", probe.control)
|
fmt.Fprintf(stdin, "{\"action\":\"caret\",\"control\":%q}\n", probe.control)
|
||||||
if !scanner.Scan() || scanner.Text() != fmt.Sprint(probe.offset) {
|
if !scanner.Scan() || scanner.Text() != fmt.Sprint(probe.offset) {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ type DocumentContent struct {
|
|||||||
End int
|
End int
|
||||||
Text string
|
Text string
|
||||||
RawText 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 is display spacing before this object, outside its native range.
|
||||||
Separator string
|
Separator string
|
||||||
Bounds Rectangle
|
Bounds Rectangle
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func (worker *Worker) read_current_line(ctx context.Context, cursor *DocumentCur
|
|||||||
result.Err = err
|
result.Err = err
|
||||||
result.DocumentCaretText, result.DocumentCaretRawText = content.Text, content.RawText
|
result.DocumentCaretText, result.DocumentCaretRawText = content.Text, content.RawText
|
||||||
result.DocumentPosition.Snapshot = content.Snapshot
|
result.DocumentPosition.Snapshot = content.Snapshot
|
||||||
|
result.DocumentCaretContext = content.SourceContext
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
// Start a private cursor at the saved browse position. Going to line start
|
// Start a private cursor at the saved browse position. Going to line start
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ func (capturer *currentLineCapturer) ReadFocusedLine(context.Context, atspi.Obje
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCurrentLineUsesNativeFocusOutsideBrowse(t *testing.T) {
|
func TestCurrentLineUsesNativeFocusOutsideBrowse(t *testing.T) {
|
||||||
capturer := ¤tLineCapturer{content: atspi.DocumentContent{Text: "Native caret line"}}
|
source := atspi.Snapshot{BusName: webID("/entry").BusName, Path: "/entry", Role: "text"}
|
||||||
|
capturer := ¤tLineCapturer{content: atspi.DocumentContent{Text: "Native caret line", Snapshot: source,
|
||||||
|
SourceContext: []atspi.Snapshot{source, {Role: "frame"}, {Role: "application"}}}}
|
||||||
worker, cancel := startWorker(t, capturer, time.Second)
|
worker, cancel := startWorker(t, capturer, time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
worker.SyncFocus(webID("/entry"))
|
worker.SyncFocus(webID("/entry"))
|
||||||
@@ -34,12 +36,25 @@ func TestCurrentLineUsesNativeFocusOutsideBrowse(t *testing.T) {
|
|||||||
if !result.CurrentLine || result.CurrentLineBrowse || result.FlatSpeech != style || result.Err != nil || result.DocumentCaretText != "Native caret line" {
|
if !result.CurrentLine || result.CurrentLineBrowse || result.FlatSpeech != style || result.Err != nil || result.DocumentCaretText != "Native caret line" {
|
||||||
t.Fatalf("native current line: %+v", result)
|
t.Fatalf("native current line: %+v", result)
|
||||||
}
|
}
|
||||||
|
if !reflect.DeepEqual(result.DocumentCaretContext, capturer.content.SourceContext) {
|
||||||
|
t.Fatal("native current line lost its owned privacy context")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if capturer.reads != 3 {
|
if capturer.reads != 3 {
|
||||||
t.Fatal("native line was cached")
|
t.Fatal("native line was cached")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentLineNativeMissingContextPreservesLocalText(t *testing.T) {
|
||||||
|
capturer := ¤tLineCapturer{content: atspi.DocumentContent{Text: "Local line"}}
|
||||||
|
worker, _ := NewWorker(capturer, time.Second)
|
||||||
|
worker.SyncFocus(webID("/entry"))
|
||||||
|
result := worker.read_current_line(t.Context(), nil, false)
|
||||||
|
if result.Err != nil || result.DocumentCaretText != "Local line" || len(result.DocumentCaretContext) != 0 {
|
||||||
|
t.Fatalf("missing privacy metadata changed local reading: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCurrentLineReadsWholeBrowseLineWithoutMoving(t *testing.T) {
|
func TestCurrentLineReadsWholeBrowseLineWithoutMoving(t *testing.T) {
|
||||||
reader := caretTextReader{webID("/text"): "First line\nSecond café 猫\nThird", webID("/next"): "Next"}
|
reader := caretTextReader{webID("/text"): "First line\nSecond café 猫\nThird", webID("/next"): "Next"}
|
||||||
cursor := newCaretTextCursor(t, reader)
|
cursor := newCaretTextCursor(t, reader)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import sys
|
|||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "3.0")
|
gi.require_version("Gtk", "3.0")
|
||||||
from gi.repository import GLib, Gtk
|
gi.require_version("Atk", "1.0")
|
||||||
|
from gi.repository import Atk, GLib, Gtk
|
||||||
|
|
||||||
window = Gtk.Window(title="Current line fixture")
|
window = Gtk.Window(title="Current line fixture")
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
@@ -21,6 +22,16 @@ button = Gtk.Button(label="Example button")
|
|||||||
controls = {"entry": entry, "text": textView, "password": password, "button": button}
|
controls = {"entry": entry, "text": textView, "password": password, "button": button}
|
||||||
for control in controls.values():
|
for control in controls.values():
|
||||||
box.pack_start(control, True, True, 0)
|
box.pack_start(control, True, True, 0)
|
||||||
|
# An ancestry walk that exhausts its bound must leave local text readable.
|
||||||
|
deep = Gtk.Entry()
|
||||||
|
deep.get_accessible().set_name("Deep entry")
|
||||||
|
controls["deep"] = deep
|
||||||
|
container = deep
|
||||||
|
for _ in range(70):
|
||||||
|
parent = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
parent.pack_start(container, True, True, 0)
|
||||||
|
container = parent
|
||||||
|
box.pack_start(container, True, True, 0)
|
||||||
window.show_all()
|
window.show_all()
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +41,10 @@ def command_ready(source, condition):
|
|||||||
Gtk.main_quit()
|
Gtk.main_quit()
|
||||||
return False
|
return False
|
||||||
command = json.loads(line)
|
command = json.loads(line)
|
||||||
|
if command["action"] == "privacy":
|
||||||
|
box.get_accessible().set_role(getattr(Atk.Role, command["role"]))
|
||||||
|
print("ready", flush=True)
|
||||||
|
return True
|
||||||
control = controls[command["control"]]
|
control = controls[command["control"]]
|
||||||
if command["action"] == "focus":
|
if command["action"] == "focus":
|
||||||
if isinstance(control, Gtk.Entry):
|
if isinstance(control, Gtk.Entry):
|
||||||
|
|||||||
Reference in new issue
Block a user