Files

389 lines
12 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"
"io"
"os/exec"
"strings"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/review"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type internalUIKeys interface {
InternalUISession() uint64
BeginInternalUI(context.Context) (uint64, error)
EndInternalUI(context.Context, uint64) error
}
type accessibleActionSource interface {
AccessibleActions(context.Context, atspi.ObjectID) (atspi.ActionSet, error)
PerformAccessibleAction(context.Context, atspi.ActionTarget, string) error
}
type internalUIResult struct {
Activated string
Values map[string]interaction.Value
Cancelled bool
Err error
}
type internalUINotice struct {
ctx context.Context
value interaction.Presentation
}
type internalUIRequest struct {
ctx context.Context
session *interaction.Session
result chan internalUIResult
ready chan error
}
// internalUI owns one modal session on its worker. The bounded event channel
// keeps AT-SPI/clipboard latency off the native input and command consumers.
// Core clients can Open a form and receive its submitted values/cancellation.
type internalUI struct {
keys internalUIKeys
actions accessibleActionSource
interest interface {
ObjectOfInterest(context.Context) (review.Interest, error)
}
speech speechEnqueuer
braille *brailleWorker
gate *focusGate
notices chan internalUINotice
events chan input.CommandEvent
requests chan internalUIRequest
done chan struct{}
}
func (ui *internalUI) Open(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
session, err := interaction.NewSession(form)
if err != nil {
return nil, err
}
request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1)}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ui.done:
return nil, errors.New("internal UI stopped")
default:
}
select {
case ui.requests <- request:
default:
return nil, errors.New("internal UI busy")
}
select {
case err := <-request.ready:
if err != nil {
return nil, err
}
return request.result, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-ui.done:
return nil, errors.New("internal UI stopped")
}
}
func (ui *internalUI) Notice(ctx context.Context, value tentaclewire.Presentation) {
if value.Speech == "" && value.Braille == "" {
return
}
select {
case ui.notices <- internalUINotice{ctx, interaction.Presentation{Speech: value.Speech, Braille: value.Braille, Cursor: -1}}:
default:
}
}
func (ui *internalUI) Handle(event input.CommandEvent) bool {
if ui == nil || event.Command != input.ShowActions && event.Command != input.InternalUIKey && event.Command != input.InternalUIDismiss {
return false
}
select {
case ui.events <- event:
default:
return false
}
return true
}
func (ui *internalUI) present(output interaction.Presentation) {
if ui.gate.Locked() || ui.gate.Suspended() {
return
}
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
speech.InterruptInternalUI()
} else {
ui.speech.Interrupt()
}
if output.Speech != "" {
ui.speech.Enqueue(speechRequest{Text: output.Speech, Role: "system", Cause: "internal-ui"})
}
if ui.braille != nil && output.Braille != "" {
ui.braille.SetInternalUIFrame(&braille.Frame{Text: output.Braille, Cursor: output.Cursor})
}
}
func (ui *internalUI) run(ctx context.Context) {
defer close(ui.done)
var session *interaction.Session
var sessionID uint64
var target atspi.ActionTarget
var result chan internalUIResult
var requestDone <-chan struct{}
finish := func(reply internalUIResult) error {
var closeErr error
if sessionID != 0 {
closeCtx, cancel := context.WithTimeout(ctx, time.Second)
closeErr = ui.keys.EndInternalUI(closeCtx, sessionID)
cancel()
}
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
speech.InterruptInternalUI()
} else {
ui.speech.Interrupt()
}
if ui.braille != nil {
ui.braille.SetInternalUIFrame(nil)
}
if result != nil {
if reply.Err == nil {
reply.Err = closeErr
}
result <- reply
close(result)
}
session, sessionID, result, requestDone = nil, 0, nil, nil
target = atspi.ActionTarget{}
return closeErr
}
defer func() { finish(internalUIResult{Cancelled: true}) }()
// Native capture can be revoked by lock, focus change or handoff without a
// command being delivered (secure input deliberately drains command queues).
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-requestDone:
finish(internalUIResult{Cancelled: true})
case <-ticker.C:
if sessionID != 0 && ui.keys.InternalUISession() != sessionID {
finish(internalUIResult{Cancelled: true})
}
case notice := <-ui.notices:
if sessionID != 0 && requestDone == notice.ctx.Done() && ui.keys.InternalUISession() == sessionID {
ui.present(notice.value)
}
case request := <-ui.requests:
if sessionID != 0 {
request.ready <- errors.New("internal UI busy")
close(request.result)
continue
}
var err error
sessionID, err = ui.keys.BeginInternalUI(request.ctx)
if err != nil {
request.ready <- err
close(request.result)
continue
}
session, result, requestDone = request.session, request.result, request.ctx.Done()
ui.present(session.Opening())
request.ready <- nil
case event := <-ui.events:
if event.Command == input.ShowActions {
if sessionID != 0 {
finish(internalUIResult{Cancelled: true})
}
sessionID = event.UISession
if sessionID == 0 || ui.actions == nil || ui.interest == nil {
finish(internalUIResult{Err: errors.New("actions unavailable")})
ui.speech.Enqueue(speechRequest{Text: "Actions unavailable", Role: "system", Cause: "internal-ui"})
if ui.braille != nil {
ui.braille.EnqueueMessage("Actions unavailable")
}
continue
}
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
interest, err := ui.interest.ObjectOfInterest(lookupCtx)
var actions atspi.ActionSet
if err == nil {
actions, err = ui.actions.AccessibleActions(lookupCtx, interest.Object)
}
cancel()
if ui.keys.InternalUISession() != sessionID {
finish(internalUIResult{Cancelled: true})
continue
}
if err != nil || len(actions.Actions) == 0 {
finish(internalUIResult{Err: err})
message := "No actions found"
if err != nil {
message = "Actions unavailable"
}
ui.speech.Interrupt()
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "internal-ui"})
if ui.braille != nil {
ui.braille.EnqueueMessage(message)
}
continue
}
items := make([]interaction.Item, 0, len(actions.Actions))
for _, action := range actions.Actions {
items = append(items, interaction.Item{ID: action.Name, Label: action.Label})
}
session, err = interaction.NewSession(interaction.Form{Title: "Actions", Elements: []interaction.Element{{ID: "actions", Label: "Actions", Kind: interaction.List, Items: items}}})
if err != nil {
finish(internalUIResult{Err: err})
continue
}
target = actions.Target
ui.present(session.Opening())
continue
}
if session == nil || event.UISession != sessionID {
continue
}
if event.Command == input.InternalUIDismiss {
finish(internalUIResult{Cancelled: true})
continue
}
key := internal_ui_event(event)
effect := session.Handle(key)
if effect.Cancelled {
finish(internalUIResult{Cancelled: true})
continue
}
if ui.keys.InternalUISession() != sessionID {
finish(internalUIResult{Cancelled: true})
continue
}
if effect.Paste {
clipboardCtx, cancel := context.WithTimeout(ctx, time.Second)
text, err := read_internal_clipboard(clipboardCtx)
cancel()
if ui.keys.InternalUISession() != sessionID {
finish(internalUIResult{Cancelled: true})
continue
}
if err == nil {
effect = session.Insert(text)
} else {
effect.Presentation = interaction.Presentation{Speech: "Clipboard text unavailable", Braille: "Clipboard text unavailable", Cursor: -1}
}
}
if effect.Copy != nil {
copyCtx, cancel := context.WithTimeout(ctx, time.Second)
err := (xclipOCRCopier{}).Copy(copyCtx, *effect.Copy)
cancel()
if ui.keys.InternalUISession() != sessionID {
finish(internalUIResult{Cancelled: true})
continue
}
message := "Copied"
if err != nil {
message = "Copy failed"
} else if strings.EqualFold(key.Text, "x") {
effect = session.Handle(interaction.Event{Key: interaction.Delete})
message = "Cut"
}
effect.Presentation = session.Presentation()
effect.Speech = message
}
if effect.Activated != "" {
values := session.Values()
actionTarget := target
closeErr := finish(internalUIResult{Activated: effect.Activated, Values: values})
if actionTarget.Object.Valid() && closeErr == nil {
actionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
err := ui.actions.PerformAccessibleAction(actionCtx, actionTarget, effect.Activated)
cancel()
if err != nil && !ui.gate.Locked() && !ui.gate.Suspended() {
ui.speech.Enqueue(speechRequest{Text: "Action unavailable", Role: "system", Cause: "internal-ui"})
if ui.braille != nil {
ui.braille.EnqueueMessage("Action unavailable")
}
}
} else if actionTarget.Object.Valid() && !ui.gate.Locked() && !ui.gate.Suspended() {
ui.speech.Enqueue(speechRequest{Text: "Action unavailable", Role: "system", Cause: "internal-ui"})
if ui.braille != nil {
ui.braille.EnqueueMessage("Action unavailable")
}
}
continue
}
if effect.Speech != "" || effect.Braille != "" {
ui.present(effect.Presentation)
}
}
}
}
func internal_ui_event(event input.CommandEvent) interaction.Event {
key := interaction.Text
switch event.Chord.Symbol {
case 0xff52, 0xff97:
key = interaction.Up
case 0xff54, 0xff99:
key = interaction.Down
case 0xff51, 0xff96:
key = interaction.Left
case 0xff53, 0xff98:
key = interaction.Right
case 0xff50, 0xff95:
key = interaction.Home
case 0xff57, 0xff9c:
key = interaction.End
case 0xff55, 0xff9a:
key = interaction.PageUp
case 0xff56, 0xff9b:
key = interaction.PageDown
case 0xff09, 0xfe20:
key = interaction.Tab
case 0xff0d, 0xff8d:
key = interaction.Enter
case 0xff1b:
key = interaction.Escape
case 0xff08:
key = interaction.Backspace
case 0xffff, 0xff9f:
key = interaction.Delete
}
text := event.Text
if event.Chord.CthulhuModifier {
text = ""
}
return interaction.Event{Key: key, Text: text, Shift: event.Chord.Modifiers&1 != 0, Control: event.Chord.Modifiers&4 != 0, Alt: event.Chord.Modifiers&(8|64) != 0}
}
func read_internal_clipboard(ctx context.Context) (string, error) {
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out")
output, err := command.StdoutPipe()
if err != nil {
return "", err
}
if err = command.Start(); err != nil {
return "", err
}
data, readErr := io.ReadAll(io.LimitReader(output, 32769))
if len(data) > 32768 || readErr != nil {
_ = command.Process.Kill()
_ = command.Wait()
return "", errors.New("clipboard text too large")
}
if err = command.Wait(); err != nil {
return "", err
}
return string(data), nil
}