From a3dabd8f541b5f3816843d8dec4d708e1d15cd01 Mon Sep 17 00:00:00 2001 From: GlobalTechInfo Date: Thu, 29 Oct 2026 15:44:13 +0500 Subject: [PATCH] Bridge the Android clipboard into distro sessions A guest tool that needs a clipboard - gh auth login copies its one-time code with xsel/xclip/wl-clipboard/Termux:API - has none inside a distro: no X, no Wayland, and Termux:API talks to the Termux app, which this is not. The device flow then prints a code nobody can get onto the phone's system clipboard, so the paste into the browser's login page is manual best-effort at best. The rootfs gets xsel/xclip shims in /usr/local/bin (on the session PATH ahead of anything the distro ships) that write the content into the app's own external storage. The guest reaches that path through the /sdcard bind, and the write needs no all-files grant because the guest processes run as this app's uid. The app side pumps the trigger file while the terminal is on screen and hands the content to the system clipboard, with a toast. The protocol is files, not a socket: no port to collide with, and a set left pending when the process dies is picked up on the next resume, because the trigger outlives the process. - ClipboardBridge: shim text, marker-based install, consumption - ProotLaunch: optional clipboardGuestDir exports REDTERM_CLIPDIR - TerminalActivity: installs the shims per session and pumps the bridge - DistroRunner: same install for one-shot commands - 20 new tests, including the shim driven through a real /bin/sh --- .../com/redtermapp/distro/DistroRunner.kt | 9 +- .../java/com/redtermapp/distro/ProotLaunch.kt | 25 ++- .../com/redtermapp/ui/TerminalActivity.kt | 49 +++++- .../com/redtermapp/util/ClipboardBridge.kt | 126 +++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../com/redtermapp/distro/ProotLaunchTest.kt | 21 ++- .../redtermapp/util/ClipboardBridgeTest.kt | 149 ++++++++++++++++++ 7 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/redtermapp/util/ClipboardBridge.kt create mode 100644 app/src/test/java/com/redtermapp/util/ClipboardBridgeTest.kt diff --git a/app/src/main/java/com/redtermapp/distro/DistroRunner.kt b/app/src/main/java/com/redtermapp/distro/DistroRunner.kt index 93b69f1..f76c37f 100644 --- a/app/src/main/java/com/redtermapp/distro/DistroRunner.kt +++ b/app/src/main/java/com/redtermapp/distro/DistroRunner.kt @@ -2,6 +2,7 @@ package com.redtermapp.distro import android.content.Context import com.redtermapp.R +import com.redtermapp.util.ClipboardBridge import android.os.Build import android.util.Log import java.io.File @@ -55,6 +56,11 @@ class DistroRunner(private val context: Context) { } File(rootfsDir, "tmp").mkdirs() installer.refreshNetworkConfig(distroName) + val clipHost = ClipboardBridge.hostDir(context) + val clipGuest = clipHost?.let { ClipboardBridge.guestPath(it.absolutePath) } + if (clipGuest != null) { + ClipboardBridge.installShims(rootfsDir, clipGuest) + } val launcher = ProotLaunch.writeLauncher( context = context, rootfsDir = rootfsDir, @@ -62,7 +68,8 @@ class DistroRunner(private val context: Context) { command = command, // Its own script: sharing launch.sh with the terminal meant a // background update could rewrite the file the terminal was starting. - scriptName = "command-run.sh" + scriptName = "command-run.sh", + clipboardGuestDir = clipGuest ) return try { val process = ProcessBuilder("/system/bin/sh", launcher.absolutePath) diff --git a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt index d783711..68029af 100644 --- a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt +++ b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt @@ -36,7 +36,8 @@ object ProotLaunch { startInner: String? = null, command: String? = null, scriptName: String = "launch.sh", - mergeStderr: Boolean = true + mergeStderr: Boolean = true, + clipboardGuestDir: String? = null ): File { val out = File(context.filesDir, scriptName) out.parentFile?.mkdirs() @@ -47,7 +48,8 @@ object ProotLaunch { hasLoader32 = File("${context.applicationInfo.nativeLibraryDir}/libproot-loader32.so").exists(), startInner = startInner, command = command, - mergeStderr = mergeStderr + mergeStderr = mergeStderr, + clipboardGuestDir = clipboardGuestDir ) ) out.setExecutable(true, true) @@ -61,7 +63,8 @@ object ProotLaunch { hasLoader32: Boolean, startInner: String? = null, command: String? = null, - mergeStderr: Boolean = true + mergeStderr: Boolean = true, + clipboardGuestDir: String? = null ): String { return buildScriptText( nativeLibDir = nativeLibDir, @@ -69,7 +72,8 @@ object ProotLaunch { hasLoader32 = hasLoader32, startInner = startInner, command = command, - mergeStderr = mergeStderr + mergeStderr = mergeStderr, + clipboardGuestDir = clipboardGuestDir ) } @@ -79,7 +83,8 @@ object ProotLaunch { hasLoader32: Boolean, startInner: String?, command: String?, - mergeStderr: Boolean + mergeStderr: Boolean, + clipboardGuestDir: String? ): String { val prootBin = "$nativeLibDir/libproot.so" val prootLoader = "$nativeLibDir/libproot-loader.so" @@ -92,6 +97,15 @@ object ProotLaunch { // middle of an SFTP packet stream desynchronises it permanently. Callers // that speak a protocol turn this off and read stderr themselves. val redirect = if (mergeStderr) " 2>&1" else "" + // The clipboard bridge directory, if this session is wired to one: + // the xsel/xclip shims in the rootfs write here and the app side + // moves the content onto the system clipboard. The path is the + // host's, because /sdcard binds onto /sdcard. + val clip = + if (clipboardGuestDir != null) { + "export REDTERM_CLIPDIR=${quoteForShell(clipboardGuestDir)}\n" + + "mkdir -p ${quoteForShell(clipboardGuestDir)}\n" + } else "" val tail = if (command == null) { "/system/bin/sh -i" + redirect } else { @@ -118,6 +132,7 @@ export PATH=/system/bin:/system/xbin:/bin:/sbin:/usr/local/sbin:/usr/local/bin:/ export ENV=/root/.startup export TERM=xterm-256color export COLORTERM=truecolor +$clip if command -v locale >/dev/null 2>&1 && locale -a 2>/dev/null | grep -qi '^C\.utf-\?8$'; then export LANG=C.UTF-8 export LC_ALL=C.UTF-8 diff --git a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt index fe40e2c..afbb983 100644 --- a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt @@ -1,5 +1,7 @@ package com.redtermapp.ui +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.os.Bundle @@ -35,6 +37,7 @@ import com.redtermapp.mosh.MoshBootstrap import com.redtermapp.mosh.MoshClient import com.redtermapp.mosh.MoshNative import com.redtermapp.util.AppLog +import com.redtermapp.util.ClipboardBridge import com.redtermapp.util.SshClient import com.redtermapp.distro.DistroInstaller import com.redtermapp.distro.ProotLaunch @@ -211,6 +214,34 @@ class TerminalActivity : AppCompatActivity() { } } + private val clipboardHandler = android.os.Handler(Looper.getMainLooper()) + private val clipboardRunnable = object : Runnable { + override fun run() { + pumpClipboardBridge() + clipboardHandler.postDelayed(this, 1000) + } + } + + /** + * The app half of [ClipboardBridge]: whatever a + * session's xsel/xclip wrote into the protocol directory goes onto the + * system clipboard here. Runs only while resumed, because + * setPrimaryClip matters only while this app is focused — the moment + * the user opens a browser to paste, the code is already in the paste + * box. A set missed while backgrounded, or across a process death, is + * picked up by the first pump on resume, because the protocol files + * outlive the process. + */ + private fun pumpClipboardBridge() { + val dir = ClipboardBridge.hostDir(applicationContext) ?: return + val data = ClipboardBridge.consume(dir) ?: return + if (data.isEmpty()) return + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return + clipboard.setPrimaryClip(ClipData.newPlainText("RedTerm", data)) + Toast.makeText(this, R.string.clipboard_bridge_copied, Toast.LENGTH_SHORT).show() + } + private fun wireBackend(backend: TerminalBackend) { backend.onSessionFinished = { finishedSession -> handleSessionFinished(finishedSession) } backend.onChildOutput = { session -> onConnectWatchOutput(session) } @@ -1187,12 +1218,23 @@ class TerminalActivity : AppCompatActivity() { ?.let { File(rootfsDir, it.removePrefix("/")) }?.absolutePath ?: filesDir.absolutePath + // The clipboard bridge: xsel/xclip shims in the rootfs write the + // content into the app's own external storage, where the app side + // moves it onto the system clipboard. Off when there is no + // external storage, which is the same as before the bridge existed. + val clipHost = ClipboardBridge.hostDir(applicationContext) + val clipGuest = clipHost?.let { ClipboardBridge.guestPath(it.absolutePath) } + if (clipGuest != null) { + ClipboardBridge.installShims(rootfsDir, clipGuest) + } + val launchSh = ProotLaunch.writeLauncher( context = this, rootfsDir = rootfsDir, startInner = startInner, command = null, - scriptName = launcherName(id) + scriptName = launcherName(id), + clipboardGuestDir = clipGuest ) return PreparedSession( @@ -2365,11 +2407,16 @@ class TerminalActivity : AppCompatActivity() { terminalView.requestFocus() terminalView.onScreenUpdated() titleHandler.postDelayed(titleRunnable, 1000) + // Pick up a clipboard set that happened while backgrounded, or in a + // process that no longer exists: the protocol files outlive both. + pumpClipboardBridge() + clipboardHandler.postDelayed(clipboardRunnable, 1000) RedTermWidgetProvider.updateAll(this) } override fun onPause() { titleHandler.removeCallbacks(titleRunnable) + clipboardHandler.removeCallbacks(clipboardRunnable) super.onPause() } diff --git a/app/src/main/java/com/redtermapp/util/ClipboardBridge.kt b/app/src/main/java/com/redtermapp/util/ClipboardBridge.kt new file mode 100644 index 0000000..0fc5443 --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/ClipboardBridge.kt @@ -0,0 +1,126 @@ +package com.redtermapp.util + +import android.content.Context +import java.io.File + +/** + * Bridges the Android clipboard into a proot distro session. + * + * Guest tools that need a clipboard (`gh auth login` prints its one-time + * code and tries to copy it with xsel/xclip/wl-clipboard/Termux:API) have + * none inside a distro: there is no X, no Wayland, and Termux:API talks to + * the Termux app, which this is not. So the rootfs gets `xsel`/`xclip` + * shims that write the content into this app's own external storage, where + * the guest reaches it through the `/sdcard` bind, and set a trigger + * marker. The app consumes the trigger and hands the content to the system + * clipboard, which is what a browser's paste box then reads. + * + * The protocol is files, not a socket: no port to collide with, nothing + * that dies with a process. A pending set the app died before consuming is + * picked up on the next start, because the trigger file outlives it. + */ +object ClipboardBridge { + + /** Bumped when the shim's text changes; the marker line carries it. */ + const val SHIM_VERSION = 1 + + const val TRIGGER_NAME = "trigger" + const val PENDING_NAME = "pending" + const val LAST_NAME = "last" + + /** + * The protocol directory, on the host side. The guest sees the same + * path, because it lives under /sdcard, which proot binds 1:1; and the + * app's own external files directory is writable by the guest's + * processes without any all-files grant, because they run as this + * app's uid. + * + * Null when there is no external storage at all; then the bridge is + * off, which is the same as today rather than a new failure. + */ + fun hostDir(context: Context): File? { + val root = context.getExternalFilesDir(null) ?: return null + val dir = File(root, "clipboard") + dir.mkdirs() + return dir + } + + /** + * The path the launcher exports to the guest. It is the host path + * verbatim: /sdcard binds onto /sdcard, so there is no translation. + */ + fun guestPath(hostPath: String): String = hostPath + + /** + * The shim, installed as both `xsel` and `xclip` in the rootfs' + * /usr/local/bin, which the session's PATH puts ahead of anything the + * distro might ship. + * + * GET (`-o`) prints the last thing the app put on the clipboard; SET + * (stdin, with or without `-i`) writes it for the app to pick up. + * Unknown flags fall through to SET, which is the only mode that ever + * matters here. The fallback directory covers a session whose + * launcher predates the environment export. + */ + fun shim(fallbackGuestDir: String): String = + listOf( + "#!/system/bin/sh", + "# managed by RedTerm (clipboard shim v$SHIM_VERSION); do not edit", + "d=\"${'$'}{REDTERM_CLIPDIR:-$fallbackGuestDir}\"", + "case \"${'$'}{1:-}\" in", + " -o|--output)", + " cat \"${'$'}d/$LAST_NAME\" 2>/dev/null", + " exit 0", + " ;;", + " *)", + " if [ -t 0 ]; then", + " echo \"${'$'}0: nothing to copy: no input (get the clipboard with '${'$'}0 -o')\" 1>&2", + " exit 1", + " fi", + " cat > \"${'$'}d/$PENDING_NAME\" || exit 1", + " : > \"${'$'}d/$TRIGGER_NAME\"", + " exit 0", + " ;;", + "esac", + ).joinToString("\n") + "\n" + + /** + * Writes the shims into the rootfs' /usr/local/bin, executable, and + * only when the content differs from what is there. That is the rule + * for a managed file: an older RedTerm shim is rewritten on the next + * session start, which is how a corrected shim reaches an install that + * already has one; the marker in the first lines says which hand wrote + * the file. + */ + fun installShims(rootfsDir: File, fallbackGuestDir: String) { + val bin = File(rootfsDir, "usr/local/bin") + bin.mkdirs() + val script = shim(fallbackGuestDir) + for (name in listOf("xsel", "xclip")) { + val file = File(bin, name) + val current = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() } + if (current != script) { + file.writeText(script) + } + file.setExecutable(true, false) + } + } + + /** + * One step of the consumption loop: if the guest set something, move it + * to [LAST_NAME] for the next GET and return it, otherwise null. A + * trigger with no pending file (a kill mid-write) is swept, not an + * error: there is nothing to deliver and the marker must not pile up. + */ + fun consume(dir: File): String? { + val trigger = File(dir, TRIGGER_NAME) + if (!trigger.exists()) return null + val pending = File(dir, PENDING_NAME) + val data = if (pending.isFile) runCatching { pending.readText() }.getOrNull() else null + if (data != null) { + pending.copyTo(File(dir, LAST_NAME), overwrite = true) + } + trigger.delete() + return data + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 263af45..e55d5e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -528,6 +528,7 @@ Delete all recorded crash reports? Crash reports deleted Crash report copied to clipboard + Copied to clipboard Saved to %1$s Copy Download diff --git a/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt b/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt index f6b9a21..6b4fa2e 100644 --- a/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt +++ b/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt @@ -85,13 +85,32 @@ class ProotLaunchTest { assertFalse(withoutLoader.contains("PROOT_LOADER_32")) } + @Test + fun `clipboard bridge export appears only when requested`() { + val withBridge = ProotLaunch.buildScript( + nativeLibDir, rootfsPath, hasLoader32 = true, + clipboardGuestDir = "/sdcard/Android/data/com.redtermapp/files/clipboard" + ) + assertTrue( + withBridge.contains( + "export REDTERM_CLIPDIR='/sdcard/Android/data/com.redtermapp/files/clipboard'" + ) + ) + assertTrue(withBridge.contains("mkdir -p '/sdcard/Android/data/com.redtermapp/files/clipboard'")) + assertFalse("the bridge must be opt-in", interactive().contains("REDTERM_CLIPDIR")) + } + @Test fun `generated scripts are valid shell`() { val outDir = File("build/proot-launch-scripts").apply { mkdirs() } val scripts = mapOf( "interactive" to interactive("/root"), "one-shot" to oneShot("apt-get update -y"), - "no-loader" to ProotLaunch.buildScript(nativeLibDir, rootfsPath, hasLoader32 = false) + "no-loader" to ProotLaunch.buildScript(nativeLibDir, rootfsPath, hasLoader32 = false), + "bridge" to ProotLaunch.buildScript( + nativeLibDir, rootfsPath, hasLoader32 = true, + clipboardGuestDir = "/sdcard/Android/data/com.redtermapp/files/clipboard" + ) ) scripts.forEach { (name, body) -> val file = File(outDir, "$name.sh") diff --git a/app/src/test/java/com/redtermapp/util/ClipboardBridgeTest.kt b/app/src/test/java/com/redtermapp/util/ClipboardBridgeTest.kt new file mode 100644 index 0000000..699968f --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/ClipboardBridgeTest.kt @@ -0,0 +1,149 @@ +package com.redtermapp.util + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * The guest half of the clipboard bridge is a shell file, so it is run, not + * read: the SET and GET paths go through a real /bin/sh with the shim's + * text, and the app half is checked on real files. + */ +class ClipboardBridgeTest { + + private val scratch = ArrayList() + + private fun tempDir(name: String): File = + File(System.getProperty("java.io.tmpdir"), "redterm-$name-${System.nanoTime()}") + .apply { mkdirs(); scratch.add(this) } + + @After + fun clean() { + scratch.forEach { it.deleteRecursively() } + } + + private fun runSh( + args: List, + env: Map = emptyMap(), + stdin: String? = null + ): Pair { + val out = ByteArrayOutputStream() + val builder = ProcessBuilder(listOf("/bin/sh") + args) + for ((k, v) in env) builder.environment()[k] = v + var stdinFile: File? = null + if (stdin != null) { + stdinFile = tempDir("cb-stdin").let { d -> File(d, "in").apply { writeText(stdin) } } + builder.redirectInput(ProcessBuilder.Redirect.from(stdinFile)) + } + builder.redirectOutput(ProcessBuilder.Redirect.PIPE) + val p = builder.start() + out.write(p.inputStream.readBytes()) + val exited = p.waitFor(30, TimeUnit.SECONDS) + if (!exited) { + p.destroyForcibly() + throw AssertionError("sh run did not exit: $args") + } + return p.exitValue() to out.toString(Charsets.UTF_8) + } + + @Test + fun `shim starts with a shebang and carries the managed marker`() { + val s = ClipboardBridge.shim("/sd/clip") + assertTrue(s.startsWith("#!/system/bin/sh\n")) + assertTrue(s.contains("# managed by RedTerm (clipboard shim v${ClipboardBridge.SHIM_VERSION})")) + assertTrue(s.contains("d=\"${'$'}{REDTERM_CLIPDIR:-/sd/clip}\"")) + } + + @Test + fun `set then consume then get round-trips through a real shell`() { + val clip = tempDir("cb-clip") + val bin = tempDir("cb-bin") + val xsel = File(bin, "xsel").apply { + writeText(ClipboardBridge.shim(clip.absolutePath)) + setExecutable(true) + } + + val (setCode, _) = runSh( + listOf(xsel.absolutePath, "-i"), + env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath), + stdin = "CODE-1234" + ) + assertEquals("SET must exit 0", 0, setCode) + assertEquals("CODE-1234", File(clip, ClipboardBridge.PENDING_NAME).readText()) + assertTrue(File(clip, ClipboardBridge.TRIGGER_NAME).exists()) + + assertEquals("CODE-1234", ClipboardBridge.consume(clip)) + assertFalse(File(clip, ClipboardBridge.TRIGGER_NAME).exists()) + assertEquals("CODE-1234", File(clip, ClipboardBridge.LAST_NAME).readText()) + + val (getCode, getOut) = runSh( + listOf(xsel.absolutePath, "-o"), + env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath) + ) + assertEquals(0, getCode) + assertEquals("CODE-1234", getOut) + } + + @Test + fun `set with empty input still delivers, as an empty clipboard`() { + val clip = tempDir("cb-clip-empty") + val xsel = File(tempDir("cb-bin-empty"), "xsel").apply { + writeText(ClipboardBridge.shim(clip.absolutePath)) + setExecutable(true) + } + val (code, _) = runSh( + listOf(xsel.absolutePath, "-i"), + env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath), + stdin = "" + ) + assertEquals(0, code) + assertEquals("", ClipboardBridge.consume(clip)) + } + + @Test + fun `get with nothing ever set prints nothing and succeeds`() { + val clip = tempDir("cb-clip-none") + val xsel = File(tempDir("cb-bin-none"), "xsel").apply { + writeText(ClipboardBridge.shim(clip.absolutePath)) + setExecutable(true) + } + val (code, out) = runSh( + listOf(xsel.absolutePath, "-o"), + env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath) + ) + assertEquals(0, code) + assertEquals("", out) + } + + @Test + fun `a stale trigger without a pending file is swept, not an error`() { + val clip = tempDir("cb-clip-stale") + File(clip, ClipboardBridge.TRIGGER_NAME).writeText("") + assertNull(ClipboardBridge.consume(clip)) + assertFalse(File(clip, ClipboardBridge.TRIGGER_NAME).exists()) + assertNull(ClipboardBridge.consume(clip)) + } + + @Test + fun `install writes both shims and rewrites a drifted file`() { + val rootfs = tempDir("cb-rootfs") + val guest = "/sdcard/Android/data/com.redtermapp/files/clipboard" + ClipboardBridge.installShims(rootfs, guest) + val expected = ClipboardBridge.shim(guest) + for (name in listOf("xsel", "xclip")) { + val f = File(rootfs, "usr/local/bin/$name") + assertTrue("$name missing", f.isFile) + assertEquals(name, expected, f.readText()) + } + File(rootfs, "usr/local/bin/xsel").writeText("#!/bin/sh\necho manual\n") + ClipboardBridge.installShims(rootfs, guest) + assertEquals("a hand-written shim is replaced on the next session start", + expected, File(rootfs, "usr/local/bin/xsel").readText()) + } +}