diff --git a/build.gradle.kts b/build.gradle.kts index ce286de..ce5b594 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,6 @@ import com.jetbrains.plugin.structure.toolbox.ToolboxMeta import com.jetbrains.plugin.structure.toolbox.ToolboxPluginDescriptor import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory import org.jetbrains.intellij.pluginRepository.model.ProductFamily -import org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.nio.file.Path import kotlin.io.path.createDirectories @@ -204,12 +203,14 @@ tasks.register("cleanAll", Delete::class.java) { private fun getPluginInstallDir(): Path { val userHome = System.getProperty("user.home").let { Path.of(it) } + val osName = System.getProperty("os.name").lowercase() val pluginsDir = when { - SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") - // currently this is the location that TBA uses on Linux - SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share") - SystemInfoRt.isMac -> userHome / "Library" / "Caches" - else -> error("Unknown os") + osName.contains("win") -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") + osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> System.getenv("XDG_DATA_HOME") + ?.let { Path.of(it) } ?: (userHome / ".local" / "share") + + osName.contains("mac") -> userHome / "Library" / "Caches" + else -> error("Unknown os: $osName") } / "JetBrains" / "Toolbox" / "plugins" return pluginsDir / extension.id diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 82a8a5a..a260ed4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -toolbox-plugin-api = "1.3.47293" -kotlin = "2.1.20" +toolbox-plugin-api = "1.10.76281" +kotlin = "2.3.10" coroutines = "1.10.2" -serialization = "1.8.1" +serialization = "1.9.0" okhttp = "4.12.0" dependency-license-report = "3.1.1" marketplace-client = "2.0.50" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.20-2.0.1" +ksp = "2.3.6" retrofit = "3.0.0" changelog = "2.5.0" gettext = "0.7.0" diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index a8b5fb6..ff8db83 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -20,8 +20,14 @@ import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView +import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentStateV2 import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState +import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState +import com.jetbrains.toolbox.api.remoteDev.tools.ToolDetails +import com.jetbrains.toolbox.api.remoteDev.tools.ToolLaunchResult +import com.jetbrains.toolbox.api.remoteDev.tools.ToolLifetimeListener import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.TextType import com.squareup.moshi.Moshi @@ -34,7 +40,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import org.zeroturnaround.exec.ProcessExecutor +import java.io.ByteArrayOutputStream import java.io.File import java.nio.file.Path import kotlin.time.Duration.Companion.minutes @@ -42,6 +51,13 @@ import kotlin.time.Duration.Companion.seconds private val POLL_INTERVAL = 5.seconds +private val INSTALL_PLUGINS_SCRIPT: String by lazy { + CoderRemoteEnvironment::class.java.getResourceAsStream("/install-plugins.sh") + ?.bufferedReader() + ?.readText() + ?: error("Could not load install-plugins.sh resource") +} + /** * Represents an agent and workspace combination. * @@ -71,6 +87,13 @@ class CoderRemoteEnvironment( private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null + /** + * When true, the environment displays "Installing plugins..." with a spinner + * and the poller's [update] calls are suppressed so they don't overwrite the status. + */ + @Volatile + private var installingPlugins = false + init { if (context.settingsStore.shouldAutoConnect(id)) { context.logger.info("Last session to $id was still active, trying to establish SSH connection") @@ -269,6 +292,13 @@ class CoderRemoteEnvironment( this.workspace = workspace this.agent = agent + // While plugins are being installed, keep the workspace/agent data fresh + // but don't touch the UI state — the "Installing plugins..." spinner must stay visible. + if (installingPlugins) { + context.logger.debug("Poller update suppressed for $id while plugins are being installed") + return + } + // workspace&agent status can be different from "environment status" // which is forced to queued state when a workspace is scheduled to start updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) @@ -288,6 +318,30 @@ class CoderRemoteEnvironment( context.logger.info("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } + private fun showInstallingPluginsState() { + installingPlugins = true + state.update { + CustomRemoteEnvironmentStateV2( + label = context.i18n.pnotr("Installing plugins..."), + color = context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating), + isReachable = true, + iconId = EnvironmentStateIcons.Connecting.id, + isPriorityShow = true + ) + } + context.logger.info("Showing 'Installing plugins...' status for $id") + } + + private fun clearInstallingPluginsState() { + if (!installingPlugins) return + installingPlugins = false + // Re-apply the real workspace status so the poller can resume normally. + updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) + context.connectionMonitoringService.checkConnectionStatus(workspace, agent) + refreshAvailableActions() + context.logger.info("Cleared 'Installing plugins...' status for $id") + } + /** * The contents are provided by the SSH view provided by Toolbox, all we * have to do is provide it a host name. @@ -377,4 +431,65 @@ class CoderRemoteEnvironment( this.client = client this.cli = cli } + + override fun getToolLifetimeListener(): ToolLifetimeListener = object : ToolLifetimeListener { + override suspend fun onToolLaunchAttempt(toolDetails: ToolDetails) { + context.logger.info("Tool Lifetime Launch attempt: $toolDetails from workspace $id") + + val installationPath = toolDetails.installationPath + if (installationPath.isNullOrBlank()) { + context.logger.warn("No installation path available for $toolDetails, skipping plugin installation") + return + } + + val pluginIds = context.settingsStore.pluginIds + if (pluginIds.isEmpty()) { + context.logger.info("No plugin IDs configured, skipping plugin installation for $id") + return + } + + showInstallingPluginsState() + try { + withContext(Dispatchers.IO) { + val hostname = cli.getHostname(client.url, workspace, agent) + val command = listOf("ssh", "-T", hostname, "bash", "-s", "--", installationPath) + pluginIds + + context.logger.info("Installing plugins $pluginIds on $id via SSH ($hostname)") + + val output = ByteArrayOutputStream() + val result = ProcessExecutor() + .command(command) + .redirectInput(INSTALL_PLUGINS_SCRIPT.byteInputStream()) + .redirectOutput(output) + .redirectError(output) + .exitValueAny() + .execute() + + val outputText = output.toString(Charsets.UTF_8) + if (result.exitValue == 0) { + context.logger.info("Plugin installation succeeded for $id: $outputText") + } else { + context.logger.error("Plugin installation failed for $id (exit code ${result.exitValue}): $outputText") + } + } + } catch (e: Exception) { + context.logger.error(e, "Failed to run plugin installation script on $id") + } finally { + clearInstallingPluginsState() + } + } + + override suspend fun onToolLaunchResult( + toolDetails: ToolDetails, + result: ToolLaunchResult + ) { + context.logger.info("Tool Lifetime Launch result: $toolDetails, $result from workspace $id") + clearInstallingPluginsState() + } + + override suspend fun onToolShutdown(toolDetails: ToolDetails) { + context.logger.info("Tool Lifetime Shutdown: $toolDetails from workspace $id") + clearInstallingPluginsState() + } + } } diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 6c373fc..e2b87d3 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -176,6 +176,12 @@ interface ReadOnlyCoderSettings { */ val preferOAuth2IfAvailable: Boolean + /** + * A list of JetBrains plugin IDs to install on the remote IDE before it launches. + * Stored as a comma-separated string in the settings store. + */ + val pluginIds: List + /** * Where the specified deployment should put its data. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 8f514c7..8f1b5a4 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -87,6 +87,12 @@ class CoderSettingsStore( .toString() override val preferOAuth2IfAvailable: Boolean get() = store[PREFER_OAUTH2_IF_AVAILABLE]?.toBooleanStrictOrNull() ?: false + override val pluginIds: List + get() = store[PLUGIN_IDS] + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + ?: emptyList() override val workspaceViewUrl: String? get() = store[WORKSPACE_VIEW_URL] override val workspaceCreateUrl: String? @@ -264,6 +270,10 @@ class CoderSettingsStore( store[PREFER_OAUTH2_IF_AVAILABLE] = preferAuthViaOAuth2.toString() } + fun updatePluginIds(ids: List) { + store[PLUGIN_IDS] = ids.joinToString(",") + } + private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(getWinAppData(), "coder-toolbox") diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 3de6109..c2c71fa 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -58,3 +58,5 @@ internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" internal const val PREFER_OAUTH2_IF_AVAILABLE = "preferOAuth2IfAvailable" + +internal const val PLUGIN_IDS = "pluginIds" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 0ff4866..464921d 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -129,6 +129,12 @@ class CoderSettingsPage( TextType.General ) + private val pluginIdsField = TextField( + context.i18n.ptrl("Plugin IDs (comma-separated)"), + settings.pluginIds.joinToString(", "), + TextType.General + ) + private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( @@ -176,6 +182,13 @@ class CoderSettingsPage( networkInfoDirField, sshExtraArgs, ) + ), + SectionField( + "IDE Plugins", + false, + listOf( + pluginIdsField, + ) ) ) ) @@ -220,6 +233,12 @@ class CoderSettingsPage( updateSshLogDir(sshLogDirField.contentState.value) updateNetworkInfoDir(networkInfoDirField.contentState.value) updateSshConfigOptions(sshExtraArgs.contentState.value) + updatePluginIds( + pluginIdsField.contentState.value + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + ) } } ) @@ -298,6 +317,10 @@ class CoderSettingsPage( settings.networkInfoDir } + pluginIdsField.contentState.update { + settings.pluginIds.joinToString(", ") + } + visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { disableSignatureVerificationField.checkedState.collect { state -> signatureFallbackStrategyField.visibility.update {