// 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 ( "bytes" "context" "fmt" "os" "os/exec" "strings" "syscall" "time" ) // xClipboard delegates X11 ownership, TARGETS and incremental transfers to // xclip. Only the transfer worker touches it. There is no global initialization // or environment mutation, and no shell interprets clipboard contents. type xClipboard struct { target Target owner *exec.Cmd ownerDone chan error } type boundedOutput struct { bytes.Buffer limit int } func (b *boundedOutput) Write(data []byte) (int, error) { if len(data) > b.limit-b.Len() { return 0, fmt.Errorf("clipboard output exceeds size limit") } return b.Buffer.Write(data) } func (x *xClipboard) command(ctx context.Context, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, "xclip", args...) cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} for _, variable := range os.Environ() { name := strings.SplitN(variable, "=", 2)[0] if name != "DISPLAY" && name != "XAUTHORITY" { cmd.Env = append(cmd.Env, variable) } } cmd.Env = append(cmd.Env, "DISPLAY="+x.target.Display, "XAUTHORITY="+x.target.Authority) return cmd } func (x *xClipboard) output(ctx context.Context, target string, limit int) ([]byte, error) { cmd := x.command(ctx, "-selection", "clipboard", "-out", "-target", target) output := &boundedOutput{limit: limit} stderr := &boundedOutput{limit: 4096} cmd.Stdout, cmd.Stderr = output, stderr if err := cmd.Run(); err != nil { // xclip uses these errors for an empty selection or unsupported format. message := strings.TrimSpace(stderr.String()) if strings.HasPrefix(message, "Error: target ") && strings.HasSuffix(message, " not available") { return nil, nil } return nil, fmt.Errorf("X clipboard transfer failed: check xclip, display and X authorization") } return output.Bytes(), nil } func (x *xClipboard) read(ctx context.Context) (string, error) { targets, err := x.output(ctx, "TARGETS", 64<<10) if err != nil { return "", err } available := make(map[string]bool) for _, target := range strings.Fields(string(targets)) { available[target] = true } for _, target := range []string{"UTF8_STRING", "text/plain;charset=utf-8", "text/plain", "STRING", "TEXT"} { if !available[target] { continue } data, err := x.output(ctx, target, MaxBytes) if err != nil { return "", err } text := string(data) if target == "STRING" { // ICCCM STRING is Latin-1, not UTF-8. runes := make([]rune, len(data)) for i, value := range data { runes[i] = rune(value) } text = string(runes) } return text, Validate_text(text) } return "", nil // Images, files and unowned selections are ordinary no-data. } func (x *xClipboard) write(ctx context.Context, text string) error { if text == "" { return fmt.Errorf("clipboard text is empty") } if err := Validate_text(text); err != nil { return err } // The owner outlives this request. Its parent-death signal and Close own // cleanup; normal xclip forking would leave an unmanaged process behind. cmd := x.command(context.Background(), "-selection", "clipboard", "-in", "-quiet") cmd.Stdin = strings.NewReader(text) if err := cmd.Start(); err != nil { return fmt.Errorf("X clipboard writer unavailable: %w", err) } done := make(chan error, 1) go func() { done <- cmd.Wait(); close(done) }() accepted := false defer func() { if !accepted { _ = cmd.Process.Kill() <-done } }() for { select { case <-ctx.Done(): return ctx.Err() case <-done: return fmt.Errorf("X clipboard writer exited before acknowledgement") default: } observed, err := x.read(ctx) if err != nil { return err } if observed == text { x.close() x.owner, x.ownerDone, accepted = cmd, done, true return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(10 * time.Millisecond): } } } func (x *xClipboard) close() { if x.owner != nil { _ = x.owner.Process.Kill() <-x.ownerDone x.owner = nil x.ownerDone = nil } }