Add Android clipboard text paste into Steam sessions

This commit is contained in:
xXJsonDeruloXx committed 2026-10-02 17:29:41 -04:00
1 parent 3404bbc128
commit eda5ece382
11 files changed
+309 -3

No files matched your search

+4 -1
View File
@@ -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
@@ -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() {
@@ -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()
}
}
}
@@ -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",
@@ -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")
@@ -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() {
@@ -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) {
+31
View File
@@ -0,0 +1,31 @@
# 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`.
+3
View File
@@ -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
+154
View File
@@ -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 <sys/inotify.h>
#include <sys/stat.h>
#include <sys/prctl.h>
#include <signal.h>
#include <poll.h>
#include <fcntl.h>
#include <unistd.h>
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>
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;
}
@@ -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