After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
// 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 (
"fmt"
"os"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
)
type Target struct{ Display, Authority string }
// Display_candidates never falls through an explicit setting or DISPLAY to a
// different session. Discovery uses existing local sockets, not a fixed :0..:9.
func Display_candidates(configured, current, authority, home, socketDirectory string) ([]Target, error) {
if authority == "" {
authority = filepath.Join(home, ".Xauthority")
}
if configured != "" {
return []Target{{configured, authority}}, nil
}
if current != "" {
return []Target{{current, authority}}, nil
}
entries, err := os.ReadDir(socketDirectory)
if err != nil {
return nil, err
}
var targets []Target
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, "X") {
continue
}
number, err := strconv.Atoi(name[1:])
if err != nil || number < 0 {
continue
}
info, err := entry.Info()
if err == nil && info.Mode()&os.ModeSocket != 0 {
targets = append(targets, Target{fmt.Sprintf(":%d", number), authority})
}
}
sort.Slice(targets, func(i, j int) bool { return targets[i].Display < targets[j].Display })
if len(targets) == 0 {
return nil, fmt.Errorf("no local X display")
}
// Multiple desktops require an explicit choice; do not silently mix users.
if len(targets) > 1 {
return nil, fmt.Errorf("multiple X displays; set clipboard.sync_display")
}
return targets, nil
}
func system_targets(configured string) ([]Target, error) {
home, _ := os.UserHomeDir()
if os.Geteuid() == 0 && os.Getenv("SUDO_UID") != "" {
if caller, err := user.LookupId(os.Getenv("SUDO_UID")); err == nil {
home = caller.HomeDir
}
}
targets, err := Display_candidates(configured, os.Getenv("DISPLAY"), os.Getenv("XAUTHORITY"), home, "/tmp/.X11-unix")
if err != nil {
return nil, err
}
if os.Getenv("XAUTHORITY") == "" {
// Console services may lack a desktop environment. Xorg's -auth names
// the actual cookie file, including display-manager runtime locations.
// Read command-line metadata only; never log or copy cookie contents.
paths, _ := filepath.Glob("/proc/[0-9]*/cmdline")
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
args := strings.Split(strings.TrimRight(string(data), "\x00"), "\x00")
if len(args) == 0 {
continue
}
switch filepath.Base(args[0]) {
case "Xorg", "X", "Xwayland":
default:
continue
}
display, auth := "", ""
for i, arg := range args {
if strings.HasPrefix(arg, ":") {
display = arg
}
if arg == "-auth" && i+1 < len(args) {
auth = args[i+1]
}
}
for i := range targets {
if display != "" && strings.SplitN(targets[i].Display, ".", 2)[0] == display && auth != "" {
targets[i].Authority = auth
}
}
}
}
return targets, nil
}
+100
View File
@@ -0,0 +1,100 @@
// 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 owns text history and asynchronous clipboard transfers.
package clipboard
import (
"fmt"
"unicode/utf8"
)
// MaxBytes bounds individual entries and file/helper messages.
const MaxBytes = 4 << 20
// History belongs to the application loop. New entries select the newest item;
// navigation wraps, matching Python Fenrir. Clear removes the entire history.
type History struct {
entries []string
index int
limit int
}
func New_history(limit int) *History { return &History{limit: max(1, limit)} }
func Validate_text(text string) error {
if len(text) > MaxBytes || !utf8.ValidString(text) {
return fmt.Errorf("clipboard text must be valid UTF-8 and at most 4 MiB")
}
return nil
}
func (h *History) Add(text string) error {
if err := Validate_text(text); err != nil {
return err
}
h.entries = append([]string{text}, h.entries[:min(len(h.entries), h.limit-1)]...)
h.index = 0
return nil
}
func (h *History) Current() (string, bool) {
if len(h.entries) == 0 {
return "", false
}
return h.entries[h.index], true
}
func (h *History) Move(delta int) {
if len(h.entries) > 0 {
h.index = (h.index + delta + len(h.entries)) % len(h.entries)
}
}
func (h *History) First() { h.index = 0 }
func (h *History) Last() { h.index = max(0, len(h.entries)-1) }
func (h *History) At_first() bool { return len(h.entries) > 0 && h.index == 0 }
func (h *History) At_last() bool { return len(h.entries) > 0 && h.index == len(h.entries)-1 }
func (h *History) Clear() { h.entries, h.index = nil, 0 }
// Refresh adopts shared entries while retaining this reader's selected copy.
// Count duplicates from the oldest end so a new identical copy stays distinct.
func (h *History) Refresh(state Snapshot) error {
text, selected := h.Current()
occurrence := 0
if selected {
for _, entry := range h.entries[h.index:] {
if entry == text {
occurrence++
}
}
}
if err := h.Restore(state); err != nil {
return err
}
if selected {
for i := len(h.entries) - 1; i >= 0; i-- {
if h.entries[i] == text {
occurrence--
if occurrence == 0 {
h.index = i
break
}
}
}
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
// 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 (
"net"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
func TestHistoryLimitUnicodeWrappingAndClear(t *testing.T) {
history := New_history(2)
for _, text := range []string{"old", "é界\nnext", "new"} {
if err := history.Add(text); err != nil {
t.Fatal(err)
}
}
history.Move(-1)
if text, ok := history.Current(); !ok || text != "é界\nnext" || !history.At_last() {
t.Fatal(text, ok)
}
history.Move(1)
if text, _ := history.Current(); text != "new" || !history.At_first() {
t.Fatal(text)
}
history.Last()
history.First()
if text, _ := history.Current(); text != "new" {
t.Fatal(text)
}
for _, text := range []string{string([]byte{0xff}), strings.Repeat("x", MaxBytes+1)} {
if err := history.Add(text); err == nil {
t.Fatal("unbounded or invalid text accepted")
}
}
history.Clear()
if _, ok := history.Current(); ok {
t.Fatal("clear retained history")
}
}
func TestDisplaySelectionDoesNotFallThroughAndDiscoversHigherNumbers(t *testing.T) {
root := t.TempDir()
for _, fixture := range []struct{ configured, current, want string }{{":8", ":7", ":8"}, {"", ":7", ":7"}} {
targets, err := Display_candidates(fixture.configured, fixture.current, "/test/auth", root, "/does-not-exist")
if err != nil || len(targets) != 1 || targets[0] != (Target{fixture.want, "/test/auth"}) {
t.Fatal(targets, err)
}
}
listener, err := net.Listen("unix", filepath.Join(root, "X42"))
if err != nil {
t.Fatal(err)
}
defer listener.Close()
if err := os.WriteFile(filepath.Join(root, "X3"), nil, 0600); err != nil {
t.Fatal(err)
}
targets, err := Display_candidates("", "", "", root, root)
if err != nil || len(targets) != 1 || targets[0].Display != ":42" {
t.Fatal(targets, err)
}
other, err := net.Listen("unix", filepath.Join(root, "X43"))
if err != nil {
t.Fatal(err)
}
defer other.Close()
if _, err := Display_candidates("", "", "", root, root); err == nil {
t.Fatal("silently chose among multiple desktops")
}
}
func TestFileRoundTripBoundsPrivacyAndNonregularInput(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "clipboard")
text := "é界\nsecond line\n"
if err := write_file(path, text); err != nil {
t.Fatal(err)
}
if got, err := read_file(path); err != nil || got != text {
t.Fatal(got, err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal(info, err)
}
if err := write_file(path, strings.Repeat("x", MaxBytes+1)); err == nil {
t.Fatal("oversize export accepted")
}
if got, _ := read_file(path); got != text {
t.Fatal("failed export changed original")
}
if err := syscall.Mkfifo(filepath.Join(root, "fifo"), 0600); err != nil {
t.Fatal(err)
}
if _, err := read_file(filepath.Join(root, "fifo")); err == nil {
t.Fatal("FIFO accepted")
}
if err := os.WriteFile(path, []byte{0xff}, 0600); err != nil {
t.Fatal(err)
}
if _, err := read_file(path); err == nil {
t.Fatal("invalid UTF-8 imported")
}
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package clipboard
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unicode/utf8"
)
const maxHistoryBytes = 64 << 20
// Snapshot preserves newest-first order and the selected history entry.
type Snapshot struct {
Version int `json:"version"`
Entries []string `json:"entries"`
Selected int `json:"selected"`
}
func History_path(home string, terminalMode bool) string {
if terminalMode {
return filepath.Join(home, ".local/state/fenrir/clipboard/terminal.json")
}
return "/var/lib/fenrir/clipboard/tty.json"
}
func (h *History) Snapshot() Snapshot {
return Snapshot{Version: 1, Entries: append([]string{}, h.entries...), Selected: h.index}
}
func validate_snapshot(state Snapshot) error {
if state.Version != 1 || len(state.Entries) > 10000 || state.Selected < 0 || (len(state.Entries) == 0 && state.Selected != 0) || (len(state.Entries) > 0 && state.Selected >= len(state.Entries)) {
return fmt.Errorf("invalid clipboard history")
}
total := 0
for _, text := range state.Entries {
if err := Validate_text(text); err != nil {
return err
}
total += len(text)
if total > maxHistoryBytes {
return fmt.Errorf("clipboard history exceeds 64 MiB")
}
}
return nil
}
func (h *History) Restore(state Snapshot) error {
if err := validate_snapshot(state); err != nil {
return err
}
h.entries = append([]string(nil), state.Entries[:min(len(state.Entries), h.limit)]...)
h.index = min(state.Selected, max(0, len(h.entries)-1))
return nil
}
func (h *History) Resize(limit int) {
h.limit = max(1, limit)
h.entries = h.entries[:min(len(h.entries), h.limit)]
h.index = min(h.index, max(0, len(h.entries)-1))
}
// A stable lock inode serializes only individual transactions. Closing it
// releases ownership; another reader can immediately read, update or clear.
func acquire_history(path string, create bool) (*os.File, error) {
if err := private_directory(filepath.Dir(path), create); err != nil {
if !create && errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
file, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err == nil && (!info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid())) {
err = fmt.Errorf("clipboard history lock must be private and owned by the current account")
}
deadline := time.Now().Add(2 * time.Second)
for err == nil {
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if !errors.Is(err, syscall.EWOULDBLOCK) || time.Now().After(deadline) {
break
}
time.Sleep(10 * time.Millisecond)
err = nil
}
if err == nil {
err = remove_temporary_history(path)
}
if err != nil {
file.Close()
return nil, err
}
return file, nil
}
func private_directory(path string, create bool) error {
if create {
if err := os.MkdirAll(path, 0700); err != nil {
return err
}
}
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.IsDir() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) {
return fmt.Errorf("clipboard history directory must be private and owned by the current account")
}
return nil
}
func load_history(path string) (Snapshot, error) {
empty := Snapshot{Version: 1}
if err := private_directory(filepath.Dir(path), false); err != nil {
if errors.Is(err, os.ErrNotExist) {
return empty, nil
}
return empty, err
}
file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if errors.Is(err, os.ErrNotExist) {
return empty, nil
}
if err != nil {
return empty, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return empty, err
}
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) {
return empty, fmt.Errorf("clipboard history must be a private regular file owned by the current account")
}
data, err := io.ReadAll(io.LimitReader(file, maxHistoryBytes+1))
if err != nil {
return empty, err
}
if len(data) > maxHistoryBytes || !utf8.Valid(data) {
return empty, fmt.Errorf("invalid or oversized clipboard history")
}
var state Snapshot
if err := json.Unmarshal(data, &state); err != nil {
return empty, fmt.Errorf("invalid clipboard history JSON")
}
if err := validate_snapshot(state); err != nil {
return empty, err
}
return state, nil
}
func save_history(path string, state Snapshot) error {
if err := validate_snapshot(state); err != nil {
return err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
if len(data) > maxHistoryBytes {
return fmt.Errorf("clipboard history exceeds 64 MiB")
}
if err := private_directory(filepath.Dir(path), true); err != nil {
return err
}
// Do not replace symlinks, devices or directories supplied at the destination.
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("clipboard history must be a regular file")
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
file, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp")
if err != nil {
return err
}
defer os.Remove(file.Name())
_, writeErr := file.Write(data)
err = errors.Join(writeErr, file.Sync(), file.Close())
if err != nil {
return err
}
if err := os.Rename(file.Name(), path); err != nil {
return err
}
return sync_directory(filepath.Dir(path))
}
func remove_history(path string) error {
if err := private_directory(filepath.Dir(path), false); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
return sync_directory(filepath.Dir(path))
}
func sync_directory(path string) error {
dir, err := os.Open(path)
if err != nil {
return err
}
return errors.Join(dir.Sync(), dir.Close())
}
// A killed process can leave an atomic-write temporary file. Only clean this
// history's own files after acquiring its lock, including when clearing it.
func remove_temporary_history(path string) error {
entries, err := os.ReadDir(filepath.Dir(path))
if err != nil {
return err
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "."+filepath.Base(path)+"-") || !strings.HasSuffix(entry.Name(), ".tmp") || !entry.Type().IsRegular() {
continue
}
if err := os.Remove(filepath.Join(filepath.Dir(path), entry.Name())); err != nil {
return err
}
}
return nil
}
+227
View File
@@ -0,0 +1,227 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package clipboard
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"
"time"
)
func TestPersistentHistoryRoundTripResizeAndPrivateModes(t *testing.T) {
path := filepath.Join(t.TempDir(), "private", "terminal.json")
history := New_history(3)
for _, text := range []string{"old", "é界\nsecond line", "🎃"} {
if err := history.Add(text); err != nil {
t.Fatal(err)
}
}
history.Move(1)
store := New_persistence(path, false)
store.Enable(history.Snapshot(), 3)
if err := store.Close(); err != nil {
t.Fatal(err)
}
for file, mode := range map[string]os.FileMode{path: 0600, filepath.Dir(path): 0700} {
info, err := os.Stat(file)
if err != nil || info.Mode().Perm() != mode {
t.Fatal(info, err)
}
}
store = New_persistence(path, true)
if store.LoadErr != nil {
t.Fatal(store.LoadErr)
}
restored := New_history(2)
if err := restored.Restore(store.Initial); err != nil {
t.Fatal(err)
}
if got, _ := restored.Current(); got != "é界\nsecond line" || !restored.At_last() {
t.Fatal(got)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
restored.Resize(1)
if got, _ := restored.Current(); got != "🎃" {
t.Fatal(got)
}
restored.Resize(4)
restored.Add("new")
if !reflect.DeepEqual(restored.Snapshot().Entries, []string{"new", "🎃"}) {
t.Fatal(restored.Snapshot())
}
}
func TestPersistenceDisabledDoesNotReadOrCreateAndModesAreSeparate(t *testing.T) {
home := t.TempDir()
terminalPath := History_path(home, true)
if !strings.HasPrefix(terminalPath, home+string(os.PathSeparator)) || History_path(home, false) != "/var/lib/fenrir/clipboard/tty.json" || terminalPath == History_path(home, false) {
t.Fatal(terminalPath, History_path(home, false))
}
store := New_persistence(terminalPath, false)
if store.LoadErr != nil || len(store.Initial.Entries) != 0 {
t.Fatal(store)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Dir(terminalPath)); !errors.Is(err, os.ErrNotExist) {
t.Fatal("disabled persistence created state", err)
}
// A pre-existing malformed file is also ignored while disabled.
path := filepath.Join(home, "malformed.json")
os.WriteFile(path, []byte("not json"), 0600)
store = New_persistence(path, false)
if store.LoadErr != nil {
t.Fatal("disabled persistence read a file", store.LoadErr)
}
store.Close()
}
func TestPersistenceClearWinsOverQueuedWritesAndQuitFlushes(t *testing.T) {
path := filepath.Join(t.TempDir(), "private", "clipboard.json")
state := Snapshot{Version: 1, Entries: []string{"first"}}
store := New_persistence(path, false)
store.Enable(state, 200)
deadline := time.Now().Add(time.Second)
for {
if _, err := os.Stat(path); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("initial write did not finish")
}
time.Sleep(time.Millisecond)
}
for range 10 {
store.Add(strings.Repeat("x", 1<<20), 200)
}
store.Clear()
if err := store.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatal("old write resurrected cleared history", err)
}
store = New_persistence(path, false)
store.Enable(state, 200)
if err := store.Close(); err != nil {
t.Fatal(err)
}
got, err := load_history(path)
if err != nil || !reflect.DeepEqual(got, state) {
t.Fatal("quit did not flush newest state", got, err)
}
}
func TestPersistenceRejectsCorruptUnsafeAndUnboundedFiles(t *testing.T) {
root := filepath.Join(t.TempDir(), "private")
if err := os.Mkdir(root, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "history.json")
for _, data := range [][]byte{
[]byte("not JSON"), []byte{0xff},
[]byte(`{"version":2,"entries":[],"selected":0}`),
[]byte(`{"version":1,"entries":["word"],"selected":2}`),
[]byte(`{"version":1,"entries":[],"selected":-1}`),
} {
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
if _, err := load_history(path); err == nil {
t.Fatal("invalid snapshot accepted")
}
}
state := Snapshot{Version: 1, Entries: []string{"word"}}
if err := save_history(path, state); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0644); err != nil {
t.Fatal(err)
}
if _, err := load_history(path); err == nil {
t.Fatal("public file accepted")
}
os.Chmod(path, 0600)
file, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
file.Truncate(maxHistoryBytes + 1)
file.Close()
if _, err := load_history(path); err == nil {
t.Fatal("oversized file accepted")
}
if err := save_history(path, state); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(path)
if err := save_history(path, Snapshot{Version: 1, Entries: []string{strings.Repeat("x", MaxBytes+1)}}); err == nil {
t.Fatal("oversized entry saved")
}
after, _ := os.ReadFile(path)
if string(before) != string(after) {
t.Fatal("rejected save replaced history")
}
link := filepath.Join(root, "link.json")
if err := os.Symlink(path, link); err != nil {
t.Fatal(err)
}
if _, err := load_history(link); err == nil {
t.Fatal("symlink read")
}
if err := save_history(link, state); err == nil {
t.Fatal("symlink replaced")
}
fifo := filepath.Join(root, "fifo")
if err := syscall.Mkfifo(fifo, 0600); err != nil {
t.Fatal(err)
}
if _, err := load_history(fifo); err == nil {
t.Fatal("FIFO accepted")
}
public := filepath.Join(root, "public")
os.Mkdir(public, 0755)
if err := save_history(filepath.Join(public, "history.json"), state); err == nil {
t.Fatal("public directory accepted")
}
var decoded Snapshot
if err := json.Unmarshal(before, &decoded); err != nil || !reflect.DeepEqual(state, decoded) {
t.Fatal(decoded, err)
}
}
func TestClearRemovesTemporaryHistoryLeftByInterruptedWrite(t *testing.T) {
root := filepath.Join(t.TempDir(), "private")
if err := os.Mkdir(root, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "terminal.json")
stale := filepath.Join(root, ".terminal.json-interrupted.tmp")
other := filepath.Join(root, ".other.json-interrupted.tmp")
for _, name := range []string{stale, other} {
if err := os.WriteFile(name, []byte("old clipboard text"), 0600); err != nil {
t.Fatal(err)
}
}
store := New_persistence(path, false)
store.Clear()
if err := store.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) {
t.Fatal("clear retained interrupted write", err)
}
if _, err := os.Stat(other); err != nil {
t.Fatal("clear removed another history's file", err)
}
}
+358
View File
@@ -0,0 +1,358 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package clipboard
import (
"errors"
"fmt"
"io"
"os"
"reflect"
"sync"
"syscall"
"time"
)
// HistoryUpdate contains shared entries and the local submission it acknowledges.
// Selection stays local; Selected is only the next launch's saved selection.
type HistoryUpdate struct {
State Snapshot
Enabled bool
Sequence uint64
}
type historyJob struct {
action string
state Snapshot
text string
limit int
sequence uint64
imported bool
}
// Persistence applies actions to the latest shared snapshot under a short lock.
// Disk I/O and lock contention stay off the input loop. Close drains actions.
type Persistence struct {
Initial Snapshot
LoadErr error
InitialEnabled bool
wake chan struct{}
errors chan error
updates chan HistoryUpdate
done chan struct{}
mutex sync.Mutex
jobs []historyJob
sequence uint64
closed bool
lastErr error
}
func New_persistence(path string, load bool) *Persistence {
p := &Persistence{Initial: Snapshot{Version: 1}, InitialEnabled: load, wake: make(chan struct{}, 1), errors: make(chan error, 1), updates: make(chan HistoryUpdate, 1), done: make(chan struct{})}
if load {
file, err := acquire_history(path, false)
if err == nil {
p.InitialEnabled, err = persistence_enabled(file)
if err == nil && p.InitialEnabled {
p.Initial, err = load_history(path)
}
if file != nil {
file.Close()
}
}
p.LoadErr = err
}
go p.run(path, load)
return p
}
func (p *Persistence) Errors() <-chan error { return p.errors }
func (p *Persistence) Updates() <-chan HistoryUpdate { return p.updates }
func (p *Persistence) Sequence() uint64 {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.sequence
}
func (p *Persistence) submit(job historyJob) {
p.mutex.Lock()
p.sequence++
job.sequence = p.sequence
// Intermediate navigation is disposable; data and enable/disable actions
// retain their order, including an enable immediately followed by Clear.
if job.action == "select" && len(p.jobs) > 0 && p.jobs[len(p.jobs)-1].action == "select" {
p.jobs = p.jobs[:len(p.jobs)-1]
}
p.jobs = append(p.jobs, job)
p.mutex.Unlock()
select {
case p.wake <- struct{}{}:
default:
}
}
func (p *Persistence) Enable(state Snapshot, limit int) {
p.submit(historyJob{action: "enable", state: state, limit: limit})
}
func (p *Persistence) Add(text string, limit int) {
p.submit(historyJob{action: "add", text: text, limit: limit})
}
func (p *Persistence) Import(text string, limit int) {
p.submit(historyJob{action: "add", text: text, limit: limit, imported: true})
}
func (p *Persistence) Select(state Snapshot) { p.submit(historyJob{action: "select", state: state}) }
func (p *Persistence) Resize(limit int) { p.submit(historyJob{action: "resize", limit: limit}) }
func (p *Persistence) Clear() { p.submit(historyJob{action: "clear"}) }
func (p *Persistence) Disable() { p.submit(historyJob{action: "disable"}) }
func (p *Persistence) Close() error {
p.mutex.Lock()
p.closed = true
p.mutex.Unlock()
select {
case p.wake <- struct{}{}:
default:
}
<-p.done
return p.lastErr
}
func (p *Persistence) report(err error) {
p.lastErr = err
if err != nil {
select {
case <-p.errors:
default:
}
p.errors <- err
}
}
func (p *Persistence) run(path string, watching bool) {
defer close(p.done)
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
last := HistoryUpdate{State: p.Initial, Enabled: p.InitialEnabled}
var signature string
var acknowledged uint64
for {
select {
case <-p.wake:
case <-tick.C:
}
p.mutex.Lock()
jobs, closed := p.jobs, p.closed
p.jobs = nil
p.mutex.Unlock()
for _, job := range jobs {
update, err := apply_history(path, job)
acknowledged = job.sequence
p.report(err)
if job.action == "enable" {
watching = true
}
if err == nil && watching {
update.Sequence = acknowledged
p.publish(update)
last = update
}
signature = ""
}
if closed {
return
}
// Starting disabled never imports a previous snapshot. Once participating,
// readers also observe another participant's disable/re-enable operations.
if !watching {
continue
}
nextSignature := history_signature(path)
if nextSignature == signature {
continue
}
file, err := acquire_history(path, false)
update := HistoryUpdate{State: Snapshot{Version: 1}, Sequence: acknowledged}
if err == nil {
update.Enabled, err = persistence_enabled(file)
if err == nil && update.Enabled {
update.State, err = load_history(path)
}
if file != nil {
file.Close()
}
}
if err != nil {
// Report a stable failure once, and retry after a file changes.
if signature != nextSignature {
p.report(err)
}
} else {
if !reflect.DeepEqual(update, last) {
p.publish(update)
last = update
}
}
signature = nextSignature
}
}
func (p *Persistence) publish(update HistoryUpdate) {
select {
case <-p.updates:
default:
}
p.updates <- update
}
func history_signature(path string) string {
result := ""
for _, name := range []string{path, path + ".lock"} {
info, err := os.Lstat(name)
if err != nil {
result += fmt.Sprint(err)
continue
}
stat := info.Sys().(*syscall.Stat_t)
result += fmt.Sprintf("%d:%d:%d:%d;", stat.Dev, stat.Ino, info.Size(), info.ModTime().UnixNano())
}
return result
}
// The empty original lock file means enabled. One metadata byte coordinates
// disabling without retaining clipboard text or resurrecting a deleted file.
func persistence_enabled(file *os.File) (bool, error) {
if file == nil {
return true, nil
}
var value [1]byte
n, err := file.ReadAt(value[:], 0)
if n == 0 && errors.Is(err, io.EOF) {
return true, nil
}
if err != nil {
return false, err
}
if value[0] != '0' && value[0] != '1' {
return false, fmt.Errorf("invalid clipboard persistence state")
}
return value[0] == '1', nil
}
func set_persistence_enabled(file *os.File, enabled bool) error {
value := []byte("0")
if enabled {
value[0] = '1'
}
_, err := file.WriteAt(value, 0)
return errors.Join(err, file.Sync())
}
func apply_history(path string, job historyJob) (HistoryUpdate, error) {
result := HistoryUpdate{State: Snapshot{Version: 1}}
create := job.action == "enable" || job.action == "add" || job.action == "disable"
file, err := acquire_history(path, create)
if err != nil {
return result, err
}
if file != nil {
defer file.Close()
}
result.Enabled, err = persistence_enabled(file)
if err != nil {
return result, err
}
switch job.action {
case "clear", "disable":
if job.action == "disable" {
if err := set_persistence_enabled(file, false); err != nil {
return result, err
}
result.Enabled = false
}
return result, remove_history(path)
case "enable":
if err := validate_snapshot(job.state); err != nil {
return result, err
}
// Enabling deliberately saves retained memory, but preserves newer shared
// entries when another reader already has an active saved history.
state, err := load_history(path)
if err != nil {
return result, err
}
if len(state.Entries) == 0 {
state = job.state
} else {
for i := len(job.state.Entries) - 1; i >= 0; i-- {
text := job.state.Entries[i]
found := false
for _, existing := range state.Entries {
if existing == text {
found = true
break
}
}
if !found {
state.Entries = append([]string{text}, state.Entries...)
}
}
}
result.State = trim_history(state, job.limit)
if len(result.State.Entries) > 0 {
err = save_history(path, result.State)
} else {
err = remove_history(path)
}
if err != nil {
return result, err
}
if err := set_persistence_enabled(file, true); err != nil {
return result, err
}
result.Enabled = true
return result, nil
}
if !result.Enabled {
return result, nil
}
result.State, err = load_history(path)
if err != nil {
return result, err
}
switch job.action {
case "add":
if err := Validate_text(job.text); err != nil {
return result, err
}
if job.imported && len(result.State.Entries) > 0 && result.State.Entries[0] == job.text {
return result, nil // Two X observers imported the same clipboard change.
}
result.State.Entries = append([]string{job.text}, result.State.Entries...)
result.State.Selected = 0
result.State = trim_history(result.State, job.limit)
case "select":
// Navigation cannot recreate entries removed by another reader.
if job.state.Selected < len(job.state.Entries) && job.state.Selected >= 0 {
history := New_history(10000)
if err := history.Restore(job.state); err != nil {
return result, err
}
if err := history.Refresh(result.State); err != nil {
return result, err
}
result.State.Selected = history.Snapshot().Selected
}
case "resize":
result.State = trim_history(result.State, job.limit)
}
if len(result.State.Entries) == 0 {
return result, nil
}
return result, save_history(path, result.State)
}
func trim_history(state Snapshot, limit int) Snapshot {
state.Entries = state.Entries[:min(len(state.Entries), max(1, limit))]
state.Selected = min(state.Selected, max(0, len(state.Entries)-1))
return state
}
@@ -0,0 +1,330 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package clipboard
import (
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"sync"
"testing"
"time"
)
type sharedCommand struct {
Action string
Text string
State Snapshot
Limit int
}
type sharedReply struct {
State Snapshot
Enabled bool
Error string
}
// Separate processes exercise flock and polling, rather than sharing Go state.
func TestSharedClipboardProcess(t *testing.T) {
path := os.Getenv("FENRIR_CLIPBOARD_PROCESS_PATH")
if path == "" {
return
}
store := New_persistence(path, true)
current := sharedReply{State: store.Initial, Enabled: store.InitialEnabled}
if store.LoadErr != nil {
current.Error = store.LoadErr.Error()
}
encoder, decoder := json.NewEncoder(os.Stdout), json.NewDecoder(os.Stdin)
if err := encoder.Encode(current); err != nil {
os.Exit(2)
}
for {
var command sharedCommand
if err := decoder.Decode(&command); err != nil {
store.Close()
os.Exit(0)
}
switch command.Action {
case "add":
store.Add(command.Text, command.Limit)
case "select":
store.Select(command.State)
case "resize":
store.Resize(command.Limit)
case "clear":
store.Clear()
case "disable":
store.Disable()
case "enable":
store.Enable(command.State, command.Limit)
case "quit":
if err := store.Close(); err != nil {
current.Error = err.Error()
}
encoder.Encode(current)
os.Exit(0)
}
if command.Action != "state" {
deadline := time.NewTimer(4 * time.Second)
wait:
for {
select {
case update := <-store.Updates():
current.State, current.Enabled = update.State, update.Enabled
if update.Sequence == store.Sequence() {
break wait
}
case err := <-store.Errors():
current.Error = err.Error()
break wait
case <-deadline.C:
current.Error = "timed out waiting for action"
break wait
}
}
deadline.Stop()
} else {
drain:
for {
select {
case update := <-store.Updates():
current.State, current.Enabled = update.State, update.Enabled
case err := <-store.Errors():
current.Error = err.Error()
default:
break drain
}
}
}
if err := encoder.Encode(current); err != nil {
os.Exit(2)
}
}
}
type sharedProcess struct {
command *exec.Cmd
input io.WriteCloser
encoder *json.Encoder
decoder *json.Decoder
}
func start_shared_process(t *testing.T, path string) *sharedProcess {
t.Helper()
command := exec.Command(os.Args[0], "-test.run=^TestSharedClipboardProcess$")
command.Env = append(os.Environ(), "FENRIR_CLIPBOARD_PROCESS_PATH="+path)
command.Stderr = os.Stderr
input, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
process := &sharedProcess{command: command, input: input, encoder: json.NewEncoder(input), decoder: json.NewDecoder(output)}
if err := command.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { input.Close(); command.Wait() })
reply := process.read(t)
if reply.Error != "" {
t.Fatal(reply.Error)
}
return process
}
func (p *sharedProcess) read(t *testing.T) sharedReply {
t.Helper()
// A broken worker must fail the test instead of blocking the whole suite.
result := make(chan sharedReply, 1)
failures := make(chan error, 1)
go func() {
var reply sharedReply
if err := p.decoder.Decode(&reply); err != nil {
failures <- err
} else {
result <- reply
}
}()
select {
case reply := <-result:
return reply
case err := <-failures:
t.Fatal(err)
case <-time.After(6 * time.Second):
p.command.Process.Kill()
t.Fatal("clipboard process did not reply")
}
return sharedReply{}
}
func (p *sharedProcess) action(t *testing.T, command sharedCommand) sharedReply {
t.Helper()
if command.Limit == 0 {
command.Limit = 200
}
if err := p.encoder.Encode(command); err != nil {
t.Fatal(err)
}
reply := p.read(t)
if reply.Error != "" {
t.Fatal(reply.Error)
}
return reply
}
func (p *sharedProcess) await(t *testing.T, entries []string, enabled bool) sharedReply {
t.Helper()
deadline := time.Now().Add(4 * time.Second)
for {
reply := p.action(t, sharedCommand{Action: "state"})
if reflect.DeepEqual(reply.State.Entries, entries) && reply.Enabled == enabled {
return reply
}
if time.Now().After(deadline) {
t.Fatalf("shared state = %+v, want %v enabled=%v", reply, entries, enabled)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestPersistentClipboardSharesAcrossProcessesAndClearCannotResurrect(t *testing.T) {
path := filepath.Join(t.TempDir(), "private", "terminal.json")
first, second := start_shared_process(t, path), start_shared_process(t, path)
first.action(t, sharedCommand{Action: "add", Text: "first"})
second.await(t, []string{"first"}, true)
second.action(t, sharedCommand{Action: "add", Text: "second"})
first.await(t, []string{"second", "first"}, true)
stale := Snapshot{Version: 1, Entries: []string{"second", "first"}, Selected: 1}
first.action(t, sharedCommand{Action: "select", State: stale})
second.action(t, sharedCommand{Action: "clear"})
first.action(t, sharedCommand{Action: "select", State: stale})
first.action(t, sharedCommand{Action: "resize", Limit: 10})
first.await(t, nil, true)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("navigation recreated cleared history", err)
}
first.action(t, sharedCommand{Action: "add", Text: "after clear"})
second.await(t, []string{"after clear"}, true)
second.action(t, sharedCommand{Action: "disable"})
first.await(t, nil, false)
first.action(t, sharedCommand{Action: "select", State: stale})
first.action(t, sharedCommand{Action: "add", Text: "late copy"})
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("peer recreated disabled snapshot", err)
}
// An explicit enable is required to resume writes.
second.action(t, sharedCommand{Action: "enable", State: Snapshot{Version: 1, Entries: []string{"retained"}}})
first.await(t, []string{"retained"}, true)
first.action(t, sharedCommand{Action: "quit"})
second.action(t, sharedCommand{Action: "add", Text: "survivor"})
third := start_shared_process(t, path)
third.await(t, []string{"survivor", "retained"}, true)
if err := second.command.Process.Kill(); err != nil {
t.Fatal(err)
}
third.action(t, sharedCommand{Action: "add", Text: "after crash"})
third.await(t, []string{"after crash", "survivor", "retained"}, true)
}
func TestPersistentClipboardConcurrentProcessCopiesKeepBothUpdates(t *testing.T) {
path := filepath.Join(t.TempDir(), "private", "terminal.json")
first, second := start_shared_process(t, path), start_shared_process(t, path)
var group sync.WaitGroup
for _, process := range []*sharedProcess{first, second} {
group.Add(1)
go func() {
defer group.Done()
for _, text := range []string{"one", "two", "three", "four"} {
process.action(t, sharedCommand{Action: "add", Text: text})
}
}()
}
group.Wait()
state, err := load_history(path)
if err != nil || len(state.Entries) != 8 {
t.Fatal("lost concurrent copies", state, err)
}
counts := make(map[string]int)
for _, text := range state.Entries {
counts[text]++
}
for _, text := range []string{"one", "two", "three", "four"} {
if counts[text] != 2 {
t.Fatal("copy lost or duplicated", counts)
}
}
}
func TestPersistentClipboardRefreshRetainsLocalSelectionAndDuplicateIdentity(t *testing.T) {
history := New_history(5)
history.Add("same")
history.Add("middle")
history.Add("same")
history.Move(2)
if err := history.Refresh(Snapshot{Version: 1, Entries: []string{"same", "same", "middle", "same"}}); err != nil {
t.Fatal(err)
}
if history.Snapshot().Selected != 3 {
t.Fatal("selected older duplicate moved", history.Snapshot())
}
if err := history.Refresh(Snapshot{Version: 1}); err != nil {
t.Fatal(err)
}
if _, ok := history.Current(); ok {
t.Fatal("shared clear retained a local entry")
}
}
func TestPersistenceQueuedEnableThenClearStillParticipates(t *testing.T) {
path := filepath.Join(t.TempDir(), "private", "terminal.json")
store := New_persistence(path, false)
store.Enable(Snapshot{Version: 1, Entries: []string{"old"}}, 200)
store.Clear()
store.Add("fresh", 200)
deadline := time.NewTimer(3 * time.Second)
defer deadline.Stop()
for {
select {
case update := <-store.Updates():
if update.Sequence == store.Sequence() {
if !update.Enabled || !reflect.DeepEqual(update.State.Entries, []string{"fresh"}) {
t.Fatal(update)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
return
}
case err := <-store.Errors():
t.Fatal(err)
case <-deadline.C:
store.Close()
t.Fatal("queued enable/Clear lost participation")
}
}
}
func TestPersistenceDuplicateXImportsAreOneEntryButCopiesRemainDistinct(t *testing.T) {
path := filepath.Join(t.TempDir(), "private", "terminal.json")
first, second := New_persistence(path, true), New_persistence(path, true)
first.Import("shared X text", 200)
if err := first.Close(); err != nil {
t.Fatal(err)
}
second.Import("shared X text", 200)
second.Add("shared X text", 200)
if err := second.Close(); err != nil {
t.Fatal(err)
}
state, err := load_history(path)
if err != nil || !reflect.DeepEqual(state.Entries, []string{"shared X text", "shared X text"}) {
t.Fatal(state, err)
}
}
+205
View File
@@ -0,0 +1,205 @@
// 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 (
"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)
}
+70
View File
@@ -0,0 +1,70 @@
// 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 (
"context"
"errors"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/command"
)
func TestQueuedPasteCannotStartAfterTargetRetirement(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
called := false
worker := &Worker{jobs: make(chan queuedRequest, 1), results: make(chan Result, 1), ctx: ctx, cancel: cancel, done: make(chan struct{}), paste: func(context.Context, string, int) error { called = true; return nil }}
worker.Submit(Request{Command: command.PasteClipboard, Text: "old target", Terminal: 2})
worker.Cancel()
go worker.run()
defer worker.Close()
select {
case result := <-worker.Results():
if called || !errors.Is(result.Err, context.Canceled) {
t.Fatal(called, result.Err)
}
case <-time.After(time.Second):
t.Fatal("canceled paste did not finish")
}
}
func TestActivePasteCancellationDoesNotBlockShutdown(t *testing.T) {
started := make(chan struct{})
worker := New_worker(context.Background(), func(ctx context.Context, text string, terminal int) error {
close(started)
<-ctx.Done()
return ctx.Err()
})
worker.Submit(Request{Command: command.PasteClipboard, Text: "target", Terminal: 2})
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("paste did not start")
}
worker.Cancel()
select {
case result := <-worker.Results():
if !errors.Is(result.Err, context.Canceled) {
t.Fatal(result.Err)
}
case <-time.After(time.Second):
t.Fatal("paste did not cancel")
}
worker.Close()
}
+167
View File
@@ -0,0 +1,167 @@
// 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
}
}