Fix Devolutions Auto-Complete (#508)

## Description

I’ve completed a set of modifications to improve the user experience and
session behaviour within Devolutions Gateway:

- Auto-Complete Fix: Resolved issues with auto-complete functionality.
- Container Visibility: Implemented logic to hide the app-net-scan
container, preventing it from displaying during the initial session
load.
- Default Settings: Enabled Unicode keyboard mode and dynamic window
resizing by default to enhance usability.
- Session Closure Behaviour: Modified the "Close Session" button to
fully close the session window, avoiding returns to the session manager.
- Dynamic Module Path Construction: Refactored the PowerShell module
path setup to be dynamically constructed.
- Input Variables: Added `slug` and `display_name` as input variables.

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder/modules/windows-rdp`  
**New version:** `v1.3.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally

## Related Issues

"None"

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: DevelopmentCats <chris@dualriver.com>
Co-authored-by: Eric Paulsen <ericpaulsen@coder.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Rhys Williams 2025-10-28 10:00:41 +00:00 committed by GitHub
parent 1a15ad650a
commit d6d0101f09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 462 additions and 376 deletions

View File

@ -15,7 +15,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
module "windows_rdp" { module "windows_rdp" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder" source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3" version = "1.3.0"
agent_id = resource.coder_agent.main.id agent_id = resource.coder_agent.main.id
} }
``` ```
@ -32,7 +32,7 @@ module "windows_rdp" {
module "windows_rdp" { module "windows_rdp" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder" source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3" version = "1.3.0"
agent_id = resource.coder_agent.main.id agent_id = resource.coder_agent.main.id
} }
``` ```
@ -43,7 +43,7 @@ module "windows_rdp" {
module "windows_rdp" { module "windows_rdp" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder" source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3" version = "1.3.0"
agent_id = resource.coder_agent.main.id agent_id = resource.coder_agent.main.id
} }
``` ```
@ -54,7 +54,7 @@ module "windows_rdp" {
module "windows_rdp" { module "windows_rdp" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder" source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3" version = "1.3.0"
agent_id = resource.coder_agent.main.id agent_id = resource.coder_agent.main.id
devolutions_gateway_version = "2025.2.2" # Specify a specific version devolutions_gateway_version = "2025.2.2" # Specify a specific version
} }

View File

