diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index a30d3fb..21586e3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -7,9 +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 import com.coder.toolbox.views.ConnectPage @@ -53,7 +52,6 @@ class CoderRemoteProvider( 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) // The REST client, if we are signed in private var client: CoderRestClient? = null @@ -65,7 +63,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 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()) ) @@ -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) { @@ -171,14 +177,14 @@ 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() - client = null + client?.close() lastEnvironments = null environments.value = LoadableState.Value(emptyList()) + isInitialized.update { false } } override val svgIcon: SvgIcon = @@ -213,8 +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 = - NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: "")) + override fun getNewEnvironmentUiPage(): UiPage = coderHeaderPage /** * We always show a list of environments. @@ -233,11 +238,13 @@ class CoderRemoteProvider( * Handle incoming links (like from the dashboard). */ 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(uri, shouldDoAutoLogin()) { restClient, cli -> + // stop polling and de-initialize resources + close() + // start initialization with the new settings + this@CoderRemoteProvider.client = restClient + coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) + pollJob = poll(restClient, cli) } } @@ -263,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 -> @@ -302,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/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/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/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 2d2c49e..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. @@ -272,4 +285,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/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/LinkHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt similarity index 50% rename from src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt rename to src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 31a6602..77969e8 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -1,25 +1,35 @@ 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 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.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.time.withTimeout 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 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 @@ -29,71 +39,81 @@ open class LinkHandler( * connectable state. */ suspend fun handle( - parameters: Map, - indicator: ((t: String) -> Unit)? = null, - ): String { - val deploymentURL = - parameters.url() ?: dialogUi.ask( - context.i18n.ptrl("Deployment URL"), - context.i18n.ptrl("Enter the full URL of your Coder deployment") - ) + uri: URI, + shouldWaitForAutoLogin: Boolean, + reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + ) { + 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 queryToken = if (!queryTokenRaw.isNullOrBlank()) { - Pair(queryTokenRaw, Source.QUERY) - } else { - null - } - val client = try { + 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 = - parameters.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 = client.workspaces() - val workspace = - workspaces.firstOrNull { - it.name == workspaceName - } ?: throw IllegalArgumentException("The workspace $workspaceName does not exist") + val workspaces = restClient.workspaces() + 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 -> - // 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 } // 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()) { @@ -115,56 +135,93 @@ open class LinkHandler( ensureCLI( context, deploymentURL.toURL(), - client.buildInfo().version, - settings, - indicator, + restClient.buildInfo().version, + settings ) // We only need to log in if we are using token-based auth. - if (client.token != null) { - indicator?.invoke("Authenticating Coder CLI...") - cli.login(client.token) + if (restClient.token != null) { + context.logger.info("Authenticating Coder CLI...") + cli.login(restClient.token) } - indicator?.invoke("Configuring Coder CLI...") - cli.configSsh(client.agentNames(workspaces)) + context.logger.info("Configuring Coder CLI...") + cli.configSsh(restClient.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 + if (shouldWaitForAutoLogin) { + isInitialized.waitForTrue() + } + reInitialize(restClient, cli) + + val environmentId = "${workspace.name}.${agent.name}" + context.ui.showWindow() + context.envPageManager.showPluginEnvironmentsPage(true) + 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" + context.logger.info("installing $ideVersion on $environmentId") + val job = context.cs.launch { + context.ideOrchestrator.prepareClient(environmentId, ideVersion) + } + job.join() + context.logger.info("launching $ideVersion on $environmentId") + context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectPath) + } + } + } + + 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) + 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 - * 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") } @@ -173,93 +230,16 @@ open class LinkHandler( 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 - } - } - } - - /** - * 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") - } + client.authenticate() + return client } -} -/** - * 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) } /** @@ -332,4 +312,9 @@ internal fun getMatchingAgent( return agent } +/** + * 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) 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/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index ae05524..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 = "ide_path_on_host" +private const val PROJECT_PATH = "project_path" // Helper functions for reading from the map. Prefer these to directly // interacting with the map. @@ -28,12 +26,8 @@ fun Map.agentName() = this[AGENT_NAME] 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] -fun Map.idePathOnHost() = this[IDE_PATH_ON_HOST] +fun Map.projectPath() = this[PROJECT_PATH] 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) 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), 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(),