// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package clipboard import ( "context" "errors" "fmt" "io" "os" "path/filepath" "sync" "syscall" "time" "git.stormux.org/storm/fenrir/internal/command" ) type Request struct { Command command.ID // empty means a background X poll Revision uint64 Text, Path, Display string Terminal int Background bool Feedback uint64 } type Result struct { Request Request Text string Err error } type queuedRequest struct { request Request epoch uint64 } type Worker struct { jobs chan queuedRequest results chan Result ctx context.Context cancel context.CancelFunc done chan struct{} targets func(string) ([]Target, error) paste func(context.Context, string, int) error mu sync.Mutex jobCancel context.CancelFunc jobCommand command.ID epoch uint64 } func New_worker(ctx context.Context, paste func(context.Context, string, int) error) *Worker { ctx, cancel := context.WithCancel(ctx) w := &Worker{jobs: make(chan queuedRequest, 1), results: make(chan Result, 8), ctx: ctx, cancel: cancel, done: make(chan struct{}), targets: system_targets, paste: paste} go w.run() return w } func (w *Worker) Results() <-chan Result { return w.results } func (w *Worker) Submit(request Request) bool { w.mu.Lock() defer w.mu.Unlock() select { case w.jobs <- queuedRequest{request, w.epoch}: return true default: return false } } func (w *Worker) Cancel() { w.mu.Lock() w.epoch++ // Revisions reject stale X reads/writes without killing a selection owner. // Only a console paste must stop immediately when its target changes. if w.jobCancel != nil && w.jobCommand == command.PasteClipboard { w.jobCancel() } w.mu.Unlock() } func (w *Worker) Close() { w.cancel(); <-w.done } func (w *Worker) run() { defer close(w.done) var xTransport *xClipboard selectedDisplay := "" defer func() { if xTransport != nil { xTransport.close() } }() for { select { case <-w.ctx.Done(): return case job := <-w.jobs: request := job.request ctx, cancel := context.WithTimeout(w.ctx, 2*time.Second) w.mu.Lock() w.jobCancel = cancel w.jobCommand = request.Command if request.Command == command.PasteClipboard && job.epoch != w.epoch { cancel() } w.mu.Unlock() result := Result{Request: request} switch request.Command { case command.ImportClipboard: result.Text, result.Err = read_file(request.Path) case command.ExportClipboard: result.Err = write_file(request.Path, request.Text) case command.PasteClipboard: if ctx.Err() != nil { result.Err = ctx.Err() } else if w.paste == nil { result.Err = fmt.Errorf("clipboard paste unavailable") } else { result.Err = w.paste(ctx, request.Text, request.Terminal) } default: if xTransport != nil && selectedDisplay != request.Display { xTransport.close() xTransport = nil } if xTransport == nil { targets, err := w.targets(request.Display) if err != nil { result.Err = err break } xTransport = &xClipboard{target: targets[0]} selectedDisplay = request.Display } if request.Command == command.ExportXClipboard { result.Err = xTransport.write(ctx, request.Text) } else { result.Text, result.Err = xTransport.read(ctx) } if result.Err != nil && xTransport != nil { xTransport.close() xTransport = nil } } w.mu.Lock() w.jobCancel = nil w.mu.Unlock() cancel() select { case w.results <- result: case <-w.ctx.Done(): return } } } } func read_file(path string) (string, error) { file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return "", err } defer file.Close() info, err := file.Stat() if err != nil { return "", err } if !info.Mode().IsRegular() { return "", fmt.Errorf("clipboard file must be regular") } data, err := io.ReadAll(io.LimitReader(file, MaxBytes+1)) if err != nil { return "", err } return string(data), Validate_text(string(data)) } func write_file(path, text string) error { if err := Validate_text(text); err != nil { return err } file, err := os.CreateTemp(filepath.Dir(path), ".fenrir-clipboard-*") if err != nil { return err } defer os.Remove(file.Name()) _, writeErr := file.WriteString(text) err = errors.Join(writeErr, file.Sync(), file.Close()) if err != nil { return err } return os.Rename(file.Name(), path) }