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