Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Changed

- upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2

## 0.9.4 - 2026-08-26

### Added
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
toolbox-plugin-api = "1.10.76281"
toolbox-plugin-api = "1.13.87111"
kotlin = "2.3.10"
coroutines = "1.10.2"
serialization = "1.9.0"
Expand Down
32 changes: 16 additions & 16 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ class CoderRemoteProvider(
if ((ex is APIResponseException && ex.isTokenExpired) || ex is OAuthTokenResponseException) {
close()
context.envPageManager.showPluginEnvironmentsPage(false)
context.logAndShowError(
context.logger.logAndShowError(
"Error encountered while setting up Coder",
"Your Coder session has expired. Please re-authenticate and try again.",
ex
Expand Down Expand Up @@ -242,7 +242,7 @@ class CoderRemoteProvider(
if (!isSshConfigurationWarningShown) {
isSshConfigurationWarningShown = true
val reason = ex.message?.takeIf { it.isNotBlank() } ?: ex.javaClass.simpleName
context.logAndShowWarning(
context.logger.logAndShowWarning(
SSH_CONFIGURATION_WARNING_TITLE,
"Workspaces remain available, but SSH connections are unavailable: $reason. " +
"Update ${context.settingsStore.sshConfigPath} and try again.",
Expand Down Expand Up @@ -428,7 +428,7 @@ class CoderRemoteProvider(
val params = uri.toQueryParameters()
if (params.isEmpty()) {
// probably a plugin installation scenario
context.logAndShowInfo("URI will not be handled", "No query parameters were provided")
context.logger.logAndShowInfo("URI will not be handled", "No query parameters were provided")
return
}
context.logger.info("Handling $uri...")
Expand Down Expand Up @@ -469,7 +469,7 @@ class CoderRemoteProvider(
ex.reason
} else ex.message
} else ex.message
context.logAndShowError(
context.logger.logAndShowError(
"Error encountered while handling Coder URI",
textError ?: ""
)
Expand All @@ -487,35 +487,35 @@ class CoderRemoteProvider(
val error = params["error"]
if (error != null) {
val description = params["error_description"]?.let { " - $it" } ?: ""
return context.logAndShowError(
return context.logger.logAndShowError(
FAILED_TO_HANDLE_OAUTH2_TITLE,
"OAuth2 authorization error: $error$description"
)
}

if (!router.hasActiveWizard) {
return context.logAndShowError(
return context.logger.logAndShowError(
FAILED_TO_HANDLE_OAUTH2_TITLE,
"OAuth2 callback arrived but the setup wizard is no longer active"
)
}
val pendingOAuthConnection = router.pendingOAuthConnection ?: return context.logAndShowError(
val pendingOAuthConnection = router.pendingOAuthConnection ?: return context.logger.logAndShowError(
FAILED_TO_HANDLE_OAUTH2_TITLE,
"OAuth2 callback arrived but no OAuth session was started"
)
params["state"]?.takeIf { it == pendingOAuthConnection.session.state }
?: return context.logAndShowError(
?: return context.logger.logAndShowError(
FAILED_TO_HANDLE_OAUTH2_TITLE,
"Server responded back with an invalid state that does not match the initial authorization state sent to the server"
)

val code = params["code"] ?: return context.logAndShowError(
val code = params["code"] ?: return context.logger.logAndShowError(
FAILED_TO_HANDLE_OAUTH2_TITLE,
"OAuth2 server did not respond back with an access token"
)
// before going forward we check to make sure OAuth is not disabled in the meantime
if (!context.settingsStore.preferOAuth2IfAvailable) {
context.logAndShowError(
context.logger.logAndShowError(
FAILED_TO_HANDLE_OAUTH2_TITLE,
"OAuth based authentication is not enabled for Coder plugin in Toolbox. Please enable it in plugin settings or use the API token instead."
)
Expand Down Expand Up @@ -545,19 +545,19 @@ class CoderRemoteProvider(
context.envPageManager.showPluginEnvironmentsPage(false)
context.ui.showUiPage(wizard)
} catch (e: Exception) {
context.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e)
context.logger.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e)
}
}

private suspend fun resolveDeploymentUrl(params: Map<String, String>): String? {
val deploymentURL = params.url() ?: askUrl()
if (deploymentURL.isNullOrBlank()) {
context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"${URL}\" is missing from URI")
context.logger.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}")
context.logger.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}")
return null
}
return deploymentURL
Expand All @@ -566,7 +566,7 @@ class CoderRemoteProvider(
private suspend fun resolveToken(params: Map<String, String>): String? {
val token = params.token()
if (token.isNullOrBlank()) {
context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI")
context.logger.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI")
return null
}
return token
Expand Down Expand Up @@ -647,7 +647,7 @@ class CoderRemoteProvider(
onTokenRefreshed = ::onTokenRefreshed,
)
} catch (ex: Exception) {
context.logAndShowError(
context.logger.logAndShowError(
"Error encountered while setting up Coder",
"Failed to set up Coder: ${ex.message}",
ex
Expand Down Expand Up @@ -756,7 +756,7 @@ class CoderRemoteProvider(
try {
handleLink(params, deploymentUrl, client, cli)
} catch (ex: Exception) {
context.logAndShowError(
context.logger.logAndShowError(
"Error handling deferred link",
ex.message ?: ""
)
Expand Down
62 changes: 3 additions & 59 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.coder.toolbox

import com.coder.toolbox.diagnostics.CoderLogger
import com.coder.toolbox.store.CoderSecretsStore
import com.coder.toolbox.store.CoderSettingsStore
import com.coder.toolbox.util.ConnectionMonitoringService
Expand All @@ -14,10 +15,7 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
import com.jetbrains.toolbox.api.ui.components.UiComponents
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URL

@Suppress("UnstableApiUsage")
Expand All @@ -30,12 +28,13 @@ data class CoderToolboxContext(
val jbClientOrchestrator: ClientHelper,
val desktop: LocalDesktopManager,
val cs: CoroutineScope,
val logger: Logger,
private val underlyingLogger: Logger,
val i18n: LocalizableStringFactory,
val settingsStore: CoderSettingsStore,
val secrets: CoderSecretsStore,
val proxySettings: ToolboxProxySettings,
) {
val logger: CoderLogger = CoderLogger(underlyingLogger, ui, cs, i18n)
val connectionMonitoringService: ConnectionMonitoringService = ConnectionMonitoringService(this)

/**
Expand All @@ -54,61 +53,6 @@ data class CoderToolboxContext(
?: settingsStore.defaultURL.toURL()
}

fun logAndShowError(title: String, error: String) {
logger.error(error)
showInfoPopup(title, error)
}

fun logAndShowError(title: String, error: String, exception: Exception) {
logger.error(exception, error)
showInfoPopup(title, error)
}

fun logAndShowWarning(title: String, warning: String) {
logger.warn(warning)
showInfoPopup(title, warning)
}

fun logAndShowWarning(title: String, warning: String, exception: Exception) {
logger.warn(exception, warning)
showInfoPopup(title, warning)
}

fun logAndShowInfo(title: String, info: String) {
logger.info(info)
showInfoPopup(title, info)
}

/**
* Displays an informational popup on a child of the plugin coroutine scope rather than on
* the caller's coroutine, without waiting for it.
*
* Unlike [ToolboxUi.showSnackbar], a popup is backed by a persistent dialog state: it is
* still rendered once the window becomes visible even if it was requested while the window
* was hidden, it is not silently dropped when several are requested, and dismissing it
* resumes the [ToolboxUi.showInfoPopup] coroutine normally instead of cancelling it.
*
* It is launched fire-and-forget so the caller is not suspended until the user closes the
* popup - the caller (e.g. the URI handler) can run any follow-up code, such as resetting
* the busy state, immediately. The popups are serialized via [popupMutex] so they are
* shown one after another rather than overwriting each other.
*/
fun showInfoPopup(title: String, text: String) {
cs.launch(CoroutineName("popup")) {
try {
ui.showInfoPopup(
i18n.pnotr(title),
i18n.pnotr(text),
i18n.ptrl("OK")
)
} catch (_: CancellationException) {
// Expected when the plugin scope shuts down while the popup is open.
} catch (ex: Exception) {
logger.error(ex, "Failed to display popup with title '$title'")
}
}
}

fun popupPluginMainPage() {
this.ui.showWindow()
this.envPageManager.showPluginEnvironmentsPage(false)
Expand Down
97 changes: 97 additions & 0 deletions src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.coder.toolbox.diagnostics

import com.coder.toolbox.session.SessionId
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.ui.ToolboxUi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

private const val CLIENT_SESSION_ID_LOG_KEY = "client_session_id"

private fun withSessionId(sessionId: SessionId, message: String): String =
"$CLIENT_SESSION_ID_LOG_KEY=$sessionId $message"

/**
* The plugin's single logging entry point.
*
* Calls without a [SessionId] are delegated unchanged. Calls with a session ID add the correlation
* field to the log message.
*/
class CoderLogger(
private val delegate: Logger,
private val ui: ToolboxUi,
private val cs: CoroutineScope,
private val i18n: LocalizableStringFactory,
) : Logger by delegate {
fun error(sessionId: SessionId, message: String) {
delegate.error(withSessionId(sessionId, message))
}

fun warn(sessionId: SessionId, message: String) {
delegate.warn(withSessionId(sessionId, message))
}

fun debug(sessionId: SessionId, message: String) {
delegate.debug(withSessionId(sessionId, message))
}

fun info(sessionId: SessionId, message: String) {
delegate.info(withSessionId(sessionId, message))
}

fun logAndShowError(title: String, error: String) {
error(error)
showInfoPopup(title, error)
}

fun logAndShowError(title: String, error: String, exception: Throwable) {
error(exception, error)
showInfoPopup(title, error)
}

fun logAndShowWarning(title: String, warning: String) {
warn(warning)
showInfoPopup(title, warning)
}

fun logAndShowWarning(title: String, warning: String, exception: Throwable) {
warn(exception, warning)
showInfoPopup(title, warning)
}

fun logAndShowInfo(title: String, info: String) {
info(info)
showInfoPopup(title, info)
}

/**
* Displays an informational popup on a child of the plugin coroutine scope rather than on
* the caller's coroutine, without waiting for it.
*
* Unlike [ToolboxUi.showSnackbar], a popup is backed by a persistent dialog state: it is
* still rendered once the window becomes visible even if it was requested while the window
* was hidden, it is not silently dropped when several are requested, and dismissing it
* resumes the [ToolboxUi.showInfoPopup] coroutine normally instead of cancelling it.
*
* It is launched fire-and-forget so the caller is not suspended until the user closes the
* popup. The caller can run any follow-up work immediately.
*/
private fun showInfoPopup(title: String, text: String) {
cs.launch(CoroutineName("popup")) {
try {
ui.showInfoPopup(
i18n.pnotr(title),
i18n.pnotr(text),
i18n.ptrl("OK")
)
} catch (_: CancellationException) {
// Expected when the plugin scope shuts down while the popup is open.
} catch (ex: Exception) {
error(ex, "Failed to display popup with title '$title'")
}
}
}
}
2 changes: 1 addition & 1 deletion src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ open class CoderRestClient(
}

isInvalidDeploymentDataWarningShown = true
context.logAndShowWarning(
context.logger.logAndShowWarning(
INVALID_DEPLOYMENT_DATA_WARNING_TITLE,
INVALID_DEPLOYMENT_DATA_WARNING,
ex,
Expand Down
Loading
Loading