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" {
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
}

View File

@ -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();
}
})();

View File

@ -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>(

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" {
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

View File

@ -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