243 lines
7.6 KiB
Go
243 lines
7.6 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"image"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/ocr"
|
|
)
|
|
|
|
type ocrActivateRequest struct {
|
|
job ocrJob
|
|
word ocr.Word
|
|
capture ocr.Capture
|
|
}
|
|
|
|
type ocrDialogConfirmer struct{ executable string }
|
|
|
|
func (helper ocrDialogConfirmer) Confirm(ctx context.Context, word string) (bool, error) {
|
|
if len(word) == 0 || len(word) > 4096 || strings.ContainsRune(word, 0) {
|
|
return false, errors.New("invalid OCR word")
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
command := exec.CommandContext(ctx, helper.executable, "--ocr-confirm")
|
|
command.Stdin = strings.NewReader(word)
|
|
var output bytes.Buffer
|
|
command.Stdout = &output
|
|
if err := command.Run(); err != nil {
|
|
return false, err
|
|
}
|
|
if output.String() != "yes\n" && output.String() != "cancel\n" {
|
|
return false, errors.New("invalid OCR confirmation")
|
|
}
|
|
return output.String() == "yes\n", nil
|
|
}
|
|
|
|
func (session *ocrSession) activate_locked() {
|
|
if session.cursor == nil || session.refreshing || session.refreshFailed || session.activating || session.clicker == nil || session.activateRequests == nil ||
|
|
!session.current_locked(session.job) || session.job.kind == ocr.ClipboardSource {
|
|
session.status_locked("OCR activation unavailable; refresh the screen result")
|
|
return
|
|
}
|
|
word, ok := session.cursor.CurrentWord()
|
|
if !ok {
|
|
session.status_locked("Move to a recognized word to activate it")
|
|
return
|
|
}
|
|
if _, ok = session.pixels.Transform.ScreenRectangle(word.Bounds); !ok {
|
|
session.status_locked("OCR word has no screen position")
|
|
return
|
|
}
|
|
if session.autoTimer != nil {
|
|
session.autoTimer.Stop()
|
|
session.autoTimer = nil
|
|
}
|
|
session.activating = true
|
|
session.activationInterrupted = false
|
|
job := session.job
|
|
job.source = session.source
|
|
session.activateRequests <- ocrActivateRequest{job: job, word: word, capture: session.pixels}
|
|
}
|
|
|
|
func (session *ocrSession) activation_current(request ocrActivateRequest) bool {
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
return session.activating && !session.activationInterrupted && session.current_locked(request.job)
|
|
}
|
|
|
|
func (session *ocrSession) activate(request ocrActivateRequest) {
|
|
if !session.activation_current(request) {
|
|
session.finish_activation(request, true, "OCR source changed")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(request.job.ctx, 35*time.Second)
|
|
defer cancel()
|
|
if err := session.keys.SetOCRReview(ctx, false); err != nil {
|
|
session.mu.Lock()
|
|
current := session.activating && session.generation == request.job.generation && session.cancel != nil
|
|
if current {
|
|
session.retire_locked()
|
|
}
|
|
session.mu.Unlock()
|
|
if current && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
|
select {
|
|
case session.failures <- err:
|
|
default:
|
|
}
|
|
}
|
|
if current {
|
|
session.release_input()
|
|
}
|
|
return
|
|
}
|
|
if session.settings().OCR.ConfirmClick {
|
|
if session.confirmer == nil {
|
|
session.finish_activation(request, false, "OCR confirmation unavailable")
|
|
return
|
|
}
|
|
confirmed, err := session.confirmer.Confirm(ctx, request.word.Text)
|
|
session.wait_activation_source(ctx, request)
|
|
if err != nil || !confirmed {
|
|
session.finish_activation(request, false, "OCR activation cancelled")
|
|
return
|
|
}
|
|
}
|
|
if !session.activation_current(request) {
|
|
session.finish_activation(request, true, "OCR source changed")
|
|
return
|
|
}
|
|
if err := session.validate_activation(ctx, request); err != nil {
|
|
session.finish_activation(request, true, "OCR source changed; refresh before activation")
|
|
return
|
|
}
|
|
rectangle, ok := request.capture.Transform.ScreenRectangle(request.word.Bounds)
|
|
if !ok {
|
|
session.finish_activation(request, true, "OCR word has no screen position")
|
|
return
|
|
}
|
|
point := image.Pt(rectangle.Min.X+(rectangle.Dx()-1)/2, rectangle.Min.Y+(rectangle.Dy()-1)/2)
|
|
session.mu.Lock()
|
|
if !session.activating || session.activationInterrupted || !session.current_locked(request.job) {
|
|
session.mu.Unlock()
|
|
session.finish_activation(request, true, "OCR source changed")
|
|
return
|
|
}
|
|
session.retire_locked()
|
|
session.mu.Unlock()
|
|
clickCtx, clickCancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer clickCancel()
|
|
if err := session.clicker.ClickOCR(clickCtx, request.job.foreground.Client.Window, request.capture.RootBounds, point); err != nil {
|
|
session.mu.Lock()
|
|
session.status_locked("OCR click unavailable")
|
|
session.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func (session *ocrSession) wait_activation_source(ctx context.Context, request ocrActivateRequest) {
|
|
deadline := time.NewTimer(time.Second)
|
|
defer deadline.Stop()
|
|
ticker := time.NewTicker(20 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for !session.activation_current(request) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-deadline.C:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (session *ocrSession) finish_activation(request ocrActivateRequest, stale bool, message string) {
|
|
session.mu.Lock()
|
|
if !session.activating || session.generation != request.job.generation || request.job.ctx.Err() != nil {
|
|
session.mu.Unlock()
|
|
return
|
|
}
|
|
if session.activationInterrupted || !session.current_locked(request.job) {
|
|
session.retire_locked()
|
|
session.status_locked("OCR source changed")
|
|
session.mu.Unlock()
|
|
return
|
|
}
|
|
session.mu.Unlock()
|
|
// Re-enter only in the original client. Native entry waits for complete
|
|
// dialog chord release and refuses changed foreground ownership.
|
|
ctx, cancel := context.WithTimeout(request.job.ctx, 2*time.Second)
|
|
err := session.keys.SetOCRContext(ctx, request.job.foreground.Client.Window, false)
|
|
cancel()
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if !session.activating || session.activationInterrupted || !session.current_locked(request.job) {
|
|
session.retire_locked()
|
|
return
|
|
}
|
|
if err != nil {
|
|
session.retire_locked()
|
|
session.status_locked("OCR review unavailable")
|
|
return
|
|
}
|
|
session.activating = false
|
|
if stale {
|
|
session.refreshFailed = true
|
|
} else {
|
|
session.schedule_refresh_locked()
|
|
}
|
|
session.status_locked(message)
|
|
}
|
|
|
|
func (session *ocrSession) validate_activation(ctx context.Context, request ocrActivateRequest) error {
|
|
if request.capture.Pixels == nil {
|
|
return ocr.ErrSourceChanged
|
|
}
|
|
if request.job.kind == ocr.ObjectSource {
|
|
var zone *atspi.Rectangle
|
|
if request.job.source.HasZone {
|
|
zone = &request.job.source.Zone
|
|
}
|
|
geometry, err := session.geometry.CaptureOCRGeometry(ctx, request.job.source.Object, zone)
|
|
if err != nil || geometry.Foreground != request.job.foreground || geometry.ScreenBounds != request.capture.Request.Bounds {
|
|
return ocr.ErrSourceChanged
|
|
}
|
|
}
|
|
fresh, err := session.capture.Capture(ctx, request.capture.Request)
|
|
if err != nil || fresh.SourceWindow != request.capture.SourceWindow || fresh.RootBounds != request.capture.RootBounds ||
|
|
fresh.SourceBounds != request.capture.SourceBounds || fresh.ScreenBounds != request.capture.ScreenBounds ||
|
|
!equalOCRPixels(ctx, fresh.Pixels, request.capture.Pixels) {
|
|
return ocr.ErrSourceChanged
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func equalOCRPixels(ctx context.Context, first, second image.Image) bool {
|
|
if first == nil || second == nil || first.Bounds() != second.Bounds() {
|
|
return false
|
|
}
|
|
bounds := first.Bounds()
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
if y%32 == 0 && ctx.Err() != nil {
|
|
return false
|
|
}
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
r1, g1, b1, a1 := first.At(x, y).RGBA()
|
|
r2, g2, b2, a2 := second.At(x, y).RGBA()
|
|
if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return ctx.Err() == nil
|
|
}
|