Files
cthulhu/internal/app/clipboard_watch.go

180 lines
4.7 KiB
Go

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