Skip to content

Commit c8291f7

Browse files
authored
Fix inconsistent UI state and concurrent setup flow in Toolbox wizard (#302)
Toolbox currently relies on getOverrideUiPage to decide whether to render a custom page or fall back to environments, but this logic is stateless and leads to inconsistent behavior when the window is closed and reopened. If the user exits Toolbox mid-setup (e.g., during CLI download in ConnectStep) and reopens it, a new wizard instance is created even though the previous setup process is still running in the background. This results in multiple overlapping setup flows, race conditions, and UI inconsistencies such as: - Returning to the initial URL step while setup is still in progress - Conflicting login attempts when restarting the wizard - Background processes (CLI download, HTTP setup) continuing without visible UI - Security dialogs triggered due to shared/overwritten resources - Undefined behavior when switching deployments mid-setup The root cause is lack of persistent, centralized state management for the setup flow. Current reliance on static state (e.g., CoderSetupWizardState) and recomputation via getOverrideUiPage does not properly track in-progress operations or restore the correct UI state. - resolves https://linear.app/codercom/issue/DEVEX-223/
1 parent e714ac8 commit c8291f7

12 files changed

Lines changed: 650 additions & 309 deletions

File tree

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Lines changed: 114 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import com.coder.toolbox.browser.browse
44
import com.coder.toolbox.cli.CoderCLIManager
55
import com.coder.toolbox.feed.IdeFeedManager
66
import com.coder.toolbox.oauth.OAuth2Client
7-
import com.coder.toolbox.oauth.OAuthTokenResponse
87
import com.coder.toolbox.plugin.PluginManager
98
import com.coder.toolbox.sdk.CoderRestClient
109
import com.coder.toolbox.sdk.ex.APIResponseException
@@ -22,15 +21,16 @@ import com.coder.toolbox.util.url
2221
import com.coder.toolbox.util.validateStrictWebUrl
2322
import com.coder.toolbox.util.withPath
2423
import com.coder.toolbox.views.Action
25-
import com.coder.toolbox.views.CoderCliSetupWizardPage
2624
import com.coder.toolbox.views.CoderDelimiter
2725
import com.coder.toolbox.views.CoderSettingsPage
26+
import com.coder.toolbox.views.CoderSetupWizardPage
2827
import com.coder.toolbox.views.NewEnvironmentPage
2928
import com.coder.toolbox.views.SuspendBiConsumer
3029
import com.coder.toolbox.views.state.CoderOAuthSessionContext
31-
import com.coder.toolbox.views.state.CoderSetupWizardContext
32-
import com.coder.toolbox.views.state.CoderSetupWizardState
33-
import com.coder.toolbox.views.state.WizardStep
30+
import com.coder.toolbox.views.state.Credentials
31+
import com.coder.toolbox.views.state.PageRouter
32+
import com.coder.toolbox.views.state.PendingOAuthConnection
33+
import com.coder.toolbox.views.state.toSessionContext
3434
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
3535
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
3636
import com.jetbrains.toolbox.api.core.util.LoadableState
@@ -69,8 +69,6 @@ class CoderRemoteProvider(
6969
private var pollJob: Job? = null
7070
internal val lastEnvironments = mutableListOf<CoderRemoteEnvironment>()
7171

72-
private val settings = context.settingsStore.readOnly()
73-
7472
private val triggerSshConfig = Channel<Boolean>(Channel.CONFLATED)
7573
private val triggerProviderVisible = Channel<Boolean>(Channel.CONFLATED)
7674
private val dialogUi = DialogUi(context)
@@ -113,6 +111,8 @@ class CoderRemoteProvider(
113111

114112
private val errorBuffer = mutableListOf<Throwable>()
115113

114+
private val router = PageRouter()
115+
116116
/**
117117
* With the provided client, start polling for workspaces. Every time a new
118118
* workspace is added, reconfigure SSH using the provided cli (including the
@@ -269,6 +269,7 @@ class CoderRemoteProvider(
269269
lastEnvironments.clear()
270270
environments.value = LoadableState.Value(emptyList())
271271
isInitialized.update { false }
272+
router.clear()
272273
context.logger.info("Coder plugin is now closed")
273274
}
274275

@@ -345,9 +346,6 @@ class CoderRemoteProvider(
345346
*/
346347
override suspend fun handleUri(uri: URI) {
347348
try {
348-
// Obtain focus. This switches to the main plugin screen, even
349-
// if last opened provider was not Coder
350-
context.envPageManager.showPluginEnvironmentsPage()
351349
if (uri.toString().startsWith("jetbrains://gateway/com.coder.toolbox/auth")) {
352350
handleOAuthUri(uri)
353351
return
@@ -372,25 +370,22 @@ class CoderRemoteProvider(
372370
linkHandler.handle(params, newUrl, this.client!!, this.cli!!)
373371
coderHeaderPage.isBusy.update { false }
374372
} else {
375-
// Different URL - we need a new connection.
376-
// Chain the link handling after onConnect so it runs once the connection is established.
377-
CoderSetupWizardContext.apply {
378-
url = newUrl
379-
token = newToken
380-
}
381-
CoderSetupWizardState.goToStep(WizardStep.CONNECT)
382-
context.ui.showUiPage(
383-
CoderCliSetupWizardPage(
384-
context, settingsPage, visibilityState,
385-
initialAutoSetup = true,
386-
jumpToMainPageOnError = true,
387-
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl))
388-
.andThen { _, _ ->
389-
coderHeaderPage.isBusy.update { false }
390-
},
391-
onTokenRefreshed = ::onTokenRefreshed
392-
)
373+
// Different URL - we need a new connection. Tear down any
374+
// in-flight wizard, install a fresh one on the router, and let
375+
// showPluginEnvironmentsPage() pull it through getOverrideUiPage.
376+
val credentials = newToken?.let { Credentials.Token(it) } ?: Credentials.MTls
377+
val wizard = CoderSetupWizardPage.connectStep(
378+
context, settingsPage, visibilityState,
379+
url = newUrl,
380+
credentials = credentials,
381+
onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl))
382+
.andThen { _, _ ->
383+
coderHeaderPage.isBusy.update { false }
384+
},
385+
onTokenRefreshed = ::onTokenRefreshed,
393386
)
387+
router.navigate(wizard)
388+
context.popupPluginMainPage()
394389
}
395390
} catch (ex: Exception) {
396391
val textError = if (ex is APIResponseException) {
@@ -403,7 +398,6 @@ class CoderRemoteProvider(
403398
textError ?: ""
404399
)
405400
coderHeaderPage.isBusy.update { false }
406-
context.envPageManager.showPluginEnvironmentsPage()
407401
} finally {
408402
firstRun = false
409403
}
@@ -424,7 +418,17 @@ class CoderRemoteProvider(
424418
)
425419
}
426420

427-
params["state"]?.takeIf { it == CoderSetupWizardContext.oauthSession?.state }
421+
if (!router.hasActiveWizard) {
422+
return context.logAndShowError(
423+
FAILED_TO_HANDLE_OAUTH2_TITLE,
424+
"OAuth2 callback arrived but the setup wizard is no longer active"
425+
)
426+
}
427+
val pendingOAuthConnection = router.pendingOAuthConnection ?: return context.logAndShowError(
428+
FAILED_TO_HANDLE_OAUTH2_TITLE,
429+
"OAuth2 callback arrived but no OAuth session was started"
430+
)
431+
params["state"]?.takeIf { it == pendingOAuthConnection.session.state }
428432
?: return context.logAndShowError(
429433
FAILED_TO_HANDLE_OAUTH2_TITLE,
430434
"Server responded back with an invalid state that does not match the initial authorization state sent to the server"
@@ -442,18 +446,29 @@ class CoderRemoteProvider(
442446
)
443447
return
444448
}
445-
exchangeOAuthCodeForToken(code, CoderSetupWizardContext.oauthSession!!)
449+
exchangeOAuthCodeForToken(code, pendingOAuthConnection)
446450
}
447451

448-
private suspend fun exchangeOAuthCodeForToken(code: String, oauthSessionContext: CoderOAuthSessionContext) {
452+
private suspend fun exchangeOAuthCodeForToken(
453+
code: String,
454+
pendingOAuthConnection: PendingOAuthConnection,
455+
) {
449456
try {
450457
context.logger.info("Handling OAuth callback...")
451458

459+
val oauthSessionContext = pendingOAuthConnection.session
452460
val tokenResponse = OAuth2Client(context).exchangeCode(oauthSessionContext, code)
453-
CoderSetupWizardContext.oauthSession = oauthSessionContext.copy(tokenResponse = tokenResponse)
454-
455-
CoderSetupWizardState.goToStep(WizardStep.CONNECT)
461+
val wizard = CoderSetupWizardPage.connectStep(
462+
context, settingsPage, visibilityState,
463+
url = pendingOAuthConnection.url,
464+
credentials = Credentials.OAuth(oauthSessionContext.copy(tokenResponse = tokenResponse)),
465+
onConnect = onConnect,
466+
onTokenRefreshed = ::onTokenRefreshed,
467+
)
468+
router.navigate(wizard)
456469

470+
context.envPageManager.showPluginEnvironmentsPage(true)
471+
context.ui.showUiPage(wizard)
457472
} catch (e: Exception) {
458473
context.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e)
459474
}
@@ -520,77 +535,79 @@ class CoderRemoteProvider(
520535
* list.
521536
*/
522537
override fun getOverrideUiPage(): UiPage? {
523-
// Show the setup page if we have not configured the client yet.
524-
if (client == null) {
525-
// When coming back to the application, initializeSession immediately.
526-
if (shouldDoAutoSetup()) {
527-
try {
528-
val storedOAuthSession = context.secrets.oauthSessionFor(context.deploymentUrl.toString())
529-
CoderSetupWizardContext.apply {
530-
url = context.deploymentUrl
531-
token = context.secrets.apiTokenFor(context.deploymentUrl)
532-
if (storedOAuthSession != null) {
533-
oauthSession = CoderOAuthSessionContext(
534-
clientId = storedOAuthSession.clientId,
535-
clientSecret = storedOAuthSession.clientSecret,
536-
tokenCodeVerifier = "",
537-
state = "",
538-
tokenEndpoint = storedOAuthSession.tokenEndpoint,
539-
tokenAuthMethod = storedOAuthSession.tokenAuthMethod,
540-
tokenResponse = OAuthTokenResponse(
541-
accessToken = "",
542-
tokenType = "",
543-
expiresIn = null,
544-
refreshToken = storedOAuthSession.refreshToken,
545-
scope = null
546-
)
547-
)
548-
}
549-
}
550-
CoderSetupWizardState.goToStep(WizardStep.CONNECT)
551-
return CoderCliSetupWizardPage(
552-
context, settingsPage, visibilityState,
553-
initialAutoSetup = true,
554-
jumpToMainPageOnError = false,
555-
onConnect = onConnect,
556-
onTokenRefreshed = ::onTokenRefreshed
557-
)
558-
} catch (ex: Exception) {
559-
errorBuffer.add(ex)
560-
} finally {
561-
firstRun = false
562-
}
563-
}
538+
// Show the setup wizard if one is already scheduled.
539+
router.activePage?.let { return it }
564540

565-
// Login flow.
566-
CoderSetupWizardState.goToFirstStep()
567-
val setupWizardPage =
568-
CoderCliSetupWizardPage(
569-
context,
570-
settingsPage,
571-
visibilityState,
541+
// Let the default workspace UI render if the HTTP client is initialized.
542+
if (client != null) return null
543+
544+
// Otherwise, schedule our own setup wizard.
545+
return router.getOrCreate { buildSetupWizard() }
546+
}
547+
548+
/**
549+
* Build the wizard for the current state. Called once per provider lifetime
550+
* (until [close] clears the router); subsequent visibility cycles reuse the
551+
* same instance, preserving any in-flight connect job.
552+
*/
553+
private fun buildSetupWizard(): CoderSetupWizardPage {
554+
// When coming back to the application, initializeSession immediately.
555+
if (shouldDoAutoSetup()) {
556+
try {
557+
val url = context.deploymentUrl
558+
val credentials = autoSetupCredentials(url) ?: return CoderSetupWizardPage.deploymentUrlStep(
559+
context, settingsPage, visibilityState,
560+
onConnect = onConnect,
561+
onTokenRefreshed = ::onTokenRefreshed,
562+
)
563+
return CoderSetupWizardPage.connectStep(
564+
context, settingsPage, visibilityState,
565+
url = url,
566+
credentials = credentials,
572567
onConnect = onConnect,
573-
onTokenRefreshed = ::onTokenRefreshed
568+
onTokenRefreshed = ::onTokenRefreshed,
574569
)
575-
// We might have navigated here due to a polling error.
576-
errorBuffer.forEach {
577-
setupWizardPage.notify("Error encountered", it)
570+
} catch (ex: Exception) {
571+
errorBuffer.add(ex)
572+
} finally {
573+
firstRun = false
578574
}
579-
errorBuffer.clear()
580-
// and now reset the errors, otherwise we show it every time on the screen
581-
return setupWizardPage
582575
}
583-
return null
576+
577+
// Login flow.
578+
val setupWizardPage = CoderSetupWizardPage.deploymentUrlStep(
579+
context, settingsPage, visibilityState,
580+
onConnect = onConnect,
581+
onTokenRefreshed = ::onTokenRefreshed,
582+
)
583+
// We might have navigated here due to a polling error.
584+
errorBuffer.forEach {
585+
setupWizardPage.notify("Error encountered", it)
586+
}
587+
errorBuffer.clear()
588+
return setupWizardPage
584589
}
585590

586591
/**
587-
* Auto-login only on first the firs run if there is a url & token configured or the auth
588-
* should be done via certificates.
592+
* Auto-login only on the first run when stored credentials or mTLS auth can be used.
589593
*/
590-
private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth)
594+
private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !context.settingsStore.requiresTokenAuth)
595+
596+
fun canAutoLogin(): Boolean = autoSetupCredentials(context.deploymentUrl) != null
591597

592-
fun canAutoLogin(): Boolean = !context.secrets.apiTokenFor(context.deploymentUrl)
593-
.isNullOrBlank() || context.secrets.oauthSessionFor(context.deploymentUrl.toString()) != null
598+
private fun autoSetupCredentials(url: URL): Credentials? {
599+
if (context.settingsStore.requiresMTlsAuth) return Credentials.MTls
600+
601+
val tokenCredentials = context.secrets.apiTokenFor(url)
602+
?.takeIf { it.isNotBlank() }
603+
?.let { Credentials.Token(it) }
604+
605+
if (!context.settingsStore.preferOAuth2IfAvailable) return tokenCredentials
606+
607+
return context.secrets.oauthSessionFor(url.toString())?.let {
608+
Credentials.OAuth(it.toSessionContext())
609+
} ?: tokenCredentials
610+
}
594611

595612
private suspend fun onTokenRefreshed(url: URL, oauthSessionCtx: CoderOAuthSessionContext) {
596613
oauthSessionCtx.tokenResponse?.accessToken?.let { cli?.login(it) }
@@ -653,4 +670,4 @@ class CoderRemoteProvider(
653670
LoadableState.Loading
654671
}
655672
}
656-
}
673+
}

0 commit comments

Comments
 (0)