From 25e2e271329831bc14431f6fa64de16c2b8b2941 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 2 Dec 2025 22:32:41 +0200 Subject: [PATCH 1/5] emit flow events from the same thread Small improvement where we get rid of emitting environment and ssh connection trigger events from new coroutines. StateFlow in Kotlin is a hot, conflated flow that keeps only the most recent value. In other words we can immediately update the value without needing to launch a new coroutine, and we won't block the current thread. --- .../com/coder/toolbox/CoderRemoteEnvironment.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index a5790c3..5cf160d 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -276,10 +276,8 @@ class CoderRemoteEnvironment( private fun updateStatus(status: WorkspaceAndAgentStatus) { environmentStatus = status - context.cs.launch(CoroutineName("Workspace Status Updater")) { - state.update { - environmentStatus.toRemoteEnvironmentState(context) - } + state.update { + environmentStatus.toRemoteEnvironmentState(context) } context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } @@ -312,10 +310,8 @@ class CoderRemoteEnvironment( */ fun startSshConnection(): Boolean { if (environmentStatus.ready() && !isConnected.value) { - context.cs.launch(CoroutineName("SSH Connection Trigger")) { - connectionRequest.update { - true - } + connectionRequest.update { + true } return true } From c9eb2612034ec1d860a811875ee1c45c769ca7ef Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 3 Dec 2025 00:18:48 +0200 Subject: [PATCH 2/5] fix: simplify URI handling when the same deployment URL is already opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netflix reported that only seems to reproduce on Linux (we've only tested Ubuntu so far). I can’t reproduce it on macOS. First, here’s some context: 1. Polling workspaces: Coder Toolbox polls the deployment every 5 seconds for workspace updates. These updates (new workspaces, deletions,status changes) are stored in a cached “environments” list (an oversimplified explanation). When a URI is executed, we reset the content of the list and run the login sequence, which re-initializes the HTTP poller and CLI using the new deployment URL and token. A new polling loop then begins populating the environments list again. 2. Cache monitoring: Toolbox watches this cached list for changes—especially status changes, which determine when an SSH connection can be established. In Netflix’s case, they launched Toolbox, created a workspace from the Dashboard, and the poller added it to the environments list. When the workspace switched from starting to ready, they used a URI to connect to it. The URI reset the list, then the poller repopulated it. But because the list had the same IDs (but new object references), Toolbox didn’t detect any changes. As a result, it never triggered the SSH connection. This issue only reproduces on Linux, but it might explain some of the sporadic macOS failures Atif mentioned in the past. I need to dig deeper into the Toolbox bytecode to determine whether this is a Toolbox bug, but it does seem like Toolbox wasn’t designed to switch cleanly between multiple deployments and/or users. The current Coder plugin behavior—always performing a full login sequence on every URI—is also ...sub-optimal. It only really makes sense in these scenarios: 1. Toolbox started with deployment A, but the URI targets deployment B. 2. Toolbox started with deployment A/user X, but the URI targets deployment A/user Y. But this design is inefficient for the most common case: connecting via URI to a workspace on the same deployment and same user. While working on the fix, I realized that scenario (2) is not realistic. On the same host machine, why would multiple users log into the same deployment via Toolbox? The whole fix revolves around the idea of just recreating the http client and updating the CLI with the new token instead of going through the full authentication steps when the URI deployment URL is the same as the currently opened URL --- CHANGELOG.md | 1 + .../com/coder/toolbox/CoderRemoteProvider.kt | 85 +++++++---- .../toolbox/util/CoderProtocolHandler.kt | 137 +++++++++++------- .../com/coder/toolbox/views/CoderPage.kt | 2 + 4 files changed, 144 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 235d295..308016f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Changed - workspaces are now started with the help of the CLI +- simplified URI handling when the same deployment URL is already opened ## 0.7.2 - 2025-11-03 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 217d4b1..8d8b966 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -2,6 +2,7 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager +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.WorkspaceStatus @@ -37,6 +38,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI +import java.net.URL import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -254,6 +256,16 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { + softClose() + client = null + lastEnvironments.clear() + environments.value = LoadableState.Value(emptyList()) + isInitialized.update { false } + CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") + } + + private fun softClose() { pollJob?.let { it.cancel() context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") @@ -262,12 +274,6 @@ class CoderRemoteProvider( it.close() context.logger.info("REST API client closed and resources released") } - client = null - lastEnvironments.clear() - environments.value = LoadableState.Value(emptyList()) - isInitialized.update { false } - CoderCliSetupWizardState.goToFirstStep() - context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -333,25 +339,11 @@ class CoderRemoteProvider( try { linkHandler.handle( uri, - shouldDoAutoSetup() - ) { restClient, cli -> - context.logger.info("Stopping workspace polling and de-initializing resources") - close() - isInitialized.update { - false - } - context.logger.info("Starting initialization with the new settings") - this@CoderRemoteProvider.client = restClient - if (context.settingsStore.useAppNameAsTitle) { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) - } else { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - } - environments.showLoadingMessage() - pollJob = poll(restClient, cli) - context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") - isInitialized.waitForTrue() - } + client?.url, + shouldDoAutoSetup(), + ::refreshSession, + ::performReLogin + ) } catch (ex: Exception) { val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { @@ -366,6 +358,49 @@ class CoderRemoteProvider( } } + private suspend fun refreshSession(url: URL, token: String): Pair { + coderHeaderPage.isBusyCreatingNewEnvironment.update { true } + try { + context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token") + softClose() + val restClient = CoderRestClient( + context, + url, + token, + PluginManager.pluginInfo.version, + ).apply { initializeSession() } + val cli = CoderCLIManager(context, url).apply { + login(token) + } + this.client = restClient + pollJob = poll(restClient, cli) + triggerProviderVisible.send(true) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") + return restClient to cli + } finally { + coderHeaderPage.isBusyCreatingNewEnvironment.update { false } + } + } + + private suspend fun performReLogin(restClient: CoderRestClient, cli: CoderCLIManager) { + context.logger.info("Stopping workspace polling and de-initializing resources") + close() + isInitialized.update { + false + } + context.logger.info("Starting initialization with the new settings") + this@CoderRemoteProvider.client = restClient + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } + environments.showLoadingMessage() + pollJob = poll(restClient, cli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") + isInitialized.waitForTrue() + } + /** * Return the sign-in page if we do not have a valid client. diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 113ab9f..87562c0 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout import java.net.URI +import java.net.URL import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -52,8 +53,10 @@ open class CoderProtocolHandler( */ suspend fun handle( uri: URI, + currentUrl: URL?, shouldWaitForAutoLogin: Boolean, - reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + refreshSession: suspend (URL, String) -> Pair, + performLogin: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() if (params.isEmpty()) { @@ -72,65 +75,56 @@ open class CoderProtocolHandler( val deploymentURL = resolveDeploymentUrl(params) ?: return val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return - - suspend fun onConnect( - restClient: CoderRestClient, - cli: CoderCLIManager - ) { - val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) - if (workspace == null) { - context.envPageManager.showPluginEnvironmentsPage() - return + if (deploymentURL.toURL().toURI().normalize() == currentUrl?.toURI()?.normalize()) { + if (context.settingsStore.requiresTokenAuth) { + token?.let { + val (restClient, cli) = refreshSession(currentUrl, it) + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) + if (workspace != null) { + connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params) + } + } } - reInitialize(restClient, cli) - context.envPageManager.showPluginEnvironmentsPage() - if (!prepareWorkspace(workspace, restClient, cli, 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 - // we also need to retrieve the workspace again to have the latest resources (ex: agent) - // attached to the workspace. - val agent: WorkspaceAgent = resolveAgent( - params, - restClient.workspace(workspace.id) - ) ?: return - if (!ensureAgentIsReady(workspace, agent)) return - delay(2.seconds) - 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) + } else { + suspend fun onConnect( + restClient: CoderRestClient, + cli: CoderCLIManager + ) { + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) + if (workspace == null) { + context.envPageManager.showPluginEnvironmentsPage() + return + } + performLogin(restClient, cli) + context.envPageManager.showPluginEnvironmentsPage() + connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params) } - } - CoderCliSetupContext.apply { - url = deploymentURL.toURL() - CoderCliSetupContext.token = token - } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - - // If Toolbox is already opened and URI is executed the setup page - // from below is never called. I tried a couple of things, including - // yielding the coroutine - but it seems to be of no help. What works - // delaying the coroutine for 66 - to 100 milliseconds, these numbers - // were determined by trial and error. - // The only explanation that I have is that inspecting the TBX bytecode it seems the - // UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events - // and a drop oldest strategy. For some reason it seems that the UI collector - // is not yet active, causing the event to be lost unless we wait > 66 ms. - // I think this delay ensures the collector is ready before processEvent() is called. - delay(100.milliseconds) - context.ui.showUiPage( - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, true, - jumpToMainPageOnError = true, - onConnect = ::onConnect + CoderCliSetupContext.apply { + url = deploymentURL.toURL() + CoderCliSetupContext.token = token + } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + + // If Toolbox is already opened and URI is executed the setup page + // from below is never called. I tried a couple of things, including + // yielding the coroutine - but it seems to be of no help. What works + // delaying the coroutine for 66 - to 100 milliseconds, these numbers + // were determined by trial and error. + // The only explanation that I have is that inspecting the TBX bytecode it seems the + // UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events + // and a drop oldest strategy. For some reason it seems that the UI collector + // is not yet active, causing the event to be lost unless we wait > 66 ms. + // I think this delay ensures the collector is ready before processEvent() is called. + delay(100.milliseconds) + context.ui.showUiPage( + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, true, + jumpToMainPageOnError = true, + onConnect = ::onConnect + ) ) - ) + } } private suspend fun resolveDeploymentUrl(params: Map): String? { @@ -434,6 +428,37 @@ open class CoderProtocolHandler( } } + private suspend fun connectToWorkspace( + workspace: Workspace, + restClient: CoderRestClient, + cli: CoderCLIManager, + workspaceName: String, + deploymentURL: String, + params: Map + ) { + if (!prepareWorkspace(workspace, restClient, cli, 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 + // we also need to retrieve the workspace again to have the latest resources (ex: agent) + // attached to the workspace. + val agent: WorkspaceAgent = resolveAgent( + params, + restClient.workspace(workspace.id) + ) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + delay(2.seconds) + 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 askUrl(): String? { context.popupPluginMainPage() return dialogUi.ask( diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index a7ad70f..fa963e3 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -34,6 +34,8 @@ abstract class CoderPage( } } + override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) + /** * Return the icon, if showing one. * From cd2abb4b6e20fe4dbc6eb1de5302ca8f1cac79d2 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 8 Dec 2025 23:11:29 +0200 Subject: [PATCH 3/5] impl: rework the URI handler With this commit we split the responsibilities. `CoderProtocolHander` is now only responsible searching the workspace and agent and orchestrating the IDE installation and launching. The code around initializing the http client and cli with a new URL and a new token, plus cleaning up the old resources like the polling loop and the list of environments. There are two major benefits to this approach: - allows us to easily share/reuse the logic around cleaning up resources and re-initializing the http client and cli without passing so many callbacks to CoderProtocolHandler (less coupling, code that is cleaner and easier to read, easier to maintain and test) - provides a nice and easy way to check whether the URI url is the same as the one from current deployment and properly react if they differ. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 151 +++++++++----- .../toolbox/util/CoderProtocolHandler.kt | 190 ++++-------------- .../toolbox/views/CoderCliSetupWizardPage.kt | 6 +- .../com/coder/toolbox/views/CoderPage.kt | 2 +- .../com/coder/toolbox/views/ConnectStep.kt | 20 +- .../toolbox/util/CoderProtocolHandlerTest.kt | 10 +- 6 files changed, 163 insertions(+), 216 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 8d8b966..9980a15 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -8,6 +8,14 @@ import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi +import com.coder.toolbox.util.TOKEN +import com.coder.toolbox.util.URL +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.toQueryParameters +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.token +import com.coder.toolbox.util.url +import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action @@ -46,6 +54,7 @@ import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownM import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory private val POLL_INTERVAL = 5.seconds +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @OptIn(ExperimentalCoroutinesApi::class) class CoderRemoteProvider( @@ -63,6 +72,7 @@ class CoderRemoteProvider( // The REST client, if we are signed in private var client: CoderRestClient? = null + private var cli: CoderCLIManager? = null // On the first load, automatically log in if we can. private var firstRun = true @@ -84,7 +94,7 @@ class CoderRemoteProvider( providerVisible = false ) ) - private val linkHandler = CoderProtocolHandler(context, dialogUi, settingsPage, visibilityState, isInitialized) + private val linkHandler = CoderProtocolHandler(context) override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") override val environments: MutableStateFlow>> = MutableStateFlow( @@ -258,6 +268,7 @@ class CoderRemoteProvider( override fun close() { softClose() client = null + cli = null lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } @@ -337,13 +348,50 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { - linkHandler.handle( - uri, - client?.url, - shouldDoAutoSetup(), - ::refreshSession, - ::performReLogin - ) + 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 + } + // this switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() + coderHeaderPage.isBusy.update { true } + if (shouldDoAutoSetup()) { + isInitialized.waitForTrue() + } + context.logger.info("Handling $uri...") + val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return + val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return + if (sameUrl(newUrl, client?.url)) { + if (context.settingsStore.requiresTokenAuth) { + newToken?.let { + refreshSession(newUrl, it) + } + } + } else { + CoderCliSetupContext.apply { + url = newUrl + token = newToken + } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + connectSynchronously = true, + onConnect = ::onConnect + ).apply { + beforeShow() + } + } + // TODO - do I really need these two lines? I'm anyway doing a workspace call + triggerProviderVisible.send(true) + isInitialized.waitForTrue() + + linkHandler.handle(params, newUrl, this.client!!, this.cli!!) + coderHeaderPage.isBusy.update { false } } catch (ex: Exception) { val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { @@ -355,50 +403,61 @@ class CoderRemoteProvider( textError ?: "" ) context.envPageManager.showPluginEnvironmentsPage() + } finally { + coderHeaderPage.isBusy.update { false } } } - private suspend fun refreshSession(url: URL, token: String): Pair { - coderHeaderPage.isBusyCreatingNewEnvironment.update { true } - try { - context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token") - softClose() - val restClient = CoderRestClient( - context, - url, - token, - PluginManager.pluginInfo.version, - ).apply { initializeSession() } - val cli = CoderCLIManager(context, url).apply { - login(token) - } - this.client = restClient - pollJob = poll(restClient, cli) - triggerProviderVisible.send(true) - context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") - return restClient to cli - } finally { - coderHeaderPage.isBusyCreatingNewEnvironment.update { false } + private suspend fun resolveDeploymentUrl(params: Map): String? { + val deploymentURL = params.url() ?: askUrl() + if (deploymentURL.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"${URL}\" is missing from URI") + return null + } + val validationResult = deploymentURL.validateStrictWebUrl() + if (validationResult is Invalid) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") + return null } + return deploymentURL } - private suspend fun performReLogin(restClient: CoderRestClient, cli: CoderCLIManager) { - context.logger.info("Stopping workspace polling and de-initializing resources") - close() - isInitialized.update { - false + private suspend fun resolveToken(params: Map): String? { + val token = params.token() + if (token.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") + return null } - context.logger.info("Starting initialization with the new settings") - this@CoderRemoteProvider.client = restClient - if (context.settingsStore.useAppNameAsTitle) { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) - } else { - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + return token + } + + private fun sameUrl(first: URL, second: URL?): Boolean = first.toURI().normalize() == second?.toURI()?.normalize() + + private suspend fun refreshSession(url: URL, token: String): Pair { + context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token") + softClose() + val newRestClient = CoderRestClient( + context, + url, + token, + PluginManager.pluginInfo.version, + ).apply { initializeSession() } + val newCli = CoderCLIManager(context, url).apply { + login(token) } - environments.showLoadingMessage() - pollJob = poll(restClient, cli) + this.client = newRestClient + this.cli = newCli + pollJob = poll(newRestClient, newCli) context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") - isInitialized.waitForTrue() + return newRestClient to newCli + } + + private suspend fun askUrl(): String? { + context.popupPluginMainPage() + return dialogUi.ask( + context.i18n.ptrl("Deployment URL"), + context.i18n.ptrl("Enter the full URL of your Coder deployment") + ) } /** @@ -455,6 +514,7 @@ class CoderRemoteProvider( private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. + close() context.settingsStore.updateLastUsedUrl(client.url) if (context.settingsStore.requiresTokenAuth) { context.secrets.storeTokenFor(client.url, client.token ?: "") @@ -463,10 +523,7 @@ class CoderRemoteProvider( context.logger.info("Deployment URL was stored and will be available for automatic connection") } this.client = client - pollJob?.let { - it.cancel() - context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") - } + this.cli = cli environments.showLoadingMessage() if (context.settingsStore.useAppNameAsTitle) { coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 87562c0..ae6d13a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -7,27 +7,16 @@ 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.coder.toolbox.util.WebUrlValidationResult.Invalid -import com.coder.toolbox.views.CoderCliSetupWizardPage -import com.coder.toolbox.views.CoderSettingsPage -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState -import com.coder.toolbox.views.state.WizardStep -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.URI import java.net.URL import java.util.UUID import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @@ -37,10 +26,6 @@ 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, - private val settingsPage: CoderSettingsPage, - private val visibilityState: MutableStateFlow, - private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -52,102 +37,37 @@ open class CoderProtocolHandler( * connectable state. */ suspend fun handle( - uri: URI, - currentUrl: URL?, - shouldWaitForAutoLogin: Boolean, - refreshSession: suspend (URL, String) -> Pair, - performLogin: suspend (CoderRestClient, CoderCLIManager) -> Unit + params: Map, + url: URL, + restClient: CoderRestClient, + cli: CoderCLIManager ) { - 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 - } - // this switches to the main plugin screen, even - // if last opened provider was not Coder - context.envPageManager.showPluginEnvironmentsPage() - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() - } - - context.logger.info("Handling $uri...") - val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return - if (deploymentURL.toURL().toURI().normalize() == currentUrl?.toURI()?.normalize()) { - if (context.settingsStore.requiresTokenAuth) { - token?.let { - val (restClient, cli) = refreshSession(currentUrl, it) - val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) - if (workspace != null) { - connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params) - } - } + val workspace = restClient.workspaces().matchName(workspaceName, url) + if (workspace != null) { + if (!prepareWorkspace(workspace, restClient, cli, url)) 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 + // we also need to retrieve the workspace again to have the latest resources (ex: agent) + // attached to the workspace. + val agent: WorkspaceAgent = resolveAgent( + params, + restClient.workspace(workspace.id) + ) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + delay(2.seconds) + 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) } - } else { - suspend fun onConnect( - restClient: CoderRestClient, - cli: CoderCLIManager - ) { - val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) - if (workspace == null) { - context.envPageManager.showPluginEnvironmentsPage() - return - } - performLogin(restClient, cli) - context.envPageManager.showPluginEnvironmentsPage() - connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params) - } - - CoderCliSetupContext.apply { - url = deploymentURL.toURL() - CoderCliSetupContext.token = token - } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - - // If Toolbox is already opened and URI is executed the setup page - // from below is never called. I tried a couple of things, including - // yielding the coroutine - but it seems to be of no help. What works - // delaying the coroutine for 66 - to 100 milliseconds, these numbers - // were determined by trial and error. - // The only explanation that I have is that inspecting the TBX bytecode it seems the - // UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events - // and a drop oldest strategy. For some reason it seems that the UI collector - // is not yet active, causing the event to be lost unless we wait > 66 ms. - // I think this delay ensures the collector is ready before processEvent() is called. - delay(100.milliseconds) - context.ui.showUiPage( - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, true, - jumpToMainPageOnError = true, - onConnect = ::onConnect - ) - ) - } - } - - private suspend fun resolveDeploymentUrl(params: Map): String? { - val deploymentURL = params.url() ?: askUrl() - if (deploymentURL.isNullOrBlank()) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") - return null - } - val validationResult = deploymentURL.validateStrictWebUrl() - if (validationResult is Invalid) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") - return null - } - return deploymentURL - } - private suspend fun resolveToken(params: Map): 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? { @@ -159,7 +79,7 @@ open class CoderProtocolHandler( return workspace } - private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + private suspend fun List.matchName(workspaceName: String, deploymentURL: URL): Workspace? { val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { context.logAndShowError( @@ -175,15 +95,14 @@ open class CoderProtocolHandler( workspace: Workspace, restClient: CoderRestClient, cli: CoderCLIManager, - workspaceName: String, - deploymentURL: String + url: URL ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be ready on time" + "${workspace.name} from $url could not be ready on time" ) return false } @@ -193,7 +112,7 @@ open class CoderProtocolHandler( if (settings.disableAutostart) { context.logAndShowWarning( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL is not running and autostart is disabled" + "${workspace.name} from $url is not running and autostart is disabled" ) return false } @@ -207,7 +126,7 @@ open class CoderProtocolHandler( } catch (e: Exception) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started", + "${workspace.name} from $url could not be started", e ) return false @@ -216,7 +135,7 @@ open class CoderProtocolHandler( if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started on time", + "${workspace.name} from $url could not be started on time", ) return false } @@ -225,7 +144,7 @@ open class CoderProtocolHandler( WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "Unable to connect to $workspaceName from $deploymentURL" + "Unable to connect to ${workspace.name} from $url" ) return false } @@ -427,50 +346,9 @@ open class CoderProtocolHandler( return false } } - - private suspend fun connectToWorkspace( - workspace: Workspace, - restClient: CoderRestClient, - cli: CoderCLIManager, - workspaceName: String, - deploymentURL: String, - params: Map - ) { - if (!prepareWorkspace(workspace, restClient, cli, 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 - // we also need to retrieve the workspace again to have the latest resources (ex: agent) - // attached to the workspace. - val agent: WorkspaceAgent = resolveAgent( - params, - restClient.workspace(workspace.id) - ) ?: return - if (!ensureAgentIsReady(workspace, agent)) return - delay(2.seconds) - 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 askUrl(): String? { - context.popupPluginMainPage() - return dialogUi.ask( - context.i18n.ptrl("Deployment URL"), - context.i18n.ptrl("Enter the full URL of your Coder deployment") - ) - } } 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) +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index eca1179..2c74024 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -18,6 +18,7 @@ class CoderCliSetupWizardPage( visibilityState: StateFlow, initialAutoSetup: Boolean = false, jumpToMainPageOnError: Boolean = false, + connectSynchronously: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, @@ -33,9 +34,10 @@ class CoderCliSetupWizardPage( private val connectStep = ConnectStep( context, shouldAutoLogin = shouldAutoSetup, - jumpToMainPageOnError, + jumpToMainPageOnError = jumpToMainPageOnError, + connectSynchronously = connectSynchronously, visibilityState, - this::displaySteps, + refreshWizard = this::displaySteps, onConnect ) private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index fa963e3..29a1e15 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -34,7 +34,7 @@ abstract class CoderPage( } } - override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) + override val isBusy: MutableStateFlow = MutableStateFlow(false) /** * Return the icon, if showing one. diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 247d2c4..3c1c8ef 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -13,10 +13,12 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -28,6 +30,7 @@ class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, private val jumpToMainPageOnError: Boolean, + private val connectSynchronously: Boolean, visibilityState: StateFlow, private val refreshWizard: () -> Unit, private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, @@ -74,11 +77,15 @@ class ConnectStep( errorField.textState.update { context.i18n.ptrl("Token is required") } return } + // Capture the host name early for error reporting val hostName = url.host + // Cancel previous job regardless of the new mode signInJob?.cancel() - signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { + + // 1. Extract the logic into a reusable suspend lambda + val connectionLogic: suspend CoroutineScope.() -> Unit = { try { context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( @@ -125,6 +132,17 @@ class ConnectStep( refreshWizard() } } + + // 2. Choose the execution strategy based on the flag + if (connectSynchronously) { + // Blocks the current thread until connectionLogic completes + runBlocking(CoroutineName("Synchronous Http and CLI Setup")) { + connectionLogic() + } + } else { + // Runs asynchronously using the context's scope + signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) + } } private fun logAndReportProgress(msg: String) { diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 1a84061..326fce0 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -5,11 +5,9 @@ import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore -import com.coder.toolbox.views.CoderSettingsPage 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.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings @@ -18,8 +16,6 @@ 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.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import java.util.UUID import kotlin.test.Test @@ -58,11 +54,7 @@ internal class CoderProtocolHandlerTest { ) private val protocolHandler = CoderProtocolHandler( - context, - DialogUi(context), - CoderSettingsPage(context, Channel(Channel.CONFLATED), {}), - MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), - MutableStateFlow(false) + context ) @Test From e4b4fca3d11c2d1549e12c03df4579fdc004dc91 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 8 Dec 2025 23:17:09 +0200 Subject: [PATCH 4/5] chore: update Changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 532bb92..62cf837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ ### Changed -- simplified URI handling when the same deployment URL is already opened +- streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience + +### Fixed + +- URI handling on Linux can now launch IDEs on newly started workspaces ## 0.8.0 - 2025-12-03 From 451aa74a329fc7f5828b1d90ae327dcf5ca345e4 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 9 Dec 2025 00:06:59 +0200 Subject: [PATCH 5/5] chore: next version is 0.8.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 60ed663..d9ce8bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.8.0 +version=0.8.1 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file