Version bump and some lable/relation fixes.

This commit is contained in:
Storm Dragon committed 2026-10-18 22:11:54 -04:00
1 parent d717b93849
commit 396efbd7c5
15 files changed
+474 -8

No files matched your search

+49 -3
View File
@@ -8,6 +8,46 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot);
static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining);
// Some native entries leave Name empty and expose their label only through
// LABELLED_BY. Follow explicit relationships, never nearby text or field values.
static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
GArray *relations = atspi_accessible_get_relation_set(source, NULL);
if (relations == NULL) return NULL;
GString *result = g_string_new("");
guint remaining = 16;
for (guint i = 0; i < relations->len && remaining > 0; i++) {
AtspiRelation *relation = g_array_index(relations, AtspiRelation *, i);
if (atspi_relation_get_relation_type(relation) != ATSPI_RELATION_LABELLED_BY) continue;
gint targets = atspi_relation_get_n_targets(relation);
for (gint j = 0; j < targets && remaining > 0; j++, remaining--) {
AtspiAccessible *label = atspi_relation_get_target(relation, j);
if (label == NULL) continue;
gchar *name = atspi_accessible_get_name(label, NULL);
if ((name == NULL || *name == '\0') &&
atspi_accessible_get_role(label, NULL) == ATSPI_ROLE_LABEL) {
AtspiText *text = atspi_accessible_get_text(label);
if (text != NULL) {
gint count = atspi_text_get_character_count(text, NULL);
g_free(name);
name = count > 0 ? atspi_text_get_text(text, 0, MIN(count, 1024), NULL) : NULL;
g_object_unref(text);
}
}
if (name != NULL && *g_strstrip(name) != '\0' &&
result->len + strlen(name) + 1 <= 4096) {
if (result->len > 0) g_string_append_c(result, ' ');
g_string_append(result, name);
}
g_free(name);
g_object_unref(label);
}
}
for (guint i = 0; i < relations->len; i++)
g_object_unref(g_array_index(relations, AtspiRelation *, i));
g_array_unref(relations);
return g_string_free(result, FALSE);
}
// Collect only the selected row. Walking an entire table on every arrow key
// would block input in large application tables.
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
@@ -389,6 +429,12 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
g_error_free(error);
error = NULL;
}
if (!snapshot.defunct && (snapshot.name == NULL || *snapshot.name == '\0') &&
(g_strcmp0(snapshot.role, "text") == 0 || g_strcmp0(snapshot.role, "entry") == 0 ||
g_strcmp0(snapshot.role, "password text") == 0)) {
g_free(snapshot.name);
snapshot.name = cthulhu_text_control_label(source);
}
cthulhu_capture_menu_checkability(source, &snapshot);
if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 ||
g_strcmp0(snapshot.role, "list box") == 0)) {
@@ -556,7 +602,7 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
g_object_unref(text);
return NULL;
}
AtspiTextRange *range = atspi_text_get_string_at_offset(
AtspiTextRange *range = cthulhu_text_get_string_at_offset(
text, caretOffset, ATSPI_TEXT_GRANULARITY_LINE, &error);
// AXText.get_line_at_offset retries Chromium's invalid end-of-text
// range. A valid empty range is a real blank line and must stay empty.
@@ -568,7 +614,7 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
}
if (retry) {
g_boxed_free(atspi_text_range_get_type(), range);
range = atspi_text_get_string_at_offset(
range = cthulhu_text_get_string_at_offset(
text, caretOffset - 1, ATSPI_TEXT_GRANULARITY_LINE, &error);
}
}
@@ -836,7 +882,7 @@ gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity
g_object_unref(text);
return NULL;
}
AtspiTextRange *range = atspi_text_get_string_at_offset(
AtspiTextRange *range = cthulhu_text_get_string_at_offset(
text, caretOffset, unit, &error);
g_object_unref(text);
if (error != NULL) {
+2
View File
@@ -101,6 +101,8 @@ void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attribute
gboolean cthulhu_has_set_position(const gchar *role);
gchar *cthulhu_live_content(AtspiAccessible *source);
gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit);
AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset,
AtspiTextGranularity unit, GError **error);
guint start_foreground_timer(gpointer data);
const gchar *cthulhu_event_type(AtspiEvent *event);
gint cthulhu_event_detail1(AtspiEvent *event);
+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "adapter_native.h"
#include <string.h>
AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset,
AtspiTextGranularity unit, GError **error) {
if (unit != ATSPI_TEXT_GRANULARITY_LINE || offset < 0) {
return atspi_text_get_string_at_offset(text, offset, unit, error);
}
// Chromium can retain layout boundaries past truncated hypertext. In a
// captured crash, LINE at the final U+2026 (140 of 141 characters) tried
// to take a substring starting at 146 and aborted the entire browser.
// Checking returned boundaries is too late: use bounded GetText for an
// ellipsis before invoking the provider's boundary calculation.
GError *probeError = NULL;
guint character = atspi_text_get_character_at_offset(text, offset, &probeError);
if (probeError != NULL) {
g_propagate_error(error, probeError);
return NULL;
}
if (character != 0x2026) {
return atspi_text_get_string_at_offset(text, offset, unit, error);
}
gint count = atspi_text_get_character_count(text, &probeError);
if (probeError != NULL) {
g_propagate_error(error, probeError);
return NULL;
}
if (count <= 0 || offset != count - 1) {
return atspi_text_get_string_at_offset(text, offset, unit, error);
}
gint start = MAX(0, offset - 4095);
gchar *content = atspi_text_get_text(text, start, offset + 1, error);
if (content == NULL) return NULL;
if (!g_utf8_validate(content, -1, NULL) ||
g_utf8_strlen(content, -1) != offset + 1 - start ||
!g_str_has_suffix(content, "\342\200\246")) {
g_free(content);
return NULL;
}
// Preserve explicit line breaks. A provider's visual wrapping is unsafe
// at this character; retain the bounded logical line and native offsets.
gchar *line = strrchr(content, '\n');
line = line != NULL ? line + 1 : content;
AtspiTextRange *range = g_new0(AtspiTextRange, 1);
range->start_offset = start + g_utf8_pointer_to_offset(content, line);
range->end_offset = offset + 1;
range->content = g_strdup(line);
g_free(content);
return range;
}
@@ -62,4 +62,33 @@ func TestNativeBrowserLogicalLines(t *testing.T) {
t.Fatalf("preserved %s line = %+v, %v", test.name, line, err)
}
}
for _, name := range []string{"Truncated preview…", "猫🐈…", "…"} {
ref := findNamedReference(t, tree, name)
if !ref.Valid() {
t.Fatalf("ellipsis object %q missing", name)
}
offset := len([]rune(name)) - 1
for _, unit := range []DocumentCaretUnit{DocumentCaretLine, DocumentCaretBrailleLine, DocumentCaretLineRemainder} {
line, err := adapter.ReadDocumentCaret(ctx, ref, offset, unit)
if err != nil || line.End != offset+1 || !strings.HasSuffix(line.Text, "…") {
t.Fatalf("ellipsis %q unit %d = %+v, %v", name, unit, line, err)
}
}
if err := adapter.SetDocumentCaretPosition(ctx, ref, focus, offset); err != nil {
t.Fatalf("place caret on ellipsis: %v", err)
}
// Drain the actual native caret event, exercising snapshot text,
// character/word capture and logical-line repair before proceeding.
for {
select {
case event := <-adapter.Events():
if event.Type == CaretEvent && strings.HasSuffix(event.Source.BrailleLine, "…") {
goto caretCaptured
}
case <-ctx.Done():
t.Fatal("ellipsis caret event missing: ", ctx.Err())
}
}
caretCaptured:
}
}
+1 -1
View File
@@ -160,7 +160,7 @@ func nativePointText(ctx context.Context, source, window *C.AtspiAccessible, x,
}
read := func(granularity C.AtspiTextGranularity) (string, int) {
var err *C.GError
value := C.atspi_text_get_string_at_offset(text, offset, granularity, &err)
value := C.cthulhu_text_get_string_at_offset(text, offset, granularity, &err)
if err != nil {
C.g_error_free(err)
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (adapter *Adapter) nativeTransferItem(ctx context.Context, object ObjectID,
offset = 0
}
}
line := C.atspi_text_get_string_at_offset(text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, nil)
line := C.cthulhu_text_get_string_at_offset(text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, nil)
if line == nil {
return TransferItem{}, errors.New("focused line unavailable")
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "adapter_native.h"
#include <string.h>
static const gchar *content;
static guint boundaryCalls, characterCalls, textCalls;
static gboolean failProbe, changedText;
guint atspi_text_get_character_at_offset(AtspiText *text, gint offset, GError **error) {
characterCalls++;
if (failProbe) {
g_set_error_literal(error, g_quark_from_static_string("probe"), 1, "removed text");
return 0;
}
return offset >= 0 && offset < g_utf8_strlen(content, -1)
? g_utf8_get_char(g_utf8_offset_to_pointer(content, offset)) : 0;
}
gint atspi_text_get_character_count(AtspiText *text, GError **error) {
return g_utf8_strlen(content, -1);
}
gchar *atspi_text_get_text(AtspiText *text, gint start, gint end, GError **error) {
textCalls++;
if (changedText) return g_strdup("");
g_assert_cmpint(start, >=, 0);
g_assert_cmpint(end - start, <=, 4096);
g_assert_cmpint(end, <=, g_utf8_strlen(content, -1));
const gchar *first = g_utf8_offset_to_pointer(content, start);
return g_strndup(first, g_utf8_offset_to_pointer(content, end) - first);
}
AtspiTextRange *atspi_text_get_string_at_offset(AtspiText *text, gint offset,
AtspiTextGranularity unit, GError **error) {
boundaryCalls++;
// Model the verified browser failure: asking for LINE at the final
// ellipsis aborts inside the provider, before it can return a bad range.
if (unit == ATSPI_TEXT_GRANULARITY_LINE && offset >= 0 &&
offset == g_utf8_strlen(content, -1) - 1) {
g_assert_cmpuint(g_utf8_get_char(g_utf8_offset_to_pointer(content, offset)), !=, 0x2026);
}
AtspiTextRange *range = g_new0(AtspiTextRange, 1);
range->end_offset = g_utf8_strlen(content, -1);
range->content = g_strdup(content);
return range;
}
static void assert_range(const gchar *value, gint offset, AtspiTextGranularity unit,
gint start, gint end, const gchar *expected, guint calls) {
content = value;
boundaryCalls = characterCalls = textCalls = 0;
GError *error = NULL;
AtspiTextRange *range = cthulhu_text_get_string_at_offset(NULL, offset, unit, &error);
g_assert_no_error(error);
g_assert_nonnull(range);
g_assert_cmpint(range->start_offset, ==, start);
g_assert_cmpint(range->end_offset, ==, end);
g_assert_cmpstr(range->content, ==, expected);
g_assert_cmpuint(boundaryCalls, ==, calls);
if (unit != ATSPI_TEXT_GRANULARITY_LINE || offset < 0) g_assert_cmpuint(characterCalls, ==, 0);
g_assert_cmpuint(textCalls, ==, calls == 0 ? 1 : 0);
g_free(range->content);
g_free(range);
}
int main(void) {
assert_range("Truncated text…", 14, ATSPI_TEXT_GRANULARITY_LINE, 0, 15, "Truncated text…", 0);
assert_range("…", 0, ATSPI_TEXT_GRANULARITY_LINE, 0, 1, "…", 0);
assert_range("First\n猫🐈…", 8, ATSPI_TEXT_GRANULARITY_LINE, 6, 9, "猫🐈…", 0);
assert_range("Ordinary text", 12, ATSPI_TEXT_GRANULARITY_LINE, 0, 13, "Ordinary text", 1);
assert_range("An… inside", 2, ATSPI_TEXT_GRANULARITY_LINE, 0, 10, "An… inside", 1);
assert_range("Text…", 4, ATSPI_TEXT_GRANULARITY_CHAR, 0, 5, "Text…", 1);
assert_range("Text…", 4, ATSPI_TEXT_GRANULARITY_WORD, 0, 5, "Text…", 1);
assert_range("Text…", -1, ATSPI_TEXT_GRANULARITY_LINE, 0, 5, "Text…", 1);
gchar *prefix = g_strnfill(5000, 'x');
gchar *longText = g_strconcat(prefix, "…", NULL);
assert_range(longText, 5000, ATSPI_TEXT_GRANULARITY_LINE, 905, 5001, longText + 905, 0);
g_free(prefix);
g_free(longText);
content = "Changed…";
boundaryCalls = 0;
changedText = TRUE;
GError *error = NULL;
g_assert_null(cthulhu_text_get_string_at_offset(NULL, 7, ATSPI_TEXT_GRANULARITY_LINE, &error));
g_assert_no_error(error);
g_assert_cmpuint(boundaryCalls, ==, 0);
changedText = FALSE;
failProbe = TRUE;
g_assert_null(cthulhu_text_get_string_at_offset(NULL, 7, ATSPI_TEXT_GRANULARITY_LINE, &error));
g_assert_nonnull(error);
g_assert_cmpuint(boundaryCalls, ==, 0);
g_clear_error(&error);
return 0;
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestTextBoundaryAvoidsProviderEllipsisAbort(t *testing.T) {
flags, err := exec.Command("pkg-config", "--cflags", "--libs", "atspi-2").CombinedOutput()
if err != nil {
t.Fatalf("native text dependencies: %v: %s", err, flags)
}
binary := filepath.Join(t.TempDir(), "text-boundary")
args := []string{"-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-I.",
"adapter_text_boundary.c", "testdata/text_boundary.c", "-o", binary}
args = append(args, strings.Fields(string(flags))...)
if output, err := exec.Command("cc", args...).CombinedOutput(); err != nil {
t.Fatalf("compile native boundary regression: %v: %s", err, output)
}
if output, err := exec.Command(binary).CombinedOutput(); err != nil {
t.Fatalf("native boundary regression: %v: %s", err, output)
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ package atspi
/*
#cgo pkg-config: atspi-2
#include <atspi/atspi.h>
#include "adapter_native.h"
#include <string.h>
// Bound the Go copy even when an application returns an oversized line.
static int review_text_length(const gchar *text, int limit) {
@@ -176,7 +176,7 @@ func (source nativeReviewText) line(ctx context.Context, offset int) (TextRange,
return TextRange{}, err
}
var nativeErr *C.GError
line := C.atspi_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr)
line := C.cthulhu_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr)
if line != nil {
defer C.g_boxed_free(C.atspi_text_range_get_type(), C.gpointer(line))
}
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestNativeTextLabelRelations(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TEXT_LABELS") != "1" {
t.Skip("requires private GTK/AT-SPI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/text_label_fixture.py")
fixture.Env = append(os.Environ(), "NO_AT_BRIDGE=0", "GTK_MODULES=gail:atk-bridge")
stdin, err := fixture.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := fixture.StdoutPipe()
if err != nil {
t.Fatal(err)
}
fixture.Stderr = os.Stderr
if err := fixture.Start(); err != nil {
t.Fatal(err)
}
defer func() { stdin.Close(); fixture.Wait() }()
if scanner := bufio.NewScanner(stdout); !scanner.Scan() || scanner.Text() != "ready" {
t.Fatal("fixture did not start")
}
var previous atspi.ObjectID
for index, probe := range []struct{ name, value string }{
{"Destination:", ""},
{"Display name:", "Example document"},
{"Preferred name", "Example value"},
{"Secret:", ""},
{"", "Unlabeled value"},
{"Account identifier", ""},
} {
if _, err := fmt.Fprintln(stdin, index); err != nil {
t.Fatal(err)
}
waitFocus:
for {
select {
case <-ctx.Done():
t.Fatal("missing entry focus", index)
case event := <-adapter.Events():
if (event.Type != atspi.FocusEvent && event.Type != atspi.FocusedStateEvent) || !event.Source.Focused {
continue
}
snapshot := event.Source
if snapshot.Role != "text" && snapshot.Role != "password text" {
continue
}
if snapshot.ID() == previous {
continue
}
previous = snapshot.ID()
if snapshot.Name != probe.name {
t.Errorf("entry %d name = %q, want %q", index, snapshot.Name, probe.name)
}
speech := FocusText(snapshot)
for _, part := range []string{probe.name, probe.value} {
if part != "" && !strings.Contains(speech, part) {
t.Errorf("entry %d speech %q omits %q", index, speech, part)
}
}
if strings.Contains(speech, "private fixture value") {
t.Error("password content leaked into speech")
}
break waitFocus
}
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import "strings"
const Name = "Cthulhu"
const Version = "2026.10.17"
const Version = "2026.10.18"
const Channel = "master"
var Build = Version