@ -25,401 +25,426 @@
* @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry * @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
* @typedef {Readonly<Record<string, FormFieldEntry>>} FormFieldEntries * @typedef {Readonly<Record<string, FormFieldEntry>>} FormFieldEntries
*/ */
(function () {
/**
* The communication protocol to set Devolutions to.
*/
const PROTOCOL = "RDP";
/** /**
* The communication protocol to set Devolutions to. * The hostname to use with Devolutions.
*/ */
const PROTOCOL = "RDP"; const HOSTNAME = "localhost";
/** /**
* The hostname to use with Devolutions. * How often to poll the screen for the main Devolutions form.
*/ */
const HOSTNAME = "localhost"; const POLL_INTERVAL_MS = 500;
/** /**
* How often to poll the screen for the main Devolutions form. * The fields in the Devolutions sign-in form that should be populated with
*/ * values from the Coder workspace.
const SCREEN_POLL_INTERVAL_MS = 500; *
* 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
* The fields in the Devolutions sign-in form that should be populated with * replace the template slots with actual values. These values should never
* values from the Coder workspace. * change from within JavaScript itself.
* *
* All properties should be defined as placeholder templates in the form * @satisfies {FormFieldEntries}
* 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 const formFieldEntries = {
* change from within JavaScript itself.
*
* @satisfies {FormFieldEntries}
*/
const formFieldEntries = {
/** @readonly */
username: {
/** @readonly */ /** @readonly */
querySelector: "web-client-username-control input", username: {
/** @readonly */
querySelector: "web-client-username-control input",
/** @readonly */
value: "${CODER_USERNAME}",
},
/** @readonly */ /** @readonly */
value: "${CODER_USERNAME}", password: {
}, /** @readonly */
querySelector: "web-client-password-control input",
/** @readonly */ /** @readonly */
password: { value: "${CODER_PASSWORD}",
/** @readonly */ },
querySelector: "web-client-password-control input",
/** @readonly */
value: "${CODER_PASSWORD}",
},
};
/**
* 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<void>}
*/
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);
// 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,
});
inputField.value = inputText;
inputField.dispatchEvent(inputEvent);
});
}
/**
* 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<void>}
*/
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");
}
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",
);
}
protocolOption.click();
}; };
const setHostname = () => { /**
/** @type {HTMLInputElement | null} */ * This ensures that the Devolutions login form (which by default, always shows
const hostnameInput = myForm.querySelector("p-autocomplete#hostname input"); * 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";
if (hostnameInput === null) { /** @type {HTMLStyleElement | null} */
throw new Error("Unable to find field for adding hostname"); // 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;
}
/*
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);
} }
return setInputValue(hostnameInput, HOSTNAME); // 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
const setCoderFormFieldValues = async () => { // and over.
// The RDP form will not appear on screen unless the dropdown is set to use const rootNode = document.querySelector(":root");
// the RDP protocol if (!(rootNode instanceof HTMLHtmlElement)) {
const rdpSubsection = myForm.querySelector("rdp-form"); // Remove the container entirely because if the browser is busted, who knows
if (rdpSubsection === null) { // if the CSS variables can be applied correctly. Better to have something
throw new Error( // be a bit more ugly/painful to use, than have it be impossible to use
"Unable to find RDP subsection. Is the value of the protocol set to RDP?", styleContainer.remove();
);
}
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 + '"',
);
}
await setInputValue(input, value);
}
};
const triggerSubmission = () => {
/** @type {HTMLButtonElement | null} */
const submitButton = myForm.querySelector(
'p-button[ng-reflect-type="submit"] button',
);
if (submitButton === null) {
throw new Error("Unable to find submission button");
}
if (submitButton.disabled) {
throw new Error(
"Unable to submit form because submit button is disabled. Are all fields filled out correctly?",
);
}
submitButton.click();
};
setProtocolValue();
await setHostname();
await setCoderFormFieldValues();
triggerSubmission();
}
/**
* 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;
/** @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);
}
formValueFromLastMutation = latestForm;
};
/** @type {number | undefined} */
let pollingId = undefined;
/** @returns {void} */
const checkScreenForDynamicTab = () => {
const dynamicTab = document.querySelector("web-client-dynamic-tab");
// Keep polling until the main content container is on screen
if (dynamicTab === null) {
return; return;
} }
window.clearInterval(pollingId); // 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");
};
// Call the mutation callback manually, to ensure it runs at least once // If this file gets more complicated, it might make sense to set up the
onDynamicTabMutation(); // 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.
// Having the mutation observer is kind of an extra safety net that isn't // Have the form automatically reappear no matter what, so that if something
// really expected to run that often. Most of the content in the dynamic // does break, the user isn't left out to dry
// tab is being rendered through Canvas, which won't trigger any mutations window.setTimeout(restoreOpacity, 5_000);
// that the observer can detect
const dynamicTabObserver = new MutationObserver(onDynamicTabMutation);
dynamicTabObserver.observe(dynamicTab, {
subtree: true,
childList: true,
});
};
pollingId = window.setInterval( /** @type {HTMLFormElement | null} */
checkScreenForDynamicTab, const form = document.querySelector("web-client-form > form");
SCREEN_POLL_INTERVAL_MS, 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
* Sets up custom styles for hiding default Devolutions elements that Coder window.setTimeout(restoreOpacity, 1_000);
* users shouldn't need to care about. },
* { once: true },
* @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; * Sets up custom styles for hiding default Devolutions elements that Coder
styleContainer.innerHTML = ` * users shouldn't need to care about.
/* app-menu corresponds to the sidebar of the default view. */ *
app-menu { * @returns {void}
display: none !important; */
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;
} }
`;
document.head.appendChild(styleContainer); const styleContainer = document.createElement("style");
}
/**
* 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.id = styleId;
styleContainer.innerHTML = ` styleContainer.innerHTML = `
/* /* app-menu corresponds to the sidebar of the default view. */
Have to use opacity instead of visibility, because the element still app-menu {
needs to be interactive via the script so that it can be auto-filled. display: none !important;
*/
: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;
} }
/* /* app-net-scan corresponds to the auto-discovery feature. */
web-client-form is the container for the main session form, while app-net-scan {
the div is for the dropdown that is used for selecting the protocol. display: none !important;
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); document.head.appendChild(styleContainer);
} }
// 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 * Handles typing in the values for the input form. All values are written
// here so that the rest of the function doesn't need to do type checks over * immediately, even though that would be physically impossible with a real
// and over. * keyboard.
const rootNode = document.querySelector(":root"); *
if (!(rootNode instanceof HTMLHtmlElement)) { * Note: this code will never break, but you might get warnings in the console
// Remove the container entirely because if the browser is busted, who knows * from Angular about unexpected value changes. Angular patches over a lot of
// if the CSS variables can be applied correctly. Better to have something * the built-in browser APIs to support its component change detection system.
// be a bit more ugly/painful to use, than have it be impossible to use * As part of that, it has validations for checking whether an input it
styleContainer.remove(); * previously had control over changed without it doing anything.
return; *
* 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<void>}
*/
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);
// 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,
});
inputField.value = inputText;
inputField.dispatchEvent(inputEvent);
});
} }
// It's safe to make the form visible preemptively because Devolutions /**
// outputs the Windows view through an HTML canvas that it overlays on top * Takes a Devolutions remote session form, auto-fills it with data, and then
// of the rest of the app. Even if the form isn't hidden at the style level, * submits it.
// it will still be covered up. *
const restoreOpacity = () => { * The logic here is more convoluted than it should be for two main reasons:
rootNode.style.setProperty(cssOpacityVariableName, "1"); * 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...");
// If this file gets more complicated, it might make sense to set up the // By default, RDP is selected. Leaving this here if needed
// timeout and event listener so that if one triggers, it cancels the other, // in the future.
// but having restoreOpacity run more than once is a no-op for right now. const protocolTrigger = form.querySelector('p-dropdown[id="protocol"]');
// Not a big deal if these don't get cleaned up. 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.");
}
// Have the form automatically reappear no matter what, so that if something const hostnameInput = form.querySelector("p-autocomplete#hostname input");
// does break, the user isn't left out to dry if (hostnameInput) {
window.setTimeout(restoreOpacity, 5_000); await setInputValue(hostnameInput, HOSTNAME);
log(`Hostname set to $${HOSTNAME}`);
} else {
log("Hostname input not found.");
}
/** @type {HTMLFormElement | null} */ for (const [key, { querySelector, value }] of Object.entries(
const form = document.querySelector("web-client-form > form"); formFieldEntries,
form?.addEventListener( )) {
"submit", const input = document.querySelector(querySelector);
() => { if (input) {
// Not restoring opacity right away just to give the HTML canvas a little await setInputValue(input, value);
// bit of time to get spun up and cover up the main form log(`Set $${key} to $${value}`);
window.setTimeout(restoreOpacity, 1_000); } else {
}, log(`Input for $${key} not found with selector: $${querySelector}`);
{ once: true }, }
); }
}
// Always safe to call these immediately because even if the Angular app isn't const submitButton = form.querySelector(
// loaded by the time the function gets called, the CSS will always be globally 'p-button[class="p-element"] button',
// available for when Angular is finally ready );
setupAlwaysOnStyles(); if (submitButton && !submitButton.disabled) {
hideFormForInitialSubmission(); submitButton.click();
log("Form submitted.");
} else {
log("Submit button not found or disabled.");
}
} catch (err) {
console.error("[Devolutions Patch] Error during form fill:", err);
}
}
if (document.readyState === "loading") { /**
document.addEventListener("DOMContentLoaded", setupFormDetection); * Attaches a click event listener to the "Close Session" button within the provided top bar element.
} else { * When clicked, the listener triggers the window to close.
setupFormDetection(); * 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";
});
if (closeButton) {
closeButton.parentElement.addEventListener("click", () => {
window.close();
});
log("Close listener attached.");
} else {
log("Close button not found in top bar.");
}
}
/**
* Sets the checked state of a checkbox based on its label text.
* Searches all <p-checkbox> 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;
});
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.`);
}
}
/**
* Continuously polls the DOM for a specific form element.
* - Searches for a <form> inside a <web-client-form> 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);
}
}
/**
* Continuously polls the DOM for a specific form element.
* - Searches for a <session-toolbar> 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);
}
}
/**
* Logs a message to the console with a standardized prefix.
* Format: [Devolutions Patch] $<message>
*
* @param {string} msg - The message to log.
* @returns {void}
*/
function log(msg) {
console.log(`[Devolutions Patch] $${msg}`);
}
// 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();
}
})();

