// 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 }