From 1aca5ba0f7973a9bb56b4464033b53f139554924 Mon Sep 17 00:00:00 2001 From: SraqZit Date: Mon, 20 Apr 2026 22:42:11 +0100 Subject: [PATCH 1/2] fix(x11): use xclip for persistent clipboard ownership In X11, the owning process must stay alive to serve SelectionRequest events. The previous native implementation held ownership for only 1 second before exiting, causing the clipboard to appear empty to other apps after the TUI closed. - Use xclip to copy text/images so ownership persists after TUI closes - Check TARGETS atom before requesting image data to avoid xclip returning garbage bytes for unsupported formats `xclip` must be installed for X11 clipboard to work correctly. `xsel` could be impelmented the same way too except for images which are unsupported --- display/x11.go | 8 +++-- handlers/x11.go | 90 +++++++++++++++++++++++++--------------------- shell/constants.go | 1 + shell/x11.go | 20 +++++++++++ 4 files changed, 77 insertions(+), 42 deletions(-) diff --git a/display/x11.go b/display/x11.go index 05aadb4..7a2b85b 100644 --- a/display/x11.go +++ b/display/x11.go @@ -14,11 +14,15 @@ func (xds *XDS) Runtime() string { } func (xds *XDS) CopyText(text string) { - handlers.X11SetClipboardText(text) + if err := shell.X11CopyText(text); err != nil { + handlers.X11SetClipboardText(text) + } } func (xds *XDS) CopyImage(filePath string) { - handlers.X11SetClipboardImage(filePath) + if err := shell.X11CopyImage(filePath); err != nil { + handlers.X11SetClipboardImage(filePath) + } } func (xds *XDS) ReadClipboard() string { diff --git a/handlers/x11.go b/handlers/x11.go index 90fd95b..afb05d2 100644 --- a/handlers/x11.go +++ b/handlers/x11.go @@ -141,65 +141,73 @@ char* getClipboardTextX11() { unsigned char* getClipboardImageX11(int *out_len) { init_x11(); if (!dpy) return NULL; - *out_len = 0; Atom sel = XA_CLIPBOARD; + Atom TARGETS = XInternAtom(dpy, "TARGETS", False); // preferred MIME targets (BMP removed) Atom PNG = XInternAtom(dpy, "image/png", False); Atom JPEG = XInternAtom(dpy, "image/jpeg", False); - Atom targets[] = { PNG, JPEG }; - const int ntargets = sizeof(targets) / sizeof(targets[0]); - - for (int i = 0; i < ntargets; i++) { - Atom target = targets[i]; - - // Ask clipboard owner to convert to requested type - XConvertSelection(dpy, sel, target, target, win, CurrentTime); - XFlush(dpy); + // First ask what formats are available + XConvertSelection(dpy, sel, TARGETS, TARGETS, win, CurrentTime); + XFlush(dpy); - // Wait for the SelectionNotify event - XEvent ev; - XNextEvent(dpy, &ev); + XEvent ev; + XNextEvent(dpy, &ev); - if (ev.type != SelectionNotify) - continue; + if (ev.type != SelectionNotify || ev.xselection.property == None) + return NULL; - if (ev.xselection.property == None) - continue; + Atom type; + int format; + unsigned long len, bytes_left; + unsigned char *data = NULL; - Atom type; - int format; - unsigned long len, bytes_left; - unsigned char *data = NULL; + XGetWindowProperty(dpy, win, TARGETS, 0, ~0, False, + AnyPropertyType, &type, &format, + &len, &bytes_left, &data); - if (XGetWindowProperty(dpy, win, target, 0, ~0, False, - AnyPropertyType, &type, &format, - &len, &bytes_left, &data) != Success) { - continue; - } + if (!data) return NULL; - if (!data || len == 0) { - if (data) XFree(data); - continue; - } + // Check if PNG or JPEG is in the supported targets + Atom *atoms = (Atom*)data; + Atom target = None; + for (unsigned long i = 0; i < len; i++) { + if (atoms[i] == PNG) { target = PNG; break; } + if (atoms[i] == JPEG) { target = JPEG; break; } + } + XFree(data); - // XGetWindowProperty returns len in terms of format units, not bytes - // format is in bits (8, 16, or 32), so calculate actual byte length - int actual_len = len * (format / 8); + if (target == None) return NULL; // no image format available - // Copy result to malloc'd buffer (Go will free this) - unsigned char *copy = malloc(actual_len); - memcpy(copy, data, actual_len); - XFree(data); + // Now request the actual image data + XConvertSelection(dpy, sel, target, target, win, CurrentTime); + XFlush(dpy); - *out_len = actual_len; - return copy; + XNextEvent(dpy, &ev); + if (ev.type != SelectionNotify || ev.xselection.property == None) + return NULL; + + data = NULL; + if (XGetWindowProperty(dpy, win, target, 0, ~0, False, + AnyPropertyType, &type, &format, + &len, &bytes_left, &data) != Success) + return NULL; + + if (!data || len == 0) { + if (data) XFree(data); + return NULL; } - return NULL; // neither PNG nor JPEG available + int actual_len = len * (format / 8); + unsigned char *copy = malloc(actual_len); + memcpy(copy, data, actual_len); + XFree(data); + + *out_len = actual_len; + return copy; } // Clipboard data holder @@ -333,6 +341,7 @@ int setClipboardImageX11(unsigned char *data, int len, const char *mime_type) { } */ import "C" + import ( "fmt" "os" @@ -372,6 +381,7 @@ func RunX11Listener() { if imgContents != nil { utils.HandleError(SaveImage(imgContents)) + continue } // Check if the clipboard content should be excluded based on source application diff --git a/shell/constants.go b/shell/constants.go index 5b66d08..16659f3 100644 --- a/shell/constants.go +++ b/shell/constants.go @@ -5,6 +5,7 @@ const ( listenShellCmd = "--listen-shell" pgrepCmd = "pgrep 'clipse'" psCmd = "ps -o command" + x11CopyHandler = "xclip" wlCopyHandler = "wl-copy" wlPasteHandler = "wl-paste" wlPasteWatcher = "--watch" diff --git a/shell/x11.go b/shell/x11.go index 25540a8..d8bb98a 100644 --- a/shell/x11.go +++ b/shell/x11.go @@ -1,6 +1,7 @@ package shell import ( + "fmt" "os/exec" "strings" @@ -24,6 +25,25 @@ func X11ActiveWindowTitle() string { return "" } +func X11CopyText(text string) error { + cmd := exec.Command(x11CopyHandler, "-selection", "clipboard") + cmd.Stdin = strings.NewReader(text) + err := cmd.Run() + if err != nil { + utils.LogERROR(fmt.Sprintf("failed to copy text via xclip: %v", err)) + } + return err +} + +func X11CopyImage(filePath string) error { + cmd := exec.Command(x11CopyHandler, "-selection", "clipboard", "-t", "image/png", "-i", filePath) + err := cmd.Run() + if err != nil { + utils.LogERROR(fmt.Sprintf("failed to copy image via xclip: %v", err)) + } + return err +} + // tryXprop tries getting the window title for X11 systems using xprop - property displayer for X // xprop is widely available on X11 desktop environments // Example output: _NET_ACTIVE_WINDOW(WINDOW): window id # 0x1a00005 (then) WM_NAME(STRING) = "Alacritty" From b41e45cf88ad66411252716edf9fc0245d91eacc Mon Sep 17 00:00:00 2001 From: souualil Date: Tue, 21 Apr 2026 21:38:28 +0100 Subject: [PATCH 2/2] fix(x11): detach xclip process to persist clipboard ownership beyond parent terminal lifetime --- display/x11.go | 8 ++------ shell/x11.go | 23 ++++++++++------------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/display/x11.go b/display/x11.go index 7a2b85b..0f6f227 100644 --- a/display/x11.go +++ b/display/x11.go @@ -14,15 +14,11 @@ func (xds *XDS) Runtime() string { } func (xds *XDS) CopyText(text string) { - if err := shell.X11CopyText(text); err != nil { - handlers.X11SetClipboardText(text) - } + shell.X11CopyText(text) } func (xds *XDS) CopyImage(filePath string) { - if err := shell.X11CopyImage(filePath); err != nil { - handlers.X11SetClipboardImage(filePath) - } + shell.X11CopyImage(filePath) } func (xds *XDS) ReadClipboard() string { diff --git a/shell/x11.go b/shell/x11.go index d8bb98a..079b0ae 100644 --- a/shell/x11.go +++ b/shell/x11.go @@ -1,9 +1,9 @@ package shell import ( - "fmt" "os/exec" "strings" + "syscall" "github.com/savedra1/clipse/utils" ) @@ -25,23 +25,20 @@ func X11ActiveWindowTitle() string { return "" } -func X11CopyText(text string) error { +func X11CopyText(text string) { cmd := exec.Command(x11CopyHandler, "-selection", "clipboard") - cmd.Stdin = strings.NewReader(text) - err := cmd.Run() - if err != nil { - utils.LogERROR(fmt.Sprintf("failed to copy text via xclip: %v", err)) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pgid: 0, } - return err + + cmd.Stdin = strings.NewReader(text) + utils.HandleError(cmd.Start()) } -func X11CopyImage(filePath string) error { +func X11CopyImage(filePath string) { cmd := exec.Command(x11CopyHandler, "-selection", "clipboard", "-t", "image/png", "-i", filePath) - err := cmd.Run() - if err != nil { - utils.LogERROR(fmt.Sprintf("failed to copy image via xclip: %v", err)) - } - return err + runDetachedCmd(cmd) } // tryXprop tries getting the window title for X11 systems using xprop - property displayer for X