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" {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@ -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>(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user