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(), pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy