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