From aa930a4eb1f2db2e25308d37da0edd29fe645abf Mon Sep 17 00:00:00 2001
From: Faur Ioan-Aurel <fioan89@gmail.com>
Date: Sat, 29 Mar 2025 00:52:14 +0200
Subject: [PATCH 1/2] fix: read only settings acted as snapshots

- they did not reflect any subsequent update because updates create a new backing readonly instance
- with this patch we simplified the code even more by exposing a readonly interface with all of the
  implementation in the CoderSettingsStore. PluginSettingsStore and Environments are the only persist-able
  stores.
- because read only instances share the same settings store instances any update on the writable will reflect
  on the readable instances as well.
---
 .../com/coder/toolbox/cli/CoderCLIManager.kt  |  10 +-
 .../coder/toolbox/settings/CoderSettings.kt   | 244 ------------------
 .../toolbox/settings/ReadOnlyCoderSettings.kt | 174 +++++++++++++
 .../coder/toolbox/store/CoderSettingsStore.kt | 201 +++++++++++----
 src/main/kotlin/com/coder/toolbox/util/TLS.kt |   4 +-
 .../coder/toolbox/views/EnvironmentView.kt    |   4 +-
 6 files changed, 338 insertions(+), 299 deletions(-)
 delete mode 100644 src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt
 create mode 100644 src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt

diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
index d112c18..d97caf8 100644
--- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
+++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
@@ -6,7 +6,7 @@ import com.coder.toolbox.cli.ex.ResponseException
 import com.coder.toolbox.cli.ex.SSHConfigFormatException
 import com.coder.toolbox.sdk.v2.models.Workspace
 import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
-import com.coder.toolbox.settings.CoderSettings
+import com.coder.toolbox.settings.ReadOnlyCoderSettings
 import com.coder.toolbox.util.CoderHostnameVerifier
 import com.coder.toolbox.util.InvalidVersionException
 import com.coder.toolbox.util.OS
@@ -125,7 +125,7 @@ class CoderCLIManager(
     private val deploymentURL: URL,
     private val logger: Logger,
     // Plugin configuration.
-    private val settings: CoderSettings,
+    private val settings: ReadOnlyCoderSettings,
     // If the binary directory is not writable, this can be used to force the
     // manager to download to the data directory instead.
     forceDownloadToData: Boolean = false,
@@ -267,21 +267,21 @@ class CoderCLIManager(
                 "--url",
                 escape(deploymentURL.toString()),
                 if (!settings.headerCommand.isNullOrBlank()) "--header-command" else null,
-                if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand) else null,
+                if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand!!) else null,
                 "ssh",
                 "--stdio",
                 if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null,
             )
         val proxyArgs = baseArgs + listOfNotNull(
             if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null,
-            if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory) else null,
+            if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null,
             if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null,
         )
         val backgroundProxyArgs =
             baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null)
         val extraConfig =
             if (!settings.sshConfigOptions.isNullOrBlank()) {
-                "\n" + settings.sshConfigOptions.prependIndent("  ")
+                "\n" + settings.sshConfigOptions!!.prependIndent("  ")
             } else {
                 ""
             }
