Refactor multiple modules for improved flexibility

- Transition modules to use slug and agent_name variables for custom configurations.
- Update Terraform resources to dynamically generate URLs and paths.
- Enhance form handling logic in Devolutions patch script.
atif/qol-improvments
Muhammad Atif Ali 8 months ago
parent ce5a5b383a
commit 0b2bc1de9e

@ -14,7 +14,7 @@ A file browser for your workspace.
```tf ```tf
module "filebrowser" { module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder" source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
@ -28,7 +28,7 @@ module "filebrowser" {
```tf ```tf
module "filebrowser" { module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder" source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
folder = "/home/coder/project" folder = "/home/coder/project"
} }
@ -39,17 +39,29 @@ module "filebrowser" {
```tf ```tf
module "filebrowser" { module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder" source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
database_path = ".config/filebrowser.db" database_path = ".config/filebrowser.db"
} }
``` ```
### Serve from the same domain (no subdomain) ### Serve on a subpath (no wildcard subdomain)
```tf
module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
subdomain = false
}
```
### Serve on a subpath with a specific agent name (multiple agents)
```tf ```tf
module "filebrowser" { module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder" source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
agent_name = "main" agent_name = "main"
subdomain = false subdomain = false

@ -20,13 +20,8 @@ data "coder_workspace_owner" "me" {}
variable "agent_name" { variable "agent_name" {
type = string type = string
description = "The name of the main deployment. (Used to build the subpath for coder_app.)" description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = "" default = null
validation {
# If subdomain is false, then agent_name must be set.
condition = var.subdomain || var.agent_name != ""
error_message = "The agent_name must be set."
}
} }
variable "database_path" { variable "database_path" {
@ -73,6 +68,12 @@ variable "order" {
default = null default = null
} }
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "filebrowser"
}
variable "subdomain" { variable "subdomain" {
type = bool type = bool
description = <<-EOT description = <<-EOT
@ -85,7 +86,7 @@ variable "subdomain" {
resource "coder_script" "filebrowser" { resource "coder_script" "filebrowser" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "File Browser" display_name = "File Browser"
icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg" icon = "/icon/filebrowser.svg"
script = templatefile("${path.module}/run.sh", { script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path, LOG_PATH : var.log_path,
PORT : var.port, PORT : var.port,
@ -93,18 +94,30 @@ resource "coder_script" "filebrowser" {
LOG_PATH : var.log_path, LOG_PATH : var.log_path,
DB_PATH : var.database_path, DB_PATH : var.database_path,
SUBDOMAIN : var.subdomain, SUBDOMAIN : var.subdomain,
SERVER_BASE_PATH : var.subdomain ? "" : format("/@%s/%s.%s/apps/filebrowser", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name), SERVER_BASE_PATH : local.server_base_path
}) })
run_on_start = true run_on_start = true
} }
resource "coder_app" "filebrowser" { resource "coder_app" "filebrowser" {
agent_id = var.agent_id agent_id = var.agent_id
slug = "filebrowser" slug = var.slug
display_name = "File Browser" display_name = "File Browser"
url = "http://localhost:${var.port}" url = local.url
icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg" icon = "/icon/filebrowser.svg"
subdomain = var.subdomain subdomain = var.subdomain
share = var.share share = var.share
order = var.order order = var.order
healthcheck {
url = local.healthcheck_url
interval = 5
threshold = 6
}
}
locals {
server_base_path = var.subdomain ? "" : format(var.agent_name != null ? "/@%s/%s.%s/apps/%s" : "/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name, var.slug)
url = "http://localhost:${var.port}${local.server_base_path}"
healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health"
} }

@ -14,7 +14,7 @@ This module adds a JetBrains Gateway Button to open any workspace with a single
```tf ```tf
module "jetbrains_gateway" { module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder" source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.21" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
agent_name = "example" agent_name = "example"
folder = "/home/coder/example" folder = "/home/coder/example"
@ -32,7 +32,7 @@ module "jetbrains_gateway" {
```tf ```tf
module "jetbrains_gateway" { module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder" source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.21" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
agent_name = "example" agent_name = "example"
folder = "/home/coder/example" folder = "/home/coder/example"
@ -46,7 +46,7 @@ module "jetbrains_gateway" {
```tf ```tf
module "jetbrains_gateway" { module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder" source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.21" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
agent_name = "example" agent_name = "example"
folder = "/home/coder/example" folder = "/home/coder/example"
@ -61,7 +61,7 @@ module "jetbrains_gateway" {
```tf ```tf
module "jetbrains_gateway" { module "jetbrains_gateway" {
source = "registry.coder.com/modules/jetbrains-gateway/coder" source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.21" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
agent_name = "example" agent_name = "example"
folder = "/home/coder/example" folder = "/home/coder/example"

@ -20,13 +20,13 @@ variable "agent_id" {
variable "slug" { variable "slug" {
type = string type = string
description = "The slug for the coder_app. Allows resuing the module with the same template." description = "The slug for the coder_app resource."
default = "gateway" default = "gateway"
} }
variable "agent_name" { variable "agent_name" {
type = string type = string
description = "Agent name." description = "The name of the coder_agent resource."
} }
variable "folder" { variable "folder" {
@ -258,26 +258,18 @@ resource "coder_app" "gateway" {
icon = local.icon icon = local.icon
external = true external = true
order = var.order order = var.order
url = join("", [ url = format(
"jetbrains-gateway://connect#type=coder&workspace=", "jetbrains-gateway://connect#type=coder&workspace=%s&owner=%s&agent=%s&folder=%s&url=%s&token=%s&ide_product_code=%s&ide_build_number=%s&ide_download_link=%s",
data.coder_workspace.me.name, data.coder_workspace.me.name,
"&owner=",
data.coder_workspace_owner.me.name, data.coder_workspace_owner.me.name,
"&agent=",
var.agent_name, var.agent_name,
"&folder=",
var.folder, var.folder,
"&url=",
data.coder_workspace.me.access_url, data.coder_workspace.me.access_url,
"&token=",
"$SESSION_TOKEN", "$SESSION_TOKEN",
"&ide_product_code=",
data.coder_parameter.jetbrains_ide.value, data.coder_parameter.jetbrains_ide.value,
"&ide_build_number=",
local.build_number, local.build_number,
"&ide_download_link=", local.download_link
local.download_link, )
])
} }
output "identifier" { output "identifier" {

@ -11,12 +11,36 @@ tags: [jupyter, helper, ide, web]
A module that adds Jupyter Notebook in your Coder template. A module that adds Jupyter Notebook in your Coder template.
![Jupyter Notebook](../.images/jupyter-notebook.png)
```tf ```tf
module "jupyter-notebook" { module "jupyter-notebook" {
source = "registry.coder.com/modules/jupyter-notebook/coder" source = "registry.coder.com/modules/jupyter-notebook/coder"
version = "1.0.19" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
![Jupyter Notebook](../.images/jupyter-notebook.png)
## Examples
### Serve on a subpath (no wildcard subdomain)
```tf
module "jupyter-notebook" {
source = "registry.coder.com/modules/jupyter-notebook/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
subdomain = false
}
```
### Serve on a subpath with a specific agent name (multiple agents)
```tf
module "jupyter-notebook" {
source = "registry.coder.com/modules/jupyter-notebook/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
agent_name = "main"
}
```

@ -42,9 +42,30 @@ variable "order" {
default = null default = null
} }
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "jupyter-notebook"
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true
}
resource "coder_script" "jupyter-notebook" { resource "coder_script" "jupyter-notebook" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "jupyter-notebook" display_name = "Jupyter Notebook"
icon = "/icon/jupyter.svg" icon = "/icon/jupyter.svg"
script = templatefile("${path.module}/run.sh", { script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path, LOG_PATH : var.log_path,
@ -55,11 +76,22 @@ resource "coder_script" "jupyter-notebook" {
resource "coder_app" "jupyter-notebook" { resource "coder_app" "jupyter-notebook" {
agent_id = var.agent_id agent_id = var.agent_id
slug = "jupyter-notebook" slug = var.slug
display_name = "Jupyter Notebook" display_name = "Jupyter Notebook"
url = "http://localhost:${var.port}" url = local.url
icon = "/icon/jupyter.svg" icon = "/icon/jupyter.svg"
subdomain = true subdomain = true
share = var.share share = var.share
order = var.order order = var.order
healthcheck {
url = local.healthcheck_url
interval = 5
threshold = 6
}
}
locals {
server_base_path = var.subdomain ? "" : format(var.agent_name != null ? "/@%s/%s.%s/apps/%s" : "/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name, var.slug)
url = "http://localhost:${var.port}${local.server_base_path}"
healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/api"
} }

@ -11,12 +11,36 @@ tags: [jupyter, helper, ide, web]
A module that adds JupyterLab in your Coder template. A module that adds JupyterLab in your Coder template.
![JupyterLab](../.images/jupyterlab.png)
```tf ```tf
module "jupyterlab" { module "jupyterlab" {
source = "registry.coder.com/modules/jupyterlab/coder" source = "registry.coder.com/modules/jupyterlab/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
![JupyterLab](../.images/jupyterlab.png)
## Examples
### Serve on a subpath (no wildcard subdomain)
```tf
module "jupyterlab" {
source = "registry.coder.com/modules/jupyterlab/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
subdomain = false
}
```
### Serve on a subpath with a specific agent name (multiple agents)
```tf
module "jupyterlab" {
source = "registry.coder.com/modules/jupyterlab/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
agent_name = "main"
}
```

@ -41,7 +41,10 @@ variable "share" {
variable "subdomain" { variable "subdomain" {
type = bool type = bool
description = "Determines whether JupyterLab will be accessed via its own subdomain or whether it will be accessed via a path on Coder." description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true default = true
} }
@ -51,6 +54,18 @@ variable "order" {
default = null default = null
} }
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "jupyterlab"
}
resource "coder_script" "jupyterlab" { resource "coder_script" "jupyterlab" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "jupyterlab" display_name = "jupyterlab"
@ -58,18 +73,24 @@ resource "coder_script" "jupyterlab" {
script = templatefile("${path.module}/run.sh", { script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path, LOG_PATH : var.log_path,
PORT : var.port PORT : var.port
BASE_URL : var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" BASE_URL : local.server_base_path
}) })
run_on_start = true run_on_start = true
} }
resource "coder_app" "jupyterlab" { resource "coder_app" "jupyterlab" {
agent_id = var.agent_id agent_id = var.agent_id
slug = "jupyterlab" # sync with the usage in URL slug = var.slug
display_name = "JupyterLab" display_name = "JupyterLab"
url = var.subdomain ? "http://localhost:${var.port}" : "http://localhost:${var.port}/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" url = local.url
icon = "/icon/jupyter.svg" icon = "/icon/jupyter.svg"
subdomain = var.subdomain subdomain = var.subdomain
share = var.share share = var.share
order = var.order order = var.order
} }
locals {
server_base_path = var.subdomain ? "" : format(var.agent_name != null ? "/@%s/%s.%s/apps/%s" : "/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name, var.slug)
url = "http://localhost:${var.port}${local.server_base_path}"
healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/api"
}

@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
```tf ```tf
module "vscode" { module "vscode" {
source = "registry.coder.com/modules/vscode-desktop/coder" source = "registry.coder.com/modules/vscode-desktop/coder"
version = "1.0.15" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
} }
``` ```
@ -28,7 +28,7 @@ module "vscode" {
```tf ```tf
module "vscode" { module "vscode" {
source = "registry.coder.com/modules/vscode-desktop/coder" source = "registry.coder.com/modules/vscode-desktop/coder"
version = "1.0.15" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
folder = "/home/coder/project" folder = "/home/coder/project"
} }

@ -20,6 +20,12 @@ variable "folder" {
default = "" default = ""
} }
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "vscode-desktop"
}
variable "open_recent" { variable "open_recent" {
type = bool type = bool
description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
@ -39,21 +45,17 @@ resource "coder_app" "vscode" {
agent_id = var.agent_id agent_id = var.agent_id
external = true external = true
icon = "/icon/code.svg" icon = "/icon/code.svg"
slug = "vscode" slug = var.slug
display_name = "VS Code Desktop" display_name = "VS Code Desktop"
order = var.order order = var.order
url = join("", [ url = format(
"vscode://coder.coder-remote/open", "vscode://coder.coder-remote/open?owner=%s&workspace=%s%s%s&url=%s&token=$SESSION_TOKEN",
"?owner=",
data.coder_workspace_owner.me.name, data.coder_workspace_owner.me.name,
"&workspace=",
data.coder_workspace.me.name, data.coder_workspace.me.name,
var.folder != "" ? join("", ["&folder=", var.folder]) : "", var.folder != "" ? format("&folder=%s", var.folder) : "",
var.open_recent ? "&openRecent" : "", var.open_recent ? "&openRecent" : "",
"&url=", data.coder_workspace.me.access_url
data.coder_workspace.me.access_url, )
"&token=$SESSION_TOKEN",
])
} }
output "vscode_url" { output "vscode_url" {

@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
```tf ```tf
module "vscode-web" { module "vscode-web" {
source = "registry.coder.com/modules/vscode-web/coder" source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
accept_license = true accept_license = true
} }
@ -29,7 +29,7 @@ module "vscode-web" {
```tf ```tf
module "vscode-web" { module "vscode-web" {
source = "registry.coder.com/modules/vscode-web/coder" source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web" install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder" folder = "/home/coder"
@ -42,7 +42,7 @@ module "vscode-web" {
```tf ```tf
module "vscode-web" { module "vscode-web" {
source = "registry.coder.com/modules/vscode-web/coder" source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true accept_license = true
@ -56,7 +56,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
```tf ```tf
module "vscode-web" { module "vscode-web" {
source = "registry.coder.com/modules/vscode-web/coder" source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.22" version = "1.0.23"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"] extensions = ["dracula-theme.theme-dracula"]
settings = { settings = {
@ -65,3 +65,26 @@ module "vscode-web" {
accept_license = true accept_license = true
} }
``` ```
### Serve on a subpath (no wildcard subdomain)
```tf
module "vscode-web" {
source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
subdomain = false
}
```
### Serve on a subpath with a specific agent name (multiple agents)
```tf
module "vscode-web" {
source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.23"
agent_id = coder_agent.example.id
agent_name = "main"
subdomain = false
}
```

@ -130,6 +130,12 @@ variable "subdomain" {
default = true default = true
} }
variable "agent_name" {
type = string
description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
default = null
}
data "coder_workspace_owner" "me" {} data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {} data "coder_workspace" "me" {}
@ -185,7 +191,7 @@ resource "coder_app" "vscode-web" {
} }
locals { locals {
server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) server_base_path = var.subdomain ? "" : format(var.agent_name != null ? "/@%s/%s.%s/apps/%s" : "/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name, var.slug)
url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}" url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}"
healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz" healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/healthz"
} }

@ -15,7 +15,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
# AWS example. See below for examples of using this module with other providers # AWS example. See below for examples of using this module with other providers
module "windows_rdp" { module "windows_rdp" {
source = "registry.coder.com/modules/windows-rdp/coder" source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18" version = "1.0.23"
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
agent_id = resource.coder_agent.main.id agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id resource_id = resource.aws_instance.dev.id
@ -33,7 +33,7 @@ module "windows_rdp" {
```tf ```tf
module "windows_rdp" { module "windows_rdp" {
source = "registry.coder.com/modules/windows-rdp/coder" source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18" version = "1.0.23"
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
agent_id = resource.coder_agent.main.id agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id resource_id = resource.aws_instance.dev.id
@ -45,7 +45,7 @@ module "windows_rdp" {
```tf ```tf
module "windows_rdp" { module "windows_rdp" {
source = "registry.coder.com/modules/windows-rdp/coder" source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18" version = "1.0.23"
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
agent_id = resource.coder_agent.main.id agent_id = resource.coder_agent.main.id
resource_id = resource.google_compute_instance.dev[0].id resource_id = resource.google_compute_instance.dev[0].id

@ -53,23 +53,23 @@ const SCREEN_POLL_INTERVAL_MS = 500;
* @satisfies {FormFieldEntries} * @satisfies {FormFieldEntries}
*/ */
const formFieldEntries = { const formFieldEntries = {
/** @readonly */ /** @readonly */
username: { username: {
/** @readonly */ /** @readonly */
querySelector: "web-client-username-control input", querySelector: "web-client-username-control input",
/** @readonly */ /** @readonly */
value: "${CODER_USERNAME}", value: "${CODER_USERNAME}",
}, },
/** @readonly */ /** @readonly */
password: { password: {
/** @readonly */ /** @readonly */
querySelector: "web-client-password-control input", querySelector: "web-client-password-control input",
/** @readonly */ /** @readonly */
value: "${CODER_PASSWORD}", value: "${CODER_PASSWORD}",
}, },
}; };
/** /**
@ -93,32 +93,32 @@ const formFieldEntries = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
function setInputValue(inputField, inputText) { function setInputValue(inputField, inputText) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Adding timeout for input event, even though we'll be dispatching it // Adding timeout for input event, even though we'll be dispatching it
// immediately, just in the off chance that something in the Angular app // immediately, just in the off chance that something in the Angular app
// intercepts it or stops it from propagating properly // intercepts it or stops it from propagating properly
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
reject(new Error("Input event did not get processed correctly in time.")); reject(new Error("Input event did not get processed correctly in time."));
}, 3_000); }, 3_000);
const handleSuccessfulDispatch = () => { const handleSuccessfulDispatch = () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
inputField.removeEventListener("input", handleSuccessfulDispatch); inputField.removeEventListener("input", handleSuccessfulDispatch);
resolve(); resolve();
}; };
inputField.addEventListener("input", handleSuccessfulDispatch); inputField.addEventListener("input", handleSuccessfulDispatch);
// Code assumes that Angular will have an event handler in place to handle // Code assumes that Angular will have an event handler in place to handle
// the new event // the new event
const inputEvent = new Event("input", { const inputEvent = new Event("input", {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
inputField.value = inputText; inputField.value = inputText;
inputField.dispatchEvent(inputEvent); inputField.dispatchEvent(inputEvent);
}); });
} }
/** /**
@ -137,91 +137,91 @@ function setInputValue(inputField, inputText) {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function autoSubmitForm(myForm) { async function autoSubmitForm(myForm) {
const setProtocolValue = () => { const setProtocolValue = () => {
/** @type {HTMLDivElement | null} */ /** @type {HTMLDivElement | null} */
const protocolDropdownTrigger = myForm.querySelector('div[role="button"]'); const protocolDropdownTrigger = myForm.querySelector('div[role="button"]');
if (protocolDropdownTrigger === null) { if (protocolDropdownTrigger === null) {
throw new Error("No clickable trigger for setting protocol value"); throw new Error("No clickable trigger for setting protocol value");
} }
protocolDropdownTrigger.click(); protocolDropdownTrigger.click();
// Can't use form as container for querying the list of dropdown options, // Can't use form as container for querying the list of dropdown options,
// because the elements don't actually exist inside the form. They're placed // because the elements don't actually exist inside the form. They're placed
// in the top level of the HTML doc, and repositioned to make it look like // in the top level of the HTML doc, and repositioned to make it look like
// they're part of the form. Avoids CSS stacking context issues, maybe? // they're part of the form. Avoids CSS stacking context issues, maybe?
/** @type {HTMLLIElement | null} */ /** @type {HTMLLIElement | null} */
const protocolOption = document.querySelector( const protocolOption = document.querySelector(
'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li', `p-dropdownitem[ng-reflect-label="${PROTOCOL}"] li`,
); );
if (protocolOption === null) { if (protocolOption === null) {
throw new Error( throw new Error(
"Unable to find protocol option on screen that matches desired protocol", "Unable to find protocol option on screen that matches desired protocol",
); );
} }
protocolOption.click(); protocolOption.click();
}; };
const setHostname = () => { const setHostname = () => {
/** @type {HTMLInputElement | null} */ /** @type {HTMLInputElement | null} */
const hostnameInput = myForm.querySelector("p-autocomplete#hostname input"); const hostnameInput = myForm.querySelector("p-autocomplete#hostname input");
if (hostnameInput === null) { if (hostnameInput === null) {
throw new Error("Unable to find field for adding hostname"); throw new Error("Unable to find field for adding hostname");
} }
return setInputValue(hostnameInput, HOSTNAME); return setInputValue(hostnameInput, HOSTNAME);
}; };
const setCoderFormFieldValues = async () => { const setCoderFormFieldValues = async () => {
// The RDP form will not appear on screen unless the dropdown is set to use // The RDP form will not appear on screen unless the dropdown is set to use
// the RDP protocol // the RDP protocol
const rdpSubsection = myForm.querySelector("rdp-form"); const rdpSubsection = myForm.querySelector("rdp-form");
if (rdpSubsection === null) { if (rdpSubsection === null) {
throw new Error( throw new Error(
"Unable to find RDP subsection. Is the value of the protocol set to RDP?", "Unable to find RDP subsection. Is the value of the protocol set to RDP?",
); );
} }
for (const { value, querySelector } of Object.values(formFieldEntries)) { for (const { value, querySelector } of Object.values(formFieldEntries)) {
/** @type {HTMLInputElement | null} */ /** @type {HTMLInputElement | null} */
const input = document.querySelector(querySelector); const input = document.querySelector(querySelector);
if (input === null) { if (input === null) {
throw new Error( throw new Error(
'Unable to element that matches query "' + querySelector + '"', `Unable to element that matches query "${querySelector}"`,
); );
} }
await setInputValue(input, value); await setInputValue(input, value);
} }
}; };
const triggerSubmission = () => { const triggerSubmission = () => {
/** @type {HTMLButtonElement | null} */ /** @type {HTMLButtonElement | null} */
const submitButton = myForm.querySelector( const submitButton = myForm.querySelector(
'p-button[ng-reflect-type="submit"] button', 'p-button[ng-reflect-type="submit"] button',
); );
if (submitButton === null) { if (submitButton === null) {
throw new Error("Unable to find submission button"); throw new Error("Unable to find submission button");
} }
if (submitButton.disabled) { if (submitButton.disabled) {
throw new Error( throw new Error(
"Unable to submit form because submit button is disabled. Are all fields filled out correctly?", "Unable to submit form because submit button is disabled. Are all fields filled out correctly?",
); );
} }
submitButton.click(); submitButton.click();
}; };
setProtocolValue(); setProtocolValue();
await setHostname(); await setHostname();
await setCoderFormFieldValues(); await setCoderFormFieldValues();
triggerSubmission(); triggerSubmission();
} }
/** /**
@ -231,58 +231,58 @@ async function autoSubmitForm(myForm) {
* @returns {void} * @returns {void}
*/ */
function setupFormDetection() { function setupFormDetection() {
/** @type {HTMLFormElement | null} */ /** @type {HTMLFormElement | null} */
let formValueFromLastMutation = null; let formValueFromLastMutation = null;
/** @returns {void} */ /** @returns {void} */
const onDynamicTabMutation = () => { const onDynamicTabMutation = () => {
/** @type {HTMLFormElement | null} */ /** @type {HTMLFormElement | null} */
const latestForm = document.querySelector("web-client-form > form"); const latestForm = document.querySelector("web-client-form > form");
// Only try to auto-fill if we went from having no form on screen to // Only try to auto-fill if we went from having no form on screen to
// having a form on screen. That way, we don't accidentally override the // having a form on screen. That way, we don't accidentally override the
// form if the user is trying to customize values, and this essentially // form if the user is trying to customize values, and this essentially
// makes the script values function as default values // makes the script values function as default values
const mounted = formValueFromLastMutation === null && latestForm !== null; const mounted = formValueFromLastMutation === null && latestForm !== null;
if (mounted) { if (mounted) {
autoSubmitForm(latestForm); autoSubmitForm(latestForm);
} }
formValueFromLastMutation = latestForm; formValueFromLastMutation = latestForm;
}; };
/** @type {number | undefined} */ /** @type {number | undefined} */
let pollingId = undefined; let pollingId = undefined;
/** @returns {void} */ /** @returns {void} */
const checkScreenForDynamicTab = () => { const checkScreenForDynamicTab = () => {
const dynamicTab = document.querySelector("web-client-dynamic-tab"); const dynamicTab = document.querySelector("web-client-dynamic-tab");
// Keep polling until the main content container is on screen // Keep polling until the main content container is on screen
if (dynamicTab === null) { if (dynamicTab === null) {
return; return;
} }
window.clearInterval(pollingId); window.clearInterval(pollingId);
// Call the mutation callback manually, to ensure it runs at least once // Call the mutation callback manually, to ensure it runs at least once
onDynamicTabMutation(); onDynamicTabMutation();
// Having the mutation observer is kind of an extra safety net that isn't // Having the mutation observer is kind of an extra safety net that isn't
// really expected to run that often. Most of the content in the dynamic // really expected to run that often. Most of the content in the dynamic
// tab is being rendered through Canvas, which won't trigger any mutations // tab is being rendered through Canvas, which won't trigger any mutations
// that the observer can detect // that the observer can detect
const dynamicTabObserver = new MutationObserver(onDynamicTabMutation); const dynamicTabObserver = new MutationObserver(onDynamicTabMutation);
dynamicTabObserver.observe(dynamicTab, { dynamicTabObserver.observe(dynamicTab, {
subtree: true, subtree: true,
childList: true, childList: true,
}); });
}; };
pollingId = window.setInterval( pollingId = window.setInterval(
checkScreenForDynamicTab, checkScreenForDynamicTab,
SCREEN_POLL_INTERVAL_MS, SCREEN_POLL_INTERVAL_MS,
); );
} }
/** /**
@ -292,34 +292,34 @@ function setupFormDetection() {
* @returns {void} * @returns {void}
*/ */
function setupAlwaysOnStyles() { function setupAlwaysOnStyles() {
const styleId = "coder-patch--styles-always-on"; const styleId = "coder-patch--styles-always-on";
const existingContainer = document.querySelector("#" + styleId); const existingContainer = document.querySelector(`#${styleId}`);
if (existingContainer) { if (existingContainer) {
return; return;
} }
const styleContainer = document.createElement("style"); const styleContainer = document.createElement("style");
styleContainer.id = styleId; styleContainer.id = styleId;
styleContainer.innerHTML = ` styleContainer.innerHTML = `
/* app-menu corresponds to the sidebar of the default view. */ /* app-menu corresponds to the sidebar of the default view. */
app-menu { app-menu {
display: none !important; display: none !important;
} }
`; `;
document.head.appendChild(styleContainer); document.head.appendChild(styleContainer);
} }
function hideFormForInitialSubmission() { function hideFormForInitialSubmission() {
const styleId = "coder-patch--styles-initial-submission"; const styleId = "coder-patch--styles-initial-submission";
const cssOpacityVariableName = "--coder-opacity-multiplier"; const cssOpacityVariableName = "--coder-opacity-multiplier";
/** @type {HTMLStyleElement | null} */ /** @type {HTMLStyleElement | null} */
let styleContainer = document.querySelector("#" + styleId); let styleContainer = document.querySelector(`#${styleId}`);
if (!styleContainer) { if (!styleContainer) {
styleContainer = document.createElement("style"); styleContainer = document.createElement("style");
styleContainer.id = styleId; styleContainer.id = styleId;
styleContainer.innerHTML = ` styleContainer.innerHTML = `
/* /*
Have to use opacity instead of visibility, because the element still Have to use opacity instead of visibility, because the element still
needs to be interactive via the script so that it can be auto-filled. needs to be interactive via the script so that it can be auto-filled.
@ -350,50 +350,50 @@ function hideFormForInitialSubmission() {
} }
`; `;
document.head.appendChild(styleContainer); document.head.appendChild(styleContainer);
} }
// The root node being undefined should be physically impossible (if it's // The root node being undefined should be physically impossible (if it's
// undefined, the browser itself is busted), but we need to do a type check // undefined, the browser itself is busted), but we need to do a type check
// here so that the rest of the function doesn't need to do type checks over // here so that the rest of the function doesn't need to do type checks over
// and over. // and over.
const rootNode = document.querySelector(":root"); const rootNode = document.querySelector(":root");
if (!(rootNode instanceof HTMLHtmlElement)) { if (!(rootNode instanceof HTMLHtmlElement)) {
// Remove the container entirely because if the browser is busted, who knows // Remove the container entirely because if the browser is busted, who knows
// if the CSS variables can be applied correctly. Better to have something // if the CSS variables can be applied correctly. Better to have something
// be a bit more ugly/painful to use, than have it be impossible to use // be a bit more ugly/painful to use, than have it be impossible to use
styleContainer.remove(); styleContainer.remove();
return; return;
} }
// It's safe to make the form visible preemptively because Devolutions // It's safe to make the form visible preemptively because Devolutions
// outputs the Windows view through an HTML canvas that it overlays on top // outputs the Windows view through an HTML canvas that it overlays on top
// of the rest of the app. Even if the form isn't hidden at the style level, // of the rest of the app. Even if the form isn't hidden at the style level,
// it will still be covered up. // it will still be covered up.
const restoreOpacity = () => { const restoreOpacity = () => {
rootNode.style.setProperty(cssOpacityVariableName, "1"); rootNode.style.setProperty(cssOpacityVariableName, "1");
}; };
// If this file gets more complicated, it might make sense to set up the // If this file gets more complicated, it might make sense to set up the
// timeout and event listener so that if one triggers, it cancels the other, // timeout and event listener so that if one triggers, it cancels the other,
// but having restoreOpacity run more than once is a no-op for right now. // but having restoreOpacity run more than once is a no-op for right now.
// Not a big deal if these don't get cleaned up. // Not a big deal if these don't get cleaned up.
// Have the form automatically reappear no matter what, so that if something // Have the form automatically reappear no matter what, so that if something
// does break, the user isn't left out to dry // does break, the user isn't left out to dry
window.setTimeout(restoreOpacity, 5_000); window.setTimeout(restoreOpacity, 5_000);
/** @type {HTMLFormElement | null} */ /** @type {HTMLFormElement | null} */
const form = document.querySelector("web-client-form > form"); const form = document.querySelector("web-client-form > form");
form?.addEventListener( form?.addEventListener(
"submit", "submit",
() => { () => {
// Not restoring opacity right away just to give the HTML canvas a little // Not restoring opacity right away just to give the HTML canvas a little
// bit of time to get spun up and cover up the main form // bit of time to get spun up and cover up the main form
window.setTimeout(restoreOpacity, 1_000); window.setTimeout(restoreOpacity, 1_000);
}, },
{ once: true }, { once: true },
); );
} }
// Always safe to call these immediately because even if the Angular app isn't // Always safe to call these immediately because even if the Angular app isn't
@ -403,7 +403,7 @@ setupAlwaysOnStyles();
hideFormForInitialSubmission(); hideFormForInitialSubmission();
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupFormDetection); document.addEventListener("DOMContentLoaded", setupFormDetection);
} else { } else {
setupFormDetection(); setupFormDetection();
} }

@ -39,6 +39,18 @@ variable "admin_password" {
sensitive = true sensitive = true
} }
variable "port" {
type = number
description = "The port to run the Devolutions Gateway on."
default = 7171
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
default = "web-rdp"
}
resource "coder_script" "windows-rdp" { resource "coder_script" "windows-rdp" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "windows-rdp" display_name = "windows-rdp"
@ -47,6 +59,7 @@ resource "coder_script" "windows-rdp" {
script = templatefile("${path.module}/powershell-installation-script.tftpl", { script = templatefile("${path.module}/powershell-installation-script.tftpl", {
admin_username = var.admin_username admin_username = var.admin_username
admin_password = var.admin_password admin_password = var.admin_password
port = var.port
# Wanted to have this be in the powershell template file, but Terraform # Wanted to have this be in the powershell template file, but Terraform
# doesn't allow recursive calls to the templatefile function. Have to feed # doesn't allow recursive calls to the templatefile function. Have to feed
@ -63,14 +76,14 @@ resource "coder_script" "windows-rdp" {
resource "coder_app" "windows-rdp" { resource "coder_app" "windows-rdp" {
agent_id = var.agent_id agent_id = var.agent_id
share = var.share share = var.share
slug = "web-rdp" slug = var.slug
display_name = "Web RDP" display_name = "Web RDP"
url = "http://localhost:7171" url = "http://localhost:${var.port}"
icon = "/icon/desktop.svg" icon = "/icon/desktop.svg"
subdomain = true subdomain = true
healthcheck { healthcheck {
url = "http://localhost:7171" url = "http://localhost:${var.port}"
interval = 5 interval = 5
threshold = 15 threshold = 15
} }
@ -80,7 +93,7 @@ resource "coder_app" "rdp-docs" {
agent_id = var.agent_id agent_id = var.agent_id
display_name = "Local RDP" display_name = "Local RDP"
slug = "rdp-docs" slug = "rdp-docs"
icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg" icon = "/icon/windows.svg"
url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop" url = "https://coder.com/docs/user-guides/workspace-access/remote-desktops#rdp-desktop"
external = true external = true
} }

@ -47,7 +47,7 @@ Install-DGatewayPackage
# Configure Devolutions Gateway # Configure Devolutions Gateway
$Hostname = "localhost" $Hostname = "localhost"
$HttpListener = New-DGatewayListener 'http://*:7171' 'http://*:7171' $HttpListener = New-DGatewayListener "http://*:${port}" "http://*:${port}"
$WebApp = New-DGatewayWebAppConfig -Enabled $true -Authentication None $WebApp = New-DGatewayWebAppConfig -Enabled $true -Authentication None
$ConfigParams = @{ $ConfigParams = @{
Hostname = $Hostname Hostname = $Hostname

Loading…
Cancel
Save