Files

691 lines
23 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"
"image"
"log/slog"
"os"
"sync"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/ocr"
"git.stormux.org/storm/cthulhu/internal/review"
)
type ocrKeyOwner interface {
SetOCRReview(context.Context, bool) error
SetOCRContext(context.Context, uint64, bool) error
}
type ocrClicker interface {
ClickOCR(context.Context, uint64, image.Rectangle, image.Point) error
}
type ocrConfirmer interface {
Confirm(context.Context, string) (bool, error)
}
type ocrGeometrySource interface {
CaptureOCRGeometry(context.Context, atspi.ObjectID, *atspi.Rectangle) (atspi.OCRGeometry, error)
}
type ocrInterestSource interface {
ObjectOfInterest(context.Context) (review.Interest, error)
}
type ocrCaptureSource interface {
Capture(context.Context, ocr.CaptureRequest) (ocr.Capture, error)
}
type ocrRecognizer interface {
Recognize(context.Context, image.Image, ocr.Options) (ocr.Result, error)
}
type ocrReadAllStarter interface {
BeginReadAll(context.Context, uint32) context.Context
}
type ocrJob struct {
window atspi.ObjectID
ctx context.Context
generation uint64
focus atspi.ObjectID
foreground atspi.ForegroundState
kind ocr.SourceKind
options ocr.Options
source review.Interest
snapshot ocr.Capture
refresh bool
automatic bool
autoReadAll bool
copyFirstResult bool
timestamp uint32
}
// ocrSession owns source/job/result generations and the virtual cursor under mu.
// A single worker serializes capture, recognition and acknowledged input changes.
// Retirement cancels synchronously; the worker joins children and releases native
// input before admitting the next job. No native work runs on an input/event loop.
type ocrSession struct {
window atspi.ObjectID
mu sync.Mutex
logger *slog.Logger
keys ocrKeyOwner
geometry ocrGeometrySource
interest ocrInterestSource
foreground foregroundMonitor
capture ocrCaptureSource
engine ocrRecognizer
copier ocrTextCopier
copyRequests chan ocrCopyRequest
activateRequests chan ocrActivateRequest
clicker ocrClicker
confirmer ocrConfirmer
speech speechEnqueuer
readAll ocrReadAllStarter
readAllContext context.Context // Current speech request; cleared on retirement/refresh.
readAllDone chan error
display brailleFrameEnqueuer
gate *focusGate
live *liveSettings
ctx context.Context
stop context.CancelFunc
done chan struct{}
wake chan struct{}
failures chan error
focus atspi.ObjectID
focusForeground uint64
job ocrJob
cancel context.CancelFunc
generation uint64
cursor *ocr.Cursor
selectionAnchor int // -1 means no active virtual selection.
source review.Interest
pixels ocr.Capture
held map[string]bool
cutoff uint32
refreshing bool
refreshFailed bool
autoTimer *time.Timer
activating bool
activationInterrupted bool
}
func new_ocr_session(logger *slog.Logger, commands commandListener, adapter Adapter, interest *review.Worker, speech speechEnqueuer, readAll ocrReadAllStarter, display brailleFrameEnqueuer, gate *focusGate, live *liveSettings) *ocrSession {
executable, _ := os.Executable()
session := &ocrSession{logger: logger, interest: interest, speech: gatedSpeech{speech: speech, gate: gate}, readAll: readAll, display: display, gate: gate, live: live,
capture: ocr.NewCapturer(executable), engine: ocr.NewEngine(), copier: xclipOCRCopier{}, copyRequests: make(chan ocrCopyRequest, 1),
activateRequests: make(chan ocrActivateRequest, 1), confirmer: ocrDialogConfirmer{executable: executable},
wake: make(chan struct{}, 1), failures: make(chan error, 1), held: make(map[string]bool), selectionAnchor: -1}
session.keys, _ = commands.(ocrKeyOwner)
session.clicker, _ = commands.(ocrClicker)
session.geometry, _ = adapter.(ocrGeometrySource)
session.foreground, _ = adapter.(foregroundMonitor)
return session
}
func (session *ocrSession) Start(ctx context.Context) error {
session.mu.Lock()
session.ctx, session.stop = context.WithCancel(ctx)
session.done = make(chan struct{})
session.mu.Unlock()
go session.run()
return nil
}
func (session *ocrSession) Stop(ctx context.Context) error {
session.Retire()
session.mu.Lock()
stop, done := session.stop, session.done
session.mu.Unlock()
if stop == nil {
return nil
}
stop()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (session *ocrSession) signal() {
select {
case session.wake <- struct{}{}:
default:
}
}
func (session *ocrSession) retire_locked() {
if session.autoTimer != nil {
session.autoTimer.Stop()
session.autoTimer = nil
}
if session.cancel == nil {
return
}
session.logger.Debug("OCR session retired", "generation", session.generation)
session.cancel()
session.cancel = nil
session.generation++
session.cursor = nil
session.selectionAnchor = -1
session.source = review.Interest{}
session.pixels = ocr.Capture{}
session.refreshing = false
session.refreshFailed = false
session.readAllContext, session.readAllDone = nil, nil
session.activating = false
session.activationInterrupted = false
if session.display != nil {
session.display.EnqueueFrame(braille.Frame{Cursor: -1})
}
session.signal()
}
func (session *ocrSession) Retire() {
if session == nil {
return
}
session.mu.Lock()
defer session.mu.Unlock()
session.retire_locked()
}
func (session *ocrSession) Secure() {
if session == nil {
return
}
session.mu.Lock()
defer session.mu.Unlock()
session.retire_locked()
session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{}
session.focusForeground = 0
clear(session.held)
}
func ocr_source_command(command input.Command) (ocr.SourceKind, bool) {
switch command {
case input.OCRObject:
return ocr.ObjectSource, true
case input.OCRWindow:
return ocr.WindowSource, true
case input.OCRDesktop:
return ocr.DesktopSource, true
case input.OCRClipboard:
return ocr.ClipboardSource, true
}
return "", false
}
func (session *ocrSession) request_locked(kind ocr.SourceKind, timestamp uint32) {
session.retire_locked()
session.cutoff = timestamp
if session.ctx == nil || session.ctx.Err() != nil {
return
}
if session.gate.Locked() || session.gate.Suspended() {
return
}
if session.keys == nil || session.foreground == nil {
session.status_locked("OCR source unavailable")
return
}
foreground := session.foreground.Foreground()
if !foreground.Client.Known || foreground.Client.Window == 0 {
session.status_locked("OCR source unavailable")
return
}
// An inaccessible client may leave the previous application's AT-SPI focus
// behind. It must not supply object geometry or invalidate this client's job.
if session.focusForeground != foreground.Client.Window {
session.focus, session.window = atspi.ObjectID{}, atspi.ObjectID{}
}
if kind == ocr.ObjectSource && (session.geometry == nil || session.interest == nil || !session.focus.Valid()) {
session.status_locked("OCR source unavailable")
return
}
session.generation++
ctx, cancel := context.WithCancel(session.ctx)
session.cancel = cancel
settings := session.settings().OCR
session.job = ocrJob{ctx: ctx, generation: session.generation, window: session.window, focus: session.focus, foreground: foreground, kind: kind,
autoReadAll: settings.AutoReadAll, copyFirstResult: settings.CopyFirstResult, timestamp: timestamp,
options: ocr.Options{Language: settings.Language, Layout: ocr.Layout(settings.Layout), Scale: settings.Scale, Grayscale: settings.Grayscale, Invert: settings.Invert, BlackWhite: settings.BlackWhite, Threshold: settings.Threshold}}
session.signal()
}
func (session *ocrSession) refresh_locked(automatic bool) {
if session.cancel == nil || session.cursor == nil || session.refreshing || !session.current_locked(session.job) {
return
}
if automatic && (session.job.kind == ocr.ClipboardSource || !session.settings().OCR.AutoRefresh) {
return
}
// Keep a periodic capture from truncating paced reading. Completion and
// interruption are observed without waiting on the speech worker.
if automatic && session.readAllContext != nil && session.readAllContext.Err() == nil {
select {
case <-session.readAllDone:
default:
session.schedule_refresh_locked()
return
}
}
session.readAllContext, session.readAllDone = nil, nil
if session.autoTimer != nil {
session.autoTimer.Stop()
session.autoTimer = nil
}
session.cancel()
session.generation++
ctx, cancel := context.WithCancel(session.ctx)
session.cancel = cancel
old := session.job
settings := session.settings().OCR
session.job = ocrJob{ctx: ctx, generation: session.generation, window: old.window, focus: old.focus,
foreground: old.foreground, kind: old.kind, source: session.source, snapshot: session.pixels,
refresh: true, automatic: automatic,
options: ocr.Options{Language: settings.Language, Layout: ocr.Layout(settings.Layout), Scale: settings.Scale, Grayscale: settings.Grayscale, Invert: settings.Invert, BlackWhite: settings.BlackWhite, Threshold: settings.Threshold}}
session.refreshing = true
session.refreshFailed = true
session.signal()
}
func (session *ocrSession) schedule_refresh_locked() {
if session.autoTimer != nil {
session.autoTimer.Stop()
session.autoTimer = nil
}
settings := session.settings().OCR
if settings.AutoRefresh && session.cursor != nil && session.job.kind != ocr.ClipboardSource && !session.refreshFailed {
session.autoTimer = time.NewTimer(time.Duration(settings.RefreshInterval) * time.Millisecond)
}
}
func (session *ocrSession) timer_channel() <-chan time.Time {
session.mu.Lock()
defer session.mu.Unlock()
if session.autoTimer != nil {
return session.autoTimer.C
}
return nil
}
func (session *ocrSession) run() {
defer close(session.done)
for {
select {
case <-session.ctx.Done():
session.Retire()
session.release_input()
return
case <-session.wake:
session.mu.Lock()
job, active, ready := session.job, session.cancel != nil, session.cursor != nil && !session.refreshing
session.mu.Unlock()
if !active {
session.release_input()
continue
}
if !ready {
session.recognize(job)
}
case request := <-session.copyRequests:
session.copy(request)
case request := <-session.activateRequests:
session.activate(request)
case <-session.timer_channel():
session.mu.Lock()
session.autoTimer = nil
session.refresh_locked(true)
session.mu.Unlock()
}
}
}
func (session *ocrSession) release_input() {
if session.keys == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := session.keys.SetOCRReview(ctx, false); err != nil && session.ctx.Err() == nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
// A physical key may still be held. Retry after the chord releases;
// waiting for that release is not a failed native grab transaction.
session.signal()
return
}
// Failed release must stop the reader rather than leave swallowed keys.
select {
case session.failures <- err:
default:
}
}
}
func (session *ocrSession) current_locked(job ocrJob) bool {
return session.cancel != nil && session.generation == job.generation && job.ctx.Err() == nil &&
!session.gate.Locked() && !session.gate.Suspended() && session.focus == job.focus &&
session.foreground.Foreground() == job.foreground
}
func (session *ocrSession) recognize(job ocrJob) {
var err error
if !job.refresh {
err = session.keys.SetOCRContext(job.ctx, job.foreground.Client.Window, true)
}
interest := job.source
var capture ocr.Capture
var result ocr.Result
// Only object capture needs AT-SPI geometry. Window, desktop and clipboard
// jobs belong to the X11 foreground even when it has no accessible objects.
request := ocr.CaptureRequest{Kind: job.kind, Foreground: job.foreground.Client.Window, Generation: job.generation}
if err == nil && job.kind == ocr.ObjectSource {
if !job.refresh {
interest, err = session.interest.ObjectOfInterest(job.ctx)
}
if err == nil {
var zone *atspi.Rectangle
if interest.HasZone {
zone = &interest.Zone
}
var geometry atspi.OCRGeometry
geometry, err = session.geometry.CaptureOCRGeometry(job.ctx, interest.Object, zone)
if err == nil && geometry.Foreground != job.foreground {
err = ocr.ErrSourceChanged
}
request.Bounds = geometry.ScreenBounds
if err == nil {
session.mu.Lock()
if session.current_locked(job) {
session.source = interest
} else {
err = context.Canceled
}
session.mu.Unlock()
}
}
}
if err == nil {
if job.refresh && job.kind == ocr.ClipboardSource {
capture = job.snapshot
} else {
capture, err = session.capture.Capture(job.ctx, request)
}
}
if err == nil {
result, err = session.engine.Recognize(job.ctx, capture.Pixels, job.options)
if err == nil && result.Width > 0 && result.Height > 0 {
capture.Transform.ImageContent = image.Rect(0, 0, result.Width, result.Height)
}
}
if err == nil {
var fresh atspi.OCRGeometry
if err == nil && job.kind == ocr.ObjectSource {
var zone *atspi.Rectangle
if interest.HasZone {
zone = &interest.Zone
}
fresh, err = session.geometry.CaptureOCRGeometry(job.ctx, interest.Object, zone)
if err == nil && (fresh.Foreground != job.foreground || fresh.ScreenBounds != request.Bounds) {
err = ocr.ErrSourceChanged
}
}
if err == nil && result.Text != "" && !job.refresh {
err = session.keys.SetOCRContext(job.ctx, job.foreground.Client.Window, false)
}
}
session.mu.Lock()
current := session.current_locked(job)
if current {
if job.refresh {
session.finish_refresh_locked(job, interest, capture, result, err)
} else if err != nil || result.Text == "" {
session.retire_locked()
message := "OCR recognition failed"
switch {
case errors.Is(err, ocr.ErrUnavailable):
message = "OCR not available"
case err == nil:
message = "No text recognized"
case errors.Is(err, ocr.ErrSourceChanged):
message = "OCR source changed"
case errors.Is(err, atspi.ErrOCRGeometry), errors.Is(err, atspi.ErrObjectNotFound), errors.Is(err, review.ErrNoCurrent), errors.Is(err, ocr.ErrSource), errors.Is(err, ocr.ErrCapture):
message = "OCR source unavailable"
}
session.status_locked(message)
} else {
session.source, session.pixels = interest, capture
session.cursor = ocr.NewCursor(result)
session.selectionAnchor = -1
if job.autoReadAll && session.readAll != nil {
if session.display != nil {
session.display.EnqueueFrame(session.frame_locked(session.cursor.Current(ocr.LineUnit)))
}
session.read_all_locked(job.timestamp)
} else {
session.present_locked(session.cursor.Current(ocr.LineUnit), ocr.LineUnit)
}
session.schedule_refresh_locked()
if job.copyFirstResult && session.copier != nil && session.copyRequests != nil {
select {
case session.copyRequests <- ocrCopyRequest{ctx: job.ctx, generation: job.generation, text: result.Text, automatic: true}:
default:
}
}
}
} else if session.generation == job.generation {
session.retire_locked()
}
session.mu.Unlock()
if !job.refresh && (!current || err != nil || result.Text == "") {
session.release_input()
}
}
func (session *ocrSession) status_locked(message string) {
if session.gate.Locked() || session.gate.Suspended() {
return
}
session.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "ocr-status"})
if session.display != nil {
session.display.EnqueueFrame(braille.Frame{Text: message, Cursor: -1})
}
session.logger.Debug("OCR status", "text", message)
}
func (session *ocrSession) present_locked(position ocr.Position, unit ocr.Unit) {
session.present_spelling_locked(position, unit, review.FlatSpeak)
}
func (session *ocrSession) present_spelling_locked(position ocr.Position, unit ocr.Unit, style review.FlatSpeech) {
text := presentationOCRText(position.Text, unit)
request := speechRequest{Text: text, Role: "text", Cause: "ocr-cursor", context: session.job.ctx, Character: unit == ocr.Character || unit == ocr.LineStart || unit == ocr.LineEnd}
set_review_spelling(&request, position.Text, style)
session.present_request_locked(position, unit, request)
}
func (session *ocrSession) present_request_locked(position ocr.Position, unit ocr.Unit, request speechRequest) {
session.speech.Enqueue(request)
frame := session.frame_locked(position)
if session.display != nil {
session.display.EnqueueFrame(frame)
}
from, to := session.selection_locked()
session.logger.Debug("OCR cursor presentation", "generation", session.generation, "source_path", session.source.Object.Path,
"focus_path", session.focus.Path, "unit", unit, "offset", position.Offset, "line_start", position.LineStart,
"selection_start", from, "selection_end", to,
"speech_text", request.Text, "spelling", request.Spelling, "braille_text", frame.Text, "braille_cursor", frame.Cursor)
}
func (session *ocrSession) selection_locked() (int, int) {
if session.cursor == nil || session.selectionAnchor < 0 {
return -1, -1
}
return min(session.selectionAnchor, session.cursor.Offset()), max(session.selectionAnchor, session.cursor.Offset())
}
func (session *ocrSession) frame_locked(position ocr.Position) braille.Frame {
frame := ocr_braille_frame(position)
start := position.LineStart + position.Cursor - frame.Cursor
length := utf8.RuneCountInString(frame.Text)
frame.Virtual = &braille.VirtualRoute{Generation: session.generation, Start: 0, End: length, SourceStart: start}
if from, to := session.selection_locked(); from >= 0 && from != to {
indicator := byte(session.settings().Braille.SelectionIndicator)
if indicator != 0 {
frame.Dots = make([]byte, length)
for offset := max(from, start); offset < min(to, start+length); offset++ {
frame.Dots[offset-start] = indicator
}
}
}
return frame
}
// Keep long Unicode lines inside the helper frame limit without losing the
// cursor. This is a presentation window; the document keeps the full line.
func ocr_braille_frame(position ocr.Position) braille.Frame {
if len(position.Line) <= 10000 {
return braille.Frame{Text: position.Line, Cursor: position.Cursor}
}
const windowRunes = 2000 // At most 8000 UTF-8 bytes, below the helper limit.
length := utf8.RuneCountInString(position.Line)
start := max(0, min(position.Cursor-windowRunes/2, length-windowRunes))
end := min(length, start+windowRunes)
startByte, endByte, offset := 0, len(position.Line), 0
for index := range position.Line {
if offset == start {
startByte = index
}
if offset == end {
endByte = index
break
}
offset++
}
return braille.Frame{Text: position.Line[startByte:endByte], Cursor: position.Cursor - start}
}
func presentationOCRText(text string, unit ocr.Unit) string {
if text == "" {
return "blank"
}
if unit == ocr.Character || unit == ocr.LineStart || unit == ocr.LineEnd {
switch text {
case " ":
return "space"
case "\n":
return "new line"
case "\t":
return "tab"
}
}
return text
}
// Observe runs before native event presentation. Focus/owner changes retire
// synchronously, including changes within one X11 client. OCR never invents an
// AT-SPI object; duplicate source updates cannot overwrite its virtual cursor.
func (session *ocrSession) Observe(event atspi.Event) bool {
if session == nil {
return false
}
session.mu.Lock()
defer session.mu.Unlock()
if session.activating {
if (isFocusGainEvent(event) && event.Source.ID().BusName == session.job.focus.BusName && event.Source.ID() != session.job.focus) ||
(event.Type == atspi.DefunctStateEvent && (event.Source.ID() == session.job.focus || event.Source.ID() == session.source.Object)) {
session.activationInterrupted = true
}
return false
}
if session.cancel != nil && session.foreground.Foreground() != session.job.foreground {
session.retire_locked()
}
if isFocusGainEvent(event) && !event.ForegroundMismatch && !eventBypassesXTermHandoff(event) &&
(session.foreground == nil || event.Foreground.Epoch == 0 || event.Foreground.Epoch == session.foreground.Foreground().Epoch) {
if event.Type == atspi.WindowActivatedEvent && session.window.Valid() && eventWindow(event) == session.window {
return session.cursor != nil
}
session.window = eventWindow(event)
if session.focus != event.Source.ID() {
session.retire_locked()
}
session.focus = event.Source.ID()
if session.foreground != nil {
session.focusForeground = session.foreground.Foreground().Client.Window
}
}
if event.Type == atspi.WindowDeactivatedEvent || event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.DefunctStateEvent {
if session.foreground != nil && transientForegroundDeactivation(event, session.foreground.Foreground()) {
return false
}
sourceLost := event.Source.ID() == session.focus || session.cancel != nil &&
(event.Source.ID() == session.source.Object || event.Source.ID() == session.job.window)
windowLost := event.Type != atspi.DefunctStateEvent && session.cancel != nil && eventWindow(event) == session.job.window
if sourceLost || windowLost {
session.retire_locked()
session.focus = atspi.ObjectID{}
}
}
return session.cursor != nil && !eventBypassesXTermHandoff(event) &&
(isFocusGainEvent(event) || event.Type == atspi.CaretEvent || isTextChangeEvent(event.Type) || event.Type == atspi.AccessibleNameEvent || event.Type == atspi.TextSelectionChangedEvent)
}
func (session *ocrSession) ForegroundChanged() {
if session == nil {
return
}
session.mu.Lock()
defer session.mu.Unlock()
if session.activating {
return
}
if session.cancel != nil && session.foreground.Foreground() != session.job.foreground {
session.retire_locked()
}
}
func (session *ocrSession) settings() config.Settings {
if session.live != nil {
return session.live.Load()
}
return config.DefaultSettings()
}
func (session *ocrSession) Reviewing() bool {
if session == nil {
return false
}
session.mu.Lock()
defer session.mu.Unlock()
return session.cancel != nil && session.cursor != nil
}
// RouteBraille accepts only a current OCR frame's generation and moves the
// virtual cursor. It never forwards a route to AT-SPI or application focus.
func (session *ocrSession) RouteBraille(ctx context.Context, generation uint64, offset int) {
if session == nil || ctx.Err() != nil {
return
}
session.mu.Lock()
defer session.mu.Unlock()
if generation != session.generation || session.cursor == nil || !session.current_locked(session.job) || ctx.Err() != nil {
return
}
position, ok := session.cursor.Set(offset)
if !ok {
return
}
session.selectionAnchor = -1
session.logger.Debug("OCR braille route", "command", "braille-route", "generation", generation,
"focus_path", session.focus.Path, "source_path", session.source.Object.Path, "offset", offset)
session.present_locked(position, ocr.Character)
}