Some echo tests added.

This commit is contained in:
Storm Dragon committed 2026-10-27 00:40:49 -04:00
1 parent 8a32895457
commit eb5b2038d6
38 files changed
+1739 -31

No files matched your search

@@ -433,6 +433,17 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.forgetWindowDocuments(originID)
}
invalidated := adapter.applyTreeInvalidation(eventType, event)
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 &&
adapter.applicationProcesses[originID.BusName] == "pidgin" {
// Read-only transcripts need not own native focus. Forward their
// retirement before the ordinary dead-object early return, without
// querying a destroyed accessible or broadening focus ownership.
retired := NewEvent(eventType, Snapshot{BusName: originID.BusName, Path: originID.Path,
ApplicationProcess: "pidgin", Defunct: true})
retired.Detail1 = 1
adapter.enqueueSecureEvent(retired, secureEpoch)
return
}
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 && adapter.webNotificationRoots[originID] {
delete(adapter.webNotificationRoots, originID)
// Retire a known toast before the normal dead-object early return.
@@ -446,6 +457,17 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
// notification script needs their semantics before that early return,
// including when the provider is outside foreground ownership.
webNotification := adapter.webNotification(event)
if message := adapter.pidginMessage(event); message != nil {
// A transcript insertion is a passive announcement. Keep it out of
// native focus capture, selected-descendant resolution and review.
chatEvent := NewEvent(TextInsertedEvent, Snapshot{BusName: message.Provider,
Path: message.Conversation, Role: "text", ApplicationProcess: "pidgin"})
chatEvent.ChatMessage = message
chatEvent.Window = NewObjectID(message.Provider, message.Window)
chatEvent.Foreground = adapter.Foreground()
adapter.enqueueSecureEvent(chatEvent, secureEpoch)
return
}
if invalidated && webNotification == nil && eventType != ChildrenAddedEvent && (eventType != DefunctStateEvent || !adapter.nativeFocusIs(originID)) {
return
}
+4
View File
@@ -23,6 +23,7 @@ import (
"unicode"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/messaging"
"git.stormux.org/storm/cthulhu/internal/webscripts"
)
@@ -264,6 +265,9 @@ func (snapshot Snapshot) ID() ObjectID {
}
type Event struct {
// ChatMessage is copied by a verified application adapter before routine
// background filtering. It never transfers focus or retains a native object.
ChatMessage *messaging.Message
// WebNotification is selected on the native owner before background-event
// filtering. It contains copied content and no retained native references.
WebNotification *webscripts.Notification
+115
View File
@@ -0,0 +1,115 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
static gboolean pidgin_typing_attributes(AtspiText *text, gint offset,
gchar **scale, gchar **weight) {
gint start = 0, end = 0;
GError *error = NULL;
GHashTable *attributes = atspi_text_get_attribute_run(text, offset, FALSE,
&start, &end, &error);
gboolean known = attributes != NULL && error == NULL;
if (known) {
*scale = g_strdup(g_hash_table_lookup(attributes, "scale"));
*weight = g_strdup(g_hash_table_lookup(attributes, "weight"));
}
if (attributes != NULL) g_hash_table_unref(attributes);
if (error != NULL) g_clear_error(&error);
return known;
}
*/
import "C"
import (
"strconv"
"time"
"git.stormux.org/storm/cthulhu/internal/messaging"
)
// pidginMessage follows legacy chat.py's transcript contract, never a window
// title or application-supplied name. Only verified Pidgin transcript insertions
// bypass background filtering. Entry typing and buddy-list changes do not.
func (adapter *Adapter) pidginMessage(event *C.AtspiEvent) *messaging.Message {
if canonicalTextChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "pidgin" ||
C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_TEXT {
return nil
}
states := C.atspi_accessible_get_state_set(event.source)
if states == nil {
return nil
}
transcript := C.atspi_state_set_contains(states, C.ATSPI_STATE_MULTI_LINE) != 0 &&
C.atspi_state_set_contains(states, C.ATSPI_STATE_EDITABLE) == 0 &&
C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0
C.g_object_unref(C.gpointer(states))
if !transcript {
return nil
}
current := C.atspi_accessible_get_parent(event.source, nil)
if current == nil {
return nil
}
if C.atspi_accessible_get_role(current, nil) != C.ATSPI_ROLE_SCROLL_PANE {
C.g_object_unref(C.gpointer(current))
return nil
}
id := nativeObjectID(event.source)
message := &messaging.Message{Provider: id.BusName, Conversation: id.Path}
deadline := time.Now().Add(50 * time.Millisecond)
for depth := 0; current != nil && depth < 32 && time.Now().Before(deadline); depth++ {
role := C.atspi_accessible_get_role(current, nil)
if role == C.ATSPI_ROLE_PAGE_TAB && message.RoomName == "" || role == C.ATSPI_ROLE_FRAME {
if message.RoomName == "" {
name := C.atspi_accessible_get_name(current, nil)
message.RoomName = sanitizeSnapshotField(C.GoString(name))
C.g_free(C.gpointer(name))
}
if role == C.ATSPI_ROLE_FRAME {
window := nativeObjectID(current)
if window.BusName == id.BusName {
message.Window = window.Path
}
C.g_object_unref(C.gpointer(current))
current = nil
break
}
}
parent := C.atspi_accessible_get_parent(current, nil)
C.g_object_unref(C.gpointer(current))
current = parent
}
if current != nil {
C.g_object_unref(C.gpointer(current))
}
if !id.Valid() || message.Window == "" {
return nil
}
payload := C.cthulhu_event_payload(event)
message.Text = sanitizeTextContext(C.GoString(payload))
C.g_free(C.gpointer(payload))
// Pidgin inserts typing status into the transcript with smaller/lighter
// attributes. Legacy defaults suppress it and exclude it from history.
if text := C.atspi_accessible_get_text(event.source); text != nil {
// Scale is not part of the reader's ordinary spoken-attribute set.
// Query both Pidgin-specific markers without expanding generic capture.
var scaleValue, weightValue *C.gchar
known := C.pidgin_typing_attributes(text, event.detail1, &scaleValue, &weightValue) != 0
scale, scaleErr := strconv.ParseFloat(C.GoString(scaleValue), 64)
weight, weightErr := strconv.Atoi(C.GoString(weightValue))
C.g_free(C.gpointer(scaleValue))
C.g_free(C.gpointer(weightValue))
message.Typing = scaleErr == nil && scale < 1 || weightErr == nil && weight < 400
C.g_object_unref(C.gpointer(text))
if !known {
return nil
} // A destroyed range cannot establish a message.
} else {
return nil
}
return message
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestNativePidginTranscriptCapture(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_MESSAGING") != "1" {
t.Skip("requires private Xorg/AT-SPI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
for _, processName := range []string{"pidgin", "messaging-test"} {
t.Run(processName, func(t *testing.T) {
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_messaging.py", processName)
input, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
command.Stderr = os.Stderr
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer func() { input.Close(); command.Process.Kill(); command.Wait() }()
line, err := bufio.NewReader(output).ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 2 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil {
t.Fatalf("focus: %s, %v", output, err)
}
fmt.Fprintln(input, "message\ntyping\ntyping-weight\nedit\nbarrier")
messages, typing := 0, 0
var transcript ObjectID
retired, barrier := false, false
for {
select {
case event := <-adapter.Events():
if event.Source.ApplicationProcess != processName {
continue
}
if event.ChatMessage != nil {
if processName != "pidgin" {
t.Fatal("unrelated process received chat metadata")
}
message := event.ChatMessage
transcript = event.Source.ID()
// GTK can publish an empty insertion during initial enumeration.
if message.Text == "" {
continue
}
if message.Provider != event.Source.BusName || message.Conversation != event.Source.Path || message.Window == "" || message.RoomName == "" {
t.Fatalf("incomplete identities: %+v", message)
}
if message.Typing {
typing++
} else {
messages++
if message.Text != "Username: Hello\n" {
t.Fatalf("message content = %q", message.Text)
}
}
}
if event.Type == DefunctStateEvent && event.Detail1 != 0 && transcript.Valid() && event.Source.ID() == transcript {
retired = true
}
if event.Type == TextInsertedEvent && event.Payload == "Messaging barrier" {
barrier = true
if processName == "pidgin" && (messages != 1 || typing != 2) {
t.Fatalf("captured %d messages, %d typing statuses", messages, typing)
}
if processName == "pidgin" {
fmt.Fprintln(input, "destroy")
}
}
if barrier && (processName != "pidgin" || retired) {
return
}
case <-ctx.Done():
t.Fatalf("transcript/barrier missing: %v (messages=%d, typing=%d)", ctx.Err(), messages, typing)
}
}
})
}
}