diff --git a/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt
deleted file mode 100644
index 867159c..0000000
--- a/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt
+++ /dev/null
@@ -1,244 +0,0 @@
-package com.coder.toolbox.settings
-
-import com.coder.toolbox.util.expand
-import com.coder.toolbox.util.safeHost
-import com.coder.toolbox.util.toURL
-import com.coder.toolbox.util.withPath
-import java.net.URL
-import java.nio.file.Files
-import java.nio.file.Path
-
-data class CoderSettings(
-    val defaultURL: String?,
-
-    /**
-     * Used to download the Coder CLI which is necessary to proxy SSH
-     * connections.  The If-None-Match header will be set to the SHA1 of the CLI
-     * and can be used for caching.  Absolute URLs will be used as-is; otherwise
-     * this value will be resolved against the deployment domain.  Defaults to
-     * the plugin's data directory.
-     */
-    val binarySource: String?,
-
-    /**
-     * Directories are created here that store the CLI for each domain to which
-     * the plugin connects.   Defaults to the data directory.
-     */
-    val binaryDirectory: String?,
-
-    val defaultCliBinaryNameByOsAndArch: String,
-
-    /**
-     * Configurable CLI binary name with extension, dependent on OS and arch
-     */
-    val binaryName: String,
-
-    /**
-     * Where to save plugin data like the Coder binary (if not configured with
-     * binaryDirectory) and the deployment URL and session token.
-     */
-    val dataDirectory: String?,
-
-    /**
-     * Coder plugin's global data directory.
-     */
-    val globalDataDirectory: String,
-
-    /**
-     * Coder plugin's global config dir
-     */
-    val globalConfigDir: String,
-
-    /**
-     * Whether to allow the plugin to download the CLI if the current one is out
-     * of date or does not exist.
-     */
-    val enableDownloads: Boolean,
-
-    /**
-     * Whether to allow the plugin to fall back to the data directory when the
-     * CLI directory is not writable.
-     */
-    val enableBinaryDirectoryFallback: Boolean,
-
-    /**
-     * An external command that outputs additional HTTP headers added to all
-     * requests. The command must output each header as `key=value` on its own
-     * line. The following environment variables will be available to the
-     * process: CODER_URL.
-     */
-    val headerCommand: String?,
-
-    /**
-     * Optional TLS settings
-     */
-    val tls: CTLSSettings,
-
-    /**
-     * Whether login should be done with a token
-     */
-    val requireTokenAuth: Boolean = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank(),
-
-    /**
-     * Whether to add --disable-autostart to the proxy command.  This works
-     * around issues on macOS where it periodically wakes and Gateway
-     * reconnects, keeping the workspace constantly up.
-     */
-    val disableAutostart: Boolean,
-
-    val isSshWildcardConfigEnabled: Boolean,
-
-    /**
-     * The location of the SSH config.  Defaults to ~/.ssh/config.
-     */
-    val sshConfigPath: String,
-
-    /**
-     * Value for --log-dir.
-     */
-    val sshLogDirectory: String?,
-
-    /**
-     * Extra SSH config options
-     */
-    val sshConfigOptions: String?,
-) {
-
-    /**
-     * Given a deployment URL, try to find a token for it if required.
-     */
-    fun token(deploymentURL: URL): Pair<String, SettingSource>? {
-        // No need to bother if we do not need token auth anyway.
-        if (!requireTokenAuth) {
-            return null
-        }
-        // Try the deployment's config directory.  This could exist if someone
-        // has entered a URL that they are not currently connected to, but have
-        // connected to in the past.
-        val (_, deploymentToken) = readConfig(dataDir(deploymentURL).resolve("config"))
-        if (!deploymentToken.isNullOrBlank()) {
-            return deploymentToken to SettingSource.DEPLOYMENT_CONFIG
-        }
-        // Try the global config directory, in case they previously set up the
-        // CLI with this URL.
-        val (configUrl, configToken) = readConfig(Path.of(globalConfigDir))
-        if (configUrl == deploymentURL.toString() && !configToken.isNullOrBlank()) {
-            return configToken to SettingSource.CONFIG
-        }
-        return null
-    }
-
-    /**
-     * Where the specified deployment should put its data.
-     */
-    fun dataDir(url: URL): Path {
-        dataDirectory.let {
-            val dir =
-                if (it.isNullOrBlank()) {
-                    Path.of(globalDataDirectory)
-                } else {
-                    Path.of(expand(it))
-                }
-            return withHost(dir, url).toAbsolutePath()
-        }
-    }
-
-    /**
-     * From where the specified deployment should download the binary.
-     */
-    fun binSource(url: URL): URL {
-        binarySource.let {
-            return if (it.isNullOrBlank()) {
-                url.withPath("/bin/$defaultCliBinaryNameByOsAndArch")
-            } else {
-                try {
-                    it.toURL()
-                } catch (_: Exception) {
-                    url.withPath(it) // Assume a relative path.
-                }
-            }
-        }
-    }
-
-    /**
-     * To where the specified deployment should download the binary.
-     */
-    fun binPath(
-        url: URL,
-        forceDownloadToData: Boolean = false,
-    ): Path {
-        binaryDirectory.let {
-            val dir =
-                if (forceDownloadToData || it.isNullOrBlank()) {
-                    dataDir(url)
-                } else {
-                    withHost(Path.of(expand(it)), url)
-                }
-            return dir.resolve(binaryName).toAbsolutePath()
-        }
-    }
-
-    /**
-     * Return the URL and token from the config, if they exist.
-     */
-    fun readConfig(dir: Path): Pair<String?, String?> {
-//        logger.info("Reading config from $dir")
-        return try {
-            Files.readString(dir.resolve("url"))
-        } catch (e: Exception) {
-            // SSH has not been configured yet, or using some other authorization mechanism.
-            null
-        } to
-                try {
-                    Files.readString(dir.resolve("session"))
-                } catch (e: Exception) {
-                    // SSH has not been configured yet, or using some other authorization mechanism.
-                    null
-                }
-    }
-
-    /**
-     * Append the host to the path.  For example, foo/bar could become
-     * foo/bar/dev.coder.com-8080.
-     */
-    private fun withHost(
-        path: Path,
-        url: URL,
-    ): Path {
-        val host = if (url.port > 0) "${url.safeHost()}-${url.port}" else url.safeHost()
-        return path.resolve(host)
-    }
-
-}
-
-/**
- * Consolidated TLS settings.
- */
-data class CTLSSettings(
-    /**
-     * Optionally set this to the path of a certificate to use for TLS
-     * connections. The certificate should be in X.509 PEM format.
-     */
-    val certPath: String?,
-
-    /**
-     * Optionally set this to the path of the private key that corresponds to
-     * the above cert path to use for TLS connections. The key should be in
-     * X.509 PEM format.
-     */
-    val keyPath: String?,
-
-    /**
-     * Optionally set this to the path of a file containing certificates for an
-     * alternate certificate authority used to verify TLS certs returned by the
-     * Coder service. The file should be in X.509 PEM format.
-     */
-    val caPath: String?,
-
-    /**
-     * Optionally set this to an alternate hostname used for verifying TLS
-     * connections. This is useful when the hostname used to connect to the
-     * Coder service does not match the hostname in the TLS certificate.
-     */
-    val altHostname: String?,
-)
\ No newline at end of file
diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt
new file mode 100644
index 0000000..25568d3
--- /dev/null
+++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt
@@ -0,0 +1,174 @@
+package com.coder.toolbox.settings
+
+import java.net.URL
+import java.nio.file.Path
+
+/**
+ * Read-only interface for accessing Coder settings
+ */
+interface ReadOnlyCoderSettings {
+    /**
+     * The default URL to show in the connection window.
+     */
+    val defaultURL: String?
+
+    /**
+     * Used to download the Coder CLI which is necessary to proxy SSH
+     * connections.  The If-None-Match header will be set to the SHA1 of the CLI
+     * and can be used for caching.  Absolute URLs will be used as-is; otherwise
+     * this value will be resolved against the deployment domain.  Defaults to
+     * the plugin's data directory.
+     */
+    val binarySource: String?
+
+    /**
+     * Directories are created here that store the CLI for each domain to which
+     * the plugin connects.   Defaults to the data directory.
+     */
+    val binaryDirectory: String?
+
+    /**
+     * Default CLI binary name based on OS and architecture
+     */
+    val defaultCliBinaryNameByOsAndArch: String
+
+    /**
+     * Configurable CLI binary name with extension, dependent on OS and arch
+     */
+    val binaryName: String
+
+    /**
+     * Where to save plugin data like the Coder binary (if not configured with
+     * binaryDirectory) and the deployment URL and session token.
+     */
+    val dataDirectory: String?
+
+    /**
+     * Coder plugin's global data directory.
+     */
+    val globalDataDirectory: String
+
+    /**
+     * Coder plugin's global config dir
+     */
+    val globalConfigDir: String
+
+    /**
+     * Whether to allow the plugin to download the CLI if the current one is out
+     * of date or does not exist.
+     */
+    val enableDownloads: Boolean
+
+    /**
+     * Whether to allow the plugin to fall back to the data directory when the
+     * CLI directory is not writable.
+     */
+    val enableBinaryDirectoryFallback: Boolean
+
+    /**
+     * An external command that outputs additional HTTP headers added to all
+     * requests. The command must output each header as `key=value` on its own
+     * line. The following environment variables will be available to the
+     * process: CODER_URL.
+     */
+    val headerCommand: String?
+
+    /**
+     * Optional TLS settings
+     */
+    val tls: ReadOnlyTLSSettings
+
+    /**
+     * Whether login should be done with a token
+     */
+    val requireTokenAuth: Boolean
+
+    /**
+     * Whether to add --disable-autostart to the proxy command.  This works
+     * around issues on macOS where it periodically wakes and Gateway
+     * reconnects, keeping the workspace constantly up.
+     */
+    val disableAutostart: Boolean
+
+    /**
+     * Whether SSH wildcard config is enabled
+     */
+    val isSshWildcardConfigEnabled: Boolean
+
+    /**
+     * The location of the SSH config.  Defaults to ~/.ssh/config.
+     */
+    val sshConfigPath: String
+
+    /**
+     * Value for --log-dir.
+     */
+    val sshLogDirectory: String?
+
+    /**
+     * Extra SSH config options
+     */
+    val sshConfigOptions: String?
+
+    /**
+     * The default URL to show in the connection window.
+     */
+    fun defaultURL(): Pair<String, SettingSource>?
+
+    /**
+     * Given a deployment URL, try to find a token for it if required.
+     */
+    fun token(deploymentURL: URL): Pair<String, SettingSource>?
+
+    /**
+     * Where the specified deployment should put its data.
+     */
+    fun dataDir(url: URL): Path
+
+    /**
+     * From where the specified deployment should download the binary.
+     */
+    fun binSource(url: URL): URL
+
+    /**
+     * To where the specified deployment should download the binary.
+     */
+    fun binPath(url: URL, forceDownloadToData: Boolean = false): Path
+
+    /**
+     * Return the URL and token from the config, if they exist.
+     */
+    fun readConfig(dir: Path): Pair<String?, String?>
+}
+
+/**
+ * Read-only interface for TLS settings
+ */
+interface ReadOnlyTLSSettings {
+    /**
+     * Optionally set this to the path of a certificate to use for TLS
+     * connections. The certificate should be in X.509 PEM format.
+     */
+    val certPath: String?
+
+    /**
+     * Optionally set this to the path of the private key that corresponds to
+     * the above cert path to use for TLS connections. The key should be in
+     * X.509 PEM format.
+     */
+    val keyPath: String?
+
+    /**
+     * Optionally set this to the path of a file containing certificates for an
+     * alternate certificate authority used to verify TLS certs returned by the
+     * Coder service. The file should be in X.509 PEM format.
+     */
+    val caPath: String?
+
+    /**
+     * Optionally set this to an alternate hostname used for verifying TLS
+     * connections. This is useful when the hostname used to connect to the
+     * Coder service does not match the hostname in the TLS certificate.
+     */
+    val altHostname: String?
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
index e5b96a1..92c08d0 100644
--- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
+++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
@@ -1,62 +1,82 @@
 package com.coder.toolbox.store
 
-import com.coder.toolbox.settings.CTLSSettings
-import com.coder.toolbox.settings.CoderSettings
 import com.coder.toolbox.settings.Environment
+import com.coder.toolbox.settings.ReadOnlyCoderSettings
+import com.coder.toolbox.settings.ReadOnlyTLSSettings
 import com.coder.toolbox.settings.SettingSource
 import com.coder.toolbox.util.Arch
 import com.coder.toolbox.util.OS
+import com.coder.toolbox.util.expand
 import com.coder.toolbox.util.getArch
 import com.coder.toolbox.util.getOS
+import com.coder.toolbox.util.safeHost
+import com.coder.toolbox.util.toURL
+import com.coder.toolbox.util.withPath
 import com.jetbrains.toolbox.api.core.PluginSettingsStore
 import com.jetbrains.toolbox.api.core.diagnostics.Logger
+import java.net.URL
+import java.nio.file.Files
 import java.nio.file.Path
 import java.nio.file.Paths
 
+
 class CoderSettingsStore(
     private val store: PluginSettingsStore,
     private val env: Environment = Environment(),
     private val logger: Logger
-) {
-    private var backingSettings = CoderSettings(
-        defaultURL = store[DEFAULT_URL],
-        binarySource = store[BINARY_SOURCE],
-        binaryDirectory = store[BINARY_DIRECTORY],
-        defaultCliBinaryNameByOsAndArch = getCoderCLIForOS(getOS(), getArch()),
-        binaryName = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()),
-        dataDirectory = store[DATA_DIRECTORY],
-        globalDataDirectory = getDefaultGlobalDataDir().normalize().toString(),
-        globalConfigDir = getDefaultGlobalConfigDir().normalize().toString(),
-        enableDownloads = store[ENABLE_DOWNLOADS]?.toBooleanStrictOrNull() ?: true,
-        enableBinaryDirectoryFallback = store[ENABLE_BINARY_DIR_FALLBACK]?.toBooleanStrictOrNull() ?: false,
-        headerCommand = store[HEADER_COMMAND],
-        tls = CTLSSettings(
+) : ReadOnlyCoderSettings {
+
+    // Internal TLS settings implementation
+    private class TLSSettings(
+        override val certPath: String?,
+        override val keyPath: String?,
+        override val caPath: String?,
+        override val altHostname: String?
+    ) : ReadOnlyTLSSettings
+
+    // Properties implementation
+    override val defaultURL: String? get() = store[DEFAULT_URL]
+    override val binarySource: String? get() = store[BINARY_SOURCE]
+    override val binaryDirectory: String? get() = store[BINARY_DIRECTORY]
+    override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch())
+    override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch())
+    override val dataDirectory: String? get() = store[DATA_DIRECTORY]
+    override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString()
+    override val globalConfigDir: String get() = getDefaultGlobalConfigDir().normalize().toString()
+    override val enableDownloads: Boolean get() = store[ENABLE_DOWNLOADS]?.toBooleanStrictOrNull() ?: true
+    override val enableBinaryDirectoryFallback: Boolean
+        get() = store[ENABLE_BINARY_DIR_FALLBACK]?.toBooleanStrictOrNull() ?: false
+    override val headerCommand: String? get() = store[HEADER_COMMAND]
+    override val tls: ReadOnlyTLSSettings
+        get() = TLSSettings(
             certPath = store[TLS_CERT_PATH],
             keyPath = store[TLS_KEY_PATH],
             caPath = store[TLS_CA_PATH],
             altHostname = store[TLS_ALTERNATE_HOSTNAME]
-        ),
-        disableAutostart = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC),
-        isSshWildcardConfigEnabled = store[ENABLE_SSH_WILDCARD_CONFIG]?.toBooleanStrictOrNull() ?: true,
-        sshConfigPath = store[SSH_CONFIG_PATH].takeUnless { it.isNullOrEmpty() }
-            ?: Path.of(System.getProperty("user.home")).resolve(".ssh/config").normalize().toString(),
-        sshLogDirectory = store[SSH_LOG_DIR],
-        sshConfigOptions = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get(
-            CODER_SSH_CONFIG_OPTIONS
         )
