diff --git a/CHANGELOG.md b/CHANGELOG.md index d161afe..3b6e569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- improved workflow when network connection is flaky + ## 0.5.2 - 2025-07-22 ### Fixed diff --git a/gradle.properties b/gradle.properties index e6dc4d8..0becc24 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.5.2 +version=0.6.0 group=com.coder.toolbox -name=coder-toolbox +name=coder-toolbox \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 08f5a07..f8b3a17 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -68,6 +68,13 @@ class CoderRemoteEnvironment( private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null + init { + if (context.settingsStore.shouldAutoConnect(id)) { + context.logger.info("resuming SSH connection to $id — last session was still active.") + startSshConnection() + } + } + fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) private fun getAvailableActions(): List { @@ -158,6 +165,7 @@ class CoderRemoteEnvironment( override fun beforeConnection() { context.logger.info("Connecting to $id...") isConnected.update { true } + context.settingsStore.updateAutoConnect(this.id, true) pollJob = pollNetworkMetrics() } @@ -180,12 +188,9 @@ class CoderRemoteEnvironment( } context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id") try { - val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) - if (metrics == null) { - return@launch - } + val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) ?: return@launch context.logger.debug("$id metrics: $metrics") - additionalEnvironmentInformation.put(context.i18n.ptrl("Network Status"), metrics.toPretty()) + additionalEnvironmentInformation[context.i18n.ptrl("Network Status")] = metrics.toPretty() } catch (e: Exception) { context.logger.error( e, @@ -203,6 +208,10 @@ class CoderRemoteEnvironment( pollJob?.cancel() this.connectionRequest.update { false } isConnected.update { false } + if (isManual) { + // if the user manually disconnects the ssh connection we should not connect automatically + context.settingsStore.updateAutoConnect(this.id, false) + } context.logger.info("Disconnected from $id") } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index d68b077..2e5d557 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -80,6 +80,8 @@ class CoderRemoteProvider( ) ) + private val errorBuffer = mutableListOf() + /** * With the provided client, start polling for workspaces. Every time a new * workspace is added, reconfigure SSH using the provided cli (including the @@ -160,23 +162,20 @@ class CoderRemoteProvider( } catch (ex: Exception) { val elapsed = lastPollTime.elapsedNow() if (elapsed > POLL_INTERVAL * 2) { - context.logger.info("wake-up from an OS sleep was detected, going to re-initialize the http client...") - client.setupSession() + context.logger.info("wake-up from an OS sleep was detected") } else { - context.logger.error(ex, "workspace polling error encountered, trying to auto-login") + context.logger.error(ex, "workspace polling error encountered") if (ex is APIResponseException && ex.isTokenExpired) { WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true + close() + context.envPageManager.showPluginEnvironmentsPage() + errorBuffer.add(ex) + break } - close() - // force auto-login - firstRun = true - context.envPageManager.showPluginEnvironmentsPage() - break } } - // TODO: Listening on a web socket might be better? - select { + select { onTimeout(POLL_INTERVAL) { context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout") } @@ -196,9 +195,6 @@ class CoderRemoteProvider( * first page. */ private fun logout() { - // Keep the URL and token to make it easy to log back in, but set - // rememberMe to false so we do not try to automatically log in. - context.secrets.rememberMe = false WorkspaceConnectionManager.reset() close() } @@ -360,22 +356,17 @@ class CoderRemoteProvider( override fun getOverrideUiPage(): UiPage? { // Show the setup page if we have not configured the client yet. if (client == null) { - val errorBuffer = mutableListOf() // When coming back to the application, initializeSession immediately. - val autoSetup = shouldDoAutoSetup() - context.secrets.lastToken.let { lastToken -> - context.secrets.lastDeploymentURL.let { lastDeploymentURL -> - if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { - try { - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) - } catch (ex: Exception) { - errorBuffer.add(ex) - } - } + if (shouldDoAutoSetup()) { + try { + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) + } catch (ex: Exception) { + errorBuffer.add(ex) + } finally { + firstRun = false } } - firstRun = false // Login flow. val setupWizardPage = @@ -384,21 +375,24 @@ class CoderRemoteProvider( errorBuffer.forEach { setupWizardPage.notify("Error encountered", it) } + errorBuffer.clear() // and now reset the errors, otherwise we show it every time on the screen return setupWizardPage } return null } - private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true + /** + * Auto-login only on first the firs run if there is a url & token configured or the auth + * should be done via certificates. + */ + private fun shouldDoAutoSetup(): Boolean = firstRun && (context.secrets.canAutoLogin || !settings.requireTokenAuth) private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" context.secrets.storeTokenFor(client.url, context.secrets.lastToken) - // Currently we always remember, but this could be made an option. - context.secrets.rememberMe = true this.client = client pollJob?.cancel() environments.showLoadingMessage() diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index e3a882f..1a0f18e 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -60,7 +60,7 @@ open class CoderRestClient( setupSession() } - fun setupSession() { + private fun setupSession() { moshi = Moshi.Builder() .add(ArchConverter()) diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index a6d6143..693c1fd 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -146,6 +146,11 @@ interface ReadOnlyCoderSettings { * Return the URL and token from the config, if they exist. */ fun readConfig(dir: Path): Pair + + /** + * Returns whether the SSH connection should be automatically established. + */ + fun shouldAutoConnect(workspaceId: String): Boolean } /** diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index 3170a06..a807b69 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -24,9 +24,8 @@ class CoderSecretsStore(private val store: PluginSecretStore) { var lastToken: String get() = get("last-token") set(value) = set("last-token", value) - var rememberMe: Boolean - get() = get("remember-me").toBoolean() - set(value) = set("remember-me", value.toString()) + val canAutoLogin: Boolean + get() = lastDeploymentURL.isNotBlank() && lastToken.isNotBlank() fun tokenFor(url: URL): String? = store[url.host] diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index ff56380..0fa4914 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -142,6 +142,10 @@ class CoderSettingsStore( } } + override fun shouldAutoConnect(workspaceId: String): Boolean { + return store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"]?.toBooleanStrictOrNull() ?: false + } + // a readonly cast fun readOnly(): ReadOnlyCoderSettings = this @@ -213,6 +217,10 @@ class CoderSettingsStore( store[SSH_CONFIG_OPTIONS] = options } + fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) { + store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() + } + private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(env.get("LOCALAPPDATA"), "coder-toolbox") diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 91e3b74..cd1a05d 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -42,3 +42,5 @@ internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions" internal const val NETWORK_INFO_DIR = "networkInfoDir" +internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" + diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index e01971c..7ea93e4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -128,7 +128,6 @@ class ConnectStep( if (shouldAutoLogin.value) { CoderCliSetupContext.reset() CoderCliSetupWizardState.goToFirstStep() - context.secrets.rememberMe = false } else { if (context.settingsStore.requireTokenAuth) { CoderCliSetupWizardState.goToPreviousStep()