From 87ab9092ef09fc600bd425f462105e60d6a83a77 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 3 Apr 2026 01:04:35 +0100 Subject: [PATCH 1/2] feat/ADFA-3580 Plugin Build Actions & Custom Scripts System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class API for plugins to declare build actions (shell commands and Gradle tasks), execute them with streaming output to the Build Output panel, and control toolbar action visibility. Includes custom scripts support via .codeonthego/scripts.json — plugins can auto-detect project type (Node.js, Python, Rust, Go, Make, Ruby) and bootstrap user-editable run scripts on first project open. New plugin-api interfaces: BuildActionExtension, IdeCommandService, CommandExecution. New plugin-manager implementations: IdeCommandServiceImpl (ProcessBuilder-based with Termux env injection), PluginBuildActionManager (singleton orchestrator). New app integration: PluginBuildActionItem --- .../actions/build/PluginBuildActionItem.kt | 141 ++++++++++ .../extensions/BuildActionExtension.kt | 88 +++++++ .../plugins/services/IdeCommandService.kt | 20 ++ .../manager/build/PluginBuildActionManager.kt | 165 ++++++++++++ .../manager/services/IdeCommandServiceImpl.kt | 242 ++++++++++++++++++ 5 files changed, 656 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt diff --git a/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt b/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt new file mode 100644 index 0000000000..65c8e088a9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/build/PluginBuildActionItem.kt @@ -0,0 +1,141 @@ +package com.itsaky.androidide.actions.build + +import android.content.Context +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.BaseBuildAction +import com.itsaky.androidide.actions.getContext +import com.itsaky.androidide.plugins.extensions.CommandOutput +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager +import com.itsaky.androidide.plugins.manager.build.RegisteredBuildAction +import com.itsaky.androidide.plugins.manager.core.PluginManager +import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver +import com.itsaky.androidide.plugins.services.IdeCommandService +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.resolveAttr +import com.itsaky.androidide.viewmodel.BottomSheetViewModel +import com.google.android.material.bottomsheet.BottomSheetBehavior +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PluginBuildActionItem( + context: Context, + private val registered: RegisteredBuildAction, + override val order: Int +) : BaseBuildAction() { + + override val id: String = "plugin.build.${registered.pluginId}.${registered.action.id}" + + init { + label = registered.action.name + icon = resolvePluginIcon(context) + location = ActionItem.Location.EDITOR_TOOLBAR + requiresUIThread = true + } + + override fun prepare(data: ActionData) { + val context = data.getActivity() + if (context == null) { + visible = false + return + } + visible = true + + val manager = PluginBuildActionManager.getInstance() + val isRunning = manager.isActionRunning(registered.pluginId, registered.action.id) + + if (isRunning) { + label = "Cancel ${registered.action.name}" + icon = ContextCompat.getDrawable(context, R.drawable.ic_stop) + enabled = true + } else { + label = registered.action.name + icon = resolvePluginIcon(context) + enabled = true + } + } + + override fun createColorFilter(data: ActionData): ColorFilter? { + val context = data.getContext() ?: return null + val isRunning = PluginBuildActionManager.getInstance() + .isActionRunning(registered.pluginId, registered.action.id) + val attr = if (isRunning) R.attr.colorError else com.google.android.material.R.attr.colorOnSurface + return PorterDuffColorFilter( + context.resolveAttr(attr), + PorterDuff.Mode.SRC_ATOP + ) + } + + private fun resolvePluginIcon(fallbackContext: Context): Drawable? { + val iconResId = registered.action.icon ?: return ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline) + return PluginDrawableResolver.resolve(iconResId, registered.pluginId, fallbackContext) + ?: ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline) + } + + override suspend fun execAction(data: ActionData): Any { + val manager = PluginBuildActionManager.getInstance() + val pluginId = registered.pluginId + val actionId = registered.action.id + + if (manager.isActionRunning(pluginId, actionId)) { + manager.cancelAction(pluginId, actionId) + data.getActivity()?.let { resetProgressIfIdle(it) } + return true + } + + val activity = data.getActivity() ?: return false + + val pluginManager = PluginManager.getInstance() ?: return false + val loadedPlugin = pluginManager.getLoadedPlugin(pluginId) ?: return false + val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) + ?: return false + + val execution = manager.executeAction(pluginId, actionId, commandService) ?: return false + + activity.editorViewModel.isBuildInProgress = true + val currentSheetState = activity.bottomSheetViewModel.sheetBehaviorState + val targetState = if (currentSheetState == BottomSheetBehavior.STATE_HIDDEN) + BottomSheetBehavior.STATE_COLLAPSED else currentSheetState + activity.bottomSheetViewModel.setSheetState( + sheetState = targetState, + currentTab = BottomSheetViewModel.TAB_BUILD_OUTPUT + ) + activity.appendBuildOutput("━━━ ${registered.action.name} ━━━") + activity.invalidateOptionsMenu() + + actionScope.launch { + execution.output.collect { output -> + val line = when (output) { + is CommandOutput.StdOut -> output.line + is CommandOutput.StdErr -> output.line + is CommandOutput.ExitCode -> + if (output.code != 0) "Process failed with code ${output.code}" else null + } + if (line != null) { + withContext(Dispatchers.Main) { + activity.appendBuildOutput(line) + } + } + } + + val result = execution.await() + manager.notifyActionCompleted(pluginId, actionId, result) + withContext(Dispatchers.Main) { resetProgressIfIdle(activity) } + } + + return true + } + + private fun resetProgressIfIdle(activity: com.itsaky.androidide.activities.editor.EditorHandlerActivity) { + if (buildService?.isBuildInProgress != true) { + activity.editorViewModel.isBuildInProgress = false + } + activity.invalidateOptionsMenu() + } +} diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt new file mode 100644 index 0000000000..bd9d5f11a7 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/BuildActionExtension.kt @@ -0,0 +1,88 @@ +package com.itsaky.androidide.plugins.extensions + +import com.itsaky.androidide.plugins.IPlugin + +interface BuildActionExtension : IPlugin { + fun getBuildActions(): List + fun toolbarActionsToHide(): Set = emptySet() + fun onActionStarted(actionId: String) {} + fun onActionCompleted(actionId: String, result: CommandResult) {} +} + +object ToolbarActionIds { + const val QUICK_RUN = "ide.editor.build.quickRun" + const val PROJECT_SYNC = "ide.editor.syncProject" + const val DEBUG = "ide.editor.build.debug" + const val RUN_TASKS = "ide.editor.build.runTasks" + const val UNDO = "ide.editor.code.text.undo" + const val REDO = "ide.editor.code.text.redo" + const val SAVE = "ide.editor.files.saveAll" + const val PREVIEW_LAYOUT = "ide.editor.previewLayout" + const val FIND = "ide.editor.find" + const val FIND_IN_FILE = "ide.editor.find.inFile" + const val FIND_IN_PROJECT = "ide.editor.find.inProject" + const val LAUNCH_APP = "ide.editor.launchInstalledApp" + const val DISCONNECT_LOG_SENDERS = "ide.editor.service.logreceiver.disconnectSenders" + const val GENERATE_XML = "ide.editor.generatexml" + + val ALL: Set = setOf( + QUICK_RUN, PROJECT_SYNC, DEBUG, RUN_TASKS, + UNDO, REDO, SAVE, PREVIEW_LAYOUT, + FIND, FIND_IN_FILE, FIND_IN_PROJECT, + LAUNCH_APP, DISCONNECT_LOG_SENDERS, GENERATE_XML + ) +} + +data class PluginBuildAction( + val id: String, + val name: String, + val description: String, + val icon: Int? = null, + val category: BuildActionCategory = BuildActionCategory.CUSTOM, + val command: CommandSpec, + val timeoutMs: Long = 600_000 +) + +sealed class CommandSpec { + data class ShellCommand( + val executable: String, + val arguments: List = emptyList(), + val workingDirectory: String? = null, + val environment: Map = emptyMap() + ) : CommandSpec() + + data class GradleTask( + val taskPath: String, + val arguments: List = emptyList() + ) : CommandSpec() +} + +sealed class CommandOutput { + data class StdOut(val line: String) : CommandOutput() + data class StdErr(val line: String) : CommandOutput() + data class ExitCode(val code: Int) : CommandOutput() +} + +sealed class CommandResult { + data class Success( + val exitCode: Int, + val stdout: String, + val stderr: String, + val durationMs: Long + ) : CommandResult() + + data class Failure( + val exitCode: Int, + val stdout: String, + val stderr: String, + val error: String?, + val durationMs: Long + ) : CommandResult() + + data class Cancelled( + val partialStdout: String, + val partialStderr: String + ) : CommandResult() +} + +enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM } \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt new file mode 100644 index 0000000000..ed4fb6a04f --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeCommandService.kt @@ -0,0 +1,20 @@ +package com.itsaky.androidide.plugins.services + +import com.itsaky.androidide.plugins.extensions.CommandOutput +import com.itsaky.androidide.plugins.extensions.CommandResult +import com.itsaky.androidide.plugins.extensions.CommandSpec +import kotlinx.coroutines.flow.Flow + +interface IdeCommandService { + fun executeCommand(spec: CommandSpec, timeoutMs: Long = 600_000): CommandExecution + fun isCommandRunning(executionId: String): Boolean + fun cancelCommand(executionId: String): Boolean + fun getRunningCommandCount(): Int +} + +interface CommandExecution { + val executionId: String + val output: Flow + suspend fun await(): CommandResult + fun cancel() +} \ No newline at end of file diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt new file mode 100644 index 0000000000..6433ee5671 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/build/PluginBuildActionManager.kt @@ -0,0 +1,165 @@ +package com.itsaky.androidide.plugins.manager.build + +import com.itsaky.androidide.plugins.extensions.BuildActionCategory +import com.itsaky.androidide.plugins.extensions.BuildActionExtension +import com.itsaky.androidide.plugins.extensions.CommandResult +import com.itsaky.androidide.plugins.extensions.CommandSpec +import com.itsaky.androidide.plugins.extensions.PluginBuildAction +import com.itsaky.androidide.plugins.extensions.ToolbarActionIds +import com.itsaky.androidide.plugins.manager.loaders.ManifestBuildAction +import com.itsaky.androidide.plugins.manager.loaders.PluginManifest +import com.itsaky.androidide.plugins.services.CommandExecution +import com.itsaky.androidide.plugins.services.IdeCommandService +import java.util.concurrent.ConcurrentHashMap + +class PluginBuildActionManager private constructor() { + + private val pluginExtensions = ConcurrentHashMap() + private val manifestActions = ConcurrentHashMap>() + private val pluginNames = ConcurrentHashMap() + private val activeExecutions = ConcurrentHashMap() + + companion object { + @Volatile + private var INSTANCE: PluginBuildActionManager? = null + + fun getInstance(): PluginBuildActionManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: PluginBuildActionManager().also { INSTANCE = it } + } + } + } + + fun registerPlugin(pluginId: String, pluginName: String, extension: BuildActionExtension) { + pluginExtensions[pluginId] = extension + pluginNames[pluginId] = pluginName + } + + fun registerManifestActions(pluginId: String, pluginName: String, manifest: PluginManifest) { + if (manifest.buildActions.isEmpty()) return + + pluginNames[pluginId] = pluginName + manifestActions[pluginId] = manifest.buildActions.map { it.toPluginBuildAction() } + } + + fun getAllBuildActions(): List { + val actions = mutableListOf() + + for ((pluginId, extension) in pluginExtensions) { + val name = pluginNames[pluginId] ?: pluginId + try { + extension.getBuildActions().forEach { action -> + actions.add(RegisteredBuildAction(pluginId, name, action)) + } + } catch (_: Throwable) {} + } + + for ((pluginId, pluginActions) in manifestActions) { + if (pluginExtensions.containsKey(pluginId)) continue + val name = pluginNames[pluginId] ?: pluginId + pluginActions.forEach { action -> + actions.add(RegisteredBuildAction(pluginId, name, action)) + } + } + + return actions + } + + fun getHiddenActionIds(): Set { + val hidden = mutableSetOf() + + for ((_, extension) in pluginExtensions) { + try { + val requested = extension.toolbarActionsToHide() + hidden.addAll(requested.intersect(ToolbarActionIds.ALL)) + } catch (_: Throwable) {} + } + + return hidden + } + + fun executeAction( + pluginId: String, + actionId: String, + commandService: IdeCommandService + ): CommandExecution? { + val action = findAction(pluginId, actionId) ?: return null + val extension = pluginExtensions[pluginId] + + extension?.onActionStarted(actionId) + + val execution = commandService.executeCommand(action.command, action.timeoutMs) + val executionKey = "$pluginId:$actionId" + activeExecutions[executionKey] = execution + + return execution + } + + fun notifyActionCompleted(pluginId: String, actionId: String, result: CommandResult) { + activeExecutions.remove("$pluginId:$actionId") + pluginExtensions[pluginId]?.onActionCompleted(actionId, result) + } + + fun isActionRunning(pluginId: String, actionId: String): Boolean { + return activeExecutions.containsKey("$pluginId:$actionId") + } + + fun cancelAction(pluginId: String, actionId: String): Boolean { + val key = "$pluginId:$actionId" + return activeExecutions.remove(key)?.let { + it.cancel() + true + } ?: false + } + + fun cleanupPlugin(pluginId: String) { + activeExecutions.entries.removeAll { it.key.startsWith("$pluginId:") } + pluginExtensions.remove(pluginId) + manifestActions.remove(pluginId) + pluginNames.remove(pluginId) + } + + private fun findAction(pluginId: String, actionId: String): PluginBuildAction? { + pluginExtensions[pluginId]?.let { ext -> + try { + return ext.getBuildActions().find { it.id == actionId } + } catch (_: Throwable) {} + } + + return manifestActions[pluginId]?.find { it.id == actionId } + } +} + +data class RegisteredBuildAction( + val pluginId: String, + val pluginName: String, + val action: PluginBuildAction +) + +private fun ManifestBuildAction.toPluginBuildAction(): PluginBuildAction { + val spec = when { + gradleTask != null -> CommandSpec.GradleTask(gradleTask, arguments) + command != null -> CommandSpec.ShellCommand( + executable = command, + arguments = arguments, + workingDirectory = workingDirectory, + environment = environment + ) + else -> throw IllegalArgumentException("ManifestBuildAction must have either 'command' or 'gradle_task'") + } + + val cat = try { + BuildActionCategory.valueOf(category.uppercase()) + } catch (_: IllegalArgumentException) { + BuildActionCategory.CUSTOM + } + + return PluginBuildAction( + id = id, + name = name, + description = description, + category = cat, + command = spec, + timeoutMs = timeoutMs + ) +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt new file mode 100644 index 0000000000..f2c2c2cfb6 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt @@ -0,0 +1,242 @@ +package com.itsaky.androidide.plugins.manager.services + +import com.itsaky.androidide.plugins.PluginPermission +import com.itsaky.androidide.plugins.extensions.CommandOutput +import com.itsaky.androidide.plugins.extensions.CommandResult +import com.itsaky.androidide.plugins.extensions.CommandSpec +import com.itsaky.androidide.plugins.services.CommandExecution +import com.itsaky.androidide.plugins.services.IdeCommandService +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class IdeCommandServiceImpl( + private val pluginId: String, + private val permissions: Set, + private val projectRootProvider: () -> File?, + private val appFilesDir: File +) : IdeCommandService { + + private val runningCommands = ConcurrentHashMap() + + override fun executeCommand(spec: CommandSpec, timeoutMs: Long): CommandExecution { + requirePermission() + requireConcurrencyLimit() + + val executionId = "$pluginId-${UUID.randomUUID()}" + val projectRoot = projectRootProvider() + + val processBuilder = when (spec) { + is CommandSpec.ShellCommand -> { + val workDir = spec.workingDirectory?.let { File(it) } ?: projectRoot + validateWorkingDirectory(workDir) + ProcessBuilder(listOf(spec.executable) + spec.arguments).apply { + workDir?.let { directory(it) } + environment().putAll(spec.environment) + } + } + is CommandSpec.GradleTask -> { + val gradleWrapper = projectRoot?.let { File(it, "gradlew") } + ?: throw IllegalStateException("No project root available for Gradle task execution") + ProcessBuilder(listOf(gradleWrapper.absolutePath, spec.taskPath) + spec.arguments).apply { + directory(projectRoot) + } + } + } + + processBuilder.redirectErrorStream(false) + injectTermuxEnvironment(processBuilder) + + val execution = CommandExecutionImpl( + executionId = executionId, + processBuilder = processBuilder, + timeoutMs = timeoutMs + ) + runningCommands[executionId] = execution + execution.start { runningCommands.remove(executionId) } + return execution + } + + override fun isCommandRunning(executionId: String): Boolean { + return runningCommands[executionId]?.isRunning() == true + } + + override fun cancelCommand(executionId: String): Boolean { + return runningCommands[executionId]?.let { + it.cancel() + true + } ?: false + } + + override fun getRunningCommandCount(): Int = runningCommands.size + + fun cancelAllCommands() { + runningCommands.values.forEach { it.cancel() } + runningCommands.clear() + } + + private fun requirePermission() { + if (PluginPermission.SYSTEM_COMMANDS !in permissions) { + throw SecurityException( + "Plugin $pluginId does not have SYSTEM_COMMANDS permission" + ) + } + } + + private fun requireConcurrencyLimit() { + if (runningCommands.size >= MAX_CONCURRENT_COMMANDS) { + throw IllegalStateException( + "Plugin $pluginId has reached the maximum of $MAX_CONCURRENT_COMMANDS concurrent commands" + ) + } + } + + private fun validateWorkingDirectory(dir: File?) { + if (dir == null) return + val projectRoot = projectRootProvider() ?: return + val canonicalDir = dir.canonicalPath + val canonicalRoot = projectRoot.canonicalPath + if (!canonicalDir.startsWith(canonicalRoot)) { + throw SecurityException( + "Plugin $pluginId attempted to execute in directory outside project root: $canonicalDir" + ) + } + } + + private fun injectTermuxEnvironment(processBuilder: ProcessBuilder) { + val termuxBase = appFilesDir.absolutePath + val termuxBin = "$termuxBase/usr/bin" + val termuxLib = "$termuxBase/usr/lib" + val env = processBuilder.environment() + + val existingPath = env["PATH"] ?: "" + if (!existingPath.contains(termuxBin)) { + env["PATH"] = "$termuxBin:$existingPath" + } + + val existingLdPath = env["LD_LIBRARY_PATH"] ?: "" + if (!existingLdPath.contains(termuxLib)) { + env["LD_LIBRARY_PATH"] = "$termuxLib:$existingLdPath" + } + + env.putIfAbsent("HOME", "$termuxBase/home") + env.putIfAbsent("TMPDIR", "$termuxBase/usr/tmp") + env.putIfAbsent("LANG", "en_US.UTF-8") + env.putIfAbsent("PREFIX", "$termuxBase/usr") + } + + companion object { + private const val MAX_CONCURRENT_COMMANDS = 3 + } +} + +private class CommandExecutionImpl( + override val executionId: String, + private val processBuilder: ProcessBuilder, + private val timeoutMs: Long +) : CommandExecution { + + private val outputChannel = Channel(capacity = 256) + private val resultDeferred = CompletableDeferred() + private val scope = CoroutineScope(Dispatchers.IO + Job()) + private var process: Process? = null + private val stdoutBuilder = StringBuilder() + private val stderrBuilder = StringBuilder() + + override val output: Flow = outputChannel.receiveAsFlow() + + fun start(onComplete: () -> Unit) { + scope.launch { + val startTime = System.currentTimeMillis() + try { + withTimeout(timeoutMs) { + process = processBuilder.start() + val proc = process!! + + val stdoutJob = launch { readStream(proc, isStdErr = false) } + val stderrJob = launch { readStream(proc, isStdErr = true) } + + val exitCode = proc.waitFor() + stdoutJob.join() + stderrJob.join() + + outputChannel.send(CommandOutput.ExitCode(exitCode)) + outputChannel.close() + + val duration = System.currentTimeMillis() - startTime + val result = if (exitCode == 0) { + CommandResult.Success(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), duration) + } else { + CommandResult.Failure(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), null, duration) + } + resultDeferred.complete(result) + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + process?.destroyForcibly() + outputChannel.close() + val duration = System.currentTimeMillis() - startTime + resultDeferred.complete( + CommandResult.Failure(-1, stdoutBuilder.toString(), stderrBuilder.toString(), "Command timed out after ${timeoutMs}ms", duration) + ) + } catch (e: Exception) { + process?.destroyForcibly() + outputChannel.close() + val duration = System.currentTimeMillis() - startTime + if (resultDeferred.isActive) { + resultDeferred.complete( + CommandResult.Cancelled(stdoutBuilder.toString(), stderrBuilder.toString()) + ) + } + } finally { + onComplete() + } + } + } + + private suspend fun readStream(process: Process, isStdErr: Boolean) { + val stream = if (isStdErr) process.errorStream else process.inputStream + val builder = if (isStdErr) stderrBuilder else stdoutBuilder + BufferedReader(InputStreamReader(stream)).use { reader -> + var line = reader.readLine() + while (line != null) { + if (builder.length + line.length <= MAX_OUTPUT_BYTES) { + builder.appendLine(line) + } + val output = if (isStdErr) CommandOutput.StdErr(line) else CommandOutput.StdOut(line) + outputChannel.send(output) + line = reader.readLine() + } + } + } + + override suspend fun await(): CommandResult = resultDeferred.await() + + override fun cancel() { + process?.destroyForcibly() + outputChannel.close() + if (resultDeferred.isActive) { + resultDeferred.complete( + CommandResult.Cancelled(stdoutBuilder.toString(), stderrBuilder.toString()) + ) + } + scope.cancel() + } + + fun isRunning(): Boolean = process?.isAlive == true + + companion object { + private const val MAX_OUTPUT_BYTES = 10 * 1024 * 1024 + } +} \ No newline at end of file From e64ffeabe5da4def67a025fee0c49466621670cd Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 3 Apr 2026 01:04:51 +0100 Subject: [PATCH 2/2] feat/ADFA-3580 Plugin Build Actions & Custom Scripts System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class API for plugins to declare build actions (shell commands and Gradle tasks), execute them with streaming output to the Build Output panel, and control toolbar action visibility. Includes custom scripts support via .codeonthego/scripts.json — plugins can auto-detect project type (Node.js, Python, Rust, Go, Make, Ruby) and bootstrap user-editable run scripts on first project open. New plugin-api interfaces: BuildActionExtension, IdeCommandService, CommandExecution. New plugin-manager implementations: IdeCommandServiceImpl (ProcessBuilder-based with Termux env injection), PluginBuildActionManager (singleton orchestrator). New app integration: PluginBuildActionItem --- .../actions/EditorActivityAction.kt | 1 + .../editor/EditorHandlerActivity.kt | 4 ++ .../androidide/utils/EditorActivityActions.kt | 31 +++++++++-- plugin-api/build.gradle.kts | 2 + .../plugins/manager/core/PluginManager.kt | 51 ++++++++++++++++++- .../plugins/manager/loaders/PluginManifest.kt | 25 +++++++-- .../manager/ui/PluginDrawableResolver.kt | 11 ++-- 7 files changed, 113 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt b/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt index 43678a372b..6708527f7a 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt @@ -44,6 +44,7 @@ abstract class EditorActivityAction : ActionItem { super.prepare(data) if (!data.hasRequiredData(Context::class.java)) { markInvisible() + return } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 6c6315cfae..ca787b73a9 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -63,6 +63,7 @@ import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.OpenedFilesCache import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager @@ -410,12 +411,15 @@ open class EditorHandlerActivity : content.projectActionsToolbar.clearMenu() val actions = getInstance().getActions(EDITOR_TOOLBAR) + val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds() actions.onEachIndexed { index, entry -> val action = entry.value val isLast = index == actions.size - 1 action.prepare(data) + if (action.id in hiddenIds || !action.visible) return@onEachIndexed + action.icon?.apply { colorFilter = action.createColorFilter(data) alpha = if (action.enabled) 255 else 76 diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 8711300e71..8cad0bcbdd 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -57,10 +57,13 @@ import com.itsaky.androidide.actions.filetree.RenameAction import com.itsaky.androidide.actions.text.RedoAction import com.itsaky.androidide.actions.text.UndoAction import com.itsaky.androidide.actions.PluginActionItem +import com.itsaky.androidide.actions.build.PluginBuildActionItem import com.itsaky.androidide.actions.etc.GenerateXMLAction import com.itsaky.androidide.plugins.extensions.UIExtension +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.core.PluginManager + /** * Takes care of registering actions to the actions registry for the editor activity. * @@ -104,6 +107,7 @@ class EditorActivityActions { // Plugin contributions order = registerPluginActions(context, registry, order) + order = registerPluginBuildActions(context, registry, order) // editor text actions registry.registerAction(ExpandSelectionAction(context, order++)) @@ -157,7 +161,8 @@ class EditorActivityActions { registry.clearActionsExceptWhere(EDITOR_TOOLBAR) { action -> action.id == QuickRunAction.ID || action.id == RunTasksAction.ID || - action.id == ProjectSyncAction.ID + action.id == ProjectSyncAction.ID || + action.id.startsWith("plugin.build.") } } @@ -184,14 +189,30 @@ class EditorActivityActions { val action = PluginActionItem(context, menuItem, order++) registry.registerAction(action) } - } catch (e: Exception) { - // Continue with other plugins if one fails - System.err.println("") - Log.d("plugin_debug", "Failed to register menu items for plugin: ${plugin.javaClass.simpleName} - ${e.message}") + } catch (_: Exception) { } } return order } + + @JvmStatic + private fun registerPluginBuildActions(context: Context, registry: ActionsRegistry, startOrder: Int): Int { + var order = startOrder + + val buildActions = PluginBuildActionManager.getInstance().getAllBuildActions() + for (registered in buildActions) { + try { + val action = PluginBuildActionItem(context, registered, order++) + registry.registerAction(action) + Log.d("plugin_debug", "Registered build action: ${registered.action.id} from plugin: ${registered.pluginId}") + } catch (e: Exception) { + Log.d("plugin_debug", "Failed to register build action: ${registered.action.id} - ${e.message}") + } + } + + return order + } + } } diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index 7adab325d9..c3a680df44 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { compileOnly("androidx.appcompat:appcompat:1.6.1") compileOnly("androidx.fragment:fragment-ktx:1.6.2") compileOnly("com.google.android.material:material:1.11.0") + + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } tasks.register("createPluginApiJar") { 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 f903da11f3..ad980e4ec4 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 @@ -43,6 +43,10 @@ import com.itsaky.androidide.plugins.manager.services.IdeThemeServiceImpl import com.itsaky.androidide.plugins.services.IdeThemeService import com.itsaky.androidide.plugins.services.IdeFeatureFlagService import com.itsaky.androidide.plugins.manager.services.IdeFeatureFlagServiceImpl +import com.itsaky.androidide.plugins.services.IdeCommandService +import com.itsaky.androidide.plugins.manager.services.IdeCommandServiceImpl +import com.itsaky.androidide.plugins.extensions.BuildActionExtension +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.actions.SidebarSlotManager import com.itsaky.androidide.actions.SidebarSlotExceededException import kotlinx.coroutines.CoroutineScope @@ -424,6 +428,13 @@ class PluginManager private constructor( } } } + + val buildActionManager = PluginBuildActionManager.getInstance() + if (plugin is BuildActionExtension) { + buildActionManager.registerPlugin(manifest.id, manifest.name, plugin) + logger.info("Registered build actions for plugin: ${manifest.id}") + } + buildActionManager.registerManifestActions(manifest.id, manifest.name, manifest) } catch (e: Exception) { logger.error("Failed to activate plugin: ${manifest.id}", e) loadedPlugin.isEnabled = false @@ -472,6 +483,12 @@ class PluginManager private constructor( PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) + PluginBuildActionManager.getInstance().cleanupPlugin(pluginId) + val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) + if (commandService is IdeCommandServiceImpl) { + commandService.cancelAllCommands() + } + val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) if (templateService is IdeTemplateServiceImpl) { templateService.cleanupAllTemplates() @@ -599,7 +616,11 @@ class PluginManager private constructor( .filter { it.isEnabled } .map { it.plugin } } - + + fun getLoadedPlugin(pluginId: String): LoadedPlugin? { + return loadedPlugins[pluginId]?.takeIf { it.isEnabled } + } + /** * Get all enabled plugins that implement UI extensions */ @@ -942,6 +963,20 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeCommandService::class.java, + pluginId, + "command" + ) { + IdeCommandServiceImpl( + pluginId = pluginId, + permissions = permissions, + projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, + appFilesDir = context.filesDir + ) + } + // Create PluginContext with resource context return PluginContextImpl( androidContext = resourceContext, // Use the resource context instead of app context @@ -1087,6 +1122,20 @@ class PluginManager private constructor( ) } + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeCommandService::class.java, + pluginId, + "command" + ) { + IdeCommandServiceImpl( + pluginId = pluginId, + permissions = permissions, + projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, + appFilesDir = context.filesDir + ) + } + return PluginContextImpl( androidContext = context, services = pluginServiceRegistry, diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt index 9a4ccca1b8..83334f83f2 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt @@ -41,20 +41,39 @@ data class PluginManifest( val extensions: List = emptyList(), @SerializedName("sidebar_items") - val sidebarItems: Int = 0 + val sidebarItems: Int = 0, + + @SerializedName("build_actions") + val buildActions: List = emptyList() ) data class ExtensionInfo( @SerializedName("type") val type: String, - + @SerializedName("class") val className: String, - + @SerializedName("priority") val priority: Int = 0 ) +data class ManifestBuildAction( + val id: String, + val name: String, + val description: String = "", + val category: String = "CUSTOM", + val command: String? = null, + val arguments: List = emptyList(), + @SerializedName("gradle_task") + val gradleTask: String? = null, + @SerializedName("working_directory") + val workingDirectory: String? = null, + val environment: Map = emptyMap(), + @SerializedName("timeout_ms") + val timeoutMs: Long = 600_000 +) + object PluginManifestParser { private val gson = Gson() diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt index 03f925e5c1..cd583b4095 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/ui/PluginDrawableResolver.kt @@ -11,10 +11,15 @@ object PluginDrawableResolver { fun resolve(resId: Int, pluginId: String?, fallbackContext: Context): Drawable? { if (pluginId != null) { val pluginContext = PluginFragmentHelper.getPluginContext(pluginId) - ?: return loadDrawable(fallbackContext, resId) + if (pluginContext == null) { + return loadDrawable(fallbackContext, resId) + } try { - return ContextCompat.getDrawable(pluginContext, resId) - } catch (_: Resources.NotFoundException) { } + val drawable = ContextCompat.getDrawable(pluginContext, resId) + return drawable + } catch (_: Resources.NotFoundException) { + } catch (_: Throwable) { + } } return loadDrawable(fallbackContext, resId) }