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()) + } +}