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/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 } 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. *