-    )
+    override val requireTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank()
+    override val disableAutostart: Boolean
+        get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC)
+    override val isSshWildcardConfigEnabled: Boolean
+        get() = store[ENABLE_SSH_WILDCARD_CONFIG]?.toBooleanStrictOrNull() ?: true
+    override val sshConfigPath: String
+        get() = store[SSH_CONFIG_PATH].takeUnless { it.isNullOrEmpty() }
+            ?: Path.of(System.getProperty("user.home")).resolve(".ssh/config").normalize().toString()
+    override val sshLogDirectory: String? get() = store[SSH_LOG_DIR]
+    override val sshConfigOptions: String?
+        get() = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get(CODER_SSH_CONFIG_OPTIONS)
 
     /**
      * The default URL to show in the connection window.
      */
-    fun defaultURL(): Pair<String, SettingSource>? {
+    override fun defaultURL(): Pair<String, SettingSource>? {
         val envURL = env.get(CODER_URL)
-        if (!backingSettings.defaultURL.isNullOrEmpty()) {
-            return backingSettings.defaultURL!! to SettingSource.SETTINGS
+        if (!defaultURL.isNullOrEmpty()) {
+            return defaultURL!! to SettingSource.SETTINGS
         } else if (envURL.isNotBlank()) {
             return envURL to SettingSource.ENVIRONMENT
         } else {
-            val (configUrl, _) = backingSettings.readConfig(Path.of(backingSettings.globalConfigDir))
+            val (configUrl, _) = readConfig(Path.of(globalConfigDir))
             if (!configUrl.isNullOrBlank()) {
                 return configUrl to SettingSource.CONFIG
             }
@@ -65,77 +85,154 @@ class CoderSettingsStore(
     }
 
     /**
-     * Read-only access to the settings
+     * Given a deployment URL, try to find a token for it if required.
      */
-    fun readOnly(): CoderSettings = backingSettings
+    override fun token(deploymentURL: URL): Pair<String, SettingSource>? {
+        // No need to bother if we do not need token auth anyway.
+        if (!requireTokenAuth) {
+            return null
+        }
+        // Try the deployment's config directory.  This could exist if someone
+        // has entered a URL that they are not currently connected to, but have
+        // connected to in the past.
+        val (_, deploymentToken) = readConfig(dataDir(deploymentURL).resolve("config"))
+        if (!deploymentToken.isNullOrBlank()) {
+            return deploymentToken to SettingSource.DEPLOYMENT_CONFIG
+        }
+        // Try the global config directory, in case they previously set up the
+        // CLI with this URL.
+        val (configUrl, configToken) = readConfig(Path.of(globalConfigDir))
+        if (configUrl == deploymentURL.toString() && !configToken.isNullOrBlank()) {
+            return configToken to SettingSource.CONFIG
+        }
+        return null
+    }
 
+    /**
+     * Where the specified deployment should put its data.
+     */
+    override fun dataDir(url: URL): Path {
+        dataDirectory.let {
+            val dir =
+                if (it.isNullOrBlank()) {
+                    Path.of(globalDataDirectory)
+                } else {
+                    Path.of(expand(it))
+                }
+            return withHost(dir, url).toAbsolutePath()
+        }
+    }
+
+    /**
+     * From where the specified deployment should download the binary.
+     */
+    override fun binSource(url: URL): URL {
+        binarySource.let {
+            return if (it.isNullOrBlank()) {
+                url.withPath("/bin/$defaultCliBinaryNameByOsAndArch")
+            } else {
+                try {
+                    it.toURL()
+                } catch (_: Exception) {
+                    url.withPath(it) // Assume a relative path.
+                }
+            }
+        }
+    }
+
+    /**
+     * To where the specified deployment should download the binary.
+     */
+    override fun binPath(
+        url: URL,
+        forceDownloadToData: Boolean,
+    ): Path {
+        binaryDirectory.let {
+            val dir =
+                if (forceDownloadToData || it.isNullOrBlank()) {
+                    dataDir(url)
+                } else {
+                    withHost(Path.of(expand(it)), url)
+                }
+            return dir.resolve(binaryName).toAbsolutePath()
+        }
+    }
+
+    /**
+     * Return the URL and token from the config, if they exist.
+     */
+    override fun readConfig(dir: Path): Pair<String?, String?> {
+        return try {
+            Files.readString(dir.resolve("url"))
+        } catch (e: Exception) {
+            // SSH has not been configured yet, or using some other authorization mechanism.
+            null
+        } to
+                try {
+                    Files.readString(dir.resolve("session"))
+                } catch (e: Exception) {
+                    // SSH has not been configured yet, or using some other authorization mechanism.
+                    null
+                }
+    }
+
+    // a readonly cast
+    fun readOnly(): ReadOnlyCoderSettings = this
+
+    // Write operations
     fun updateBinarySource(source: String) {
-        backingSettings = backingSettings.copy(binarySource = source)
         store[BINARY_SOURCE] = source
     }
 
     fun updateBinaryDirectory(dir: String) {
-        backingSettings = backingSettings.copy(binaryDirectory = dir)
         store[BINARY_DIRECTORY] = dir
     }
 
     fun updateDataDirectory(dir: String) {
-        backingSettings = backingSettings.copy(dataDirectory = dir)
         store[DATA_DIRECTORY] = dir
     }
 
     fun updateEnableDownloads(shouldEnableDownloads: Boolean) {
-        backingSettings = backingSettings.copy(enableDownloads = shouldEnableDownloads)
         store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString()
     }
 
     fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) {
-        backingSettings = backingSettings.copy(enableBinaryDirectoryFallback = shouldEnableBinDirFallback)
         store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString()
     }
 
     fun updateHeaderCommand(cmd: String) {
-        backingSettings = backingSettings.copy(headerCommand = cmd)
         store[HEADER_COMMAND] = cmd
     }
 
     fun updateCertPath(path: String) {
-        backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(certPath = path))
         store[TLS_CERT_PATH] = path
     }
 
     fun updateKeyPath(path: String) {
-        backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(keyPath = path))
         store[TLS_KEY_PATH] = path
     }
 
     fun updateCAPath(path: String) {
-        backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(caPath = path))
         store[TLS_CA_PATH] = path
     }
 
     fun updateAltHostname(hostname: String) {
-        backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(altHostname = hostname))
         store[TLS_ALTERNATE_HOSTNAME] = hostname
     }
 
     fun updateDisableAutostart(shouldDisableAutostart: Boolean) {
-        backingSettings = backingSettings.copy(disableAutostart = shouldDisableAutostart)
         store[DISABLE_AUTOSTART] = shouldDisableAutostart.toString()
     }
 
     fun updateEnableSshWildcardConfig(enable: Boolean) {
-        backingSettings = backingSettings.copy(isSshWildcardConfigEnabled = enable)
         store[ENABLE_SSH_WILDCARD_CONFIG] = enable.toString()
     }
 
     fun updateSshLogDir(path: String) {
-        backingSettings = backingSettings.copy(sshLogDirectory = path)
         store[SSH_LOG_DIR] = path
     }
 
     fun updateSshConfigOptions(options: String) {
-        backingSettings = backingSettings.copy(sshConfigOptions = options)
         store[SSH_CONFIG_OPTIONS] = options
     }
 
