From ee07b8d87e6b87935b95a08b1644fa51d50493d3 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 14 Mar 2025 01:03:45 +0200 Subject: [PATCH 01/17] impl: support uri handling (1) - reacts to uris like: jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fdev.coder.com&token=....&workspace=bobiverse-bill - the handling still does not work correctly when Toolbox is already running. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 14 +++++----- ...LinkHandler.kt => CoderProtocolHandler.kt} | 27 ++++++++++++++----- 2 files changed, 27 insertions(+), 14 deletions(-) rename src/main/kotlin/com/coder/toolbox/util/{LinkHandler.kt => CoderProtocolHandler.kt} (93%) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index a30d3fb..d65ce8f 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -7,8 +7,8 @@ import com.coder.toolbox.services.CoderSecretsService import com.coder.toolbox.services.CoderSettingsService import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.settings.Source +import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi -import com.coder.toolbox.util.LinkHandler import com.coder.toolbox.util.toQueryParameters import com.coder.toolbox.views.Action import com.coder.toolbox.views.CoderSettingsPage @@ -47,13 +47,15 @@ class CoderRemoteProvider( private var pollJob: Job? = null private var lastEnvironments: Set? = null + private val isInitialized: MutableStateFlow = MutableStateFlow(false) + // Create our services from the Toolbox ones. private val settingsService = CoderSettingsService(context.settingsStore) private val settings: CoderSettings = CoderSettings(settingsService, context.logger) private val secrets: CoderSecretsService = CoderSecretsService(context.secretsStore) private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, settingsService) private val dialogUi = DialogUi(context, settings) - private val linkHandler = LinkHandler(context, settings, httpClient, dialogUi) + private val linkHandler = CoderProtocolHandler(context, settings, httpClient, dialogUi, isInitialized) // The REST client, if we are signed in private var client: CoderRestClient? = null @@ -65,7 +67,6 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true - override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Value(emptyList()) ) @@ -234,11 +235,7 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { val params = uri.toQueryParameters() - context.cs.launch { - val name = linkHandler.handle(params) - // TODO@JB: Now what? How do we actually connect this workspace? - context.logger.debug("External request for $name: $uri") - } + linkHandler.handle(params) } /** @@ -323,6 +320,7 @@ class CoderRemoteProvider( pollJob?.cancel() pollJob = poll(client, cli) goToEnvironmentsPage() + isInitialized.update { true } } /** diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt similarity index 93% rename from src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt rename to src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 31a6602..84b54ed 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -11,15 +11,20 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.settings.Source +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield import okhttp3.OkHttpClient import java.net.HttpURLConnection import java.net.URL -open class LinkHandler( +open class CoderProtocolHandler( private val context: CoderToolboxContext, private val settings: CoderSettings, private val httpClient: OkHttpClient?, private val dialogUi: DialogUi, + private val isInitialized: StateFlow, ) { /** * Given a set of URL parameters, prepare the CLI then return a workspace to @@ -31,7 +36,7 @@ open class LinkHandler( suspend fun handle( parameters: Map, indicator: ((t: String) -> Unit)? = null, - ): String { + ) { val deploymentURL = parameters.url() ?: dialogUi.ask( context.i18n.ptrl("Deployment URL"), @@ -129,10 +134,16 @@ open class LinkHandler( indicator?.invoke("Configuring Coder CLI...") cli.configSsh(client.agentNames(workspaces)) - val name = "${workspace.name}.${agent.name}" - // TODO@JB: Can we ask for the IDE and project path or how does - // this work? - return name + isInitialized.waitForTrue() + context.cs.launch { + context.ui.showWindow() + yield() + context.envPageManager.showEnvironmentPage("${workspace.name}.${agent.name}", false) + // without a yield or a delay(0) the env page does not show up. My assumption is that + // the coroutine is finishing too fast without giving enough time to compose main thread + // to catch the state change. Yielding gives other coroutines the chance to run + yield() + } } /** @@ -332,4 +343,8 @@ internal fun getMatchingAgent( return agent } +fun StateFlow.waitForTrue() { + this.filter { it } +} + class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) From ba78918c2ec2aedd74501cc03fdcee2c915f393a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 14 Mar 2025 21:47:45 +0200 Subject: [PATCH 02/17] chore: update comment --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index d65ce8f..a7a2733 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -172,8 +172,7 @@ class CoderRemoteProvider( /** * Cancel polling and clear the client and environments. * - * Called as part of our own logout but it is unclear where it is called by - * Toolbox. Maybe on uninstall? + * Also called as part of our own logout. */ override fun close() { pollJob?.cancel() From 0661a8d3a92d477ec61fb7711825d7989019d9ed Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 14 Mar 2025 23:08:50 +0200 Subject: [PATCH 03/17] refactor: text progress indicator can be replaced by an actual logger --- .../kotlin/com/coder/toolbox/cli/CoderCLIManager.kt | 5 ++--- .../com/coder/toolbox/util/CoderProtocolHandler.kt | 12 ++++-------- .../kotlin/com/coder/toolbox/views/ConnectPage.kt | 4 +--- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index d4f347f..ecebb44 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -60,7 +60,6 @@ fun ensureCLI( deploymentURL: URL, buildVersion: String, settings: CoderSettings, - indicator: ((t: String) -> Unit)? = null, ): CoderCLIManager { val cli = CoderCLIManager(deploymentURL, context.logger, settings) @@ -76,7 +75,7 @@ fun ensureCLI( // If downloads are enabled download the new version. if (settings.enableDownloads) { - indicator?.invoke("Downloading Coder CLI...") + context.logger.info("Downloading Coder CLI...") try { cli.download() return cli @@ -98,7 +97,7 @@ fun ensureCLI( } if (settings.enableDownloads) { - indicator?.invoke("Downloading Coder CLI...") + context.logger.info("Downloading Coder CLI...") dataCLI.download() return dataCLI } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 84b54ed..5f13f8d 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -33,10 +33,7 @@ open class CoderProtocolHandler( * Throw if required arguments are not supplied or the workspace is not in a * connectable state. */ - suspend fun handle( - parameters: Map, - indicator: ((t: String) -> Unit)? = null, - ) { + suspend fun handle(parameters: Map) { val deploymentURL = parameters.url() ?: dialogUi.ask( context.i18n.ptrl("Deployment URL"), @@ -121,17 +118,16 @@ open class CoderProtocolHandler( context, deploymentURL.toURL(), client.buildInfo().version, - settings, - indicator, + settings ) // We only need to log in if we are using token-based auth. if (client.token != null) { - indicator?.invoke("Authenticating Coder CLI...") + context.logger.info("Authenticating Coder CLI...") cli.login(client.token) } - indicator?.invoke("Configuring Coder CLI...") + context.logger.info("Configuring Coder CLI...") cli.configSsh(client.agentNames(workspaces)) isInitialized.waitForTrue() diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt index 9538d45..25a3359 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectPage.kt @@ -101,9 +101,7 @@ class ConnectPage( ) client.authenticate() updateStatus(context.i18n.ptrl("Checking Coder binary..."), error = null) - val cli = ensureCLI(context, client.url, client.buildVersion, settings) { status -> - updateStatus(context.i18n.pnotr(status), error = null) - } + val cli = ensureCLI(context, client.url, client.buildVersion, settings) // We only need to log in if we are using token-based auth. if (client.token != null) { updateStatus(context.i18n.ptrl("Configuring CLI..."), error = null) From 5b7f3e95689b8522e9eeb343f260c9f4c3e24560 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 15 Mar 2025 00:09:49 +0200 Subject: [PATCH 04/17] impl: support for uri handling (2) - reworked the main env provider to be able to close all existing resources and re-initialize with a different deployment - the re-initialization is needed in order to navigate to an env page with the targeted workspace. If the workspace is not from the current deployment there will be no env page to navigate to. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 21 +++++++++++------ .../com/coder/toolbox/sdk/CoderRestClient.kt | 8 +++++++ .../toolbox/util/CoderProtocolHandler.kt | 23 +++++++++++-------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index a7a2733..8316d08 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -47,15 +47,12 @@ class CoderRemoteProvider( private var pollJob: Job? = null private var lastEnvironments: Set? = null - private val isInitialized: MutableStateFlow = MutableStateFlow(false) - // Create our services from the Toolbox ones. private val settingsService = CoderSettingsService(context.settingsStore) private val settings: CoderSettings = CoderSettings(settingsService, context.logger) private val secrets: CoderSecretsService = CoderSecretsService(context.secretsStore) private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, settingsService) private val dialogUi = DialogUi(context, settings) - private val linkHandler = CoderProtocolHandler(context, settings, httpClient, dialogUi, isInitialized) // The REST client, if we are signed in private var client: CoderRestClient? = null @@ -67,6 +64,9 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true + private val isInitialized: MutableStateFlow = MutableStateFlow(false) + private var coderHeaderPager = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: "")) + private val linkHandler = CoderProtocolHandler(context, settings, httpClient, dialogUi, isInitialized) override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Value(emptyList()) ) @@ -176,9 +176,10 @@ class CoderRemoteProvider( */ override fun close() { pollJob?.cancel() - client = null + client?.close() lastEnvironments = null environments.value = LoadableState.Value(emptyList()) + isInitialized.update { false } } override val svgIcon: SvgIcon = @@ -213,8 +214,7 @@ class CoderRemoteProvider( * Just displays the deployment URL at the moment, but we could use this as * a form for creating new environments. */ - override fun getNewEnvironmentUiPage(): UiPage = - NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: "")) + override fun getNewEnvironmentUiPage(): UiPage = coderHeaderPager /** * We always show a list of environments. @@ -234,7 +234,14 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { val params = uri.toQueryParameters() - linkHandler.handle(params) + linkHandler.handle(params) { restClient, cli -> + // stop polling and de-initialize resources + close() + // start initialization with the new settings + this@CoderRemoteProvider.client = restClient + coderHeaderPager = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) + poll(restClient, cli) + } } /** diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 2d2c49e..7e78a99 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -272,4 +272,12 @@ open class CoderRestClient( } return buildResponse.body()!! } + + fun close() { + httpClient.apply { + dispatcher.executorService.shutdown() + connectionPool.evictAll() + cache?.close() + } + } } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 5f13f8d..66bacb5 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.models.WorkspaceAndAgentStatus import com.coder.toolbox.plugin.PluginManager @@ -33,7 +34,10 @@ open class CoderProtocolHandler( * Throw if required arguments are not supplied or the workspace is not in a * connectable state. */ - suspend fun handle(parameters: Map) { + suspend fun handle( + parameters: Map, + onReinitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + ) { val deploymentURL = parameters.url() ?: dialogUi.ask( context.i18n.ptrl("Deployment URL"), @@ -49,7 +53,7 @@ open class CoderProtocolHandler( } else { null } - val client = try { + val restClient = try { authenticate(deploymentURL, queryToken) } catch (ex: MissingArgumentException) { throw MissingArgumentException("Query parameter \"$TOKEN\" is missing", ex) @@ -59,7 +63,7 @@ open class CoderProtocolHandler( val workspaceName = parameters.workspace() ?: throw MissingArgumentException("Query parameter \"$WORKSPACE\" is missing") - val workspaces = client.workspaces() + val workspaces = restClient.workspaces() val workspace = workspaces.firstOrNull { it.name == workspaceName @@ -117,23 +121,24 @@ open class CoderProtocolHandler( ensureCLI( context, deploymentURL.toURL(), - client.buildInfo().version, + restClient.buildInfo().version, settings ) // We only need to log in if we are using token-based auth. - if (client.token != null) { + if (restClient.token != null) { context.logger.info("Authenticating Coder CLI...") - cli.login(client.token) + cli.login(restClient.token) } context.logger.info("Configuring Coder CLI...") - cli.configSsh(client.agentNames(workspaces)) + cli.configSsh(restClient.agentNames(workspaces)) - isInitialized.waitForTrue() + onReinitialize(restClient, cli) context.cs.launch { context.ui.showWindow() - yield() + context.envPageManager.showPluginEnvironmentsPage(true) + isInitialized.waitForTrue() context.envPageManager.showEnvironmentPage("${workspace.name}.${agent.name}", false) // without a yield or a delay(0) the env page does not show up. My assumption is that // the coroutine is finishing too fast without giving enough time to compose main thread From 3108f7dc593927012ca404e9431f8cb928f70643 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 15 Mar 2025 00:34:41 +0200 Subject: [PATCH 05/17] impl: resilience when `url` query parameter is missing - a pop-up dialog is displayed asking for the deployment URL - an error dialog is displayed if the URL is still not provided by the user --- .../com/coder/toolbox/CoderRemoteProvider.kt | 4 +-- .../toolbox/util/CoderProtocolHandler.kt | 30 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 8316d08..e7443a9 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -9,7 +9,6 @@ import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.settings.Source import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi -import com.coder.toolbox.util.toQueryParameters import com.coder.toolbox.views.Action import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.ConnectPage @@ -233,8 +232,7 @@ class CoderRemoteProvider( * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - val params = uri.toQueryParameters() - linkHandler.handle(params) { restClient, cli -> + linkHandler.handle(uri) { restClient, cli -> // stop polling and de-initialize resources close() // start initialization with the new settings diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 66bacb5..f255f6f 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield import okhttp3.OkHttpClient import java.net.HttpURLConnection +import java.net.URI import java.net.URL open class CoderProtocolHandler( @@ -35,19 +36,19 @@ open class CoderProtocolHandler( * connectable state. */ suspend fun handle( - parameters: Map, + uri: URI, onReinitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { - val deploymentURL = - parameters.url() ?: dialogUi.ask( - context.i18n.ptrl("Deployment URL"), - context.i18n.ptrl("Enter the full URL of your Coder deployment") - ) + val params = uri.toQueryParameters() + + val deploymentURL = params.url() ?: askUrl() if (deploymentURL.isNullOrBlank()) { - throw MissingArgumentException("Query parameter \"$URL\" is missing") + context.logger.error("Query parameter \"$URL\" is missing from URI $uri") + context.ui.showErrorInfoPopup(MissingArgumentException("Can't handle URI because query parameter \"$URL\" is missing")) + return } - val queryTokenRaw = parameters.token() + val queryTokenRaw = params.token() val queryToken = if (!queryTokenRaw.isNullOrBlank()) { Pair(queryTokenRaw, Source.QUERY) } else { @@ -61,7 +62,7 @@ open class CoderProtocolHandler( // TODO: Show a dropdown and ask for the workspace if missing. val workspaceName = - parameters.workspace() ?: throw MissingArgumentException("Query parameter \"$WORKSPACE\" is missing") + params.workspace() ?: throw MissingArgumentException("Query parameter \"$WORKSPACE\" is missing") val workspaces = restClient.workspaces() val workspace = @@ -99,7 +100,7 @@ open class CoderProtocolHandler( } // TODO: Show a dropdown and ask for an agent if missing. - val agent = getMatchingAgent(parameters, workspace) + val agent = getMatchingAgent(params, workspace) val status = WorkspaceAndAgentStatus.from(workspace, agent) if (status.pending()) { @@ -147,6 +148,15 @@ open class CoderProtocolHandler( } } + private suspend fun askUrl(): String? { + context.ui.showWindow() + context.envPageManager.showPluginEnvironmentsPage(false) + return dialogUi.ask( + context.i18n.ptrl("Deployment URL"), + context.i18n.ptrl("Enter the full URL of your Coder deployment") + ) + } + /** * Return an authenticated Coder CLI, asking for the token as long as it * continues to result in an authentication failure and token authentication From 0403ee22f6503fa5a6cd2ec29e959b11502ce2bb Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 17 Mar 2025 23:56:15 +0200 Subject: [PATCH 06/17] impl: rework the handling of missing token code - the existing code was trying indefinitely to ask for token until the user gets it right. This is not a bad idea, however Toolbox has a couple of limitations that make the existing approach almost unusable: - the input dialog doesn't allow custom actions through which we can spawn a browser at login page. The code always opened the login page when the token was wrong which ended up hammering the browser with too many tabs. - the token UI page can't be reused to request the login page (this one has a "Get token" action button) because once the user clicks on the Get token to open the webpage, Toolbox closes the window and forgets the last UI page that was visible. - instead with this patch we ask the token from the user only once. If something goes wrong (mostly during login) we show an error dialog and stop the flow. --- .../toolbox/util/CoderProtocolHandler.kt | 46 ++++------- .../kotlin/com/coder/toolbox/util/Dialogs.kt | 80 ++++++------------- .../com/coder/toolbox/views/TokenPage.kt | 2 +- 3 files changed, 38 insertions(+), 90 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index f255f6f..160e733 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -6,12 +6,10 @@ import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.models.WorkspaceAndAgentStatus import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.sdk.ex.APIResponseException 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.coder.toolbox.settings.CoderSettings -import com.coder.toolbox.settings.Source import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -48,12 +46,7 @@ open class CoderProtocolHandler( return } - val queryTokenRaw = params.token() - val queryToken = if (!queryTokenRaw.isNullOrBlank()) { - Pair(queryTokenRaw, Source.QUERY) - } else { - null - } + val queryToken = params.token() val restClient = try { authenticate(deploymentURL, queryToken) } catch (ex: MissingArgumentException) { @@ -158,35 +151,29 @@ open class CoderProtocolHandler( } /** - * Return an authenticated Coder CLI, asking for the token as long as it - * continues to result in an authentication failure and token authentication - * is required. - * - * Throw MissingArgumentException if the user aborts. Any network or invalid + * 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: Pair?, - error: String? = null, + tryToken: String? ): CoderRestClient { val token = if (settings.requireTokenAuth) { // Try the provided token immediately on the first attempt. - if (tryToken != null && error == null) { + if (!tryToken.isNullOrBlank()) { tryToken } else { + context.ui.showWindow() + context.envPageManager.showPluginEnvironmentsPage(false) // Otherwise ask for a new token, showing the previous token. - dialogUi.askToken( - deploymentURL.toURL(), - tryToken, - useExisting = true, - error, - ) + dialogUi.askToken(deploymentURL.toURL()) } } else { null } + if (settings.requireTokenAuth && token == null) { // User aborted. throw MissingArgumentException("Token is required") } @@ -195,23 +182,18 @@ open class CoderProtocolHandler( val client = CoderRestClient( context, deploymentURL.toURL(), - token?.first, + token, settings, - proxyValues = null, + proxyValues = null, // TODO - not sure the above comment applies as we are creating our own http client PluginManager.pluginInfo.version, httpClient ) return try { client.authenticate() client - } catch (ex: APIResponseException) { - // If doing token auth we can ask and try again. - if (settings.requireTokenAuth && ex.isUnauthorized) { - val msg = humanizeConnectionError(client.url, true, ex) - authenticate(deploymentURL, token, msg) - } else { - throw ex - } + } catch (ex: Exception) { + context.ui.showErrorInfoPopup(IllegalStateException(humanizeConnectionError(client.url, true, ex))) + throw ex } } diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index 0b08a3b..a1a4e3a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.browser.BrowserUtil import com.coder.toolbox.settings.CoderSettings -import com.coder.toolbox.settings.Source import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.TextType import java.net.URL @@ -26,9 +25,6 @@ class DialogUi( title: LocalizableString, description: LocalizableString, placeholder: LocalizableString? = null, - // TODO check: there is no link or error support in Toolbox so for now isError and link are unused. - isError: Boolean = false, - link: Pair? = null, ): String? { return context.ui.showTextInputPopup( title, @@ -40,6 +36,21 @@ class DialogUi( ) } + 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) { BrowserUtil.browse(url.toString()) { context.ui.showErrorInfoPopup(it) @@ -47,61 +58,16 @@ class DialogUi( } /** - * Open a dialog for providing the token. Show any existing token so - * the user can validate it if a previous connection failed. - * - * If we have not already tried once (no error) and the user has not checked - * the existing token box then also open a browser to the auth page. - * - * If the user has checked the existing token box then return the token - * on disk immediately and skip the dialog (this will overwrite any - * other existing token) unless this is a retry to avoid clobbering the - * token that just failed. + * Open a dialog for providing the token. */ suspend fun askToken( url: URL, - token: Pair?, - useExisting: Boolean, - error: String?, - ): Pair? { - val getTokenUrl = url.withPath("/login?redirect=%2Fcli-auth") - - // On the first run (no error) either open a browser to generate a new - // token or, if using an existing token, use the token on disk if it - // exists otherwise assume the user already copied an existing token and - // they will paste in. - if (error == null) { - if (!useExisting) { - openUrl(getTokenUrl) - } else { - // Look on disk in case we already have a token, either in - // the deployment's config or the global config. - val tryToken = settings.token(url) - if (tryToken != null && tryToken.first != token?.first) { - return tryToken - } - } - } - - // On subsequent tries or if not using an existing token, ask the user - // for the token. - val tokenFromUser = - ask( - title = context.i18n.ptrl("Session Token"), - description = context.i18n.pnotr( - error - ?: token?.second?.description("token") - ?: "No existing token for ${url.host} found." - ), - placeholder = token?.first?.let { context.i18n.pnotr(it) }, - link = Pair("Session Token:", getTokenUrl.toString()), - isError = error != null, - ) - if (tokenFromUser.isNullOrBlank()) { - return null - } - // If the user submitted the same token, keep the same source too. - val source = if (tokenFromUser == token?.first) token.second else Source.USER - return Pair(tokenFromUser, source) + ): 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/views/TokenPage.kt b/src/main/kotlin/com/coder/toolbox/views/TokenPage.kt index 1c0a5f7..6b4cf6c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenPage.kt @@ -20,7 +20,7 @@ import java.net.URL * enter their own. */ class TokenPage( - private val context: CoderToolboxContext, + context: CoderToolboxContext, deploymentURL: URL, token: Pair?, private val onToken: ((token: String) -> Unit), From 86532d44db9b5acfe34cf763a4852d687d72f0a1 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 18 Mar 2025 00:10:58 +0200 Subject: [PATCH 07/17] impl: show error dialog when workspace name was not provided - the error is also displayed when the workspace with the name does not exist --- .../toolbox/util/CoderProtocolHandler.kt | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 160e733..88f044c 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -49,19 +49,35 @@ open class CoderProtocolHandler( val queryToken = params.token() val restClient = try { authenticate(deploymentURL, queryToken) - } catch (ex: MissingArgumentException) { - throw MissingArgumentException("Query parameter \"$TOKEN\" is missing", ex) + } catch (ex: Exception) { + context.logger.error(ex, "Query parameter \"$TOKEN\" is missing from URI $uri") + context.ui.showErrorInfoPopup( + IllegalStateException( + humanizeConnectionError( + deploymentURL.toURL(), + true, + ex + ) + ) + ) + return } - // TODO: Show a dropdown and ask for the workspace if missing. - val workspaceName = - params.workspace() ?: throw MissingArgumentException("Query parameter \"$WORKSPACE\" is missing") + // 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.ui.showErrorInfoPopup(MissingArgumentException("Can't handle URI because query parameter \"$WORKSPACE\" is missing")) + return + } val workspaces = restClient.workspaces() - val workspace = - workspaces.firstOrNull { - it.name == workspaceName - } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist") + val workspace = workspaces.firstOrNull { it.name == workspaceName } + if (workspace == null) { + context.logger.error("There is no workspace with name $workspaceName on $deploymentURL") + context.ui.showErrorInfoPopup(MissingArgumentException("Can't handle URI because workspace with name $workspaceName does not exist")) + return + } when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> @@ -188,13 +204,8 @@ open class CoderProtocolHandler( PluginManager.pluginInfo.version, httpClient ) - return try { - client.authenticate() - client - } catch (ex: Exception) { - context.ui.showErrorInfoPopup(IllegalStateException(humanizeConnectionError(client.url, true, ex))) - throw ex - } + client.authenticate() + return client } /** From 56ed32213e5e04487a84029ec1aa28627134c052 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 18 Mar 2025 00:54:45 +0200 Subject: [PATCH 08/17] impl: wait for workspace to be running - starts and waits for the workspace to be running before showing the env page - improved error handling --- .../com/coder/toolbox/sdk/CoderRestClient.kt | 13 ++++ .../coder/toolbox/sdk/v2/CoderV2RestFacade.kt | 9 +++ .../toolbox/util/CoderProtocolHandler.kt | 59 ++++++++++++------- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 7e78a99..f3ccd58 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -169,6 +169,19 @@ open class CoderRestClient( return workspacesResponse.body()!!.workspaces } + /** + * Retrieves a workspace with the provided id. + * @throws [APIResponseException]. + */ + fun workspace(workspaceID: UUID): Workspace { + val workspacesResponse = retroRestClient.workspace(workspaceID).execute() + if (!workspacesResponse.isSuccessful) { + throw APIResponseException("retrieve workspace", url, workspacesResponse) + } + + return workspacesResponse.body()!! + } + /** * Retrieves all the agent names for all workspaces, including those that * are off. Meant to be used when configuring SSH. diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt index 86a4de6..ae29746 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -4,6 +4,7 @@ import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User +import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceBuild import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspacesResponse @@ -30,6 +31,14 @@ interface CoderV2RestFacade { @Query("q") searchParams: String, ): Call + /** + * Retrieves a workspace with the provided id. + */ + @GET("api/v2/workspaces/{workspaceID}") + fun workspace( + @Path("workspaceID") workspaceID: UUID + ): Call + @GET("api/v2/buildinfo") fun buildInfo(): Call diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 88f044c..7461bd6 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -10,14 +10,20 @@ 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.coder.toolbox.settings.CoderSettings +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import kotlinx.coroutines.time.withTimeout import kotlinx.coroutines.yield import okhttp3.OkHttpClient import java.net.HttpURLConnection import java.net.URI import java.net.URL +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration open class CoderProtocolHandler( private val context: CoderToolboxContext, @@ -81,29 +87,27 @@ open class CoderProtocolHandler( when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> - // TODO: Wait for the workspace to turn on. - throw IllegalArgumentException( - "The workspace \"$workspaceName\" is ${ - workspace.latestBuild.status.toString().lowercase() - }; please wait then try again", - ) + if (restClient.waitForReady(workspace) != true) { + context.logger.error("$workspaceName from $deploymentURL could not be ready on time") + context.ui.showErrorInfoPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be ready on time")) + return + } WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, - WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED, - -> - // TODO: Turn on the workspace. - throw IllegalArgumentException( - "The workspace \"$workspaceName\" is ${ - workspace.latestBuild.status.toString().lowercase() - }; please start the workspace and try again", - ) + WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> { + restClient.startWorkspace(workspace) + if (restClient.waitForReady(workspace) != true) { + context.logger.error("$workspaceName from $deploymentURL could not be started on time") + context.ui.showErrorInfoPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be started on time")) + return + } + } - WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> - throw IllegalArgumentException( - "The workspace \"$workspaceName\" is ${ - workspace.latestBuild.status.toString().lowercase() - }; unable to connect", - ) + WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { + context.logger.error("Unable to connect to $workspaceName from $deploymentURL") + context.ui.showErrorInfoPopup(MissingArgumentException("Can't handle URI because because we're unable to connect to workspace $workspaceName")) + return + } WorkspaceStatus.RUNNING -> Unit // All is well } @@ -157,6 +161,21 @@ open class CoderProtocolHandler( } } + private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { + var status = workspace.latestBuild.status + try { + withTimeout(2.minutes.toJavaDuration()) { + while (status != WorkspaceStatus.RUNNING) { + delay(1.seconds) + status = this@waitForReady.workspace(workspace.id).latestBuild.status + } + } + return true + } catch (_: TimeoutCancellationException) { + return false + } + } + private suspend fun askUrl(): String? { context.ui.showWindow() context.envPageManager.showPluginEnvironmentsPage(false) From 0b49bcb79cc7e217e56a57ba34d2911a88904842 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 18 Mar 2025 21:16:44 +0200 Subject: [PATCH 09/17] impl: support for ClientHelper - a service which orchestrates the IDE install, opening projects and so on --- src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt | 2 ++ src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt | 2 ++ src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt | 2 ++ src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt | 2 ++ 4 files changed, 8 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 76738d5..2819595 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -4,6 +4,7 @@ import com.jetbrains.toolbox.api.core.PluginSecretStore import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi @@ -13,6 +14,7 @@ data class CoderToolboxContext( val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, + val ideOrchestrator: ClientHelper, val cs: CoroutineScope, val logger: Logger, val i18n: LocalizableStringFactory, diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 8ee06d1..5ef5454 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -7,6 +7,7 @@ import com.jetbrains.toolbox.api.core.diagnostics.Logger 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.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi @@ -24,6 +25,7 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService(ToolboxUi::class.java), serviceLocator.getService(EnvironmentUiPageManager::class.java), serviceLocator.getService(EnvironmentStateColorPalette::class.java), + serviceLocator.getService(ClientHelper::class.java), serviceLocator.getService(CoroutineScope::class.java), serviceLocator.getService(Logger::class.java), serviceLocator.getService(LocalizableStringFactory::class.java), diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 87b659a..6b7933e 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -19,6 +19,7 @@ import com.jetbrains.toolbox.api.core.PluginSecretStore import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi @@ -48,6 +49,7 @@ internal class CoderCLIManagerTest { mockk(), mockk(), mockk(), + mockk(), mockk(), mockk(relaxed = true), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 78a2ea1..c4c73fa 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -20,6 +20,7 @@ import com.jetbrains.toolbox.api.core.PluginSecretStore import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi @@ -95,6 +96,7 @@ class CoderRestClientTest { mockk(), mockk(), mockk(), + mockk(), mockk(), mockk(relaxed = true), mockk(), From 894bcab773c80aa7f429ed7eed5876851a8aa6d8 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 18 Mar 2025 23:44:27 +0200 Subject: [PATCH 10/17] fix: URI handling race condition when creating polling jobs - when opening a URI, multiple polling jobs could be triggered on different Coder deployments if Toolbox starts from scratch. This happens because Toolbox takes longer to complete its initial plugin initialization, while the URI handling logic runs faster and doesn't wait properly for the plugin to be ready, leading to an early polling job. Meanwhile, once Toolbox finishes its initialization, it also triggers another polling job. - this patch properly waits for the plugin initialization and properly cancel the initial polling job, which is then replaced by the URI handling polling job. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 9 +++++++-- .../coder/toolbox/util/CoderProtocolHandler.kt | 16 +++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index e7443a9..45ec4de 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -122,6 +122,12 @@ class CoderRemoteProvider( environments.update { LoadableState.Value(resolvedEnvironments.toList()) } + if (isInitialized.value == false) { + context.logger.info("Environments for ${client.url} are now initialized") + isInitialized.update { + true + } + } lastEnvironments = resolvedEnvironments } catch (_: CancellationException) { @@ -238,7 +244,7 @@ class CoderRemoteProvider( // start initialization with the new settings this@CoderRemoteProvider.client = restClient coderHeaderPager = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) - poll(restClient, cli) + pollJob = poll(restClient, cli) } } @@ -324,7 +330,6 @@ class CoderRemoteProvider( pollJob?.cancel() pollJob = poll(client, cli) goToEnvironmentsPage() - isInitialized.update { true } } /** diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 7461bd6..062749f 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -13,7 +13,7 @@ import com.coder.toolbox.settings.CoderSettings import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout import kotlinx.coroutines.yield @@ -41,7 +41,7 @@ open class CoderProtocolHandler( */ suspend fun handle( uri: URI, - onReinitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() @@ -148,11 +148,12 @@ open class CoderProtocolHandler( context.logger.info("Configuring Coder CLI...") cli.configSsh(restClient.agentNames(workspaces)) - onReinitialize(restClient, cli) + isInitialized.waitForTrue() + reInitialize(restClient, cli) + context.cs.launch { context.ui.showWindow() context.envPageManager.showPluginEnvironmentsPage(true) - isInitialized.waitForTrue() context.envPageManager.showEnvironmentPage("${workspace.name}.${agent.name}", false) // without a yield or a delay(0) the env page does not show up. My assumption is that // the coroutine is finishing too fast without giving enough time to compose main thread @@ -366,8 +367,9 @@ internal fun getMatchingAgent( return agent } -fun StateFlow.waitForTrue() { - this.filter { it } -} +/** + * Suspends the coroutine until first true value is received. + */ +suspend fun StateFlow.waitForTrue() = this.first { it } class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) From 21f012d6982bf2c639991b5e69eaef7e428f7f5b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 19 Mar 2025 00:45:03 +0200 Subject: [PATCH 11/17] impl: install ide to the workspace if product code and build number were provided --- .../toolbox/util/CoderProtocolHandler.kt | 77 +++---------------- .../kotlin/com/coder/toolbox/util/LinkMap.kt | 2 - 2 files changed, 9 insertions(+), 70 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 062749f..238f9b5 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -151,10 +151,18 @@ open class CoderProtocolHandler( isInitialized.waitForTrue() reInitialize(restClient, cli) + val environmentId = "${workspace.name}.${agent.name}" context.cs.launch { context.ui.showWindow() context.envPageManager.showPluginEnvironmentsPage(true) - context.envPageManager.showEnvironmentPage("${workspace.name}.${agent.name}", false) + context.envPageManager.showEnvironmentPage(environmentId, false) + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + val ideVersion = "$productCode-$buildNumber" + context.logger.info("installing $ideVersion on $environmentId") + context.ideOrchestrator.prepareClient(environmentId, ideVersion) + } // without a yield or a delay(0) the env page does not show up. My assumption is that // the coroutine is finishing too fast without giving enough time to compose main thread // to catch the state change. Yielding gives other coroutines the chance to run @@ -228,73 +236,6 @@ open class CoderProtocolHandler( return client } - /** - * Check that the link is allowlisted. If not, confirm with the user. - */ - private suspend fun verifyDownloadLink(parameters: Map) { - val link = parameters.ideDownloadLink() - if (link.isNullOrBlank()) { - return // Nothing to verify - } - - val url = - try { - link.toURL() - } catch (ex: Exception) { - throw IllegalArgumentException("$link is not a valid URL") - } - - val (allowlisted, https, linkWithRedirect) = - try { - isAllowlisted(url) - } catch (e: Exception) { - throw IllegalArgumentException("Unable to verify $url: $e") - } - if (allowlisted && https) { - return - } - - val comment = - if (allowlisted) { - "The download link is from a non-allowlisted URL" - } else if (https) { - "The download link is not using HTTPS" - } else { - "The download link is from a non-allowlisted URL and is not using HTTPS" - } - - if (!dialogUi.confirm( - context.i18n.ptrl("Confirm download URL"), - context.i18n.pnotr("$comment. Would you like to proceed to $linkWithRedirect?"), - ) - ) { - throw IllegalArgumentException("$linkWithRedirect is not allowlisted") - } - } -} - -/** - * Return if the URL is allowlisted, https, and the URL and its final - * destination, if it is a different host. - */ -private fun isAllowlisted(url: URL): Triple { - // TODO: Setting for the allowlist, and remember previously allowed - // domains. - val domainAllowlist = listOf("intellij.net", "jetbrains.com") - - // Resolve any redirects. - val finalUrl = resolveRedirects(url) - - var linkWithRedirect = url.toString() - if (finalUrl.host != url.host) { - linkWithRedirect = "$linkWithRedirect (redirects to to $finalUrl)" - } - - val allowlisted = - domainAllowlist.any { url.host == it || url.host.endsWith(".$it") } && - domainAllowlist.any { finalUrl.host == it || finalUrl.host.endsWith(".$it") } - val https = url.protocol == "https" && finalUrl.protocol == "https" - return Triple(allowlisted, https, linkWithRedirect) } /** diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index ae05524..1a8ab67 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -30,8 +30,6 @@ fun Map.agentID() = this[AGENT_ID] fun Map.folder() = this[FOLDER] -fun Map.ideDownloadLink() = this[IDE_DOWNLOAD_LINK] - fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] fun Map.ideBuildNumber() = this[IDE_BUILD_NUMBER] From 00f9ab3745ba22012290e4e83ddc09adacc48dfd Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 20 Mar 2025 00:58:50 +0200 Subject: [PATCH 12/17] fix: wait for IDE to be installed and then connect --- .../toolbox/util/CoderProtocolHandler.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 238f9b5..7aa5dd3 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -15,8 +15,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.time.withTimeout -import kotlinx.coroutines.yield import okhttp3.OkHttpClient import java.net.HttpURLConnection import java.net.URI @@ -152,21 +152,21 @@ open class CoderProtocolHandler( reInitialize(restClient, cli) val environmentId = "${workspace.name}.${agent.name}" - context.cs.launch { - context.ui.showWindow() - context.envPageManager.showPluginEnvironmentsPage(true) - context.envPageManager.showEnvironmentPage(environmentId, false) - val productCode = params.ideProductCode() - val buildNumber = params.ideBuildNumber() - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + context.ui.showWindow() + context.envPageManager.showPluginEnvironmentsPage(true) + context.envPageManager.showEnvironmentPage(environmentId, false) + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + context.cs.launch { val ideVersion = "$productCode-$buildNumber" context.logger.info("installing $ideVersion on $environmentId") - context.ideOrchestrator.prepareClient(environmentId, ideVersion) + runBlocking { + context.ideOrchestrator.prepareClient(environmentId, ideVersion) + } + context.logger.info("launching $ideVersion on $environmentId") + context.ideOrchestrator.connectToIde(environmentId, ideVersion, null) } - // without a yield or a delay(0) the env page does not show up. My assumption is that - // the coroutine is finishing too fast without giving enough time to compose main thread - // to catch the state change. Yielding gives other coroutines the chance to run - yield() } } From b67585faa9bdc61957112caa82f5cb737d6026ac Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 20 Mar 2025 21:11:49 +0200 Subject: [PATCH 13/17] fix: use launch/join instead of runBlocking --- .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 7aa5dd3..99ddc12 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.time.withTimeout import okhttp3.OkHttpClient import java.net.HttpURLConnection @@ -161,9 +160,10 @@ open class CoderProtocolHandler( context.cs.launch { val ideVersion = "$productCode-$buildNumber" context.logger.info("installing $ideVersion on $environmentId") - runBlocking { + val job = context.cs.launch { context.ideOrchestrator.prepareClient(environmentId, ideVersion) } + job.join() context.logger.info("launching $ideVersion on $environmentId") context.ideOrchestrator.connectToIde(environmentId, ideVersion, null) } From 9f096cfd7ab279b16bdca4d234ed60d99a804bdc Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 20 Mar 2025 21:26:23 +0200 Subject: [PATCH 14/17] impl: uri support for specifying a project to open - it's optional - `project_path` is the query param --- .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 3 ++- src/main/kotlin/com/coder/toolbox/util/LinkMap.kt | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 99ddc12..3fb18d1 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -156,6 +156,7 @@ open class CoderProtocolHandler( context.envPageManager.showEnvironmentPage(environmentId, false) val productCode = params.ideProductCode() val buildNumber = params.ideBuildNumber() + val projectPath = params.projectPath() if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { context.cs.launch { val ideVersion = "$productCode-$buildNumber" @@ -165,7 +166,7 @@ open class CoderProtocolHandler( } job.join() context.logger.info("launching $ideVersion on $environmentId") - context.ideOrchestrator.connectToIde(environmentId, ideVersion, null) + context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectPath) } } } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 1a8ab67..df6c3ca 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -11,7 +11,7 @@ private const val FOLDER = "folder" private const val IDE_DOWNLOAD_LINK = "ide_download_link" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" -private const val IDE_PATH_ON_HOST = "ide_path_on_host" +private const val IDE_PATH_ON_HOST = "project_path" // Helper functions for reading from the map. Prefer these to directly // interacting with the map. @@ -34,4 +34,4 @@ fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] fun Map.ideBuildNumber() = this[IDE_BUILD_NUMBER] -fun Map.idePathOnHost() = this[IDE_PATH_ON_HOST] +fun Map.projectPath() = this[IDE_PATH_ON_HOST] From 2cd3565d62e801ca34de85b89c3031402b988bf9 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 20 Mar 2025 23:28:36 +0200 Subject: [PATCH 15/17] fix: handle the uri when we don't do autologin - currently the uri handling waits for the plugin to fully initialize i.e. to sign in to the coder deployment and have the list of workspaces retrieved. - this is done in order to avoid scenarios were uri handling moves faster than autologin and polling and potentially ending up with more than one polling job - however if there is a manual login flow (for example if the user logs from the coder deployment we no longer autologin at the next startup) we don't have to wait for the initial polling job to be initialized. --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 6 ++++-- .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 45ec4de..32e4f7c 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -238,7 +238,7 @@ class CoderRemoteProvider( * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - linkHandler.handle(uri) { restClient, cli -> + linkHandler.handle(uri, shouldDoAutoLogin()) { restClient, cli -> // stop polling and de-initialize resources close() // start initialization with the new settings @@ -270,7 +270,7 @@ class CoderRemoteProvider( // Show sign in page if we have not configured the client yet. if (client == null) { // When coming back to the application, authenticate immediately. - val autologin = firstRun && secrets.rememberMe == "true" + val autologin = shouldDoAutoLogin() var autologinEx: Exception? = null secrets.lastToken.let { lastToken -> secrets.lastDeploymentURL.let { lastDeploymentURL -> @@ -309,6 +309,8 @@ class CoderRemoteProvider( return null } + private fun shouldDoAutoLogin(): Boolean = firstRun && secrets.rememberMe == "true" + /** * Create a connect page that starts polling and resets the UI on success. */ diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 3fb18d1..77969e8 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -40,6 +40,7 @@ open class CoderProtocolHandler( */ suspend fun handle( uri: URI, + shouldWaitForAutoLogin: Boolean, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() @@ -147,7 +148,9 @@ open class CoderProtocolHandler( context.logger.info("Configuring Coder CLI...") cli.configSsh(restClient.agentNames(workspaces)) - isInitialized.waitForTrue() + if (shouldWaitForAutoLogin) { + isInitialized.waitForTrue() + } reInitialize(restClient, cli) val environmentId = "${workspace.name}.${agent.name}" From d3a483e0ab85aa841257132eaa68f03fbe17bc02 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 22 Mar 2025 00:33:51 +0200 Subject: [PATCH 16/17] refactor: proper constant name to reflect the value - plus some clean-up for unused constants --- src/main/kotlin/com/coder/toolbox/util/LinkMap.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index df6c3ca..9e2ef49 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -7,11 +7,9 @@ const val TOKEN = "token" const val WORKSPACE = "workspace" const val AGENT_NAME = "agent" const val AGENT_ID = "agent_id" -private const val FOLDER = "folder" -private const val IDE_DOWNLOAD_LINK = "ide_download_link" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" -private const val IDE_PATH_ON_HOST = "project_path" +private const val PROJECT_PATH = "project_path" // Helper functions for reading from the map. Prefer these to directly // interacting with the map. @@ -28,10 +26,8 @@ fun Map.agentName() = this[AGENT_NAME] fun Map.agentID() = this[AGENT_ID] -fun Map.folder() = this[FOLDER] - fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] fun Map.ideBuildNumber() = this[IDE_BUILD_NUMBER] -fun Map.projectPath() = this[IDE_PATH_ON_HOST] +fun Map.projectPath() = this[PROJECT_PATH] From 20441c8ffb101b660bcdc2e749591436ea45c7db Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 22 Mar 2025 00:41:10 +0200 Subject: [PATCH 17/17] chore: fix typo in property name --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 32e4f7c..21586e3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -64,7 +64,7 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) - private var coderHeaderPager = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: "")) + private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: "")) private val linkHandler = CoderProtocolHandler(context, settings, httpClient, dialogUi, isInitialized) override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Value(emptyList()) @@ -219,7 +219,7 @@ class CoderRemoteProvider( * Just displays the deployment URL at the moment, but we could use this as * a form for creating new environments. */ - override fun getNewEnvironmentUiPage(): UiPage = coderHeaderPager + override fun getNewEnvironmentUiPage(): UiPage = coderHeaderPage /** * We always show a list of environments. @@ -243,7 +243,7 @@ class CoderRemoteProvider( close() // start initialization with the new settings this@CoderRemoteProvider.client = restClient - coderHeaderPager = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) + coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) pollJob = poll(restClient, cli) } }