diff --git a/CHANGELOG.md b/CHANGELOG.md index 893daa4..fa101fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- support for Toolbox 2.6.3 with improved URI handling + ## 0.2.3 - 2025-05-26 ### Changed diff --git a/README.md b/README.md index 2b749e3..2034504 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable experience, it’s recommended to ensure the workspace is running prior to initiating the connection. +> ⚠️ Note: `folder` should point to a remote IDEA project that has already been opened and appears in the `Projects` tab. +> If the path refers to a project that doesn't exist, the remote IDE won’t start or load it. + +> Until [TBX-14952](https://youtrack.jetbrains.com/issue/TBX-14952/) is fixed, it's best to either use a path to a previously opened project or leave it empty. + ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy This section explains how to set up a local proxy (without authentication which is not yet supported) and verify that diff --git a/gradle.properties b/gradle.properties index 8433f61..a6129a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.2.3 +version=0.3.0 group=com.coder.toolbox name=coder-toolbox diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8546bd8..3cf5531 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -toolbox-plugin-api = "1.0.38881" -kotlin = "2.1.0" +toolbox-plugin-api = "1.1.41749" +kotlin = "2.1.10" coroutines = "1.10.1" serialization = "1.8.0" okhttp = "4.12.0" @@ -9,7 +9,7 @@ marketplace-client = "2.0.46" gradle-wrapper = "0.14.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.0-1.0.29" +ksp = "2.1.10-1.0.31" retrofit = "2.11.0" changelog = "2.2.1" 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 e6118c3..0608817 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -27,6 +27,7 @@ import com.squareup.moshi.Moshi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -203,7 +204,7 @@ class CoderRemoteEnvironment( private fun File.doesNotExists(): Boolean = !this.exists() - override fun afterDisconnect() { + override fun afterDisconnect(isManual: Boolean) { context.logger.info("Stopping the network metrics poll job for $id") pollJob?.cancel() this.connectionRequest.update { false } @@ -269,7 +270,7 @@ class CoderRemoteEnvironment( } } - override fun onDelete() { + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { context.cs.launch { try { client.removeWorkspace(workspace) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 06e1496..4291321 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -7,18 +7,22 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope import java.net.URL +import java.util.UUID +@Suppress("UnstableApiUsage") data class CoderToolboxContext( val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, - val ideOrchestrator: ClientHelper, + val remoteIdeOrchestrator: RemoteToolsHelper, + val jbClientOrchestrator: ClientHelper, val desktop: LocalDesktopManager, val cs: CoroutineScope, val logger: Logger, @@ -44,4 +48,44 @@ data class CoderToolboxContext( } return this.settingsStore.defaultURL.toURL() } + + suspend fun logAndShowError(title: String, error: String) { + logger.error(error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowError(title: String, error: String, exception: Exception) { + logger.error(exception, error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowWarning(title: String, warning: String) { + logger.warn(warning) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(warning), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowInfo(title: String, info: String) { + logger.info(info) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(info), + i18n.ptrl("OK") + ) + } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 05424ae..5cfcd11 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -13,6 +13,7 @@ import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -31,6 +32,7 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService<ToolboxUi>(), serviceLocator.getService<EnvironmentUiPageManager>(), serviceLocator.getService<EnvironmentStateColorPalette>(), + serviceLocator.getService<RemoteToolsHelper>(), serviceLocator.getService<ClientHelper>(), serviceLocator.getService<LocalDesktopManager>(), serviceLocator.getService<CoroutineScope>(), diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 3c9ad5f..cc04dfe 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -7,7 +7,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.jetbrains.toolbox.api.core.ui.color.StateColor -import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState +import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentStateV2 import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState @@ -61,9 +61,9 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { * Note that a reachable environment will always display "connected" or * "disconnected" regardless of the label we give that status. */ - fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentState { - return CustomRemoteEnvironmentState( - label, + fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr(label), color = getStateColor(context), reachable = ready() || unhealthy(), // TODO@JB: How does this work? Would like a spinner for pending states. @@ -90,10 +90,10 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { else EnvironmentStateIcons.NoIcon } - fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentState { + fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { val existingState = toRemoteEnvironmentState(context) - return CustomRemoteEnvironmentState( - "SSHing", + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr("SSHing"), existingState.color, existingState.isReachable, EnvironmentStateIcons.Connecting diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 6785675..9f619bc 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -192,12 +192,12 @@ open class CoderRestClient( } /** - * Maps the list of workspaces to the associated agents. + * Maps the available workspaces to the associated agents. */ - suspend fun groupByAgents(workspaces: List<Workspace>): Set<Pair<Workspace, WorkspaceAgent>> { + suspend fun workspacesByAgents(): Set<Pair<Workspace, WorkspaceAgent>> { // It is possible for there to be resources with duplicate names so we // need to use a set. - return workspaces.flatMap { ws -> + return workspaces().flatMap { ws -> when (ws.latestBuild.status) { WorkspaceStatus.RUNNING -> ws.latestBuild.resources else -> resources(ws) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index ad42d18..af07548 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,19 +9,23 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.HttpURLConnection import java.net.URI -import java.net.URL +import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" + +@Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, private val dialogUi: DialogUi, @@ -41,113 +45,245 @@ open class CoderProtocolHandler( shouldWaitForAutoLogin: Boolean, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { - context.popupPluginMainPage() val params = uri.toQueryParameters() if (params.isEmpty()) { // probably a plugin installation scenario + context.logAndShowInfo("URI will not be handled", "No query parameters were provided") return } + if (shouldWaitForAutoLogin) { + isInitialized.waitForTrue() + } + + context.logger.info("Handling $uri...") + val deploymentURL = resolveDeploymentUrl(params) ?: return + val token = resolveToken(params) ?: return + val workspaceName = resolveWorkspaceName(params) ?: return + val restClient = buildRestClient(deploymentURL, token) ?: return + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return + if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + + // we resolve the agent after the workspace is started otherwise we can get misleading + // errors like: no agent available while workspace is starting or stopping + val agent = resolveAgent(params, workspace) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + + val cli = configureCli(deploymentURL, restClient) + reInitialize(restClient, cli) + + val environmentId = "${workspace.name}.${agent.name}" + context.showEnvironmentPage(environmentId) + + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + val projectFolder = params.projectFolder() + + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(environmentId, productCode, buildNumber, projectFolder) + } + } + + private suspend fun resolveDeploymentUrl(params: Map<String, String>): String? { val deploymentURL = params.url() ?: askUrl() if (deploymentURL.isNullOrBlank()) { - context.logger.error("Query parameter \"$URL\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$URL\" is missing")) - return + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") + return null } + return deploymentURL + } - val queryToken = params.token() - val restClient = try { - authenticate(deploymentURL, queryToken) - } catch (ex: Exception) { - context.logger.error(ex, "Query parameter \"$TOKEN\" is missing from URI $uri") - context.showErrorPopup(IllegalStateException(humanizeConnectionError(deploymentURL.toURL(), true, ex))) - return + private suspend fun resolveToken(params: Map<String, String>): String? { + val token = params.token() + if (token.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") + return null + } + return token + } + + private suspend fun resolveWorkspaceName(params: Map<String, String>): String? { + val workspace = params.workspace() + if (workspace.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$WORKSPACE\" is missing from URI") + return null } + return workspace + } - // TODO: Show a dropdown and ask for the workspace if missing. Right now it's not possible because dialogs are quite limited - val workspaceName = params.workspace() - if (workspaceName.isNullOrBlank()) { - context.logger.error("Query parameter \"$WORKSPACE\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$WORKSPACE\" is missing")) - return + private suspend fun buildRestClient(deploymentURL: String, token: String): CoderRestClient? { + try { + return authenticate(deploymentURL, token) + } catch (ex: Exception) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) + return null } + } + + /** + * Returns an authenticated Coder CLI. + */ + private suspend fun authenticate(deploymentURL: String, token: String): CoderRestClient { + val client = CoderRestClient( + context, + deploymentURL.toURL(), + if (settings.requireTokenAuth) token else null, + PluginManager.pluginInfo.version + ) + client.authenticate() + return client + } - val workspaces = restClient.workspaces() - val workspace = workspaces.firstOrNull { it.name == workspaceName } + private suspend fun List<Workspace>.matchName(workspaceName: String, deploymentURL: String): Workspace? { + val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { - context.logger.error("There is no workspace with name $workspaceName on $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace with name $workspaceName does not exist")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "There is no workspace with name $workspaceName on $deploymentURL" + ) + return null } + return workspace + } + private suspend fun prepareWorkspace( + workspace: Workspace, + restClient: CoderRestClient, + workspaceName: String, + deploymentURL: String + ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be ready on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be ready on time")) - return + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be ready on time" + ) + return false } WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> { if (settings.disableAutostart) { - context.logger.warn("$workspaceName from $deploymentURL is not started and autostart is disabled.") - context.showInfoPopup( - context.i18n.pnotr("$workspaceName is not running"), - context.i18n.ptrl("Can't handle URI because workspace is not running and autostart is disabled. Please start the workspace manually and execute the URI again."), - context.i18n.ptrl("OK") + context.logAndShowWarning( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL is not running and autostart is disabled" ) - return + return false } try { restClient.startWorkspace(workspace) } catch (e: Exception) { - context.logger.error( - e, - "$workspaceName from $deploymentURL could not be started while handling URI" + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started", + e ) - context.showErrorPopup(MissingArgumentException("Can't handle URI because an error was encountered while trying to start workspace $workspaceName")) - return + return false } - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be started on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be started on time")) - return + + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started on time", + ) + return false } } WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { - context.logger.error("Unable to connect to $workspaceName from $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because because we're unable to connect to workspace $workspaceName")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to connect to $workspaceName from $deploymentURL" + ) + return false } - WorkspaceStatus.RUNNING -> Unit // All is well + WorkspaceStatus.RUNNING -> return true // All is well } + return true + } - // TODO: Show a dropdown and ask for an agent if missing. - val agent: WorkspaceAgent + private suspend fun resolveAgent( + params: Map<String, String>, + workspace: Workspace + ): WorkspaceAgent? { try { - agent = getMatchingAgent(params, workspace) + return getMatchingAgent(params, workspace) } catch (e: IllegalArgumentException) { - context.logger.error(e, "Can't resolve an agent for workspace $workspaceName from $deploymentURL") - context.showErrorPopup( - MissingArgumentException( - "Can't handle URI because we can't resolve an agent for workspace $workspaceName from $deploymentURL", - e - ) + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't resolve an agent for workspace ${workspace.name}", + e ) - return + return null + } + } + + /** + * Return the agent matching the provided agent ID or name in the parameters. + * + * @throws [IllegalArgumentException] + */ + internal suspend fun getMatchingAgent( + parameters: Map<String, String?>, + workspace: Workspace, + ): WorkspaceAgent? { + val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + if (agents.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") + return null + } + + // If the agent is missing and the workspace has only one, use that. + val agent = + if (!parameters.agentID().isNullOrBlank()) { + agents.firstOrNull { it.id.toString() == parameters.agentID() } + } else if (agents.size == 1) { + agents.first() + } else { + null + } + + if (agent == null) { + if (!parameters.agentID().isNullOrBlank()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"" + ) + return null + } else { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent" + ) + return null + } } + return agent + } + + private suspend fun ensureAgentIsReady( + workspace: Workspace, + agent: WorkspaceAgent + ): Boolean { val status = WorkspaceAndAgentStatus.from(workspace, agent) if (!status.ready()) { - context.logger.error("Agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready") - context.showErrorPopup(MissingArgumentException("Can't handle URI because agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Agent ${agent.name} for workspace ${workspace.name} is not ready" + ) + return false } + return true + } + private suspend fun configureCli( + deploymentURL: String, + restClient: CoderRestClient + ): CoderCLIManager { val cli = ensureCLI( context, deploymentURL.toURL(), @@ -161,31 +297,92 @@ open class CoderProtocolHandler( } context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.groupByAgents(workspaces)) + cli.configSsh(restClient.workspacesByAgents()) + return cli + } - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() + private fun launchIde( + environmentId: String, + productCode: String, + buildNumber: String, + projectFolder: String? + ) { + context.cs.launch { + val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch + context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + installJBClient(selectedIde, environmentId).join() + launchJBClient(selectedIde, environmentId, projectFolder) } - reInitialize(restClient, cli) + } - val environmentId = "${workspace.name}.${agent.name}" - context.popupPluginMainPage() - context.envPageManager.showEnvironmentPage(environmentId, false) - val productCode = params.ideProductCode() - val buildNumber = params.ideBuildNumber() - val projectFolder = params.projectFolder() - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - context.cs.launch { - val ideVersion = "$productCode-$buildNumber" - context.logger.info("installing $ideVersion on $environmentId") - val job = context.cs.launch { - context.ideOrchestrator.prepareClient(environmentId, ideVersion) - } - job.join() - context.logger.info("launching $ideVersion on $environmentId") - context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectFolder) + private suspend fun selectAndInstallRemoteIde( + productCode: String, + buildNumber: String, + environmentId: String + ): String? { + val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + + var selectedIde = "$productCode-$buildNumber" + if (installedIdes.firstOrNull { it.contains(buildNumber) } != null) { + context.logger.info("$selectedIde is already installed on $environmentId") + return selectedIde + } + + selectedIde = resolveAvailableIde(environmentId, productCode, buildNumber) ?: return null + + // needed otherwise TBX will install it again + if (!installedIdes.contains(selectedIde)) { + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId...") + return selectedIde + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + return null } + } else { + context.logger.info("$selectedIde is already present on $environmentId...") + return selectedIde + } + } + + private suspend fun resolveAvailableIde(environmentId: String, productCode: String, buildNumber: String): String? { + val availableVersions = context + .remoteIdeOrchestrator + .getAvailableRemoteTools(environmentId, productCode) + + if (availableVersions.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") + return null } + + val matchingBuildNumber = availableVersions.firstOrNull { it.contains(buildNumber) } != null + if (!matchingBuildNumber) { + val selectedIde = availableVersions.maxOf { it } + context.logAndShowInfo( + "$productCode-$buildNumber not available", + "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" + ) + return selectedIde + } + return null + } + + private fun installJBClient(selectedIde: String, environmentId: String): Job = context.cs.launch { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + } + + private fun launchJBClient(selectedIde: String, environmentId: String, projectFolder: String?) { + context.logger.info("Launching $selectedIde on $environmentId") + context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) } private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { @@ -203,6 +400,25 @@ open class CoderProtocolHandler( } } + private suspend fun RemoteToolsHelper.waitForIdeToBeInstalled( + environmentId: String, + ideHint: String, + waitTime: Duration = 2.minutes + ): Boolean { + var isInstalled = false + try { + withTimeout(waitTime.toJavaDuration()) { + while (!isInstalled) { + delay(5.seconds) + isInstalled = getInstalledRemoteTools(environmentId, ideHint).isNotEmpty() + } + } + return true + } catch (_: TimeoutCancellationException) { + return false + } + } + private suspend fun askUrl(): String? { context.popupPluginMainPage() return dialogUi.ask( @@ -210,119 +426,6 @@ open class CoderProtocolHandler( context.i18n.ptrl("Enter the full URL of your Coder deployment") ) } - - /** - * Return an authenticated Coder CLI, asking for the token. - * Throw MissingArgumentException if the user aborts. Any network or invalid - * token error may also be thrown. - */ - private suspend fun authenticate( - deploymentURL: String, - tryToken: String? - ): CoderRestClient { - val token = - if (settings.requireTokenAuth) { - // Try the provided token immediately on the first attempt. - if (!tryToken.isNullOrBlank()) { - tryToken - } else { - context.popupPluginMainPage() - // Otherwise ask for a new token, showing the previous token. - dialogUi.askToken(deploymentURL.toURL()) - } - } else { - null - } - - if (settings.requireTokenAuth && token == null) { // User aborted. - throw MissingArgumentException("Token is required") - } - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - PluginManager.pluginInfo.version - ) - client.authenticate() - return client - } - -} - -/** - * Follow a URL's redirects to its final destination. - */ -internal fun resolveRedirects(url: URL): URL { - var location = url - val maxRedirects = 10 - for (i in 1..maxRedirects) { - val conn = location.openConnection() as HttpURLConnection - conn.instanceFollowRedirects = false - conn.connect() - val code = conn.responseCode - val nextLocation = conn.getHeaderField("Location") - conn.disconnect() - // Redirects are triggered by any code starting with 3 plus a - // location header. - if (code < 300 || code >= 400 || nextLocation.isNullOrBlank()) { - return location - } - // Location headers might be relative. - location = URL(location, nextLocation) - } - throw Exception("Too many redirects") -} - -/** - * Return the agent matching the provided agent ID or name in the parameters. - * - * @throws [IllegalArgumentException] - */ -internal fun getMatchingAgent( - parameters: Map<String, String?>, - workspace: Workspace, -): WorkspaceAgent { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } - if (agents.isEmpty()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" has no agents") - } - - // If the agent is missing and the workspace has only one, use that. - // Prefer the ID over the name if both are set. - val agent = - if (!parameters.agentID().isNullOrBlank()) { - agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (agents.size == 1) { - agents.first() - } else { - null - } - - if (agent == null) { - if (!parameters.agentID().isNullOrBlank()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") - } else { - throw MissingArgumentException( - "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", - ) - } - } - - return agent -} - -private suspend fun CoderToolboxContext.showErrorPopup(error: Throwable) { - popupPluginMainPage() - this.ui.showErrorInfoPopup(error) -} - -private suspend fun CoderToolboxContext.showInfoPopup( - title: LocalizableString, - message: LocalizableString, - okLabel: LocalizableString -) { - popupPluginMainPage() - this.ui.showInfoPopup(title, message, okLabel) } private fun CoderToolboxContext.popupPluginMainPage() { @@ -330,4 +433,9 @@ private fun CoderToolboxContext.popupPluginMainPage() { this.envPageManager.showPluginEnvironmentsPage(true) } +private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { + this.ui.showWindow() + this.envPageManager.showEnvironmentPage(envId, false) +} + class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index d3adabc..3678813 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -1,10 +1,8 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.browser.browse import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.TextType -import java.net.URL /** * Dialog implementation for standalone Gateway. @@ -26,34 +24,4 @@ class DialogUi(private val context: CoderToolboxContext) { title, description, placeholder, TextType.General, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") ) } - - suspend fun askPassword( - title: LocalizableString, - description: LocalizableString, - placeholder: LocalizableString? = null, - ): String? { - return context.ui.showTextInputPopup( - title, description, placeholder, TextType.Password, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") - ) - } - - private suspend fun openUrl(url: URL) { - context.desktop.browse(url.toString()) { - context.ui.showErrorInfoPopup(it) - } - } - - /** - * Open a dialog for providing the token. - */ - suspend fun askToken( - url: URL, - ): String? { - openUrl(url.withPath("/login?redirect=%2Fcli-auth")) - return askPassword( - title = context.i18n.ptrl("Session Token"), - description = context.i18n.pnotr("Please paste the session token from the web-page"), - placeholder = context.i18n.pnotr("") - ) - } } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 1135227..0a15db8 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.util const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" -const val AGENT_NAME = "agent" const val AGENT_ID = "agent_id" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index ceba2e9..73da796 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -137,4 +137,7 @@ msgid "Network Status" msgstr "" msgid "Create workspace" +msgstr "" + +msgid "Error encountered while handling Coder URI" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index a7c6f72..4603fda 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -34,6 +34,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -66,6 +67,7 @@ internal class CoderCLIManagerTest { mockk<ToolboxUi>(), mockk<EnvironmentUiPageManager>(), mockk<EnvironmentStateColorPalette>(), + mockk<RemoteToolsHelper>(), mockk<ClientHelper>(), mockk<LocalDesktopManager>(), mockk<CoroutineScope>(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index c32e7b1..2727228 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -24,6 +24,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -102,6 +103,7 @@ class CoderRestClientTest { mockk<ToolboxUi>(), mockk<EnvironmentUiPageManager>(), mockk<EnvironmentStateColorPalette>(), + mockk<RemoteToolsHelper>(), mockk<ClientHelper>(), mockk<LocalDesktopManager>(), mockk<CoroutineScope>(), diff --git a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt similarity index 59% rename from src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt rename to src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index bb87151..2914eae 100644 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,41 +1,49 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.DataGen -import com.sun.net.httpserver.HttpHandler -import com.sun.net.httpserver.HttpServer -import java.net.HttpURLConnection -import java.net.InetSocketAddress +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.store.CoderSecretsStore +import com.coder.toolbox.store.CoderSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import java.util.UUID import kotlin.test.Test -import kotlin.test.assertContains import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -internal class LinkHandlerTest { - /** - * Create, start, and return a server that uses the provided handler. - */ - private fun mockServer(handler: HttpHandler): Pair<HttpServer, String> { - val srv = HttpServer.create(InetSocketAddress(0), 0) - srv.createContext("/", handler) - srv.start() - return Pair(srv, "http://localhost:" + srv.address.port) - } - - /** - * Create, start, and return a server that mocks redirects. - */ - private fun mockRedirectServer( - location: String, - temp: Boolean, - ): Pair<HttpServer, String> = mockServer { exchange -> - exchange.responseHeaders.set("Location", location) - exchange.sendResponseHeaders( - if (temp) HttpURLConnection.HTTP_MOVED_TEMP else HttpURLConnection.HTTP_MOVED_PERM, - -1, - ) - exchange.close() - } +import kotlin.test.assertNull + +internal class CoderProtocolHandlerTest { + private val context = CoderToolboxContext( + mockk<ToolboxUi>(relaxed = true), + mockk<EnvironmentUiPageManager>(), + mockk<EnvironmentStateColorPalette>(), + mockk<RemoteToolsHelper>(), + mockk<ClientHelper>(), + mockk<LocalDesktopManager>(), + mockk<CoroutineScope>(), + mockk<Logger>(relaxed = true), + mockk<LocalizableStringFactory>(relaxed = true), + CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk<Logger>(relaxed = true)), + mockk<CoderSecretsStore>(), + mockk<ToolboxProxySettings>() + ) + + private val protocolHandler = CoderProtocolHandler( + context, + DialogUi(context), + MutableStateFlow(false) + ) private val agents = mapOf( @@ -49,7 +57,7 @@ internal class LinkHandlerTest { ) @Test - fun getMatchingAgent() { + fun tstgetMatchingAgent() { val ws = DataGen.workspace("ws", agents = agents) val tests = @@ -74,9 +82,10 @@ internal class LinkHandlerTest { "b0e4c54d-9ba9-4413-8512-11ca1e826a24", ), ) - - tests.forEach { - assertEquals(UUID.fromString(it.second), getMatchingAgent(it.first, ws).id) + runBlocking { + tests.forEach { + assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -104,14 +113,10 @@ internal class LinkHandlerTest { "agent with ID", ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -126,15 +131,16 @@ internal class LinkHandlerTest { mapOf("agent" to null), mapOf("agent_id" to null), ) - - tests.forEach { - assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - getMatchingAgent( - it, - ws, - ).id, - ) + runBlocking { + tests.forEach { + assertEquals( + UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + protocolHandler.getMatchingAgent( + it, + ws, + )?.id, + ) + } } } @@ -149,14 +155,10 @@ internal class LinkHandlerTest { "agent with ID" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -177,43 +179,10 @@ internal class LinkHandlerTest { "has no agents" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) - } - } - - @Test - fun followsRedirects() { - val (srv1, url1) = - mockServer { exchange -> - exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) - exchange.close() + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) } - val (srv2, url2) = mockRedirectServer(url1, false) - val (srv3, url3) = mockRedirectServer(url2, true) - - assertEquals(url1.toURL(), resolveRedirects(java.net.URL(url3))) - - srv1.stop(0) - srv2.stop(0) - srv3.stop(0) - } - - @Test - fun followsMaximumRedirects() { - val (srv, url) = mockRedirectServer(".", true) - - assertFailsWith( - exceptionClass = Exception::class, - block = { resolveRedirects(java.net.URL(url)) }, - ) - - srv.stop(0) + } } }