Skip to content
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

### Added

- visual text progress during Coder CLI downloading

### Changed

- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically
Expand Down
29 changes: 15 additions & 14 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import com.coder.toolbox.util.CoderProtocolHandler
import com.coder.toolbox.util.DialogUi
import com.coder.toolbox.util.withPath
import com.coder.toolbox.views.Action
import com.coder.toolbox.views.AuthWizardPage
import com.coder.toolbox.views.CoderCliSetupWizardPage
import com.coder.toolbox.views.CoderSettingsPage
import com.coder.toolbox.views.NewEnvironmentPage
import com.coder.toolbox.views.state.AuthWizardState
import com.coder.toolbox.views.state.CoderCliSetupWizardState
import com.coder.toolbox.views.state.WizardStep
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
Expand Down Expand Up @@ -242,7 +242,7 @@ class CoderRemoteProvider(
environments.value = LoadableState.Value(emptyList())
isInitialized.update { false }
client = null
AuthWizardState.resetSteps()
CoderCliSetupWizardState.resetSteps()
}

override val svgIcon: SvgIcon =
Expand Down Expand Up @@ -301,7 +301,7 @@ class CoderRemoteProvider(
*/
override suspend fun handleUri(uri: URI) {
linkHandler.handle(
uri, shouldDoAutoLogin(),
uri, shouldDoAutoSetup(),
{
coderHeaderPage.isBusyCreatingNewEnvironment.update {
true
Expand Down Expand Up @@ -343,17 +343,17 @@ class CoderRemoteProvider(
* list.
*/
override fun getOverrideUiPage(): UiPage? {
// Show sign in page if we have not configured the client yet.
// Show the setup page if we have not configured the client yet.
if (client == null) {
val errorBuffer = mutableListOf<Throwable>()
// When coming back to the application, authenticate immediately.
val autologin = shouldDoAutoLogin()
// When coming back to the application, initializeSession immediately.
val autoSetup = shouldDoAutoSetup()
context.secrets.lastToken.let { lastToken ->
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
try {
AuthWizardState.goToStep(WizardStep.LOGIN)
return AuthWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
} catch (ex: Exception) {
errorBuffer.add(ex)
}
Expand All @@ -363,18 +363,19 @@ class CoderRemoteProvider(
firstRun = false

// Login flow.
val authWizard = AuthWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
val setupWizardPage =
CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect)
// We might have navigated here due to a polling error.
errorBuffer.forEach {
authWizard.notify("Error encountered", it)
setupWizardPage.notify("Error encountered", it)
}
// and now reset the errors, otherwise we show it every time on the screen
return authWizard
return setupWizardPage
}
return null
}

private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true
private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true

private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
// Store the URL and token for use next time.
Expand Down
61 changes: 48 additions & 13 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import java.net.HttpURLConnection
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.util.zip.GZIPInputStream
import javax.net.ssl.HttpsURLConnection

Expand All @@ -44,6 +44,8 @@ internal data class Version(
@Json(name = "version") val version: String,
)

private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..."

/**
* Do as much as possible to get a valid, up-to-date CLI.
*
Expand All @@ -60,6 +62,7 @@ fun ensureCLI(
context: CoderToolboxContext,
deploymentURL: URL,
buildVersion: String,
showTextProgress: (String) -> Unit
): CoderCLIManager {
val settings = context.settingsStore.readOnly()
val cli = CoderCLIManager(deploymentURL, context.logger, settings)
Expand All @@ -76,9 +79,10 @@ fun ensureCLI(

// If downloads are enabled download the new version.
if (settings.enableDownloads) {
context.logger.info("Downloading Coder CLI...")
context.logger.info(DOWNLOADING_CODER_CLI)
showTextProgress(DOWNLOADING_CODER_CLI)
try {
cli.download()
cli.download(buildVersion, showTextProgress)
return cli
} catch (e: java.nio.file.AccessDeniedException) {
// Might be able to fall back to the data directory.
Expand All @@ -98,8 +102,9 @@ fun ensureCLI(
}

if (settings.enableDownloads) {
context.logger.info("Downloading Coder CLI...")
dataCLI.download()
context.logger.info(DOWNLOADING_CODER_CLI)
showTextProgress(DOWNLOADING_CODER_CLI)
dataCLI.download(buildVersion, showTextProgress)
return dataCLI
}

Expand Down Expand Up @@ -137,7 +142,7 @@ class CoderCLIManager(
/**
* Download the CLI from the deployment if necessary.
*/
fun download(): Boolean {
fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean {
val eTag = getBinaryETag()
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
if (!settings.headerCommand.isNullOrBlank()) {
Expand All @@ -162,13 +167,27 @@ class CoderCLIManager(
when (conn.responseCode) {
HttpURLConnection.HTTP_OK -> {
logger.info("Downloading binary to $localBinaryPath")
Files.deleteIfExists(localBinaryPath)
Files.createDirectories(localBinaryPath.parent)
conn.inputStream.use {
Files.copy(
if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it,
localBinaryPath,
StandardCopyOption.REPLACE_EXISTING,
)
val outputStream = Files.newOutputStream(
localBinaryPath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
)
val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream

val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead: Int
var totalRead = 0L

sourceStream.use { source ->
outputStream.use { sink ->
while (source.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
totalRead += bytesRead
showTextProgress("${settings.defaultCliBinaryNameByOsAndArch} $buildVersion - ${totalRead.toHumanReadableSize()} downloaded")
}
}
}
if (getOS() != OS.WINDOWS) {
localBinaryPath.toFile().setExecutable(true)
Expand All @@ -178,6 +197,7 @@ class CoderCLIManager(

HttpURLConnection.HTTP_NOT_MODIFIED -> {
logger.info("Using cached binary at $localBinaryPath")
showTextProgress("Using cached binary")
return false
}
}
Expand All @@ -190,6 +210,21 @@ class CoderCLIManager(
throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode)
}

private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true)

fun Long.toHumanReadableSize(): String {
if (this < 1024) return "$this B"

val kb = this / 1024.0
if (kb < 1024) return String.format("%.1f KB", kb)

val mb = kb / 1024.0
if (mb < 1024) return String.format("%.1f MB", mb)

val gb = mb / 1024.0
return String.format("%.1f GB", gb)
}

/**
* Return the entity tag for the binary on disk, if any.
*/
Expand All @@ -203,7 +238,7 @@ class CoderCLIManager(
}

/**
* Use the provided token to authenticate the CLI.
* Use the provided token to initializeSession the CLI.
*/
fun login(token: String): String {
logger.info("Storing CLI credentials in $coderConfigPath")
Expand Down
12 changes: 8 additions & 4 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,11 @@ open class CoderRestClient(
}

/**
* Authenticate and load information about the current user and the build
* version.
* Load information about the current user and the build version.
*
* @throws [APIResponseException].
*/
suspend fun authenticate(): User {
suspend fun initializeSession(): User {
me = me()
buildVersion = buildInfo().version
return me
Expand All @@ -149,7 +148,12 @@ open class CoderRestClient(
suspend fun me(): User {
val userResponse = retroRestClient.me()
if (!userResponse.isSuccessful) {
throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi))
throw APIResponseException(
"initializeSession",
url,
userResponse.code(),
userResponse.parseErrorBody(moshi)
)
}

return userResponse.body()!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration

private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI"
private val noOpTextProgress: (String) -> Unit = { _ -> }

@Suppress("UnstableApiUsage")
open class CoderProtocolHandler(
Expand Down Expand Up @@ -143,7 +144,7 @@ open class CoderProtocolHandler(
if (settings.requireTokenAuth) token else null,
PluginManager.pluginInfo.version
)
client.authenticate()
client.initializeSession()
return client
}

Expand Down Expand Up @@ -304,7 +305,8 @@ open class CoderProtocolHandler(
val cli = ensureCLI(
context,
deploymentURL.toURL(),
restClient.buildInfo().version
restClient.buildInfo().version,
noOpTextProgress
)

// We only need to log in if we are using token-based auth.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.util.toURL
import com.coder.toolbox.views.state.AuthContext
import com.coder.toolbox.views.state.AuthWizardState
import com.coder.toolbox.views.state.CoderCliSetupContext
import com.coder.toolbox.views.state.CoderCliSetupWizardState
import com.coder.toolbox.views.state.WizardStep
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
Expand All @@ -16,26 +16,26 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID

class AuthWizardPage(
class CoderCliSetupWizardPage(
private val context: CoderToolboxContext,
private val settingsPage: CoderSettingsPage,
private val visibilityState: MutableStateFlow<ProviderVisibilityState>,
initialAutoLogin: Boolean = false,
initialAutoSetup: Boolean = false,
onConnect: suspend (
client: CoderRestClient,
cli: CoderCLIManager,
) -> Unit,
) : CoderPage(context.i18n.ptrl("Authenticate to Coder"), false) {
private val shouldAutoLogin = MutableStateFlow(initialAutoLogin)
) : CoderPage(context.i18n.ptrl("Setting up Coder"), false) {
private val shouldAutoSetup = MutableStateFlow(initialAutoSetup)
private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = {
context.ui.showUiPage(settingsPage)
})

private val signInStep = SignInStep(context, this::notify)
private val deploymentUrlStep = DeploymentUrlStep(context, this::notify)
private val tokenStep = TokenStep(context)
private val connectStep = ConnectStep(
context,
shouldAutoLogin,
shouldAutoSetup,
this::notify,
this::displaySteps,
onConnect
Expand All @@ -50,9 +50,9 @@ class AuthWizardPage(
private val errorBuffer = mutableListOf<Throwable>()

init {
if (shouldAutoLogin.value) {
AuthContext.url = context.secrets.lastDeploymentURL.toURL()
AuthContext.token = context.secrets.lastToken
if (shouldAutoSetup.value) {
CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL()
CoderCliSetupContext.token = context.secrets.lastToken
}
}

Expand All @@ -67,22 +67,22 @@ class AuthWizardPage(
}

private fun displaySteps() {
when (AuthWizardState.currentStep()) {
when (CoderCliSetupWizardState.currentStep()) {
WizardStep.URL_REQUEST -> {
fields.update {
listOf(signInStep.panel)
listOf(deploymentUrlStep.panel)
}
actionButtons.update {
listOf(
Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = {
if (signInStep.onNext()) {
Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = {
if (deploymentUrlStep.onNext()) {
displaySteps()
}
}),
settingsAction
)
}
signInStep.onVisible()
deploymentUrlStep.onVisible()
}

WizardStep.TOKEN_REQUEST -> {
Expand All @@ -106,7 +106,7 @@ class AuthWizardPage(
tokenStep.onVisible()
}

WizardStep.LOGIN -> {
WizardStep.CONNECT -> {
fields.update {
listOf(connectStep.panel)
}
Expand All @@ -115,7 +115,7 @@ class AuthWizardPage(
settingsAction,
Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = {
connectStep.onBack()
shouldAutoLogin.update {
shouldAutoSetup.update {
false
}
displaySteps()
Expand Down Expand Up @@ -150,7 +150,7 @@ class AuthWizardPage(
context.cs.launch {
context.ui.showSnackbar(
UUID.randomUUID().toString(),
context.i18n.ptrl("Error encountered during authentication"),
context.i18n.ptrl("Error encountered while setting up Coder"),
context.i18n.pnotr(textError ?: ""),
context.i18n.ptrl("Dismiss")
)
Expand Down
Loading
Loading