diff --git a/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt b/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt index 89e522fb87..42edc9f843 100644 --- a/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt +++ b/app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt @@ -4,72 +4,139 @@ import android.app.Activity import android.content.Intent import android.os.Handler import android.os.Looper +import androidx.core.os.HandlerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.itsaky.androidide.activities.TerminalActivity +import com.itsaky.androidide.plugins.manager.services.LaunchedTerminalCommand import com.itsaky.androidide.plugins.manager.services.TerminalSessionLauncher import com.itsaky.androidide.plugins.services.TerminalCommandResult +import com.itsaky.androidide.terminal.AgentRunner +import com.itsaky.androidide.terminal.CommandIntentRouter +import com.itsaky.androidide.terminal.CommandState +import com.itsaky.androidide.terminal.TerminalCommandListener import com.itsaky.androidide.terminal.TerminalCommandRequests +import com.itsaky.androidide.terminal.TerminalStartFailure import com.itsaky.androidide.utils.applyMultiWindowFlags +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.io.File /** - * Opens a plugin's command in a new Terminal session, through the same activity the Terminal - * sidebar action opens. + * Runs a plugin's command in one of its Terminal sessions, through the same activity the Terminal + * sidebar action opens. [TerminalCommandRequests] is only touched on the main thread. */ internal class PluginTerminalLauncher( + private val runner: AgentRunner = AgentRunner.termux, + private val requests: TerminalCommandRequests = TerminalCommandRequests.shared, + private val router: CommandIntentRouter = CommandIntentRouter.shared, private val foregroundActivity: () -> Activity?, ) : TerminalSessionLauncher { private val mainHandler = Handler(Looper.getMainLooper()) - override fun launch( + override suspend fun launch( command: String, workingDirectory: File?, - sessionName: String, - onResult: (TerminalCommandResult) -> Unit, - ): () -> Unit { + pluginId: String, + sessionLabel: String, + ): LaunchedTerminalCommand { + val workDir = workingDirectory?.absolutePath + val id = withContext(Dispatchers.IO) { runner.prepare(command, workDir) } + // Nothing suspends from here on, so a command handed to the Terminal is always returned. + val launched = Launched(id) + mainHandler.post { open(id, workDir, pluginId, sessionLabel, launched) } + return launched + } + + override suspend fun read( + pluginId: String, + commandId: String, + ): TerminalCommandResult? = onMain { requests.read(pluginId, commandId)?.toResult() } + + override suspend fun interrupt( + pluginId: String, + commandId: String, + ): Deferred? = onMain { (requests.interrupt(pluginId, commandId) as? Launched)?.result } + + private suspend fun onMain(block: () -> T): T = withContext(Dispatchers.Main) { block() } + + private fun open( + id: String, + workingDirectory: String?, + pluginId: String, + sessionLabel: String, + listener: TerminalCommandListener, + ) { + requests.enqueue(id, workingDirectory, pluginId, listener, sessionLabel) + // Android blocks activity starts from the background, so a plugin can only open the // Terminal while the IDE is on screen. foregroundActivity() still returns a backgrounded // activity (it is cleared only on finish or destroy), and a blocked start throws nothing, // so check the activity is at least STARTED or the plugin waits out OPEN_TIMEOUT_MS. val activity = foregroundActivity()?.takeIf { it.isStarted() } if (activity == null) { - onResult(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground")) - return {} + requests.withdraw(id, TerminalStartFailure.NotInForeground) + return } - val requestId = - TerminalCommandRequests.enqueue( - command = command, - workingDirectory = workingDirectory?.absolutePath, - sessionName = sessionName, - onExit = { exitCode, transcript -> onResult(TerminalCommandResult.Completed(exitCode, transcript)) }, - onNotStarted = { reason -> onResult(TerminalCommandResult.NotStarted(reason)) }, - ) - val intent = - Intent(activity, TerminalActivity::class.java) - .putExtra(TerminalCommandRequests.EXTRA_COMMAND_REQUEST_ID, requestId) - .applyMultiWindowFlags(activity) + val intent = router.putRequestId(Intent(activity, TerminalActivity::class.java), id).applyMultiWindowFlags(activity) try { activity.startActivity(intent) } catch (e: Exception) { logger.error("Failed to open the Terminal for a plugin command", e) - if (TerminalCommandRequests.withdraw(requestId)) { - onResult(TerminalCommandResult.NotStarted("The Terminal could not be opened: ${e.message}")) - } - return {} + requests.withdraw(id, TerminalStartFailure.TerminalNotOpened) + return } // The activity can fail to start or finish before its service connects; without this the // plugin would wait forever for a session that never comes. - mainHandler.postDelayed({ - if (TerminalCommandRequests.withdraw(requestId)) { - onResult(TerminalCommandResult.NotStarted("The Terminal did not open")) - } - }, OPEN_TIMEOUT_MS) + // Posted with the listener as its token, which removes it once the Terminal reports back. + HandlerCompat.postDelayed( + mainHandler, + { requests.withdraw(id, TerminalStartFailure.TerminalDidNotOpen) }, + listener, + OPEN_TIMEOUT_MS, + ) + } + + /** Command [id] as the plugin service sees it, fed by the Terminal's reports on the main thread. */ + private inner class Launched( + private val id: String, + ) : LaunchedTerminalCommand, + TerminalCommandListener { + override val started = CompletableDeferred() + override val result = CompletableDeferred() + + // Posted after the open in launch, so the command is queued by the time it is cancelled. + override fun interrupt() { + mainHandler.post { requests.cancel(id) } + // A command that ignores Ctrl-C would hold its session until the user ends it. + mainHandler.postDelayed({ requests.kill(id) }, KILL_GRACE_MS) + } - return { TerminalCommandRequests.cancel(requestId) } + override suspend fun snapshot(): TerminalCommandResult.Running? = onMain { requests.snapshot(id)?.toResult() } + + override fun onStarted(sessionName: String) { + cancelOpenTimeout() + started.complete(Unit) + } + + override fun onExited( + exitCode: Int, + output: String, + ) { + result.complete(TerminalCommandResult.Completed(exitCode, output)) + } + + override fun onNotStarted(reason: TerminalStartFailure) { + cancelOpenTimeout() + result.complete(TerminalCommandResult.NotStarted(reason.message)) + } + + private fun cancelOpenTimeout() = mainHandler.removeCallbacksAndMessages(this) } private fun Activity.isStarted(): Boolean = @@ -82,5 +149,16 @@ internal class PluginTerminalLauncher( private val logger = LoggerFactory.getLogger(PluginTerminalLauncher::class.java) const val OPEN_TIMEOUT_MS = 15_000L + + // How long an interrupted command has to exit before its session is ended. + const val KILL_GRACE_MS = 5_000L + + fun CommandState.Running.toResult() = TerminalCommandResult.Running(commandId, sessionName, output) + + fun CommandState.toResult(): TerminalCommandResult = + when (this) { + is CommandState.Running -> toResult() + is CommandState.Exited -> TerminalCommandResult.Completed(exitCode, output) + } } } diff --git a/app/src/test/java/com/itsaky/androidide/app/PluginTerminalLauncherTest.kt b/app/src/test/java/com/itsaky/androidide/app/PluginTerminalLauncherTest.kt index bcfcd200e9..be0ccd10d7 100644 --- a/app/src/test/java/com/itsaky/androidide/app/PluginTerminalLauncherTest.kt +++ b/app/src/test/java/com/itsaky/androidide/app/PluginTerminalLauncherTest.kt @@ -1,10 +1,19 @@ package com.itsaky.androidide.app +import android.app.Activity import android.app.Application +import android.os.Looper import androidx.activity.ComponentActivity import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.plugins.manager.services.LaunchedTerminalCommand import com.itsaky.androidide.plugins.services.TerminalCommandResult +import com.itsaky.androidide.terminal.AgentRunner +import com.itsaky.androidide.terminal.TerminalStartFailure +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner @@ -16,9 +25,23 @@ import org.robolectric.annotation.Config * mean the IDE is on screen. A start from the background is dropped without an exception, so * the launcher has to refuse up front or the plugin waits out the open timeout. */ +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @Config(application = Application::class) class PluginTerminalLauncherTest { + @get:Rule + val tmp = TemporaryFolder() + + // The open is posted to the main looper, which Robolectric runs only when idled. + private fun launch(activity: Activity): LaunchedTerminalCommand { + val launched = + runBlocking { + PluginTerminalLauncher(runner = AgentRunner(tmp.root)) { activity }.launch("true", null, "plugin", "plugin") + } + shadowOf(Looper.getMainLooper()).idle() + return launched + } + @Test fun `a backgrounded IDE is refused at once and nothing is started`() { val controller = @@ -28,23 +51,24 @@ class PluginTerminalLauncherTest { .pause() .stop() val activity = controller.get() - val results = mutableListOf() - PluginTerminalLauncher { activity }.launch("true", null, "plugin") { results += it } + val launched = launch(activity) - assertThat(results).containsExactly(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground")) + assertThat(launched.result.isCompleted).isTrue() + assertThat(launched.result.getCompleted()) + .isEqualTo(TerminalCommandResult.NotStarted(TerminalStartFailure.NotInForeground.message)) assertThat(shadowOf(activity).nextStartedActivity).isNull() } @Test fun `an IDE on screen opens the Terminal`() { val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() - val results = mutableListOf() - val cancel = PluginTerminalLauncher { activity }.launch("true", null, "plugin") { results += it } + val launched = launch(activity) - assertThat(results).isEmpty() + assertThat(launched.result.isCompleted).isFalse() assertThat(shadowOf(activity).nextStartedActivity).isNotNull() - cancel() + launched.interrupt() + shadowOf(Looper.getMainLooper()).idle() } } diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 8534f8d5bc..20e428e22c 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -36,13 +36,23 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). ### 26.41 — unreleased -- **added — Terminal readiness and visible terminal commands** _(ADFA-6373)_ **[verified]** +- **added — Terminal readiness and visible terminal commands** _(ADFA-6373, ADFA-6385)_ **[verified]** `IdeTerminalService.isTerminalReady()` reports whether the terminal environment is installed - and bash runs; it needs no permission. `runInTerminal(command, workingDirectory)` opens a new - session in the visible Terminal, runs the command with bash and suspends until it exits, - returning `TerminalCommandResult.Completed(exitCode, output)` with the session transcript, or - `NotStarted(reason)` (environment missing, IDE not in the foreground). The session stays open - so the user sees what ran; cancelling the caller kills the command. Needs `system.commands`; + and bash runs; it needs no permission. `runInTerminal(command, workingDirectory, waitMillis)` + runs the command with bash in the visible Terminal, in an idle session of the plugin; a new + session opens only while the plugin's others are busy, up to 3. Each command runs in its own + bash process, so `cd` and `export` do not carry over; it starts from the session's login shell, + so it sees the user's profile and anything the user exported there. It returns + `TerminalCommandResult.Completed(exitCode, output)` with that command's output, `Running(commandId, + sessionName, output)` when the command is still running after `waitMillis` (default 30 s; it keeps running), or + `NotStarted(reason)` (environment missing, IDE not in the foreground, all 3 sessions busy, naming them). + Cancelling the caller, or unloading the plugin, interrupts the command with Ctrl-C and ends its + session if it has not exited a few seconds later. A session where the user started a program of + their own is busy. `readCommand(commandId)` returns that command, `Running` or `Completed`, even + after another command reuses its session; null for an id that is not the plugin's, or a command + that exited before the plugin's last 8 others did. `stopCommand(commandId, waitMillis)` interrupts + that command with Ctrl-C and returns its state as `readCommand` does after waiting up to + `waitMillis` for it to exit; it never touches a later command in the same session. All three need `system.commands`; the working directory must lie inside the project. Floor `plugin.min_ide_version` at `26.41`: an older IDE has no `IdeTerminalService` class, so referencing it fails to load. - **added — Run Gradle tasks with arguments, get a structured result, cancel** _(ADFA-6373)_ **[verified]** diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 9ac8d0a975..043f35acc3 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1849,12 +1849,23 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeTempla } public abstract interface class com/itsaky/androidide/plugins/services/IdeTerminalService { + public static final field Companion Lcom/itsaky/androidide/plugins/services/IdeTerminalService$Companion; + public static final field DEFAULT_STOP_WAIT_MILLIS J + public static final field DEFAULT_WAIT_MILLIS J public abstract fun isTerminalReady (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun runInTerminal (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun readCommand (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun runInTerminal (Ljava/lang/String;Ljava/lang/String;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stopCommand (Ljava/lang/String;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/itsaky/androidide/plugins/services/IdeTerminalService$Companion { + public static final field DEFAULT_STOP_WAIT_MILLIS J + public static final field DEFAULT_WAIT_MILLIS J } public final class com/itsaky/androidide/plugins/services/IdeTerminalService$DefaultImpls { - public static synthetic fun runInTerminal$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun runInTerminal$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;Ljava/lang/String;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun stopCommand$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public abstract interface class com/itsaky/androidide/plugins/services/IdeThemeService { @@ -2195,6 +2206,21 @@ public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$ public fun toString ()Ljava/lang/String; } +public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$Running : com/itsaky/androidide/plugins/services/TerminalCommandResult { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Running; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Running;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Running; + public fun equals (Ljava/lang/Object;)Z + public final fun getCommandId ()Ljava/lang/String; + public final fun getOutput ()Ljava/lang/String; + public final fun getSessionName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class com/itsaky/androidide/plugins/services/ThemeChangeListener { public abstract fun onThemeChanged (Z)V } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt index c216a3f4be..0d7ae6a2c7 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeTerminalService.kt @@ -14,11 +14,19 @@ interface IdeTerminalService { suspend fun isTerminalReady(): Boolean /** - * Opens a new session in the visible Terminal, runs [command] there with bash, and suspends - * until it exits. The session stays open afterwards so the user can read what ran. + * Runs [command] with bash in the visible Terminal and suspends until it exits or [waitMillis] + * pass. Each plugin keeps a few Terminal sessions and reuses an idle one; a new session opens + * only while every other one is still running a command, such as a dev server. * - * [workingDirectory] is absolute or relative to the project root, and must lie inside it; - * null means the project root. Cancelling the calling coroutine kills the command. + * Each command runs in its own bash process started in [workingDirectory], so a `cd` or `export` + * does not carry over to the next command. That process starts from the session's login shell: + * it sees the user's profile, and anything the user exported in that session. + * [workingDirectory] is absolute or relative to the project root, and must lie inside it; null + * means the project root. + * + * Cancelling the calling coroutine, or unloading the plugin, interrupts the command with Ctrl-C, + * and ends its session if it has not exited a few seconds later. After [waitMillis] the command + * keeps running and [TerminalCommandResult.Running] is returned. * * Requires the SYSTEM_COMMANDS permission. * @@ -28,22 +36,72 @@ interface IdeTerminalService { suspend fun runInTerminal( command: String, workingDirectory: String? = null, + waitMillis: Long = DEFAULT_WAIT_MILLIS, ): TerminalCommandResult + + /** + * The command [commandId] a [TerminalCommandResult.Running] carries: [TerminalCommandResult.Running] + * with its output so far while it runs, [TerminalCommandResult.Completed] once it exits. Another + * command reusing its session later does not change what this returns. Null when the id is not + * one of this plugin's commands, or the command exited before the plugin's last few others did. + * + * Requires the SYSTEM_COMMANDS permission. + * + * @throws SecurityException if the plugin lacks SYSTEM_COMMANDS. + */ + suspend fun readCommand(commandId: String): TerminalCommandResult? + + /** + * Interrupts command [commandId] with Ctrl-C, as the user would, and waits up to [waitMillis] + * for it to exit. Returns its state afterwards, as [readCommand] does: + * [TerminalCommandResult.Completed] once it exited, [TerminalCommandResult.Running] if it + * ignored the interrupt. A command that already exited is left alone, and so is whatever runs + * in its session since. + * + * Requires the SYSTEM_COMMANDS permission. + * + * @throws SecurityException if the plugin lacks SYSTEM_COMMANDS. + */ + suspend fun stopCommand( + commandId: String, + waitMillis: Long = DEFAULT_STOP_WAIT_MILLIS, + ): TerminalCommandResult? + + companion object { + /** How long [runInTerminal] waits for a command to exit unless told otherwise. */ + const val DEFAULT_WAIT_MILLIS: Long = 30_000L + + /** How long [stopCommand] waits for an interrupted command to exit unless told otherwise. */ + const val DEFAULT_STOP_WAIT_MILLIS: Long = 5_000L + } } /** - * Outcome of [IdeTerminalService.runInTerminal]. + * Outcome of [IdeTerminalService.runInTerminal], and state of [IdeTerminalService.readCommand]. */ sealed class TerminalCommandResult { /** - * The command ran and exited with [exitCode]. [output] is the session transcript: the echoed - * command line followed by stdout and stderr interleaved as the terminal showed them. + * The command ran and exited with [exitCode]. [output] is what the command printed in the + * session: the echoed command line followed by stdout and stderr interleaved as the terminal + * showed them. Earlier commands in the same session are not included. [exitCode] is -1 when the + * Terminal could not tell it, e.g. the session closed or the command's end was never reported. */ data class Completed( val exitCode: Int, val output: String, ) : TerminalCommandResult() + /** + * The command was still running when the wait ended, and keeps running in the Terminal session + * named [sessionName] until it exits or the user stops it. [output] is what it printed so far. + * Pass [commandId] to [IdeTerminalService.readCommand] and [IdeTerminalService.stopCommand]. + */ + data class Running( + val commandId: String, + val sessionName: String, + val output: String, + ) : TerminalCommandResult() + /** The command never ran. [reason] says why, e.g. the terminal environment is not installed. */ data class NotStarted( val reason: String, diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index 37b9095de6..1fb0ebe0dd 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -648,7 +648,7 @@ class PluginManager private constructor( return Result.failure(RuntimeException("Failed to create plugin context for: ${manifest.id}")) } - val pluginContext = createPluginContextWithResources(manifest.id, classLoader, permissions, ctx) + val pluginContext = createPluginContextWithResources(manifest.id, manifest.name, classLoader, permissions, ctx) logger.debug("Initializing plugin: ${manifest.id}") val initResult = @@ -1410,6 +1410,7 @@ class PluginManager private constructor( */ private fun createPluginContextWithResources( pluginId: String, + pluginName: String, classLoader: ClassLoader, permissions: Set, resourceContext: Context, @@ -1647,6 +1648,7 @@ class PluginManager private constructor( ) { IdeTerminalServiceImpl( pluginId = pluginId, + sessionLabel = pluginName.ifBlank { pluginId }, permissions = permissions, projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, appFilesDir = context.filesDir, diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt index d3a92935ca..416bd7de1c 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImpl.kt @@ -5,47 +5,94 @@ import com.itsaky.androidide.plugins.services.IdeTerminalService import com.itsaky.androidide.plugins.services.TerminalCommandResult import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.TermuxProcessEnvironment -import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.job +import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.slf4j.LoggerFactory import java.io.File import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume /** - * Opens a command in a new, visible Terminal session. Implemented by the app module, which owns + * Runs commands in a plugin's visible Terminal sessions. Implemented by the app module, which owns * the Terminal screen; see [IdeTerminalServiceImpl.setSessionLauncher]. */ -fun interface TerminalSessionLauncher { +interface TerminalSessionLauncher { /** - * Runs [command] with bash in a new Terminal session named [sessionName], in [workingDirectory] - * (the default home directory when null), and calls [onResult] at most once: with - * [TerminalCommandResult.Completed] when the command exits, or [TerminalCommandResult.NotStarted] - * if the Terminal could not run it. Not called after the returned function kills the command. - * - * @return a function that kills the command. + * Hands [command] to the Terminal, to run with bash in [workingDirectory] (the default home + * directory when null) in an idle session of plugin [pluginId], or a new one when all are busy. + * A new session is named after [sessionLabel]. If cancelled before it returns, the command never runs. */ - fun launch( + suspend fun launch( command: String, workingDirectory: File?, - sessionName: String, - onResult: (TerminalCommandResult) -> Unit, - ): () -> Unit + pluginId: String, + sessionLabel: String, + ): LaunchedTerminalCommand + + /** + * Command [commandId] of plugin [pluginId]: [TerminalCommandResult.Running] while it runs, + * [TerminalCommandResult.Completed] once it exits, or null when it is not a command of that + * plugin the Terminal still knows. + */ + suspend fun read( + pluginId: String, + commandId: String, + ): TerminalCommandResult? + + /** + * Interrupts command [commandId] of plugin [pluginId] with Ctrl-C if it runs, whichever caller + * launched it. + * + * @return the command's result, which completes once it exits, or null when it does not run. + */ + suspend fun interrupt( + pluginId: String, + commandId: String, + ): Deferred? +} + +/** A command [TerminalSessionLauncher.launch] handed to the Terminal. */ +interface LaunchedTerminalCommand { + /** Completes once a Terminal session takes the command. */ + val started: Deferred + + /** [TerminalCommandResult.Completed] once the command exits, or [TerminalCommandResult.NotStarted]. */ + val result: Deferred + + /** + * Interrupts the command with Ctrl-C, and ends its session if it has not exited a few seconds + * later; withdraws it if no session has taken it yet. + */ + fun interrupt() + + /** The session the command runs in and its output so far; null before a session takes it or once it exited. */ + suspend fun snapshot(): TerminalCommandResult.Running? } +/** + * @param sessionLabel what the plugin's Terminal sessions are named after, e.g. "AI Core 1"; the + * plugin's display name, which reads better on the Terminal screen than its id. + */ class IdeTerminalServiceImpl( private val pluginId: String, + private val sessionLabel: String = pluginId, private val permissions: Set, private val projectRootProvider: () -> File?, private val appFilesDir: File, private val bashProvider: () -> File? = { Environment.BASH_SHELL }, private val launcherProvider: () -> TerminalSessionLauncher? = { sessionLauncher }, ) : IdeTerminalService { - // Cancelled on unload: a command run from a scope the plugin never cancels would outlive it. - private val running = ConcurrentHashMap.newKeySet>() + // Ended on unload: a command, or a caller waiting from a scope the plugin never cancels, would + // outlive it. A command leaves [running] once interrupted, so it never gets a second Ctrl-C. + private val running = ConcurrentHashMap.newKeySet() + private val waiting = ConcurrentHashMap.newKeySet() // Set by cancelAll, cleared by reopen. A run still in its IO checks is not in [running] yet and // must not launch after. @@ -74,10 +121,9 @@ class IdeTerminalServiceImpl( override suspend fun runInTerminal( command: String, workingDirectory: String?, + waitMillis: Long, ): TerminalCommandResult { - if (PluginPermission.SYSTEM_COMMANDS !in permissions) { - throw SecurityException("Plugin $pluginId does not have SYSTEM_COMMANDS permission") - } + requireSystemCommands() // Disk checks off the caller's thread: plugins call this from main-thread coroutines. val (workDir, notStarted) = withContext(Dispatchers.IO) { @@ -92,33 +138,79 @@ class IdeTerminalServiceImpl( if (notStarted != null) return TerminalCommandResult.NotStarted(notStarted) val launcher = launcherProvider() ?: return TerminalCommandResult.NotStarted("The Terminal is not available") - return suspendCancellableCoroutine { continuation -> - running += continuation - // Checked after joining [running]: cancelAll either sees this run or this run sees closed. - if (closed) { - running -= continuation - continuation.cancel() - return@suspendCancellableCoroutine - } - val kill = - launcher.launch(command, workDir, pluginId) { - running -= continuation - continuation.resume(it) - } - continuation.invokeOnCancellation { - running -= continuation - kill() + if (closed) throw CancellationException(UNLOADED) + val launched = launcher.launch(command, workDir, pluginId, sessionLabel) + running += launched + launched.result.invokeOnCompletion { running -= launched } + // Its own scope, so cancelAll cancels this wait and not the rest of the caller. + return coroutineScope { + val wait = coroutineContext.job + waiting += wait + try { + // Checked after joining [running] and [waiting]: cancelAll either sees this run or this run sees closed. + if (closed) throw CancellationException(UNLOADED) + withTimeoutOrNull(waitMillis.coerceAtLeast(0)) { launched.result.await() } ?: launched.stillRunning() + } catch (e: CancellationException) { + launched.interruptOnce() + throw e + } finally { + waiting -= wait } } } /** - * Kills this plugin's terminal commands; each caller's [runInTerminal] is cancelled, and so is - * any later call until [reopen]. Called on disable and unload. + * Interrupts this plugin's terminal commands, also those [runInTerminal] already returned as + * running, and ends the sessions of those that ignore it; each caller still waiting is + * cancelled, and so is any later call until [reopen]. Called on disable and unload. */ fun cancelAll() { closed = true - running.forEach { it.cancel() } + waiting.forEach { it.cancel() } + running.forEach { it.interruptOnce() } + } + + private fun LaunchedTerminalCommand.interruptOnce() { + if (running.remove(this)) interrupt() + } + + override suspend fun readCommand(commandId: String): TerminalCommandResult? { + requireSystemCommands() + return launcherProvider()?.read(pluginId, commandId) + } + + override suspend fun stopCommand( + commandId: String, + waitMillis: Long, + ): TerminalCommandResult? { + requireSystemCommands() + val launcher = launcherProvider() ?: return null + // Through the launcher, not [running]: the command may come from a cancelled caller, or from + // this plugin before it was reloaded. + val result = launcher.interrupt(pluginId, commandId) ?: return launcher.read(pluginId, commandId) + return withTimeoutOrNull(waitMillis.coerceAtLeast(0)) { result.await() } + ?: launcher.read(pluginId, commandId) + } + + private fun requireSystemCommands() { + if (PluginPermission.SYSTEM_COMMANDS !in permissions) { + throw SecurityException("Plugin $pluginId does not have SYSTEM_COMMANDS permission") + } + } + + /** + * The result once the wait is over: [TerminalCommandResult.Running] as soon as a session holds + * the command, or its result if that comes first. + */ + private suspend fun LaunchedTerminalCommand.stillRunning(): TerminalCommandResult { + // The launcher reports NotStarted if no session ever takes the command, so this wait ends. + val ended = + select { + result.onAwait { it } + started.onAwait { null } + } + // A null snapshot means the command exited in between; its result is then on its way. + return ended ?: snapshot() ?: result.await() } /** Lets [runInTerminal] launch again after [cancelAll]. Called when the plugin is re-enabled. */ @@ -131,6 +223,8 @@ class IdeTerminalServiceImpl( private const val READY_TIMEOUT_MS = 5_000L + private const val UNLOADED = "Plugin unloaded" + @Volatile private var sessionLauncher: TerminalSessionLauncher? = null diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt index 08ad2d8fda..49778e9b1a 100644 --- a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt +++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeTerminalServiceImplTest.kt @@ -3,7 +3,9 @@ package com.itsaky.androidide.plugins.manager.services import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.services.TerminalCommandResult +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield @@ -16,21 +18,67 @@ class IdeTerminalServiceImplTest { @get:Rule val tmp = TemporaryFolder() + /** + * Hands out commands that report [result] if given, after starting if [started]; each snapshot + * takes the next of [snapshots], the last repeating. [read] answers from [commands], and + * [interrupt] from [busyCommands]. + */ private class FakeLauncher( private val result: TerminalCommandResult? = null, + private val started: Boolean = result !is TerminalCommandResult.NotStarted, + private val snapshots: List = listOf(null), + private val commands: Map = emptyMap(), + private val busyCommands: Map> = emptyMap(), ) : TerminalSessionLauncher { val launches = mutableListOf>() - var killed = false + val labels = mutableListOf() + val reads = mutableListOf>() + val commandInterrupts = mutableListOf>() + var interrupted = false + var snapshotsTaken = 0 + private val results = mutableListOf>() - override fun launch( + /** Ends the last launched command with [result]. */ + fun complete(result: TerminalCommandResult) = results.last().complete(result) + + override suspend fun launch( command: String, workingDirectory: File?, - sessionName: String, - onResult: (TerminalCommandResult) -> Unit, - ): () -> Unit { - launches += Triple(command, workingDirectory, sessionName) - result?.let(onResult) - return { killed = true } + pluginId: String, + sessionLabel: String, + ): LaunchedTerminalCommand { + launches += Triple(command, workingDirectory, pluginId) + labels += sessionLabel + return object : LaunchedTerminalCommand { + override val started = CompletableDeferred().apply { if (this@FakeLauncher.started) complete(Unit) } + override val result = + CompletableDeferred().also { + results += it + this@FakeLauncher.result?.let(it::complete) + } + + override fun interrupt() { + interrupted = true + } + + override suspend fun snapshot() = snapshots[minOf(snapshotsTaken++, snapshots.lastIndex)] + } + } + + override suspend fun read( + pluginId: String, + commandId: String, + ): TerminalCommandResult? { + reads += pluginId to commandId + return commands[commandId] + } + + override suspend fun interrupt( + pluginId: String, + commandId: String, + ): CompletableDeferred? { + commandInterrupts += pluginId to commandId + return busyCommands[commandId] } } @@ -45,9 +93,11 @@ class IdeTerminalServiceImplTest { launcher: TerminalSessionLauncher? = FakeLauncher(TerminalCommandResult.Completed(0, "$ ls")), projectRoot: File? = tmp.root, permissions: Set = setOf(PluginPermission.SYSTEM_COMMANDS), + sessionLabel: String = "test.plugin", launcherProvider: () -> TerminalSessionLauncher? = { launcher }, ) = IdeTerminalServiceImpl( pluginId = "test.plugin", + sessionLabel = sessionLabel, permissions = permissions, projectRootProvider = { projectRoot }, appFilesDir = tmp.root, @@ -142,7 +192,7 @@ class IdeTerminalServiceImplTest { } @Test - fun cancellingTheCallerKillsTheCommand() { + fun cancellingTheCallerInterruptsTheCommand() { // No result: the command is still running when the caller gives up. val launcher = FakeLauncher(result = null) @@ -153,7 +203,86 @@ class IdeTerminalServiceImplTest { run.join() } - assertThat(launcher.killed).isTrue() + assertThat(launcher.interrupted).isTrue() + } + + @Test + fun commandStillRunningAfterTheWaitIsReturnedAsRunning() { + val running = TerminalCommandResult.Running("3f2a", "test.plugin 1", "$ npm start\nlistening on 3000") + val launcher = FakeLauncher(result = null, snapshots = listOf(running)) + + val result = await { service(launcher = launcher).runInTerminal("npm start", waitMillis = 50) } + + assertThat(result).isEqualTo(running) + // Left running: returning is not stopping it. + assertThat(launcher.interrupted).isFalse() + } + + @Test + fun commandThatExitsWhileTheSnapshotIsTakenReturnsItsResult() { + // Started, then exited before the snapshot: the snapshot is null and the result follows. + val launcher = FakeLauncher(result = null, snapshots = listOf(null)) + val completed = TerminalCommandResult.Completed(0, "done") + + val result = + await { + val service = service(launcher = launcher) + coroutineScope { + val run = async { service.runInTerminal("make", waitMillis = 0) } + while (launcher.snapshotsTaken == 0) yield() + launcher.complete(completed) + run.await() + } + } + + assertThat(result).isEqualTo(completed) + } + + @Test + fun commandNoSessionTookIsWaitedForUntilItIsRefused() { + // Neither started nor ended within the wait: the launcher's refusal is what ends it. + val refusal = TerminalCommandResult.NotStarted("The Terminal did not open") + val launcher = FakeLauncher(result = refusal, started = false) + + val result = await { service(launcher = launcher).runInTerminal("npm start", waitMillis = 0) } + + assertThat(result).isEqualTo(refusal) + assertThat(launcher.snapshotsTaken).isEqualTo(0) + } + + @Test + fun commandThatExitsWithinTheWaitTakesNoSnapshot() { + val launcher = FakeLauncher(TerminalCommandResult.Completed(0, "$ ls")) + + await { service(launcher = launcher).runInTerminal("ls", waitMillis = 1_000) } + + assertThat(launcher.snapshotsTaken).isEqualTo(0) + } + + @Test + fun readingACommandAsksForThisPluginsCommand() { + val running = TerminalCommandResult.Running("3f2a", "test.plugin 1", "listening on 3000") + val launcher = FakeLauncher(commands = mapOf("3f2a" to running)) + + val result = await { service(launcher = launcher).readCommand("3f2a") } + + assertThat(result).isEqualTo(running) + assertThat(launcher.reads).containsExactly("test.plugin" to "3f2a") + } + + @Test + fun readingAnUnknownCommandIsNull() { + assertThat(await { service(launcher = FakeLauncher()).readCommand("nope") }).isNull() + } + + @Test + fun readingWithoutATerminalIsNull() { + assertThat(await { service(launcher = null).readCommand("3f2a") }).isNull() + } + + @Test(expected = SecurityException::class) + fun readingRequiresSystemCommands() { + await { service(permissions = emptySet()).readCommand("3f2a") } } @Test @@ -169,7 +298,98 @@ class IdeTerminalServiceImplTest { assertThat(run.isCancelled).isTrue() } - assertThat(launcher.killed).isTrue() + assertThat(launcher.interrupted).isTrue() + } + + @Test + fun cancelAllInterruptsACommandReturnedAsRunning() { + val running = TerminalCommandResult.Running("3f2a", "test.plugin 1", "$ sleep 100") + val launcher = FakeLauncher(result = null, snapshots = listOf(running)) + val service = service(launcher = launcher) + + assertThat(await { service.runInTerminal("sleep 100", waitMillis = 0) }).isInstanceOf(TerminalCommandResult.Running::class.java) + assertThat(launcher.interrupted).isFalse() + service.cancelAll() + + assertThat(launcher.interrupted).isTrue() + } + + @Test + fun cancelAllLeavesAnExitedCommandAlone() { + val launcher = FakeLauncher(TerminalCommandResult.Completed(0, "$ ls")) + val service = service(launcher = launcher) + + await { service.runInTerminal("ls") } + service.cancelAll() + + assertThat(launcher.interrupted).isFalse() + } + + @Test + fun sessionsAreNamedAfterThePluginsLabel() { + val launcher = FakeLauncher(TerminalCommandResult.Completed(0, "$ ls")) + + await { service(launcher = launcher, sessionLabel = "AI Core").runInTerminal("ls") } + + assertThat(launcher.labels).containsExactly("AI Core") + } + + @Test + fun stopInterruptsTheCommandAndReturnsHowItExited() { + val command = CompletableDeferred() + val launcher = FakeLauncher(busyCommands = mapOf("3f2a" to command)) + val service = service(launcher = launcher) + + val result = + await { + coroutineScope { + val stop = async { service.stopCommand("3f2a") } + while (launcher.commandInterrupts.isEmpty()) yield() + command.complete(TerminalCommandResult.Completed(130, "PING\n^C")) + stop.await() + } + } + + assertThat(result).isEqualTo(TerminalCommandResult.Completed(130, "PING\n^C")) + assertThat(launcher.commandInterrupts).containsExactly("test.plugin" to "3f2a") + } + + @Test + fun stopReachesACommandThisInstanceDidNotLaunch() { + // E.g. started before the plugin was reloaded, or by a caller that was cancelled. + val command = CompletableDeferred(TerminalCommandResult.Completed(130, "^C")) + val launcher = FakeLauncher(busyCommands = mapOf("3f2a" to command)) + + assertThat(await { service(launcher = launcher).stopCommand("3f2a") }).isEqualTo(TerminalCommandResult.Completed(130, "^C")) + assertThat(launcher.commandInterrupts).containsExactly("test.plugin" to "3f2a") + } + + @Test + fun stopOfACommandThatIgnoresCtrlCReportsItStillRunning() { + val running = TerminalCommandResult.Running("3f2a", "Test 1", "still here") + val launcher = FakeLauncher(commands = mapOf("3f2a" to running), busyCommands = mapOf("3f2a" to CompletableDeferred())) + + assertThat(await { service(launcher = launcher).stopCommand("3f2a", waitMillis = 0) }).isEqualTo(running) + assertThat(launcher.commandInterrupts).containsExactly("test.plugin" to "3f2a") + } + + @Test + fun stopOfAnExitedCommandOnlyReadsIt() { + val exited = TerminalCommandResult.Completed(0, "done") + val launcher = FakeLauncher(commands = mapOf("3f2a" to exited)) + + assertThat(await { service(launcher = launcher).stopCommand("3f2a") }).isEqualTo(exited) + assertThat(launcher.reads).containsExactly("test.plugin" to "3f2a") + } + + @Test + fun stopOfAnUnknownCommandIsNull() { + assertThat(await { service(launcher = FakeLauncher()).stopCommand("nope") }).isNull() + } + + @Test(expected = SecurityException::class) + fun stopRequiresSystemCommands() { + await { service(permissions = emptySet()).stopCommand("3f2a") } } @Test diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt index c177a155ab..efa62a7d14 100644 --- a/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt +++ b/termux/termux-app/src/main/java/com/itsaky/androidide/activities/TerminalActivity.kt @@ -24,12 +24,14 @@ import android.os.IBinder import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope -import com.itsaky.androidide.terminal.TerminalCommandRequests +import com.itsaky.androidide.terminal.CommandIntentRouter +import com.itsaky.androidide.terminal.TerminalSessionFactory import com.itsaky.androidide.utils.Environment import com.termux.app.TermuxActivity import com.termux.app.TermuxService import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession import com.termux.shared.termux.TermuxConstants +import com.termux.terminal.TerminalSession import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -40,7 +42,8 @@ class TerminalActivity : TermuxActivity() { private var pendingWorkingDir: String? = null private var pendingSessionName: String? = null private var pendingIsFailsafe: Boolean = false - private var pendingCommandRequestId: String? = null + // Each command request that arrived before the service connected, in order. + private val pendingCommandRequestIds = mutableListOf() // True when this instance was started only to run a plugin's command, not recreated or reused. private var launchedForCommand = false @@ -55,8 +58,8 @@ class TerminalActivity : TermuxActivity() { controller.isAppearanceLightNavigationBars = false controller.isAppearanceLightStatusBars = false // Read before super: TermuxActivity consumes the intent once its service connects. - pendingCommandRequestId = intent?.getStringExtra(TerminalCommandRequests.EXTRA_COMMAND_REQUEST_ID) - launchedForCommand = savedInstanceState == null && pendingCommandRequestId != null + CommandIntentRouter.shared.requestId(intent)?.let(pendingCommandRequestIds::add) + launchedForCommand = savedInstanceState == null && pendingCommandRequestIds.isNotEmpty() super.onCreate(savedInstanceState) } @@ -75,15 +78,22 @@ class TerminalActivity : TermuxActivity() { pendingIsFailsafe = false } - // No service yet: keep the request and launchedForCommand for the next connection. + // No service yet: keep the requests and launchedForCommand for the next connection. if (termuxService == null) return - pendingCommandRequestId?.let { requestId -> - pendingCommandRequestId = null - // Withdrawn or cancelled before its session started: close the window opened for it. - if (!runCommand(termuxService, requestId) && launchedForCommand) finishActivityIfNotFinishing() - } + val requestIds = pendingCommandRequestIds.toList() + pendingCommandRequestIds.clear() + val closeIfNoneStarts = launchedForCommand launchedForCommand = false + var unrouted = requestIds.size + var anyStarted = false + requestIds.forEach { requestId -> + runCommand(termuxService, requestId) { started -> + anyStarted = anyStarted || started + // Withdrawn or cancelled before their sessions started: close the window opened for them. + if (--unrouted == 0 && !anyStarted && closeIfNoneStarts) finishActivityIfNotFinishing() + } + } } override fun setupTermuxSessionOnServiceConnected( @@ -93,7 +103,7 @@ class TerminalActivity : TermuxActivity() { existingSession: TermuxSession?, launchFailsafe: Boolean ) { - // The command gets its own session; a default shell beside it would be left behind. + // The command runs in a plugin session; a default shell beside it would be left behind. if (launchedForCommand && mTermuxService.isTermuxSessionsEmpty) return super.setupTermuxSessionOnServiceConnected(intent, workingDir, sessionName, existingSession, launchFailsafe) } @@ -103,10 +113,10 @@ class TerminalActivity : TermuxActivity() { setIntent(intent) if (intent == null) return - val commandRequestId = intent.getStringExtra(TerminalCommandRequests.EXTRA_COMMAND_REQUEST_ID) + val commandRequestId = CommandIntentRouter.shared.requestId(intent) if (commandRequestId != null) { val service = mTermuxService - if (service != null) runCommand(service, commandRequestId) else pendingCommandRequestId = commandRequestId + if (service != null) runCommand(service, commandRequestId) else pendingCommandRequestIds += commandRequestId return } @@ -124,31 +134,32 @@ class TerminalActivity : TermuxActivity() { } } - /** Runs a plugin's command in a new session and shows it. Returns false if it did not start. */ - private fun runCommand(service: TermuxService, requestId: String): Boolean { - // Null when already run (a recreated activity sees the same intent again) or withdrawn. - val request = TerminalCommandRequests.claim(requestId) ?: return false - // TermuxActivity finishes a window with no sessions when its service connects before - // onStart; a session started now would run the command with nothing on screen. - if (isFinishing) { - request.notStarted("The Terminal closed before the command could start") - return false - } - val newSession = service.createTermuxSession( - Environment.BASH_SHELL.absolutePath, - arrayOf("-c", TerminalCommandRequests.RUN_SCRIPT, "cogo", request.command), - null, - request.workingDirectory, - false, - request.sessionName - ) - if (newSession == null) { - request.notStarted("The terminal session could not be started") - return false + /** + * Runs a plugin's command in an idle session of that plugin, or a new one, and shows it. Tells + * [onRouted] whether it started. + */ + private fun runCommand(service: TermuxService, requestId: String, onRouted: (Boolean) -> Unit = {}) { + // TermuxActivity finishes a window with no sessions when its service connects before onStart. + CommandIntentRouter.shared.route(requestId, sessionFactory(service), screenClosing = { isFinishing }) { session -> + // Destroyed meanwhile: there is no window to show it in. + if (isDestroyed) return@route + if (session != null) mTermuxTerminalSessionActivityClient.setCurrentSession(session) + onRouted(session != null) } - TerminalCommandRequests.attach(request, newSession.terminalSession) - mTermuxTerminalSessionActivityClient.setCurrentSession(newSession.terminalSession) - return true + } + + private fun sessionFactory(service: TermuxService) = object : TerminalSessionFactory { + override fun isOpen(session: TerminalSession) = service.getIndexOfSession(session) >= 0 && session.isRunning + + override fun open(name: String, bashArguments: Array, workingDirectory: String?) = + service.createTermuxSession( + Environment.BASH_SHELL.absolutePath, + bashArguments, + null, + workingDirectory, + false, + name + )?.terminalSession } private fun createAndSetSession( diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/AgentRunner.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/AgentRunner.kt new file mode 100644 index 0000000000..7a9ac7a9b1 --- /dev/null +++ b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/AgentRunner.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.terminal + +import com.termux.shared.termux.TermuxConstants +import java.io.File +import java.io.IOException +import java.util.UUID + +/** + * Runs plugin commands through the `agent-run.sh` script, which the session's bash executes. A + * command is handed over in a file rather than on the command line, so it needs no quoting and the + * line the session shows stays short. + * + * @param directory where the runner and the command files are written; its absolute path is what + * the session's shell runs, so a profile that changes `$TMPDIR` does not lose the runner. + */ +class AgentRunner( + private val directory: File, +) { + private val scriptPath get() = "${directory.path}/$SCRIPT_NAME" + + /** + * Writes the runner, [command] and its [workingDirectory] where the runner reads them. Does file + * I/O, so call it off the main thread. + * + * @return the id the command runs under. + */ + fun prepare( + command: String, + workingDirectory: String?, + ): String { + // Short, because a reused session shows it on the line that runs the command. + val id = UUID.randomUUID().toString().take(ID_LENGTH) + directory.mkdirs() + installScript() + File(directory, "$id.cmd").writeText(command) + File(directory, "$id.dir").writeText(workingDirectory.orEmpty()) + return id + } + + // Written only when missing or stale, and then as a new file renamed over the old one: bash reads + // a script as it goes, so rewriting it in place would cut short a run in another session. + private fun installScript() { + val file = File(directory, SCRIPT_NAME) + if (file.isFile && file.readText() == script) return + val temp = File.createTempFile(SCRIPT_NAME, null, directory) + try { + temp.writeText(script) + if (!temp.renameTo(file)) throw IOException("Could not install $file") + } finally { + temp.delete() + } + } + + /** + * Takes back command [id] unless its runner already took it, so it never runs. Does file I/O, so + * call it off the main thread. + * + * @return true if taken back; false if the runner took it first, or it was never prepared. + */ + fun withdraw(id: String): Boolean = File(directory, "$id.cmd").delete().also { if (it) File(directory, "$id.dir").delete() } + + /** + * Deletes the files of command [id], which will never run; the runner deletes them once it + * reads them. Does file I/O, so call it off the main thread. + */ + fun discard(id: String) { + File(directory, "$id.cmd").delete() + File(directory, "$id.dir").delete() + } + + /** + * The bash arguments of a new session: run command [id], then become an interactive login shell + * for the commands typed after it. Both are login shells, so every command starts with the + * profile sourced. The INT trap keeps the first shell alive when Ctrl-C stops the command. + */ + fun firstRunArguments(id: String): Array = arrayOf("-l", "-c", "trap : INT; bash \"$scriptPath\" \"\$1\"; exec bash -l", "cogo", id) + + /** What is typed into an idle session to run command [id]: clear the prompt line, then run it. */ + fun typedRunLine(id: String): String = "${ControlKeys.CTRL_U}bash \"$scriptPath\" $id\r" + + companion object { + private const val SCRIPT_NAME = "agent-run" + private const val ID_LENGTH = 8 + + /** The option on each of the runner's shell-integration marks naming the command. */ + const val ID_OPTION = "cogo-id" + + /** The option on each of the runner's shell-integration marks giving the runner's process id. */ + const val PID_OPTION = "cogo-pid" + + private val script: String by lazy { + checkNotNull(AgentRunner::class.java.getResource("agent-run.sh")) { "agent-run.sh is missing" }.readText() + } + + /** Runs commands from Termux's tmp directory, which Termux clears when its service stops. */ + val termux = AgentRunner(File(TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH)) + } +} + +/** Control characters written to a session as if typed. */ +internal object ControlKeys { + /** Interrupts the foreground command. */ + const val CTRL_C = "\u0003" + + /** Clears what is typed at the prompt. */ + const val CTRL_U = "\u0015" +} diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandIntentRouter.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandIntentRouter.kt new file mode 100644 index 0000000000..9a48449fe8 --- /dev/null +++ b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandIntentRouter.kt @@ -0,0 +1,47 @@ +package com.itsaky.androidide.terminal + +import android.content.Intent +import com.termux.terminal.TerminalSession + +/** + * Carries a plugin's command from the launcher to the Terminal screen: the launcher puts the + * command's id on the intent that opens the screen, and the screen hands the id back to [route]. + * + * Every method must be called on the main thread, like [TerminalCommandRequests]. + */ +class CommandIntentRouter internal constructor( + private val requests: TerminalCommandRequests, +) { + /** The id of the command [intent] asks to run, or null if it asks for none. */ + fun requestId(intent: Intent?): String? = intent?.getStringExtra(EXTRA_COMMAND_REQUEST_ID) + + /** Makes [intent] ask the Terminal screen to run command [id]. */ + fun putRequestId( + intent: Intent, + id: String, + ): Intent = intent.putExtra(EXTRA_COMMAND_REQUEST_ID, id) + + /** + * Runs command [id] in an idle session of its plugin, or a new one from [factory], then hands + * [routed] the session it runs in, on the main thread; null if it does not run, including when it + * already ran (a recreated screen sees the same intent again) or was withdrawn. + * + * @param screenClosing whether the screen asking is finishing; a session started then would run + * the command with nothing on screen, so the command is withdrawn instead. + */ + fun route( + id: String, + factory: TerminalSessionFactory, + screenClosing: () -> Boolean, + routed: (TerminalSession?) -> Unit, + ) = requests.start(id, factory, screenClosing, routed) + + companion object { + /** Intent extra carrying the command id. */ + const val EXTRA_COMMAND_REQUEST_ID = "com.itsaky.androidide.terminal.COMMAND_REQUEST_ID" + + /** The router the plugin launcher and the Terminal screen share. */ + @JvmField + val shared = CommandIntentRouter(TerminalCommandRequests.shared) + } +} diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandMarkListener.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandMarkListener.kt new file mode 100644 index 0000000000..6bd62ee554 --- /dev/null +++ b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandMarkListener.kt @@ -0,0 +1,36 @@ +package com.itsaky.androidide.terminal + +import com.termux.terminal.ShellIntegrationMark +import com.termux.terminal.TerminalSession + +/** + * Follows the shell-integration marks the runner prints in a plugin's sessions: starts recording + * where a command's output starts, and reports through [onFinished] that it exited. Called on the + * main thread, where the sessions deliver their output. + */ +internal class CommandMarkListener( + private val pool: PluginSessionPool, + private val onFinished: (session: PluginSession, exitCode: Int, runnerPid: Int?) -> Unit, +) : TerminalSession.ShellIntegrationListener { + override fun onShellIntegrationMark( + terminal: TerminalSession, + mark: ShellIntegrationMark, + ) { + val session = pool.find(terminal) ?: return + val command = session.command ?: return + // The user's own shell may print marks too; only the runner's carry this command's id. + if (mark.options[AgentRunner.ID_OPTION] != command.id) return + + val runnerPid = mark.options[AgentRunner.PID_OPTION]?.toIntOrNull() + when (mark.kind) { + ShellIntegrationMark.Kind.OUTPUT_START -> session.startRecording(runnerPid) + ShellIntegrationMark.Kind.COMMAND_FINISHED -> onFinished(session, mark.exitCode ?: UNKNOWN_EXIT_CODE, runnerPid) + ShellIntegrationMark.Kind.PROMPT_START, ShellIntegrationMark.Kind.COMMAND_START -> Unit + } + } + + companion object { + /** The exit code reported when a command's end gives none. */ + const val UNKNOWN_EXIT_CODE = -1 + } +} diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandRecorder.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandRecorder.kt new file mode 100644 index 0000000000..c0f8c41b00 --- /dev/null +++ b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/CommandRecorder.kt @@ -0,0 +1,65 @@ +package com.itsaky.androidide.terminal + +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalOutput + +/** + * Renders the bytes one command prints in an off-screen emulator of its own, as the session renders + * them, so the command's output reads apart from the prompt and from earlier commands, however much + * the session scrolls, clears or resizes. + */ +internal class CommandRecorder( + columns: Int, + rows: Int, +) : TerminalEmulator.OutputTap { + private val emulator = + TerminalEmulator(DiscardingOutput, columns.coerceAtLeast(MIN_COLUMNS), rows.coerceAtLeast(MIN_ROWS), TRANSCRIPT_ROWS, null) + private val single = ByteArray(1) + + override fun onByte(b: Byte) { + single[0] = b + emulator.append(single, 1) + } + + // Follows the session, so later output wraps and moves the cursor where the session's does. + override fun onResize( + columns: Int, + rows: Int, + ) = emulator.resize(columns.coerceAtLeast(MIN_COLUMNS), rows.coerceAtLeast(MIN_ROWS)) + + /** What the command printed so far. */ + fun output(): String = + TerminalTranscript.stripExitBanner(emulator.screen.transcriptTextWithFullLinesJoined.orEmpty()).trim('\n').trimEnd() + + companion object { + // Lines kept; a server's earlier log scrolls out like it does in the session. + private const val TRANSCRIPT_ROWS = 2000 + private const val MIN_COLUMNS = 20 + private const val MIN_ROWS = 4 + + /** A recorder the size of [emulator], so lines wrap where the session wraps them. */ + fun sizedLike(emulator: TerminalEmulator?) = CommandRecorder(emulator?.mColumns ?: 0, emulator?.mRows ?: 0) + } + + // The command's terminal queries (cursor position, colours) are answered by the session, not here. + private object DiscardingOutput : TerminalOutput() { + override fun write( + data: ByteArray, + offset: Int, + count: Int, + ) = Unit + + override fun titleChanged( + oldTitle: String?, + newTitle: String?, + ) = Unit + + override fun onCopyTextToClipboard(text: String?) = Unit + + override fun onPasteTextFromClipboard() = Unit + + override fun onBell() = Unit + + override fun onColorsChanged() = Unit + } +} diff --git a/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/PluginSessionPool.kt b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/PluginSessionPool.kt new file mode 100644 index 0000000000..3fb64bf271 --- /dev/null +++ b/termux/termux-app/src/main/java/com/itsaky/androidide/terminal/PluginSessionPool.kt @@ -0,0 +1,175 @@ +package com.itsaky.androidide.terminal + +import com.termux.terminal.TerminalSession + +/** Opens Terminal sessions for plugin commands; implemented by the Terminal screen. */ +interface TerminalSessionFactory { + /** Whether [session] is still open and its shell running, so it can take another command. */ + fun isOpen(session: TerminalSession): Boolean + + /** Opens a visible session named [name] running bash with [bashArguments], or null if it cannot. */ + fun open( + name: String, + bashArguments: Array, + workingDirectory: String?, + ): TerminalSession? +} + +/** + * A Terminal session a plugin opened: the command it runs now, and what that printed. + * + * @param name what the session was opened as, which the plugin addresses it by even after the user + * renames it. + */ +internal class PluginSession( + val terminal: TerminalSession, + val name: String, +) { + + /** The command running here, or null while the session is idle. */ + var command: TerminalCommand? = null + private set + + val isIdle: Boolean get() = command == null + + /** Whether the running command's run line was typed at the prompt, rather than given to a new session. */ + var typed = false + private set + + /** When the running command was handed to the session. */ + var startedAt = 0L + private set + + /** + * The runner of the running command once it reported in, else of the last command, which keeps + * the foreground for a moment after it reports its end. + */ + var runnerPid: Int? = null + private set + + /** Since when the running command's runner has looked gone without reporting its end; null while it looks alive. */ + var goneSince: Long? = null + + private var recorder: CommandRecorder? = null + + /** + * Whether a line typed now reaches the session's shell rather than a program the user started in + * it, given the terminal's [foreground] process group; null means it cannot be told. + */ + fun isAtPrompt(foreground: Int?): Boolean = foreground == null || foreground == terminal.pid || foreground == runnerPid + + fun begin( + command: TerminalCommand, + typed: Boolean = false, + startedAt: Long = 0L, + ) { + this.command = command + this.typed = typed + this.startedAt = startedAt + runnerPid = null + goneSince = null + recorder = null + } + + /** Records what the running command, run by process [runnerPid], prints from now on, rendered as this session renders it. */ + fun startRecording(runnerPid: Int? = null) { + if (runnerPid != null) this.runnerPid = runnerPid + val emulator = terminal.emulator + recorder = CommandRecorder.sizedLike(emulator).also { emulator?.setOutputTap(it) } + } + + /** Ends the running command with [exitCode], run by process [runnerPid], and returns what it left behind. */ + fun finish( + exitCode: Int, + runnerPid: Int? = null, + ): CommandState.Exited { + terminal.emulator?.setOutputTap(null) + val exited = CommandState.Exited(checkNotNull(command).id, name, exitCode, output()) + if (runnerPid != null) this.runnerPid = runnerPid + command = null + recorder = null + return exited + } + + /** The running command and its output so far, or null while the session is idle. */ + fun running(): CommandState.Running? = command?.let { CommandState.Running(it.id, name, output()) } + + private fun output(): String = recorder?.output().orEmpty() +} + +/** The Terminal sessions each plugin opened: at most [maxPerPlugin], named "