180 lines
4.7 KiB
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
|
|
}
|
|
}
|
|
}
|