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