Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/clipboard"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/session"
|
||||
)
|
||||
|
||||
type clipboardScope struct {
|
||||
settings *config.Settings
|
||||
application, requested string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func clipboard_scope(live *liveSettings, gate *focusGate) clipboardScope {
|
||||
scope := clipboardScope{settings: live.current.Load()}
|
||||
if name := live.application.Load(); name != nil {
|
||||
scope.application = *name
|
||||
}
|
||||
if name := live.requestedApplication.Load(); name != nil {
|
||||
scope.requested = *name
|
||||
}
|
||||
scope.enabled = scope.settings.Reading.AutomaticallyReadClipboardChanges && !gate.Locked() && !gate.Suspended() &&
|
||||
(scope.requested == "" || scope.requested == scope.application)
|
||||
return scope
|
||||
}
|
||||
|
||||
func run_automatic_clipboard(ctx context.Context, logger *slog.Logger, live *liveSettings, gate *focusGate, speech speechEnqueuer) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
if live == nil {
|
||||
return
|
||||
}
|
||||
// No helper or clipboard reads until an application enables the feature.
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for !clipboard_scope(live, gate).enabled {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-live.clipboardChanges:
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
ticker.Stop()
|
||||
if err := watch_clipboard(ctx, live, gate, speech); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("automatic clipboard reading unavailable", "error", err)
|
||||
}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func watch_clipboard(ctx context.Context, live *liveSettings, gate *focusGate, speech speechEnqueuer) error {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
command := exec.CommandContext(childCtx, executable, "--clipboard-watch")
|
||||
command.Env = session.GTKEnvironment()
|
||||
command.WaitDelay = time.Second
|
||||
in, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
var diagnostic preferencesStderr
|
||||
command.Stderr = &diagnostic
|
||||
if err := command.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { cancel(); in.Close(); out.Close(); command.Wait() }()
|
||||
changes := make(chan clipboard.TextChange, 128)
|
||||
failures := make(chan error, 1)
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
scanner := bufio.NewScanner(out)
|
||||
scanner.Buffer(make([]byte, 4096), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
var change clipboard.TextChange
|
||||
if err := json.Unmarshal(scanner.Bytes(), &change); err != nil {
|
||||
failures <- err
|
||||
return
|
||||
}
|
||||
select {
|
||||
case changes <- change:
|
||||
case <-childCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
err := scanner.Err()
|
||||
if err == nil {
|
||||
err = io.EOF
|
||||
}
|
||||
failures <- err
|
||||
}()
|
||||
defer func() { cancel(); out.Close(); <-readerDone }()
|
||||
return serve_clipboard_changes(ctx, live, gate, speech, json.NewEncoder(in), changes, failures)
|
||||
}
|
||||
|
||||
func serve_clipboard_changes(ctx context.Context, live *liveSettings, gate *focusGate, speech speechEnqueuer, encoder *json.Encoder, changes <-chan clipboard.TextChange, failures <-chan error) error {
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var previous clipboardScope
|
||||
var epoch uint64
|
||||
var lastText string
|
||||
update := func() error {
|
||||
scope := clipboard_scope(live, gate)
|
||||
if scope == previous {
|
||||
return nil
|
||||
}
|
||||
previous = scope
|
||||
epoch++
|
||||
lastText = ""
|
||||
return encoder.Encode(clipboard.WatchSettings{Enabled: scope.enabled, Epoch: epoch})
|
||||
}
|
||||
if err := update(); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-failures:
|
||||
return err
|
||||
case <-live.clipboardChanges:
|
||||
if err := update(); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Check the screen-lock gate without polling clipboard contents.
|
||||
if err := update(); err != nil {
|
||||
return err
|
||||
}
|
||||
case change, ok := <-changes:
|
||||
if !ok {
|
||||
return errors.New("clipboard helper closed")
|
||||
}
|
||||
if change.Ready {
|
||||
continue
|
||||
}
|
||||
if clipboard_scope(live, gate) != previous {
|
||||
if err := update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !previous.enabled || change.Epoch != epoch {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(change.Text) != "" && change.Text != lastText {
|
||||
speech.Enqueue(speechRequest{Text: change.Text, Role: "system", Cause: "clipboard", Append: true, ClipboardTimestamp: change.Timestamp})
|
||||
}
|
||||
lastText = change.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user