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:
parent
1a15ad650a
commit
d6d0101f09
@ -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
|
||||
}
|
||||
|
||||
@ -25,23 +25,23 @@
|
||||
* @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
|
||||
* @typedef {Readonly<Record<string, FormFieldEntry>>} FormFieldEntries
|
||||
*/
|
||||
|
||||
/**
|
||||
(function () {
|
||||
/**
|
||||
* The communication protocol to set Devolutions to.
|
||||
*/
|
||||
const PROTOCOL = "RDP";
|
||||
const PROTOCOL = "RDP";
|
||||
|
||||
/**
|
||||
/**
|
||||
* The hostname to use with Devolutions.
|
||||
*/
|
||||
const HOSTNAME = "localhost";
|
||||
const HOSTNAME = "localhost";
|
||||
|
||||
/**
|
||||
/**
|
||||
* How often to poll the screen for the main Devolutions form.
|
||||
*/
|
||||
const SCREEN_POLL_INTERVAL_MS = 500;
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
/**
|
||||
* The fields in the Devolutions sign-in form that should be populated with
|
||||
* values from the Coder workspace.
|
||||
*
|
||||
@ -52,7 +52,7 @@ const SCREEN_POLL_INTERVAL_MS = 500;
|
||||
*
|
||||
* @satisfies {FormFieldEntries}
|
||||
*/
|
||||
const formFieldEntries = {
|
||||
const formFieldEntries = {
|
||||
/** @readonly */
|
||||
username: {
|
||||
/** @readonly */
|
||||
@ -61,7 +61,6 @@ const formFieldEntries = {
|
||||
/** @readonly */
|
||||
value: "${CODER_USERNAME}",
|
||||
},
|
||||
|
||||
/** @readonly */
|
||||
password: {
|
||||
/** @readonly */
|
||||
@ -70,250 +69,9 @@ const formFieldEntries = {
|
||||
/** @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} */
|
||||
const hostnameInput = myForm.querySelector("p-autocomplete#hostname input");
|
||||
|
||||
if (hostnameInput === null) {
|
||||
throw new Error("Unable to find field for adding hostname");
|
||||
}
|
||||
|
||||
return setInputValue(hostnameInput, HOSTNAME);
|
||||
};
|
||||
|
||||
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?",
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
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.
|
||||
@ -325,7 +83,7 @@ function setupAlwaysOnStyles() {
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function hideFormForInitialSubmission() {
|
||||
function hideFormForInitialSubmission() {
|
||||
const styleId = "coder-patch--styles-initial-submission";
|
||||
const cssOpacityVariableName = "--coder-opacity-multiplier";
|
||||
|
||||
@ -410,16 +168,283 @@ function hideFormForInitialSubmission() {
|
||||
},
|
||||
{ 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();
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupFormDetection);
|
||||
} else {
|
||||
setupFormDetection();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/* app-net-scan corresponds to the auto-discovery feature. */
|
||||
app-net-scan {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(styleContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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} 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.");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
||||
@ -59,9 +59,11 @@ describe("Web RDP", async () => {
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining<string>([
|
||||
'$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: "(?<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
|
||||
const defaultState = await runTerraformApply<TestVariables>(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user