178 lines
5.4 KiB
Go
178 lines
5.4 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/messaging"
|
|
)
|
|
|
|
type messageCopyRequest struct {
|
|
ctx context.Context
|
|
serial uint64
|
|
text string
|
|
}
|
|
|
|
type messageCopyResult struct {
|
|
messageCopyRequest
|
|
err error
|
|
}
|
|
|
|
// 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.
|
|
type messagingController struct {
|
|
history messaging.History
|
|
speech speechEnqueuer
|
|
braille interface {
|
|
Flash(string, config.BrailleOptions) bool
|
|
}
|
|
live *liveSettings
|
|
gate *focusGate
|
|
ctx context.Context
|
|
cancelWorker context.CancelFunc
|
|
cancelCopy context.CancelFunc
|
|
serial uint64
|
|
requests chan messageCopyRequest
|
|
results chan messageCopyResult
|
|
done chan struct{}
|
|
}
|
|
|
|
func newMessagingController(ctx context.Context, speech speechEnqueuer, gate *focusGate, live *liveSettings, write func(context.Context, string) error) *messagingController {
|
|
ctx, cancelWorker := context.WithCancel(ctx)
|
|
controller := &messagingController{ctx: ctx, cancelWorker: cancelWorker, speech: speech, gate: gate, live: live,
|
|
requests: make(chan messageCopyRequest, 1), results: make(chan messageCopyResult, 1), done: make(chan struct{})}
|
|
go func() {
|
|
defer close(controller.done)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case request := <-controller.requests:
|
|
if request.ctx.Err() != nil || gate.Locked() {
|
|
continue
|
|
}
|
|
err := write(request.ctx, request.text)
|
|
if errors.Is(request.ctx.Err(), context.Canceled) {
|
|
continue
|
|
}
|
|
select {
|
|
case controller.results <- messageCopyResult{request, err}:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return controller
|
|
}
|
|
|
|
func (controller *messagingController) handle(event atspi.Event) bool {
|
|
if controller == nil {
|
|
return false
|
|
}
|
|
if event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.DefunctStateEvent && event.Detail1 != 0 {
|
|
id := event.Source.ID()
|
|
if event.Type == atspi.WindowDestroyedEvent && event.Window.Valid() {
|
|
id = event.Window
|
|
}
|
|
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" ||
|
|
message.Provider != event.Source.BusName || message.Conversation != event.Source.Path ||
|
|
!event.Source.ID().Valid() || event.Window != atspi.NewObjectID(message.Provider, message.Window) {
|
|
return false
|
|
}
|
|
if !controller.gate.Locked() && controller.history.Add(*message) {
|
|
stored, _ := controller.history.Previous(message.Provider, 0)
|
|
controller.present(stored.Text, true)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (controller *messagingController) present(text string, background bool) {
|
|
if controller.gate.Locked() {
|
|
return
|
|
}
|
|
if controller.braille != nil {
|
|
options := config.DefaultSettings().Braille
|
|
if controller.live != nil {
|
|
options = controller.live.Load().Braille
|
|
}
|
|
controller.braille.Flash(text, options)
|
|
}
|
|
// The controller owns the flash; avoid a duplicate system-message flash.
|
|
controller.speech.Enqueue(speechRequest{Text: text, Role: "notification", Cause: "chat-message", BypassFocusGate: background})
|
|
}
|
|
|
|
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" {
|
|
return
|
|
}
|
|
message, exists := controller.history.Previous(current.BusName, index)
|
|
if !exists {
|
|
return
|
|
} // Legacy leaves empty history slots silent.
|
|
if !copyMessage {
|
|
controller.present(message.Text, false)
|
|
return
|
|
}
|
|
controller.cancelCopyRequest()
|
|
copyCtx, cancel := context.WithTimeout(controller.ctx, clipboardReadTimeout)
|
|
controller.cancelCopy = cancel
|
|
request := messageCopyRequest{ctx: copyCtx, serial: controller.serial, text: message.Text}
|
|
// Only the event loop produces requests. Replace an obsolete queued copy
|
|
// rather than dropping the newest command while the worker cancels a write.
|
|
select {
|
|
case <-controller.requests:
|
|
default:
|
|
}
|
|
select {
|
|
case controller.requests <- request:
|
|
default:
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
func (controller *messagingController) copied(result messageCopyResult) {
|
|
if result.serial != controller.serial || errors.Is(result.ctx.Err(), context.Canceled) || controller.gate.Locked() || controller.gate.Suspended() {
|
|
return
|
|
}
|
|
text := fmt.Sprintf("Copied %s to clipboard.", result.text)
|
|
if result.err != nil {
|
|
text = "Error accessing clipboard"
|
|
}
|
|
controller.present(text, false)
|
|
controller.cancelCopyRequest()
|
|
}
|
|
|
|
func (controller *messagingController) cancelCopyRequest() {
|
|
controller.serial++
|
|
if controller.cancelCopy != nil {
|
|
controller.cancelCopy()
|
|
controller.cancelCopy = nil
|
|
}
|
|
}
|
|
|
|
func (controller *messagingController) reset() {
|
|
controller.cancelCopyRequest()
|
|
controller.history.Reset()
|
|
}
|
|
|
|
func (controller *messagingController) stop() {
|
|
controller.reset()
|
|
controller.cancelWorker()
|
|
<-controller.done
|
|
}
|