diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md index f19afc475..92c5ac17e 100644 --- a/registry/coder/modules/windows-rdp/README.md +++ b/registry/coder/modules/windows-rdp/README.md @@ -15,7 +15,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.3" + version = "1.3.0" agent_id = resource.coder_agent.main.id } ``` @@ -32,7 +32,7 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.3" + version = "1.3.0" agent_id = resource.coder_agent.main.id } ``` @@ -43,7 +43,7 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.3" + version = "1.3.0" agent_id = resource.coder_agent.main.id } ``` @@ -54,7 +54,7 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.3" + version = "1.3.0" agent_id = resource.coder_agent.main.id devolutions_gateway_version = "2025.2.2" # Specify a specific version } diff --git a/registry/coder/modules/windows-rdp/devolutions-patch.js b/registry/coder/modules/windows-rdp/devolutions-patch.js index ef7364523..1f231ca3f 100644 --- a/registry/coder/modules/windows-rdp/devolutions-patch.js +++ b/registry/coder/modules/windows-rdp/devolutions-patch.js @@ -25,401 +25,426 @@ * @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry * @typedef {Readonly>} FormFieldEntries */ - -/** - * The communication protocol to set Devolutions to. - */ -const PROTOCOL = "RDP"; - -/** - * The hostname to use with Devolutions. - */ -const HOSTNAME = "localhost"; - -/** - * How often to poll the screen for the main Devolutions form. - */ -const SCREEN_POLL_INTERVAL_MS = 500; - -/** - * The fields in the Devolutions sign-in form that should be populated with - * values from the Coder workspace. - * - * All properties should be defined as placeholder templates in the form - * VALUE_NAME. The Coder module, when spun up, should then run some logic to - * replace the template slots with actual values. These values should never - * change from within JavaScript itself. - * - * @satisfies {FormFieldEntries} - */ -const formFieldEntries = { - /** @readonly */ - username: { - /** @readonly */ - querySelector: "web-client-username-control input", - - /** @readonly */ - value: "${CODER_USERNAME}", - }, - - /** @readonly */ - password: { +(function () { + /** + * The communication protocol to set Devolutions to. + */ + const PROTOCOL = "RDP"; + + /** + * The hostname to use with Devolutions. + */ + const HOSTNAME = "localhost"; + + /** + * How often to poll the screen for the main Devolutions form. + */ + const POLL_INTERVAL_MS = 500; + + /** + * The fields in the Devolutions sign-in form that should be populated with + * values from the Coder workspace. + * + * All properties should be defined as placeholder templates in the form + * VALUE_NAME. The Coder module, when spun up, should then run some logic to + * replace the template slots with actual values. These values should never + * change from within JavaScript itself. + * + * @satisfies {FormFieldEntries} + */ + const formFieldEntries = { /** @readonly */ - querySelector: "web-client-password-control input", + username: { + /** @readonly */ + querySelector: "web-client-username-control input", + /** @readonly */ + value: "${CODER_USERNAME}", + }, /** @readonly */ - value: "${CODER_PASSWORD}", - }, -}; + password: { + /** @readonly */ + querySelector: "web-client-password-control input", -/** - * Handles typing in the values for the input form. All values are written - * immediately, even though that would be physically impossible with a real - * keyboard. - * - * Note: this code will never break, but you might get warnings in the console - * from Angular about unexpected value changes. Angular patches over a lot of - * the built-in browser APIs to support its component change detection system. - * As part of that, it has validations for checking whether an input it - * previously had control over changed without it doing anything. - * - * But the only way to simulate a keyboard input is by setting the input's - * .value property, and then firing an input event. So basically, the inner - * value will change, which Angular won't be happy about, but then the input - * event will fire and sync everything back together. - * - * @param {HTMLInputElement} inputField - * @param {string} inputText - * @returns {Promise} - */ -function setInputValue(inputField, inputText) { - return new Promise((resolve, reject) => { - // Adding timeout for input event, even though we'll be dispatching it - // immediately, just in the off chance that something in the Angular app - // intercepts it or stops it from propagating properly - const timeoutId = window.setTimeout(() => { - reject(new Error("Input event did not get processed correctly in time.")); - }, 3_000); - - const handleSuccessfulDispatch = () => { - window.clearTimeout(timeoutId); - inputField.removeEventListener("input", handleSuccessfulDispatch); - resolve(); - }; - - inputField.addEventListener("input", handleSuccessfulDispatch); + /** @readonly */ + value: "${CODER_PASSWORD}", + }, + }; - // Code assumes that Angular will have an event handler in place to handle - // the new event - const inputEvent = new Event("input", { - bubbles: true, - cancelable: true, - }); + /** + * This ensures that the Devolutions login form (which by default, always shows + * up on screen when the app first launches) stays visually hidden from the user + * when they open Devolutions via the Coder module. + * + * The form will still be filled out automatically and submitted in the + * background via the rest of the logic in this file, so this function is mainly + * to help avoid screen flickering and make the overall experience feel a little + * more polished (even though it's just one giant hack). + * + * @returns {void} + */ + function hideFormForInitialSubmission() { + const styleId = "coder-patch--styles-initial-submission"; + const cssOpacityVariableName = "--coder-opacity-multiplier"; + + /** @type {HTMLStyleElement | null} */ + // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation + let styleContainer = document.querySelector("#" + styleId); + if (!styleContainer) { + styleContainer = document.createElement("style"); + styleContainer.id = styleId; + styleContainer.innerHTML = ` + /* + 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. + */ + :root { + /* + Can be 0 or 1. Start off invisible to avoid risks of UI flickering, + but the rest of the function should be in charge of making the form + container visible again if something goes wrong during setup. - inputField.value = inputText; - inputField.dispatchEvent(inputEvent); - }); -} + Double dollar sign needed to avoid Terraform script false positives + */ + $${cssOpacityVariableName}: 0; + } -/** - * Takes a Devolutions remote session form, auto-fills it with data, and then - * submits it. - * - * The logic here is more convoluted than it should be for two main reasons: - * 1. Devolutions' HTML markup has errors. There are labels, but they aren't - * bound to the inputs they're supposed to describe. This means no easy hooks - * for selecting the elements, unfortunately. - * 2. Trying to modify the .value properties on some of the inputs doesn't - * work. Probably some combo of Angular data-binding and some inputs having - * the readonly attribute. Have to simulate user input to get around this. - * - * @param {HTMLFormElement} myForm - * @returns {Promise} - */ -async function autoSubmitForm(myForm) { - const setProtocolValue = () => { - /** @type {HTMLDivElement | null} */ - const protocolDropdownTrigger = myForm.querySelector('div[role="button"]'); - if (protocolDropdownTrigger === null) { - throw new Error("No clickable trigger for setting protocol value"); + /* + web-client-form is the container for the main session form, while + the div is for the dropdown that is used for selecting the protocol. + The dropdown is not inside of the form for CSS styling reasons, so we + need to select both. + */ + web-client-form, + body > div.p-overlay { + /* + Double dollar sign needed to avoid Terraform script false positives + */ + opacity: calc(100% * var($${cssOpacityVariableName})) !important; + } + `; + + document.head.appendChild(styleContainer); } - protocolDropdownTrigger.click(); - - // 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 - // 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? - /** @type {HTMLLIElement | null} */ - const protocolOption = document.querySelector( - // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation - 'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li', - ); - - if (protocolOption === null) { - throw new Error( - "Unable to find protocol option on screen that matches desired protocol", - ); + // 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 + // here so that the rest of the function doesn't need to do type checks over + // and over. + const rootNode = document.querySelector(":root"); + if (!(rootNode instanceof HTMLHtmlElement)) { + // Remove the container entirely because if the browser is busted, who knows + // 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 + styleContainer.remove(); + return; } - protocolOption.click(); - }; + // It's safe to make the form visible preemptively because Devolutions + // 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, + // it will still be covered up. + const restoreOpacity = () => { + rootNode.style.setProperty(cssOpacityVariableName, "1"); + }; - const setHostname = () => { - /** @type {HTMLInputElement | null} */ - const hostnameInput = myForm.querySelector("p-autocomplete#hostname input"); + // 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, + // 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. - if (hostnameInput === null) { - throw new Error("Unable to find field for adding hostname"); - } + // Have the form automatically reappear no matter what, so that if something + // does break, the user isn't left out to dry + window.setTimeout(restoreOpacity, 5_000); - return setInputValue(hostnameInput, HOSTNAME); - }; + /** @type {HTMLFormElement | null} */ + const form = document.querySelector("web-client-form > form"); + form?.addEventListener( + "submit", + () => { + // 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 + window.setTimeout(restoreOpacity, 1_000); + }, + { once: true }, + ); + } - const setCoderFormFieldValues = async () => { - // The RDP form will not appear on screen unless the dropdown is set to use - // the RDP protocol - const rdpSubsection = myForm.querySelector("rdp-form"); - if (rdpSubsection === null) { - throw new Error( - "Unable to find RDP subsection. Is the value of the protocol set to RDP?", - ); + /** + * Sets up custom styles for hiding default Devolutions elements that Coder + * users shouldn't need to care about. + * + * @returns {void} + */ + function setupAlwaysOnStyles() { + const styleId = "coder-patch--styles-always-on"; + // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation + const existingContainer = document.querySelector("#" + styleId); + if (existingContainer) { + return; } - for (const { value, querySelector } of Object.values(formFieldEntries)) { - /** @type {HTMLInputElement | null} */ - const input = document.querySelector(querySelector); - - if (input === null) { - throw new Error( - // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation - 'Unable to element that matches query "' + querySelector + '"', - ); + const styleContainer = document.createElement("style"); + styleContainer.id = styleId; + styleContainer.innerHTML = ` + /* app-menu corresponds to the sidebar of the default view. */ + app-menu { + display: none !important; } - await setInputValue(input, value); - } - }; - - const triggerSubmission = () => { - /** @type {HTMLButtonElement | null} */ - const submitButton = myForm.querySelector( - 'p-button[ng-reflect-type="submit"] button', - ); + /* app-net-scan corresponds to the auto-discovery feature. */ + app-net-scan { + display: none !important; + } + `; - if (submitButton === null) { - throw new Error("Unable to find submission button"); - } + document.head.appendChild(styleContainer); + } - if (submitButton.disabled) { - throw new Error( - "Unable to submit form because submit button is disabled. Are all fields filled out correctly?", - ); - } + /** + * Handles typing in the values for the input form. All values are written + * immediately, even though that would be physically impossible with a real + * keyboard. + * + * Note: this code will never break, but you might get warnings in the console + * from Angular about unexpected value changes. Angular patches over a lot of + * the built-in browser APIs to support its component change detection system. + * As part of that, it has validations for checking whether an input it + * previously had control over changed without it doing anything. + * + * But the only way to simulate a keyboard input is by setting the input's + * .value property, and then firing an input event. So basically, the inner + * value will change, which Angular won't be happy about, but then the input + * event will fire and sync everything back together. + * + * @param {HTMLInputElement} inputField + * @param {string} inputText + * @returns {Promise} + */ + function setInputValue(inputField, inputText) { + return new Promise((resolve, reject) => { + // Adding timeout for input event, even though we'll be dispatching it + // immediately, just in the off chance that something in the Angular app + // intercepts it or stops it from propagating properly + const timeoutId = window.setTimeout(() => { + reject( + new Error("Input event did not get processed correctly in time."), + ); + }, 3_000); - submitButton.click(); - }; + const handleSuccessfulDispatch = () => { + window.clearTimeout(timeoutId); + inputField.removeEventListener("input", handleSuccessfulDispatch); + resolve(); + }; - setProtocolValue(); - await setHostname(); - await setCoderFormFieldValues(); - triggerSubmission(); -} + inputField.addEventListener("input", handleSuccessfulDispatch); -/** - * Sets up logic for auto-populating the form data when the form appears on - * screen. - * - * @returns {void} - */ -function setupFormDetection() { - /** @type {HTMLFormElement | null} */ - let formValueFromLastMutation = null; + // Code assumes that Angular will have an event handler in place to handle + // the new event + const inputEvent = new Event("input", { + bubbles: true, + cancelable: true, + }); - /** @returns {void} */ - const onDynamicTabMutation = () => { - /** @type {HTMLFormElement | null} */ - const latestForm = document.querySelector("web-client-form > form"); - - // 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 - // form if the user is trying to customize values, and this essentially - // makes the script values function as default values - const mounted = formValueFromLastMutation === null && latestForm !== null; - if (mounted) { - autoSubmitForm(latestForm); - } + inputField.value = inputText; + inputField.dispatchEvent(inputEvent); + }); + } - formValueFromLastMutation = latestForm; - }; + /** + * Takes a Devolutions remote session form, auto-fills it with data, and then + * submits it. + * + * The logic here is more convoluted than it should be for two main reasons: + * 1. Devolutions' HTML markup has errors. There are labels, but they aren't + * bound to the inputs they're supposed to describe. This means no easy hooks + * for selecting the elements, unfortunately. + * 2. Trying to modify the .value properties on some of the inputs doesn't + * work. Probably some combo of Angular data-binding and some inputs having + * the readonly attribute. Have to simulate user input to get around this. + * + * @param {HTMLFormElement} form + */ + async function fillForm(form) { + try { + log("Form detected. Starting auto-fill..."); + + // By default, RDP is selected. Leaving this here if needed + // in the future. + const protocolTrigger = form.querySelector('p-dropdown[id="protocol"]'); + if (protocolTrigger) { + protocolTrigger.click(); + const protocolOption = document.querySelector( + `li[aria-label="$${PROTOCOL}"]`, + ); + if (protocolOption) { + protocolOption.click(); + log(`Protocol set to $${PROTOCOL}`); + } else { + log("Protocol option not found."); + } + } else { + log("Protocol dropdown trigger not found."); + } - /** @type {number | undefined} */ - let pollingId = undefined; + const hostnameInput = form.querySelector("p-autocomplete#hostname input"); + if (hostnameInput) { + await setInputValue(hostnameInput, HOSTNAME); + log(`Hostname set to $${HOSTNAME}`); + } else { + log("Hostname input not found."); + } - /** @returns {void} */ - const checkScreenForDynamicTab = () => { - const dynamicTab = document.querySelector("web-client-dynamic-tab"); + for (const [key, { querySelector, value }] of Object.entries( + formFieldEntries, + )) { + const input = document.querySelector(querySelector); + if (input) { + await setInputValue(input, value); + log(`Set $${key} to $${value}`); + } else { + log(`Input for $${key} not found with selector: $${querySelector}`); + } + } - // Keep polling until the main content container is on screen - if (dynamicTab === null) { - return; + const submitButton = form.querySelector( + 'p-button[class="p-element"] button', + ); + if (submitButton && !submitButton.disabled) { + submitButton.click(); + log("Form submitted."); + } else { + log("Submit button not found or disabled."); + } + } catch (err) { + console.error("[Devolutions Patch] Error during form fill:", err); } + } - window.clearInterval(pollingId); - - // Call the mutation callback manually, to ensure it runs at least once - onDynamicTabMutation(); - - // 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 - // tab is being rendered through Canvas, which won't trigger any mutations - // that the observer can detect - const dynamicTabObserver = new MutationObserver(onDynamicTabMutation); - dynamicTabObserver.observe(dynamicTab, { - subtree: true, - childList: true, + /** + * Attaches a click event listener to the "Close Session" button within the provided top bar element. + * When clicked, the listener triggers the window to close. + * Logs a message indicating whether the listener was successfully attached or if the button was not found. + * + * @param {HTMLElement} topBar - The container element that includes the "Close Session" button. + * @returns {void} + */ + function attachCloseListener(topBar) { + const buttons = topBar.querySelectorAll("button"); + + const closeButton = Array.from(buttons).find((button) => { + const labelSpan = button.querySelector(".p-button-label"); + return labelSpan && labelSpan.textContent.trim() === "Close Session"; }); - }; - - pollingId = window.setInterval( - checkScreenForDynamicTab, - SCREEN_POLL_INTERVAL_MS, - ); -} -/** - * Sets up custom styles for hiding default Devolutions elements that Coder - * users shouldn't need to care about. - * - * @returns {void} - */ -function setupAlwaysOnStyles() { - const styleId = "coder-patch--styles-always-on"; - // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation - const existingContainer = document.querySelector("#" + styleId); - if (existingContainer) { - return; - } - - const styleContainer = document.createElement("style"); - styleContainer.id = styleId; - styleContainer.innerHTML = ` - /* app-menu corresponds to the sidebar of the default view. */ - app-menu { - display: none !important; + if (closeButton) { + closeButton.parentElement.addEventListener("click", () => { + window.close(); + }); + log("Close listener attached."); + } else { + log("Close button not found in top bar."); } - `; - - document.head.appendChild(styleContainer); -} - -/** - * This ensures that the Devolutions login form (which by default, always shows - * up on screen when the app first launches) stays visually hidden from the user - * when they open Devolutions via the Coder module. - * - * The form will still be filled out automatically and submitted in the - * background via the rest of the logic in this file, so this function is mainly - * to help avoid screen flickering and make the overall experience feel a little - * more polished (even though it's just one giant hack). - * - * @returns {void} - */ -function hideFormForInitialSubmission() { - const styleId = "coder-patch--styles-initial-submission"; - const cssOpacityVariableName = "--coder-opacity-multiplier"; - - /** @type {HTMLStyleElement | null} */ - // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation - let styleContainer = document.querySelector("#" + styleId); - if (!styleContainer) { - styleContainer = document.createElement("style"); - styleContainer.id = styleId; - styleContainer.innerHTML = ` - /* - 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. - */ - :root { - /* - Can be 0 or 1. Start off invisible to avoid risks of UI flickering, - but the rest of the function should be in charge of making the form - container visible again if something goes wrong during setup. + } - Double dollar sign needed to avoid Terraform script false positives - */ - $${cssOpacityVariableName}: 0; - } + /** + * Sets the checked state of a checkbox based on its label text. + * Searches all components in the document and identifies the one + * whose label matches the provided `filterText`. Once found, it sets the checkbox + * to the specified `checked` state (true or false) and dispatches a change event + * to ensure any bound listeners (e.g., Angular change detection) are triggered. + * Logs the outcome of the operation for debugging or audit purposes. + * + * @param {string} filterText - The exact label text of the checkbox to target. + * @param {boolean} checked - The desired checked state (true to check, false to uncheck). + * @returns {void} + */ + function setCheckbox(filterText, checked) { + const checkboxes = document.querySelectorAll("p-checkbox"); + + const targetCheckbox = Array.from(checkboxes).find((checkbox) => { + const label = checkbox.querySelector(".p-checkbox-label"); + return label && label.textContent.trim() === filterText; + }); - /* - web-client-form is the container for the main session form, while - the div is for the dropdown that is used for selecting the protocol. - The dropdown is not inside of the form for CSS styling reasons, so we - need to select both. - */ - web-client-form, - body > div.p-overlay { - /* - Double dollar sign needed to avoid Terraform script false positives - */ - opacity: calc(100% * var($${cssOpacityVariableName})) !important; + if (targetCheckbox) { + const input = targetCheckbox.querySelector('input[type="checkbox"]'); + if (input) { + input.checked = checked; + input.dispatchEvent(new Event("change", { bubbles: true })); } - `; + log(`$${filterText} set to $${checked}.`); + } else { + log(`$${filterText} checkbox not found in top bar.`); + } + } - document.head.appendChild(styleContainer); + /** + * Continuously polls the DOM for a specific form element. + * - Searches for a
inside a element. + * - If found, calls `fillForm(form)` to process it. + * - If not found, logs a retry message and schedules another check after a delay. + * + * @returns {void} + */ + function pollForForm() { + const form = document.querySelector("web-client-form form"); + if (form) { + fillForm(form); + + // Start polling for top bar after form is filled + pollForSessionToolBar(); + } else { + log("Form not yet available. Retrying..."); + setTimeout(pollForForm, POLL_INTERVAL_MS); + } } - // 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 - // here so that the rest of the function doesn't need to do type checks over - // and over. - const rootNode = document.querySelector(":root"); - if (!(rootNode instanceof HTMLHtmlElement)) { - // Remove the container entirely because if the browser is busted, who knows - // 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 - styleContainer.remove(); - return; + /** + * Continuously polls the DOM for a specific form element. + * - Searches for a element. + * - If found, adds another listener to session toolbar + * - If not found, logs a retry message and schedules another check after a delay. + * + * @returns {void} + */ + function pollForSessionToolBar() { + const sessionToolBar = document.querySelector("session-toolbar"); + if (sessionToolBar) { + log("Top bar detected. Proceeding with next steps..."); + attachCloseListener(sessionToolBar); + + // Automatically set checkboxes to improve user experience + setCheckbox("Unicode Keyboard Mode", true); + setCheckbox("Dynamic Resize", true); + } else { + log("Top bar not yet available. Retrying..."); + setTimeout(pollForSessionToolBar, POLL_INTERVAL_MS); + } } - // It's safe to make the form visible preemptively because Devolutions - // 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, - // it will still be covered up. - const restoreOpacity = () => { - rootNode.style.setProperty(cssOpacityVariableName, "1"); - }; + /** + * Logs a message to the console with a standardized prefix. + * Format: [Devolutions Patch] $ + * + * @param {string} msg - The message to log. + * @returns {void} + */ + function log(msg) { + console.log(`[Devolutions Patch] $${msg}`); + } - // 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, - // 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. - - // Have the form automatically reappear no matter what, so that if something - // does break, the user isn't left out to dry - window.setTimeout(restoreOpacity, 5_000); - - /** @type {HTMLFormElement | null} */ - const form = document.querySelector("web-client-form > form"); - form?.addEventListener( - "submit", - () => { - // 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 - window.setTimeout(restoreOpacity, 1_000); - }, - { once: true }, - ); -} - -// Always safe to call these immediately because even if the Angular app isn't -// loaded by the time the function gets called, the CSS will always be globally -// available for when Angular is finally ready -setupAlwaysOnStyles(); -hideFormForInitialSubmission(); - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", setupFormDetection); -} else { - setupFormDetection(); -} + // Always safe to call these immediately because even if the Angular app isn't + // loaded by the time the function gets called, the CSS will always be globally + // available for when Angular is finally ready + setupAlwaysOnStyles(); + hideFormForInitialSubmission(); + + log("Script loaded. Starting form detection..."); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", pollForForm); + } else { + pollForForm(); + } +})(); diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts index 125b3b3b6..80c09fd0d 100644 --- a/registry/coder/modules/windows-rdp/main.test.ts +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -59,9 +59,11 @@ describe("Web RDP", async () => { expect(lines).toEqual( expect.arrayContaining([ '$moduleName = "DevolutionsGateway"', - // Devolutions does versioning in the format year.minor.patch - expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/), - "Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force", + // Default is "latest" to automatically get the newest version + '$moduleVersion = "latest"', + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12", + "Set-PSRepository -Name PSGallery -InstallationPolicy Trusted", + "Install-Module -Name $moduleName -Force", ]), ); }); @@ -86,7 +88,7 @@ describe("Web RDP", async () => { * @see {@link https://regex101.com/r/UMgQpv/2} */ const formEntryValuesRe = - /^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?.+?)",$.*?^};$/ms; + /username:\s*\{[\s\S]*?value:\s*"(?[^"]+)"[\s\S]*?password:\s*\{[\s\S]*?value:\s*"(?[^"]+)"/; // Test that things work with the default username/password const defaultState = await runTerraformApply( diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf index c1b996dd6..3c83d195b 100644 --- a/registry/coder/modules/windows-rdp/main.tf +++ b/registry/coder/modules/windows-rdp/main.tf @@ -9,6 +9,24 @@ terraform { } } +variable "display_name" { + type = string + description = "The display name for the Web RDP application." + default = "Web RDP" +} + +variable "slug" { + type = string + description = "The slug for the Web RDP application." + default = "web-rdp" +} + +variable "icon" { + type = string + description = "The icon for the Web RDP application." + default = "/icon/desktop.svg" +} + variable "order" { type = number description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." @@ -48,8 +66,8 @@ variable "admin_password" { variable "devolutions_gateway_version" { type = string - default = "2025.2.2" - description = "Version of Devolutions Gateway to install. Defaults to the latest available version." + default = "latest" + description = "Version of Devolutions Gateway to install. Use 'latest' for the most recent version, or specify a version like '2025.3.2'." } resource "coder_script" "windows-rdp" { @@ -77,10 +95,10 @@ resource "coder_script" "windows-rdp" { resource "coder_app" "windows-rdp" { agent_id = var.agent_id share = var.share - slug = "web-rdp" - display_name = "Web RDP" + slug = var.slug + display_name = var.display_name url = "http://localhost:7171" - icon = "/icon/desktop.svg" + icon = var.icon subdomain = true order = var.order group = var.group diff --git a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl index 27c45b45b..1657b878d 100644 --- a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl +++ b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl @@ -2,6 +2,9 @@ function Set-AdminPassword { param ( [string]$adminPassword ) + # Explicitly import LocalAccounts module + Import-Module Microsoft.PowerShell.LocalAccounts -ErrorAction SilentlyContinue + # Set admin password Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force) # Enable admin user @@ -28,23 +31,61 @@ function Install-DevolutionsGateway { $moduleName = "DevolutionsGateway" $moduleVersion = "${devolutions_gateway_version}" +# Ensure TLS 1.2 is enabled for PSGallery +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + # Install the module with the specified version for all users # This requires administrator privileges try { # Install-PackageProvider is required for AWS. Need to set command to # terminate on failure so that try/catch actually triggers Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop - Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + + # Set PSGallery as trusted after NuGet is installed + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + + if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) { + Install-Module -Name $moduleName -Force + } else { + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + } } catch { # If the first command failed, assume that we're on GCP and run # Install-Module only - Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) { + Install-Module -Name $moduleName -Force + } else { + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force + } } # Construct the module path for system-wide installation -$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion" -$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1" +$modulePath = $null # Declare outside the loop + +if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) { + $installedModule = Get-InstalledModule -Name $moduleName -ErrorAction SilentlyContinue + if ($installedModule) { + $installedVersion = $installedModule.Version.ToString() + } +} else { + $installedVersion = $moduleVersion +} + +$paths = $env:PSModulePath -split ';' + +foreach ($path in $paths) { + $candidatePath = Join-Path -Path $path -ChildPath $moduleName + if ($installedVersion) { + $candidatePath = Join-Path -Path $candidatePath -ChildPath $installedVersion + } + + $psd1Path = Join-Path -Path $candidatePath -ChildPath "$moduleName.psd1" + if (Test-Path $psd1Path) { + $modulePath = $psd1Path + break + } +} # Import the module using the full path Import-Module $modulePath