Files

168 lines
4.7 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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
}
}