View File

@ -59,9 +59,11 @@ describe("Web RDP", async () => {
expect(lines).toEqual( expect(lines).toEqual(
expect.arrayContaining<string>([ expect.arrayContaining<string>([
'$moduleName = "DevolutionsGateway"', '$moduleName = "DevolutionsGateway"',
// Devolutions does versioning in the format year.minor.patch // Default is "latest" to automatically get the newest version
expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/), '$moduleVersion = "latest"',
"Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force", "[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} * @see {@link https://regex101.com/r/UMgQpv/2}
*/ */
const formEntryValuesRe = const formEntryValuesRe =
/^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?<username>.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?<password>.+?)",$.*?^};$/ms; /username:\s*\{[\s\S]*?value:\s*"(?<username>[^"]+)"[\s\S]*?password:\s*\{[\s\S]*?value:\s*"(?<password>[^"]+)"/;
// Test that things work with the default username/password // Test that things work with the default username/password
const defaultState = await runTerraformApply<TestVariables>( const defaultState = await runTerraformApply<TestVariables>(

View File

@ -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" { variable "order" {
type = number 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)." 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" { variable "devolutions_gateway_version" {
type = string type = string
default = "2025.2.2" default = "latest"
description = "Version of Devolutions Gateway to install. Defaults to the latest available version." 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" { resource "coder_script" "windows-rdp" {
@ -77,10 +95,10 @@ 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 = var.display_name
url = "http://localhost:7171" url = "http://localhost:7171"
icon = "/icon/desktop.svg" icon = var.icon
subdomain = true subdomain = true
order = var.order order = var.order
group = var.group group = var.group

View File

@ -2,6 +2,9 @@ function Set-AdminPassword {
param ( param (
[string]$adminPassword [string]$adminPassword
) )
# Explicitly import LocalAccounts module
Import-Module Microsoft.PowerShell.LocalAccounts -ErrorAction SilentlyContinue
# Set admin password # Set admin password
Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force) Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force)
# Enable admin user # Enable admin user
@ -28,23 +31,61 @@ function Install-DevolutionsGateway {
$moduleName = "DevolutionsGateway" $moduleName = "DevolutionsGateway"
$moduleVersion = "${devolutions_gateway_version}" $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 # Install the module with the specified version for all users
# This requires administrator privileges # This requires administrator privileges
try { try {
# Install-PackageProvider is required for AWS. Need to set command to # Install-PackageProvider is required for AWS. Need to set command to
# terminate on failure so that try/catch actually triggers # terminate on failure so that try/catch actually triggers
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop 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 { catch {
# If the first command failed, assume that we're on GCP and run # If the first command failed, assume that we're on GCP and run
# Install-Module only # 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 # Construct the module path for system-wide installation
$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion" $modulePath = $null # Declare outside the loop
$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1"
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 the module using the full path
Import-Module $modulePath Import-Module $modulePath