Some echo tests added.
This commit is contained in:
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user