@@ -210,4 +307,16 @@ class CoderSettingsStore(
                 }
         }
     }
-}
+
+    /**
+     * Append the host to the path.  For example, foo/bar could become
+     * foo/bar/dev.coder.com-8080.
+     */
+    private fun withHost(
+        path: Path,
+        url: URL,
+    ): Path {
+        val host = if (url.port > 0) "${url.safeHost()}-${url.port}" else url.safeHost()
+        return path.resolve(host)
+    }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt
index 17952df..dac816e 100644
--- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt
+++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt
@@ -1,6 +1,6 @@
 package com.coder.toolbox.util
 
-import com.coder.toolbox.settings.CTLSSettings
+import com.coder.toolbox.settings.ReadOnlyTLSSettings
 import okhttp3.internal.tls.OkHostnameVerifier
 import java.io.File
 import java.io.FileInputStream
@@ -81,7 +81,7 @@ fun sslContextFromPEMs(
     return sslContext
 }
 
-fun coderSocketFactory(settings: CTLSSettings): SSLSocketFactory {
+fun coderSocketFactory(settings: ReadOnlyTLSSettings): SSLSocketFactory {
     val sslContext = sslContextFromPEMs(settings.certPath, settings.keyPath, settings.caPath)
     if (settings.altHostname.isNullOrBlank()) {
         return sslContext.socketFactory
diff --git a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt
index 4f41eee..89ef3dd 100644
--- a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt
+++ b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt
@@ -3,7 +3,7 @@ package com.coder.toolbox.views
 import com.coder.toolbox.cli.CoderCLIManager
 import com.coder.toolbox.sdk.v2.models.Workspace
 import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
-import com.coder.toolbox.settings.CoderSettings
+import com.coder.toolbox.settings.ReadOnlyCoderSettings
 import com.jetbrains.toolbox.api.remoteDev.environments.SshEnvironmentContentsView
 import com.jetbrains.toolbox.api.remoteDev.ssh.SshConnectionInfo
 import java.net.URL
@@ -17,7 +17,7 @@ import java.net.URL
  * SSH must be configured before this will work.
  */
 class EnvironmentView(
-    private val settings: CoderSettings,
+    private val settings: ReadOnlyCoderSettings,
     private val url: URL,
     private val workspace: Workspace,
     private val agent: WorkspaceAgent,

From ae41bda13a5b89c175bcde2a2e06fdb9d079d984 Mon Sep 17 00:00:00 2001
From: Faur Ioan-Aurel <fioan89@gmail.com>
Date: Sat, 29 Mar 2025 01:05:47 +0200
Subject: [PATCH 2/2] impl: reconfigure the ssh config when user modifies the
 wildcard settings

- right now the ssh config is triggered only when at the login and only when new workspaces are
  created. In the last case, the ssh config updates only the new sections.
- but the user can go into settings page and enable or disable the wildcard ssh config. With this patch
  after the user hits Save, the ssh re-configuration is triggered, without the need to restart Toolbox.
---
 .../coder/toolbox/CoderRemoteEnvironment.kt   |  4 ++
 .../com/coder/toolbox/CoderRemoteProvider.kt  | 43 +++++++++++++------
 .../coder/toolbox/views/CoderSettingsPage.kt  | 17 +++++++-
 3 files changed, 49 insertions(+), 15 deletions(-)

diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
index 8f265fe..62fe4bb 100644
--- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
+++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
@@ -204,4 +204,8 @@ class CoderRemoteEnvironment(
      * Companion to equals, for sets.
      */
     override fun hashCode(): Int = id.hashCode()
+
+    override fun toString(): String {
+        return "CoderRemoteEnvironment(name='$name')"
+    }
 }
diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
index fd0ad57..c9acc57 100644
--- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
+++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
@@ -20,13 +20,16 @@ import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
 import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment
 import com.jetbrains.toolbox.api.ui.actions.ActionDescription
 import com.jetbrains.toolbox.api.ui.components.UiPage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
+import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.selects.onTimeout
+import kotlinx.coroutines.selects.select
 import okhttp3.OkHttpClient
 import java.net.URI
 import java.net.URL
@@ -35,18 +38,20 @@ import kotlin.time.Duration.Companion.seconds
 import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownMenu
 import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory
 
+@OptIn(ExperimentalCoroutinesApi::class)
 class CoderRemoteProvider(
     private val context: CoderToolboxContext,
     private val httpClient: OkHttpClient,
 ) : RemoteProvider("Coder") {
     // Current polling job.
     private var pollJob: Job? = null
-    private var lastEnvironments: Set<CoderRemoteEnvironment>? = null
+    private val lastEnvironments = mutableSetOf<CoderRemoteEnvironment>()
 
-    private val cSettings = context.settingsStore.readOnly()
+    private val settings = context.settingsStore.readOnly()
 
     // Create our services from the Toolbox ones.
-    private val settingsPage: CoderSettingsPage = CoderSettingsPage(context)
+    private val triggerSshConfig = Channel<Boolean>(Channel.CONFLATED)
+    private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig)
     private val dialogUi = DialogUi(context)
 
     // The REST client, if we are signed in
@@ -92,7 +97,7 @@ class CoderRemoteProvider(
                         }?.map { agent ->
                             // If we have an environment already, update that.
                             val env = CoderRemoteEnvironment(context, client, ws, agent)
-                            lastEnvironments?.firstOrNull { it == env }?.let {
+                            lastEnvironments.firstOrNull { it == env }?.let {
                                 it.update(ws, agent)
                                 it
                             } ?: env
@@ -107,9 +112,7 @@ class CoderRemoteProvider(
 
                 // Reconfigure if a new environment is found.
                 // TODO@JB: Should we use the add/remove listeners instead?
-                val newEnvironments = lastEnvironments
-                    ?.let { resolvedEnvironments.subtract(it) }
-                    ?: resolvedEnvironments
+                val newEnvironments = resolvedEnvironments.subtract(lastEnvironments)
                 if (newEnvironments.isNotEmpty()) {
                     context.logger.info("Found new environment(s), reconfiguring CLI: $newEnvironments")
                     cli.configSsh(newEnvironments.map { it.name }.toSet())
@@ -124,8 +127,10 @@ class CoderRemoteProvider(
                         true
                     }
                 }
-
-                lastEnvironments = resolvedEnvironments
+                lastEnvironments.apply {
+                    clear()
+                    addAll(resolvedEnvironments)
+                }
             } catch (_: CancellationException) {
                 context.logger.debug("${client.url} polling loop canceled")
                 break
@@ -136,7 +141,17 @@ class CoderRemoteProvider(
                 break
             }
             // TODO: Listening on a web socket might be better?
-            delay(5.seconds)
+            select<Unit> {
+                onTimeout(5.seconds) {
+                    context.logger.trace("workspace poller waked up by the 5 seconds timeout")
+                }
+                triggerSshConfig.onReceive { shouldTrigger ->
+                    if (shouldTrigger) {
+                        context.logger.trace("workspace poller waked up because it should reconfigure the ssh configurations")
+                        cli.configSsh(lastEnvironments.map { it.name }.toSet())
+                    }
+                }
+            }
         }
     }
 
@@ -178,7 +193,7 @@ class CoderRemoteProvider(
     override fun close() {
         pollJob?.cancel()
         client?.close()
-        lastEnvironments = null
+        lastEnvironments.clear()
         environments.value = LoadableState.Value(emptyList())
         isInitialized.update { false }
     }
@@ -270,7 +285,7 @@ class CoderRemoteProvider(
             var autologinEx: Exception? = null
             context.secrets.lastToken.let { lastToken ->
                 context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
-                    if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !cSettings.requireTokenAuth)) {
+                    if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
                         try {
                             return createConnectPage(URL(lastDeploymentURL), lastToken)
                         } catch (ex: Exception) {
@@ -342,7 +357,7 @@ class CoderRemoteProvider(
         if (it.isNotBlank() && context.secrets.lastDeploymentURL == deploymentURL.toString()) {
             it to SettingSource.LAST_USED
         } else {
-            cSettings.token(deploymentURL)
+            settings.token(deploymentURL)
         }
     }
 
diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
index d4ac2c8..ff86c42 100644
--- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
+++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
@@ -6,8 +6,11 @@ import com.jetbrains.toolbox.api.ui.components.CheckboxField
 import com.jetbrains.toolbox.api.ui.components.TextField
 import com.jetbrains.toolbox.api.ui.components.TextType
 import com.jetbrains.toolbox.api.ui.components.UiField
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedSendChannelException
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
 
 /**
  * A page for modifying Coder settings.
@@ -16,7 +19,8 @@ import kotlinx.coroutines.flow.StateFlow
  * TODO@JB: There is no scroll, and our settings do not fit.  As a consequence,
  *          I have not been able to test this page.
  */
-class CoderSettingsPage(context: CoderToolboxContext) : CoderPage(context, context.i18n.ptrl("Coder Settings"), false) {
+class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<Boolean>) :
+    CoderPage(context, context.i18n.ptrl("Coder Settings"), false) {
     private val settings = context.settingsStore.readOnly()
 
     // TODO: Copy over the descriptions, holding until I can test this page.
@@ -87,7 +91,18 @@ class CoderSettingsPage(context: CoderToolboxContext) : CoderPage(context, conte
                 context.settingsStore.updateCAPath(tlsCAPathField.textState.value)
                 context.settingsStore.updateAltHostname(tlsAlternateHostnameField.textState.value)
                 context.settingsStore.updateDisableAutostart(disableAutostartField.checkedState.value)
+                val oldIsSshWildcardConfigEnabled = settings.isSshWildcardConfigEnabled
                 context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value)
+
+                if (enableSshWildCardConfig.checkedState.value != oldIsSshWildcardConfigEnabled) {
+                    context.cs.launch {
+                        try {
+                            triggerSshConfig.send(true)
+                            context.logger.info("Wildcard settings have been modified from $oldIsSshWildcardConfigEnabled to ${!oldIsSshWildcardConfigEnabled}, ssh config is going to be regenerated...")
+                        } catch (_: ClosedSendChannelException) {
+                        }
+                    }
+                }
                 context.settingsStore.updateSshLogDir(sshLogDirField.textState.value)
                 context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value)
             }