Add Mumble message review shortcuts

This commit is contained in:
ak47 committed 2026-11-07 19:59:23 -05:00
1 parent d5f8a392cf
commit 64440fbb78
18 files changed
+484 -30

No files matched your search

+14 -3
View File
@@ -26,6 +26,17 @@ type messageCopyResult struct {
err error
}
func messageHistorySlots(process string) int {
switch process {
case "pidgin":
return 9
case "mumble":
return 12
default:
return 0
}
}
// messagingController belongs to the accessibility event loop. Histories survive
// focus changes, retire with their native window/transcript, and clear on lock.
// Its one clipboard worker has a bounded queue and never performs native queries.
@@ -87,8 +98,8 @@ func (controller *messagingController) handle(event atspi.Event) bool {
controller.history.Retire(id.BusName, id.Path)
}
message := event.ChatMessage
if message == nil || event.Type != atspi.TextInsertedEvent || event.Source.ApplicationProcess != "pidgin" ||
event.Source.Defunct || event.Source.Role != "text" ||
if message == nil || event.Type != atspi.TextInsertedEvent || messageHistorySlots(event.Source.ApplicationProcess) == 0 ||
event.Source.Defunct || (event.Source.Role != "text" && !(event.Source.ApplicationProcess == "mumble" && event.Source.Role == "label")) ||
message.Provider != event.Source.BusName || message.Conversation != event.Source.Path ||
!event.Source.ID().Valid() || event.Window != atspi.NewObjectID(message.Provider, message.Window) {
return false
@@ -117,7 +128,7 @@ func (controller *messagingController) present(text string, background bool) {
func (controller *messagingController) review(command input.Command, current atspi.Snapshot) {
index, copyMessage, ok := input.MessageCommandIndex(command)
if !ok || controller.gate.Locked() || controller.gate.Suspended() || current.ApplicationProcess != "pidgin" {
if !ok || controller.gate.Locked() || controller.gate.Suspended() || index >= messageHistorySlots(current.ApplicationProcess) {
return
}
message, exists := controller.history.Previous(current.BusName, index)
+73 -3
View File
@@ -7,6 +7,7 @@ package app
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"testing"
@@ -26,7 +27,71 @@ func pidginMessageEvent(text string) atspi.Event {
return event
}
func TestMumbleMessagingReviewBoundsOwnershipAndRetirement(t *testing.T) {
queue, braille := &learnSpeechRecorder{}, &flashRecorder{}
controller := newMessagingController(context.Background(), queue, nil, nil, func(_ context.Context, text string) error {
if text != "Mumble message 3" {
return fmt.Errorf("copied wrong message: %q", text)
}
return nil
})
controller.braille = braille
defer controller.stop()
current := atspi.Snapshot{ApplicationProcess: "mumble", BusName: ":1.42"}
for i := 0; i < 15; i++ {
event := pidginMessageEvent(fmt.Sprintf("Mumble message %d", i))
event.Source.ApplicationProcess = "mumble"
if i%2 == 0 {
event.Source.Role = "label"
}
if !controller.handle(event) {
t.Fatal("Mumble log insertion was not handled")
}
}
for i := 0; i < 12; i++ {
controller.review(input.MessageCommand(i, false), current)
want := fmt.Sprintf("Mumble message %d", 14-i)
if queue.texts[len(queue.texts)-1] != want || braille.messages[len(braille.messages)-1] != want {
t.Fatalf("review slot %d did not present %q", i, want)
}
}
controller.review(input.MessageCommand(11, true), current)
select {
case result := <-controller.results:
if result.err != nil {
t.Fatal(result.err)
}
controller.copied(result)
case <-time.After(time.Second):
t.Fatal("Mumble F12 copy did not complete")
}
if queue.texts[len(queue.texts)-1] != "Copied Mumble message 3 to clipboard." {
t.Fatal("Mumble F12 copy was not confirmed")
}
count := len(queue.texts)
for _, focus := range []atspi.Snapshot{
{ApplicationProcess: "mumble", BusName: ":1.43"},
{ApplicationProcess: "chromium", BusName: ":1.42"},
{ApplicationProcess: "pidgin", BusName: ":1.42"},
} {
controller.review(input.MessageCommand(11, false), focus)
}
retired := atspi.NewEvent(atspi.DefunctStateEvent, atspi.Snapshot{BusName: ":1.42", Path: "/transcript", Defunct: true})
retired.Detail1 = 1
controller.handle(retired)
controller.review(input.MessageCommand(0, false), current)
if len(queue.texts) != count || len(braille.messages) != count {
t.Fatal("unowned or retired history was presented")
}
}
func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T) {
for _, process := range []string{"pidgin", "mumble"} {
t.Run(process, func(t *testing.T) { testMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t, process) })
}
}
func testMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T, process string) {
ctx, cancel := context.WithCancel(context.Background())
adapter := &fakeAdapter{events: make(chan atspi.Event)}
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
@@ -37,13 +102,18 @@ func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T)
}()
defer func() { cancel(); <-done }()
focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/entry")
focus.Source.ApplicationProcess, focus.Source.Role, focus.Source.Name = "pidgin", "entry", "Message input"
focus.Source.ApplicationProcess, focus.Source.Role, focus.Source.Name = process, "entry", "Message input"
focus.Source.Editable, focus.Source.Focused, focus.Detail1 = true, true, 1
adapter.events <- focus
expectActivationSpeech(t, queue.spoken, "Message input, entry")
adapter.events <- pidginMessageEvent("First message")
messageEvent := func(text string) atspi.Event {
event := pidginMessageEvent(text)
event.Source.ApplicationProcess = process
return event
}
adapter.events <- messageEvent("First message")
expectActivationSpeech(t, queue.spoken, "First message")
adapter.events <- pidginMessageEvent("Second message")
adapter.events <- messageEvent("Second message")
expectActivationSpeech(t, queue.spoken, "Second message")
requests <- input.MessageCommand(0, false)
expectActivationSpeech(t, queue.spoken, "Second message")
@@ -495,12 +495,12 @@ func dispatch_native_event(event *C.AtspiEvent, userData unsafe.Pointer, deferre
return
}
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 &&
adapter.applicationProcesses[originID.BusName] == "pidgin" {
(adapter.applicationProcesses[originID.BusName] == "pidgin" || adapter.applicationProcesses[originID.BusName] == "mumble") {
// 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})
ApplicationProcess: adapter.applicationProcesses[originID.BusName], Defunct: true})
retired.Detail1 = 1
adapter.enqueueSecureEvent(retired, secureEpoch)
return
@@ -518,11 +518,17 @@ func dispatch_native_event(event *C.AtspiEvent, userData unsafe.Pointer, deferre
// 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 {
message := adapter.pidginMessage(event)
if message == nil {
message = adapter.mumbleMessage(event)
}
if message != nil {
// A transcript insertion is a passive announcement. Keep it out of
// native focus capture, selected-descendant resolution and review.
roleName := C.atspi_accessible_get_role_name(event.source, nil)
chatEvent := NewEvent(TextInsertedEvent, Snapshot{BusName: message.Provider,
Path: message.Conversation, Role: "text", ApplicationProcess: "pidgin"})
Path: message.Conversation, Role: C.GoString(roleName), ApplicationProcess: adapter.applicationProcesses[originID.BusName]})
C.g_free(C.gpointer(roleName))
chatEvent.ChatMessage = message
chatEvent.Window = NewObjectID(message.Provider, message.Window)
chatEvent.Foreground = adapter.Foreground()
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"regexp"
"strings"
)
var mumbleTimestamp = regexp.MustCompile(`^\[\d{2}:\d{2}:\d{2}\]\s*`)
// Match legacy Mumble's inserted-message cleanup. Qt can insert the timestamp
// separately, and uses paragraph separators and noncharacters in its log.
func mumbleMessageText(text string) string {
text = strings.Map(func(character rune) rune {
if character >= '\ufdd0' && character <= '\ufdef' {
return -1
}
return character
}, text)
text = strings.Join(strings.Fields(text), " ")
return mumbleTimestamp.ReplaceAllString(text, "")
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
import (
"time"
"git.stormux.org/storm/cthulhu/internal/messaging"
)
// Only Mumble's named, read-only Activity log supplies message history. The
// bounded walk copies its owning window; no native handles survive the callback.
func (adapter *Adapter) mumbleMessage(event *C.AtspiEvent) *messaging.Message {
if canonicalObjectChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "mumble" {
return nil
}
role := C.atspi_accessible_get_role(event.source, nil)
if role != C.ATSPI_ROLE_TEXT && role != C.ATSPI_ROLE_LABEL {
return nil
}
name := C.atspi_accessible_get_name(event.source, nil)
activityLog := C.GoString(name) == "Activity log"
C.g_free(C.gpointer(name))
if !activityLog {
return nil
}
states := C.atspi_accessible_get_state_set(event.source)
if states == nil {
return nil
}
valid := 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 !valid {
return nil
}
id := nativeObjectID(event.source)
message := &messaging.Message{Provider: id.BusName, Conversation: id.Path, RoomName: "Mumble"}
current := C.atspi_accessible_get_parent(event.source, nil)
deadline := time.Now().Add(50 * time.Millisecond)
for depth := 0; current != nil && depth < 32 && time.Now().Before(deadline); depth++ {
if isWindowRole(C.atspi_accessible_get_role(current, nil)) {
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 = mumbleMessageText(sanitizeTextContext(C.GoString(payload)))
C.g_free(C.gpointer(payload))
return message
}
+88
View File
@@ -0,0 +1,88 @@
// 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 TestNativeMumbleTranscriptCapture(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_MUMBLE") != "1" {
t.Skip("requires private Qt/Xorg/AT-SPI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
for _, process := range []string{"mumble", "messaging-test"} {
t.Run(process, func(t *testing.T) {
command := exec.CommandContext(ctx, "python3", "../../tests/qt_messaging.py", process)
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 out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil {
t.Fatalf("focus: %s, %v", out, err)
}
fmt.Fprintln(input, "messages\nbarrier")
var messages []string
for {
select {
case event := <-adapter.Events():
if event.Source.ApplicationProcess != process {
continue
}
if message := event.ChatMessage; message != nil {
if process != "mumble" {
t.Fatal("unrelated process received chat metadata")
}
if message.Provider != event.Source.BusName || message.Conversation != event.Source.Path || message.Window == "" || message.RoomName != "Mumble" {
t.Fatalf("incomplete message identities: %+v", message)
}
if message.Text != "" {
messages = append(messages, message.Text)
}
}
if event.Type == TextInsertedEvent && event.Payload == "Messaging barrier" {
want := "Username: hello world 界|Repeated message|Repeated message"
if process == "mumble" && strings.Join(messages, "|") != want {
t.Fatalf("captured messages = %q", messages)
}
return
}
case <-ctx.Done():
t.Fatalf("transcript/barrier missing: %v (messages=%q)", ctx.Err(), messages)
}
}
})
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import "testing"
func TestMumbleMessageText(t *testing.T) {
for _, test := range []struct{ input, want string }{
{"[12:34:56]", ""},
{" \ufdd0[12:34:56]\ufdef \n", ""},
{"[12:34:56] Username: hello\u2028world \ufdd0界\ufdef", "Username: hello world 界"},
{"Username: [12:34:56] hello", "Username: [12:34:56] hello"},
{"Username: hello\n\tworld", "Username: hello world"},
{" \n\ufdd0\ufdef", ""},
} {
if got := mumbleMessageText(test.input); got != test.want {
t.Errorf("cleanup %q = %q, want %q", test.input, got, test.want)
}
}
}
+1 -1
View File
@@ -237,7 +237,7 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings {
{Symbol: leftKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectPreviousSibling,
{Symbol: rightKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectNextSibling,
}
for index := 0; index < 9; index++ {
for index := 0; index < 12; index++ {
bindings[CommandChord{Symbol: 0xffbe + uint32(index), CthulhuModifier: true}] = MessageCommand(index, false)
bindings[CommandChord{Symbol: 0xffbe + uint32(index), Modifiers: 1, CthulhuModifier: true}] = MessageCommand(index, true)
}
+9 -5
View File
@@ -4,19 +4,22 @@
package input
import "strings"
import (
"strconv"
"strings"
)
// Message commands are independent of the physical key. Configured bindings,
// learn mode and both keyboard layouts use the same numbered requests.
func MessageCommand(index int, copyMessage bool) Command {
if index < 0 || index >= 9 {
if index < 0 || index >= 12 {
return ""
}
action := "review"
if copyMessage {
action = "copy"
}
return Command(action + "-chat-message-" + string(rune('1'+index)))
return Command(action + "-chat-message-" + strconv.Itoa(index+1))
}
func MessageCommandIndex(command Command) (index int, copyMessage, ok bool) {
@@ -25,8 +28,9 @@ func MessageCommandIndex(command Command) (index int, copyMessage, ok bool) {
number, found = strings.CutPrefix(string(command), "copy-chat-message-")
copyMessage = true
}
if !found || len(number) != 1 || number[0] < '1' || number[0] > '9' {
value, err := strconv.Atoi(number)
if !found || err != nil || value < 1 || value > 12 || strconv.Itoa(value) != number {
return 0, false, false
}
return int(number[0] - '1'), copyMessage, true
return value - 1, copyMessage, true
}
+2 -2
View File
@@ -16,7 +16,7 @@ func TestMessagingBindingsAndExplicitAssignments(t *testing.T) {
if bindings[CommandChord{Symbol: 'h', CthulhuModifier: true}] != ToggleLearnMode {
t.Fatal("learn mode shortcut changed")
}
for index := 0; index < 9; index++ {
for index := 0; index < 12; index++ {
for _, copyMessage := range []bool{false, true} {
modifiers := uint16(0)
if copyMessage {
@@ -45,7 +45,7 @@ func TestMessagingBindingsAndExplicitAssignments(t *testing.T) {
}
}
}
for _, command := range []Command{"review-chat-message-0", "review-chat-message-10", "copy-chat-message-01", ToggleLearnMode} {
for _, command := range []Command{"review-chat-message-0", "review-chat-message-13", "copy-chat-message-01", "review-chat-message-+1", "review-chat-message- 1", ToggleLearnMode} {
if _, _, ok := MessageCommandIndex(command); ok {
t.Fatalf("accepted %q", command)
}
+1 -1
View File
@@ -8,7 +8,7 @@ package messaging
import "strings"
const HistorySize = 9
const HistorySize = 12
const maxProviders = 32
// Message contains no native objects. Provider is the unique accessibility bus
+3 -3
View File
@@ -12,17 +12,17 @@ import (
func TestHistoryOrdersConversationsAndIsolatesApplicationInstances(t *testing.T) {
var history History
for i := 0; i < 12; i++ {
for i := 0; i < 15; i++ {
history.Add(Message{Provider: ":1.42", Conversation: fmt.Sprintf("/room%d", i%2), Window: "/window", Text: fmt.Sprintf("Message %d", i)})
}
history.Add(Message{Provider: ":1.43", Conversation: "/room0", Window: "/window", Text: "Other instance"})
for i := 0; i < HistorySize; i++ {
message, ok := history.Previous(":1.42", i)
if !ok || message.Text != fmt.Sprintf("Message %d", 11-i) {
if !ok || message.Text != fmt.Sprintf("Message %d", 14-i) {
t.Fatalf("slot %d = %+v, %t", i, message, ok)
}
}
for _, index := range []int{-1, 9} {
for _, index := range []int{-1, HistorySize} {
if _, ok := history.Previous(":1.42", index); ok {
t.Fatal("out-of-range slot exists")
}
@@ -169,6 +169,12 @@ func TestNativeLearnModeCapturesKeysAndRestoresCommands(t *testing.T) {
}
key("Insert+Shift_L+F9")
want(input.MessageCommand(8, true))
for index := 9; index < 12; index++ {
key(fmt.Sprintf("Insert+F%d", index+1))
want(input.MessageCommand(index, false))
key(fmt.Sprintf("Insert+Shift_L+F%d", index+1))
want(input.MessageCommand(index, true))
}
for _, opener := range []string{"Insert+h"} {
key(opener)
want(input.ToggleLearnMode)