diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9645c6b1..a16556d3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -157,7 +157,7 @@ jobs: uses: actions/cache@v4 with: path: app/src/main/assets/linuxfs - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }} + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/clipboard/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' @@ -179,6 +179,9 @@ jobs: aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ -o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so" + mkdir -p "$D/usr/local/bin" + aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl + aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard" for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" done diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 6e2acff33..43606b3d0 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -41,6 +41,7 @@ import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.gpu.TurnipDriver import com.droiddeck.launcher.input.EvdevKeys import com.droiddeck.launcher.input.KeyboardHost +import com.droiddeck.launcher.input.SessionClipboard import com.droiddeck.launcher.input.OnScreenControls import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PointerGestures @@ -125,6 +126,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private var onScreenControls: OnScreenControls? = null private var keyboard: KeyboardHost? = null + private val sessionClipboard by lazy { + SessionClipboard(this) { + window.decorView.hasWindowFocus() || secondScreenPresentation?.window?.decorView?.hasWindowFocus() == true + } + } private var watching = true private lateinit var touchpad: TouchpadGestures private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) @@ -1657,12 +1663,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onScreenControls?.reload() updateOnScreenControls() resumed = true + sessionClipboard.start() updatePadMotion() readPrefs() if (CompositorHost.isStarted) applyFrameGen() } override fun onPause() { + sessionClipboard.stop() // Do not carry transient session UI across an app/display transition. In particular, the // drawer's dim layer can otherwise remain over Steam when this activity returns. drawerOpen = false @@ -1687,7 +1695,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) - if (hasFocus) goFullscreen() + if (hasFocus) { + goFullscreen() + refreshClipboard() + } + } + + /** The keyboard/trackpad presentation can own focus instead of the main display. */ + fun refreshClipboard() { + if (resumed) sessionClipboard.refresh() } private fun goFullscreen() { diff --git a/app/src/main/java/com/droiddeck/launcher/input/SessionClipboard.kt b/app/src/main/java/com/droiddeck/launcher/input/SessionClipboard.kt new file mode 100644 index 000000000..faa6e1ace --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/SessionClipboard.kt @@ -0,0 +1,81 @@ +package com.droiddeck.launcher.input + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.system.Os +import android.util.Log +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.io.File +import java.io.IOException + +/** Text selections shared by Android and the guest while the session is visible. */ +class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) { + private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private val handler = Handler(Looper.getMainLooper()) + private val hostFile = File(context.filesDir, "session/android-clipboard") + private var active = false + private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh() } + private val guestListener = object : WaylandCompositor.ClipboardListener { + override fun onGuestClipboardText(text: String) { + // Native callbacks arrive on the compositor thread. Do not publish a delayed callback + // after this activity has paused or been replaced. + handler.post { + if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) { + val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text + if (current?.toString() != text) { + clipboard.setPrimaryClip(ClipData.newPlainText("DroidDeck", text)) + } + } + } + } + } + + fun start() { + if (active) return + active = true + clipboard.addPrimaryClipChangedListener(androidListener) + WaylandCompositor.setClipboardListener(guestListener) + refresh() + } + + fun stop() { + if (!active) return + active = false + clipboard.removePrimaryClipChangedListener(androidListener) + WaylandCompositor.clearClipboardListener(guestListener) + handler.removeCallbacksAndMessages(null) + } + + /** Android 10+ permits clipboard reads only with window focus. Resume can precede focus. */ + fun refresh() { + if (!active || !hasFocus()) return + val clip = clipboard.primaryClip + // Only share literal text, never resolve content URIs or load copied files. + if (clip == null) { + publish(null) + } else if (clip.itemCount > 0) { + publish(clip.getItemAt(0).text?.toString()) + } + } + + private fun publish(text: String?) { + WaylandCompositor.setClipboardText(text) + // Gamescope needs the same selection in its own Xwayland. Rename complete UTF-8 text + // into place so its helper never sees half a launch command. Kept in private app data. + val pending = File(hostFile.parentFile, "android-clipboard.pending") + try { + hostFile.parentFile?.mkdirs() + pending.writeText(text.orEmpty(), Charsets.UTF_8) + Os.rename(pending.path, hostFile.path) + } catch (e: IOException) { + Log.w("SessionClipboard", "Could not share text with Xwayland", e) + } catch (e: android.system.ErrnoException) { + Log.w("SessionClipboard", "Could not publish text for Xwayland", e) + } finally { + pending.delete() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index a98a05460..b5e36f00d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -32,6 +32,7 @@ object SessionFiles { "libfakeinput.so" to "usr/local/lib/libfakeinput.so", "libblfastpath.so" to "usr/local/lib/libblfastpath.so", "usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session", + "usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard", "usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat", "usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install", "usr/local/bin/bannerlator-steam-library" to "usr/local/bin/bannerlator-steam-library", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index cd1ef2e9d..77cb816bd 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -514,6 +514,7 @@ class SessionService : Service() { guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR) guest.add("XDG_SESSION_TYPE=wayland") guest.add("WAYLAND_DISPLAY=wayland-0") + guest.add("BL_ANDROID_CLIPBOARD=" + File(filesDir, "session/android-clipboard").path) guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1") // Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink. guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 78430d16f..44f56bad8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -100,7 +100,10 @@ class SecondScreenPresentation( override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) - if (hasFocus) applyImmersiveMode() + if (hasFocus) { + applyImmersiveMode() + sessionActivity.refreshClipboard() + } } private fun renderMode() { diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 71c7172a0..e49394194 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -132,6 +132,13 @@ public final class WaylandCompositor { public static void setClipboardListener(ClipboardListener l) { clipboardListener = l; } + public static boolean isClipboardListener(ClipboardListener l) { return clipboardListener == l; } + + /** A replaced activity must not unhook the clipboard listener of its successor. */ + public static void clearClipboardListener(ClipboardListener l) { + if (clipboardListener == l) clipboardListener = null; + } + /** Invoked from native (banner_on_clipboard_text) with UTF-8 bytes. */ @SuppressWarnings("unused") static void onClipboardText(byte[] utf8) { diff --git a/docs/testing/android-clipboard.md b/docs/testing/android-clipboard.md new file mode 100644 index 000000000..87e6f8905 --- /dev/null +++ b/docs/testing/android-clipboard.md @@ -0,0 +1,49 @@ +# Android clipboard text + +Copy text in an Android app, return to DroidDeck, and paste into Steam with Ctrl+V. +The session's PC keyboard supports this through its sticky Ctrl key and V. + +Android clipboard reads follow window focus, including the secondary-display presentation. +The existing Wayland text selection handles native guest clients. Gamescope does not relay +that selection to its inner Xwayland, so a session helper publishes Android text there. +It waits for file and X11 events, and exits when its parent session exits. + +The Gamescope bridge currently supports Android-to-guest plain text up to 64 KiB. +It does not transfer images/files or export Steam's copied text back to Android. +Oversized text clears an earlier Android selection rather than pasting a truncated command. +Clipboard text stays in private app data and is never included in session logs. + +## Thor validation, 2026-10-02 + +AYN Thor, serial `d234a848`, Android 13, release APK installed in place. +Built with `tools/build_local.sh`; shell syntax, workflow YAML, and diff checks passed. +Installed APK SHA-256 matched the local artifact: +`624782af4319af538a9ae5dba6e012ebb51c7fd43a3955c4f568d214f9c32c07`. + +- The supplied launch-options string pasted through Ctrl+V into Steam's actual Launch Options field exactly. The field was restored to its original empty value. +- Unicode (`café 日本語 😀`, quotes, dollar sign, and backslash) also pasted exactly into Steam. The field was restored again. +- Final-build X11 selection checks passed for launch options, multiline text, Unicode, 8 KiB, clearing, and replacing the clipboard across Android app switches. +- Copying on Thor's second display transferred the text while Steam remained visible on the first display. +- Copying the same Android text after a guest owned the X11 selection restored the Android selection. +- A 65,537-byte clipboard was rejected without truncation; a subsequent normal clipboard transferred correctly. +- The helper exited during APK replacement. The temporary Android test app, guest probe, and local ADB forward were removed. + +Local screenshots, field values, build log, and check results: `/tmp/droiddeck-clipboard-evidence`. + +## Visible editor-to-Steam test + +Typed the supplied launch command into a temporary native Android EditText editor +using Android keyboard events, selected all, and copied with Android's standard +Ctrl+C action. Returned to the running DroidDeck Steam session and pasted into +The Sims Legacy Collection's Launch Options. Steam's persisted value matched the +complete 126-byte command exactly. No clipboard-setting API was used by the editor. + +Repeated with `DroidDeck Thor clipboard test 2026-10-02`, using Steam's on-screen +keyboard Paste button. The whole value is visible in these unedited Thor screenshots: + +![Text selected and copied in the Android editor](android-clipboard/android-copy.png) + +![The same text pasted into Steam](android-clipboard/steam-paste.png) + +The launch options were restored to their original empty value and verified in +Steam's configuration. The temporary editor and ADB forward were removed. diff --git a/docs/testing/android-clipboard/android-copy.png b/docs/testing/android-clipboard/android-copy.png new file mode 100644 index 000000000..37c283e07 Binary files /dev/null and b/docs/testing/android-clipboard/android-copy.png differ diff --git a/docs/testing/android-clipboard/steam-paste.png b/docs/testing/android-clipboard/steam-paste.png new file mode 100644 index 000000000..778659d13 Binary files /dev/null and b/docs/testing/android-clipboard/steam-paste.png differ diff --git a/tools/build_local.sh b/tools/build_local.sh index 28656829a..374cff887 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -120,6 +120,9 @@ docker run --rm --platform linux/amd64 \ aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ -o "$d/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl aarch64-linux-gnu-strip --strip-unneeded "$d/libblfastpath.so" + mkdir -p "$d/usr/local/bin" + aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$d/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl + aarch64-linux-gnu-strip --strip-unneeded "$d/usr/local/bin/droiddeck-clipboard" for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do install -Dm644 "$script" "$d/usr/local/bin/$(basename "$script")" done diff --git a/tools/linuxfs/clipboard/clipboard.c b/tools/linuxfs/clipboard/clipboard.c new file mode 100644 index 000000000..cd91d65d1 --- /dev/null +++ b/tools/linuxfs/clipboard/clipboard.c @@ -0,0 +1,154 @@ +/* Android text -> Gamescope's Xwayland selection. Gamescope's Wayland backend does not + * relay the outer compositor's clipboard. libX11 is loaded from the guest at runtime; + * the small stable Xlib ABI below keeps this helper independent of cross X11 headers. + * The private host file is atomically replaced, never put in session logs. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct _XDisplay Display; +typedef unsigned long Window, Atom, Time; +typedef struct { + int type; unsigned long serial; int send_event; Display *display; + Window owner, requestor; Atom selection, target, property; Time time; +} SelectionRequest; +typedef struct { + int type; unsigned long serial; int send_event; Display *display; + Window requestor; Atom selection, target, property; Time time; +} SelectionNotify; +typedef union { int type; SelectionRequest request; SelectionNotify selection; long pad[24]; } Event; + +#define API(ret, name, args) static ret (*name) args +API(Display *, XOpenDisplay, (const char *)); +API(Window, XDefaultRootWindow, (Display *)); +API(Window, XCreateSimpleWindow, (Display *, Window, int, int, unsigned, unsigned, unsigned, unsigned long, unsigned long)); +API(Atom, XInternAtom, (Display *, const char *, int)); +API(int, XSetSelectionOwner, (Display *, Atom, Window, Time)); +API(Window, XGetSelectionOwner, (Display *, Atom)); +API(int, XChangeProperty, (Display *, Window, Atom, Atom, int, int, const unsigned char *, int)); +API(int, XSendEvent, (Display *, Window, int, long, Event *)); +API(int, XPending, (Display *)); +API(int, XNextEvent, (Display *, Event *)); +API(int, XFlush, (Display *)); +API(int, XConnectionNumber, (Display *)); +API(int, XCloseDisplay, (Display *)); +API(void *, XSetErrorHandler, (int (*)(Display *, void *))); + +/* Stay below X11's normal maximum property request. Never paste a truncated command. */ +#define MAX_TEXT (64 * 1024) +static int ignore_x_error(Display *d, void *event) { (void)d; (void)event; return 0; } + +/* The host renames complete files into place. The inode distinguishes copying the same + * text again after a guest program has taken ownership of the clipboard. */ +static int refresh(Display *d, Window w, Atom clipboard, const char *path, + char **text, size_t *len, ino_t *inode) { + int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) return 0; + struct stat st; + if (fstat(fd, &st) || !S_ISREG(st.st_mode) || st.st_ino == *inode) { close(fd); return 0; } + *inode = st.st_ino; + if (st.st_size < 0 || st.st_size > MAX_TEXT) { + fprintf(stderr, "== clipboard: text exceeds %d bytes, not pasted\n", MAX_TEXT); + if (XGetSelectionOwner(d, clipboard) == w) XSetSelectionOwner(d, clipboard, 0, 0); + XFlush(d); + free(*text); *text = NULL; *len = 0; + close(fd); return 0; + } + char *copy = malloc((size_t)st.st_size + 1); + if (!copy) { close(fd); return 0; } + size_t got = 0; + while (got < (size_t)st.st_size) { + ssize_t n = read(fd, copy + got, (size_t)st.st_size - got); + if (n > 0) got += n; + else if (n < 0 && errno == EINTR) continue; + else break; + } + close(fd); + if (got != (size_t)st.st_size) { free(copy); return 0; } + copy[got] = 0; + free(*text); *text = copy; *len = got; + if (got) XSetSelectionOwner(d, clipboard, w, 0); + else if (XGetSelectionOwner(d, clipboard) == w) XSetSelectionOwner(d, clipboard, 0, 0); + XFlush(d); + fprintf(stderr, "== clipboard: Android text available in Xwayland (%zu bytes)\n", got); + return 1; +} + +int main(int argc, char **argv) { + if (argc != 2 || !getenv("DISPLAY")) return 1; + pid_t parent_pid = getppid(); + if (parent_pid == 1 || prctl(PR_SET_PDEATHSIG, SIGTERM) || getppid() != parent_pid) return 1; + void *lib = dlopen("libX11.so.6", RTLD_NOW | RTLD_LOCAL); + if (!lib) { fprintf(stderr, "== clipboard: libX11 unavailable\n"); return 1; } +#define LOAD(name) do { *(void **)(&name) = dlsym(lib, #name); if (!name) return 1; } while (0) + LOAD(XOpenDisplay); LOAD(XDefaultRootWindow); LOAD(XCreateSimpleWindow); LOAD(XInternAtom); + LOAD(XSetSelectionOwner); LOAD(XGetSelectionOwner); LOAD(XChangeProperty); LOAD(XSendEvent); + LOAD(XPending); LOAD(XNextEvent); LOAD(XFlush); LOAD(XConnectionNumber); LOAD(XCloseDisplay); + LOAD(XSetErrorHandler); + Display *d = XOpenDisplay(NULL); + if (!d) { fprintf(stderr, "== clipboard: cannot open Xwayland\n"); return 1; } + XSetErrorHandler(ignore_x_error); /* A paste requester can close its window at any time. */ + Window w = XCreateSimpleWindow(d, XDefaultRootWindow(d), 0, 0, 1, 1, 0, 0, 0); + Atom clipboard = XInternAtom(d, "CLIPBOARD", 0); + Atom targets = XInternAtom(d, "TARGETS", 0), utf8 = XInternAtom(d, "UTF8_STRING", 0); + Atom plain = XInternAtom(d, "text/plain;charset=utf-8", 0), text_atom = XInternAtom(d, "TEXT", 0); + Atom string = XInternAtom(d, "STRING", 0); + char parent[PATH_MAX]; + if (strlen(argv[1]) >= sizeof(parent)) return 1; + strcpy(parent, argv[1]); char *slash = strrchr(parent, '/'); + if (!slash) return 1; + *slash = 0; + int watch = inotify_init1(IN_CLOEXEC | IN_NONBLOCK); + if (watch < 0 || inotify_add_watch(watch, parent, IN_MOVED_TO | IN_CLOSE_WRITE) < 0) return 1; + char *text = NULL; size_t len = 0; ino_t inode = 0; + refresh(d, w, clipboard, argv[1], &text, &len, &inode); + struct pollfd fds[] = {{XConnectionNumber(d), POLLIN, 0}, {watch, POLLIN, 0}}; + for (;;) { + while (XPending(d)) { + Event e; XNextEvent(d, &e); + if (e.type != 30) continue; /* SelectionRequest */ + SelectionRequest *r = &e.request; + Atom property = r->property ? r->property : r->target; + Event reply = {0}; + reply.selection = (SelectionNotify){31, 0, 1, d, r->requestor, r->selection, r->target, 0, r->time}; + if (r->selection == clipboard && text && len) { + size_t ascii; for (ascii = 0; ascii < len && (unsigned char)text[ascii] < 128; ascii++) {} + if (r->target == targets) { + Atom supported[] = {targets, utf8, plain, text_atom, string}; + XChangeProperty(d, r->requestor, property, 4 /* XA_ATOM */, 32, 0, + (const unsigned char *)supported, ascii == len ? 5 : 4); + reply.selection.property = property; + } else if (r->target == utf8 || r->target == plain || r->target == text_atom) { + XChangeProperty(d, r->requestor, property, utf8, 8, 0, (const unsigned char *)text, (int)len); + reply.selection.property = property; + } else if (r->target == string) { + /* STRING is Latin-1. Offer it only for ASCII rather than corrupt UTF-8. */ + if (ascii == len) { + XChangeProperty(d, r->requestor, property, string, 8, 0, (const unsigned char *)text, (int)len); + reply.selection.property = property; + } + } + } + XSendEvent(d, r->requestor, 0, 0, &reply); XFlush(d); + } + if (poll(fds, 2, -1) < 0) { if (errno == EINTR) continue; break; } + if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) break; + if (fds[1].revents & POLLIN) { + char events[4096]; while (read(watch, events, sizeof(events)) > 0) {} + refresh(d, w, clipboard, argv[1], &text, &len, &inode); + } + } + free(text); close(watch); XCloseDisplay(d); dlclose(lib); + return 0; +} diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index cb6595340..4a4049315 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -216,6 +216,12 @@ reap_orphans() { done } +# Gamescope's Wayland backend does not forward the outer clipboard into Xwayland. +# This helper owns only Android text selections and exits with this guest session. +if [ -n "${BL_ANDROID_CLIPBOARD:-}" ] && [ -x /usr/local/bin/droiddeck-clipboard ]; then + /usr/local/bin/droiddeck-clipboard "$BL_ANDROID_CLIPBOARD" & +fi + case $mode in desktop) exec pcmanfm