Reset master to testing content
This commit is contained in:
1 parent
0f2587b719
commit
42afa2a56c
1483 files changed
+145308
-938069
No files matched your search
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Snapshot contains only the clipboard facts needed for Cthulhu's announcement.
|
||||
type Snapshot struct {
|
||||
Text string `json:"text"`
|
||||
HasText bool `json:"hasText"`
|
||||
Image bool `json:"image"`
|
||||
URIs []string `json:"uris"`
|
||||
}
|
||||
|
||||
// WriteMessage is called only by the short lived clipboard helper process.
|
||||
// URI filesystem checks stay here so the parent can enforce a read deadline.
|
||||
func WriteMessage(output io.Writer) error {
|
||||
snapshot, err := read_native()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(output).Encode(struct {
|
||||
Message string `json:"message"`
|
||||
}{Message: Format(snapshot)})
|
||||
}
|
||||
|
||||
func Format(snapshot Snapshot) string {
|
||||
if snapshot.Image {
|
||||
return "The clipboard contains a image"
|
||||
}
|
||||
if len(snapshot.URIs) != 0 {
|
||||
return format_uris(snapshot.URIs)
|
||||
}
|
||||
if snapshot.HasText {
|
||||
return "Text in clipboard " + snapshot.Text
|
||||
}
|
||||
return "The clipboard is empty"
|
||||
}
|
||||
|
||||
func format_uris(uris []string) string {
|
||||
objects := make([]string, 0, len(uris))
|
||||
for _, raw := range uris {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
objects = append(objects, raw)
|
||||
continue
|
||||
}
|
||||
name := path.Base(parsed.Path)
|
||||
if name == "." || name == "/" {
|
||||
name = raw
|
||||
}
|
||||
parts := []string{name}
|
||||
if parsed.Scheme == "file" && (parsed.Host == "" || parsed.Host == "localhost") {
|
||||
localPath := parsed.Path
|
||||
link := false
|
||||
if info, err := os.Lstat(localPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
link = true
|
||||
}
|
||||
if info, err := os.Stat(localPath); err == nil {
|
||||
if info.IsDir() {
|
||||
parts = append(parts, "Folder")
|
||||
} else if info.Mode().IsRegular() {
|
||||
parts = append(parts, "File")
|
||||
}
|
||||
if !link && is_mount(localPath, info) {
|
||||
parts = append(parts, "Disk")
|
||||
}
|
||||
}
|
||||
if link {
|
||||
parts = append(parts, "Link")
|
||||
}
|
||||
}
|
||||
objects = append(objects, strings.Join(parts, " "))
|
||||
}
|
||||
if len(objects) == 0 {
|
||||
return "The clipboard is empty"
|
||||
}
|
||||
label := " Object in clipboard "
|
||||
if len(objects) > 1 {
|
||||
label = " Objects in clipboard "
|
||||
}
|
||||
return strconv.Itoa(len(objects)) + label + strings.Join(objects, " ")
|
||||
}
|
||||
|
||||
func is_mount(localPath string, info os.FileInfo) bool {
|
||||
parentPath := filepath.Dir(filepath.Clean(localPath))
|
||||
parent, err := os.Stat(parentPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
currentStat, currentOK := info.Sys().(*syscall.Stat_t)
|
||||
parentStat, parentOK := parent.Sys().(*syscall.Stat_t)
|
||||
return currentOK && parentOK && (currentStat.Dev != parentStat.Dev || currentStat.Ino == parentStat.Ino)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatClipboardContent(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
snapshot Snapshot
|
||||
want string
|
||||
}{
|
||||
{"empty", Snapshot{}, "The clipboard is empty"},
|
||||
{"text", Snapshot{HasText: true, Text: "hello"}, "Text in clipboard hello"},
|
||||
{"empty text", Snapshot{HasText: true}, "Text in clipboard "},
|
||||
{"image overrides text", Snapshot{HasText: true, Text: "hello", Image: true}, "The clipboard contains a image"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := Format(test.snapshot); got != test.want {
|
||||
t.Fatalf("got %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatURIObjects(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
folder := filepath.Join(root, "a folder")
|
||||
file := filepath.Join(root, "a file")
|
||||
link := filepath.Join(root, "link")
|
||||
if err := os.Mkdir(folder, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(file, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uri := func(localPath string) string { return (&url.URL{Scheme: "file", Path: localPath}).String() }
|
||||
snapshot := Snapshot{URIs: []string{uri(folder), uri(file), uri(link)}}
|
||||
want := "3 Objects in clipboard a folder Folder a file File link File Link"
|
||||
if got := Format(snapshot); got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
if got := Format(Snapshot{URIs: []string{"", uri(file)}}); got != "1 Object in clipboard a file File" {
|
||||
t.Fatalf("single URI = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package clipboard
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk+-3.0
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
typedef struct {
|
||||
char *text;
|
||||
char **uris;
|
||||
int image;
|
||||
int initialized;
|
||||
} ClipboardSnapshot;
|
||||
|
||||
static ClipboardSnapshot read_clipboard(void) {
|
||||
ClipboardSnapshot result = {0};
|
||||
if (!gtk_init_check(NULL, NULL)) return result;
|
||||
result.initialized = 1;
|
||||
GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
|
||||
if (!clipboard) { result.initialized = 0; return result; }
|
||||
result.text = gtk_clipboard_wait_for_text(clipboard);
|
||||
GdkPixbuf *image = gtk_clipboard_wait_for_image(clipboard);
|
||||
if (image) {
|
||||
result.image = 1;
|
||||
g_object_unref(image);
|
||||
}
|
||||
if (gtk_clipboard_wait_is_uris_available(clipboard)) {
|
||||
result.uris = gtk_clipboard_wait_for_uris(clipboard);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void free_clipboard(ClipboardSnapshot *snapshot) {
|
||||
g_free(snapshot->text);
|
||||
g_strfreev(snapshot->uris);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func read_native() (Snapshot, error) {
|
||||
native := C.read_clipboard()
|
||||
defer C.free_clipboard(&native)
|
||||
if native.initialized == 0 {
|
||||
return Snapshot{}, errors.New("cannot access the desktop clipboard")
|
||||
}
|
||||
snapshot := Snapshot{Image: native.image != 0, HasText: native.text != nil}
|
||||
if native.text != nil {
|
||||
snapshot.Text = C.GoString(native.text)
|
||||
}
|
||||
if native.uris != nil {
|
||||
for offset := uintptr(0); ; offset += unsafe.Sizeof(uintptr(0)) {
|
||||
uri := *(**C.char)(unsafe.Add(unsafe.Pointer(native.uris), offset))
|
||||
if uri == nil {
|
||||
break
|
||||
}
|
||||
snapshot.URIs = append(snapshot.URIs, C.GoString(uri))
|
||||
}
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "watch.h"
|
||||
static GQueue changes = G_QUEUE_INIT;
|
||||
static gboolean watchEnabled;
|
||||
static unsigned long long watchEpoch;
|
||||
|
||||
void clipboard_watch_free(ClipboardChange *change) {
|
||||
if (--change->references) return;
|
||||
g_free(change->text);
|
||||
g_free(change);
|
||||
}
|
||||
|
||||
static void text_received(GtkClipboard *clipboard, const gchar *text, gpointer data) {
|
||||
(void)clipboard;
|
||||
ClipboardChange *change = data;
|
||||
change->text = g_strdup(text);
|
||||
change->done = TRUE;
|
||||
clipboard_watch_free(change);
|
||||
}
|
||||
|
||||
static void targets_received(GtkClipboard *clipboard, GdkAtom *targets, gint count, gpointer data) {
|
||||
ClipboardChange *change = data;
|
||||
if (!watchEnabled || watchEpoch != change->epoch || !targets ||
|
||||
gtk_targets_include_image(targets, count, FALSE) ||
|
||||
gtk_targets_include_uri(targets, count) ||
|
||||
!gtk_targets_include_text(targets, count)) {
|
||||
change->done = TRUE;
|
||||
clipboard_watch_free(change);
|
||||
return;
|
||||
}
|
||||
// No image or file payload is requested, only a text target.
|
||||
gtk_clipboard_request_text(clipboard, text_received, change);
|
||||
}
|
||||
|
||||
static void owner_changed(GtkClipboard *clipboard, GdkEventOwnerChange *event, gpointer data) {
|
||||
(void)data;
|
||||
if (!watchEnabled) return;
|
||||
if (g_queue_get_length(&changes) >= 128)
|
||||
clipboard_watch_free(g_queue_pop_head(&changes));
|
||||
ClipboardChange *change = g_new0(ClipboardChange, 1);
|
||||
change->epoch = watchEpoch;
|
||||
change->timestamp = event->time;
|
||||
change->deadline = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
|
||||
change->references = 2; // queue and asynchronous callback
|
||||
g_queue_push_tail(&changes, change);
|
||||
gtk_clipboard_request_targets(clipboard, targets_received, change);
|
||||
}
|
||||
|
||||
int clipboard_watch_init(void) {
|
||||
if (!gtk_init_check(NULL, NULL)) return 0;
|
||||
GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
|
||||
if (!clipboard) return 0;
|
||||
g_signal_connect(clipboard, "owner-change", G_CALLBACK(owner_changed), NULL);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void clipboard_watch_enable(int enabled, unsigned long long epoch) {
|
||||
watchEnabled = enabled;
|
||||
watchEpoch = epoch;
|
||||
}
|
||||
void clipboard_watch_iterate(void) {
|
||||
while (gtk_events_pending()) gtk_main_iteration_do(FALSE);
|
||||
}
|
||||
ClipboardChange *clipboard_watch_next(void) {
|
||||
ClipboardChange *change = g_queue_peek_head(&changes);
|
||||
if (!change || (!change->done && g_get_monotonic_time() < change->deadline)) return NULL;
|
||||
return g_queue_pop_head(&changes);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package clipboard
|
||||
|
||||
/*
|
||||
#include "watch.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WatchSettings struct {
|
||||
Enabled bool
|
||||
Epoch uint64
|
||||
}
|
||||
|
||||
type TextChange struct {
|
||||
Text string
|
||||
Epoch uint64
|
||||
Timestamp uint32
|
||||
Ready bool
|
||||
}
|
||||
|
||||
// WatchText keeps a GTK selection connection warm so successive clipboard
|
||||
// changes are captured asynchronously and emitted in arrival order. Disabled
|
||||
// applications never request clipboard targets or contents.
|
||||
func WatchText(ctx context.Context, input io.Reader, output io.Writer) error {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
settings := make(chan WatchSettings, 1)
|
||||
inputDone := make(chan error, 1)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(input)
|
||||
scanner.Buffer(make([]byte, 1024), 4096)
|
||||
for scanner.Scan() {
|
||||
var request WatchSettings
|
||||
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
|
||||
inputDone <- err
|
||||
return
|
||||
}
|
||||
select {
|
||||
case settings <- request:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
inputDone <- scanner.Err()
|
||||
}()
|
||||
if C.clipboard_watch_init() == 0 {
|
||||
return errors.New("cannot watch clipboard on this display")
|
||||
}
|
||||
encoder := json.NewEncoder(output)
|
||||
if err := encoder.Encode(TextChange{Ready: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
ticker := time.NewTicker(time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-inputDone:
|
||||
return err
|
||||
case request := <-settings:
|
||||
enabled := C.int(0)
|
||||
if request.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
C.clipboard_watch_enable(enabled, C.ulonglong(request.Epoch))
|
||||
if err := encoder.Encode(TextChange{Ready: true, Epoch: request.Epoch}); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-ticker.C:
|
||||
C.clipboard_watch_iterate()
|
||||
for {
|
||||
change := C.clipboard_watch_next()
|
||||
if change == nil {
|
||||
break
|
||||
}
|
||||
value := TextChange{Text: C.GoString(change.text), Epoch: uint64(change.epoch), Timestamp: uint32(change.timestamp)}
|
||||
C.clipboard_watch_free(change)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include <gtk/gtk.h>
|
||||
typedef struct {
|
||||
char *text;
|
||||
unsigned long long epoch;
|
||||
gboolean done;
|
||||
gint64 deadline;
|
||||
guint timestamp;
|
||||
int references;
|
||||
} ClipboardChange;
|
||||
int clipboard_watch_init(void);
|
||||
void clipboard_watch_enable(int enabled, unsigned long long epoch);
|
||||
void clipboard_watch_iterate(void);
|
||||
ClipboardChange *clipboard_watch_next(void);
|
||||
void clipboard_watch_free(ClipboardChange *change);
|
||||
Reference in new issue
Block a user