You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
modules/windows-rdp/devolutions-patch.js

362 lines
11 KiB
JavaScript

// @ts-check
/**
* @file Defines the custom logic for patching in UI changes/behavior into the
* base Devolutions Gateway Angular app.
*
* Defined as a JS file to remove the need to have a separate compilation step.
* It is highly recommended that you work on this file from within VS Code so
* that you can take advantage of the @ts-check directive and get some type-
* checking still.
*
* A lot of the HTML selectors in this file will look nonstandard. This is
* because they are actually custom Angular components.
*
* @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
* @typedef {Readonly<Record<string, FormFieldEntry>>} FormFieldEntries
*/
/**
* The communication protocol to set Devolutions to.
*/
const PROTOCOL = "RDP";
/**
* The hostname to use with Devolutions.
*/
const HOSTNAME = "localhost";
/**
* How often to poll the screen for the main Devolutions form.
*/
const SCREEN_POLL_INTERVAL_MS = 500;
/**
* The fields in the Devolutions sign-in form that should be populated with
* values from the Coder workspace.
*
* All properties should be defined as placeholder templates in the form
* VALUE_NAME. The Coder module, when spun up, should then run some logic to
* replace the template slots with actual values. These values should never
* change from within JavaScript itself.
*
* @satisfies {FormFieldEntries}
*/
const formFieldEntries = {
/** @readonly */
username: {
/** @readonly */
querySelector: "web-client-username-control input",
/** @readonly */
value: "${CODER_USERNAME}",
},
/** @readonly */
password: {
/** @readonly */
querySelector: "web-client-password-control input",
/** @readonly */
value: "${CODER_PASSWORD}",
},
};
/**
* Handles typing in the values for the input form, dispatching each character
* as an event. This function assumes that all characters in the input will be
* UTF-8.
*
* 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) {
const continueEventName = "coder-patch--continue";
const promise = /** @type {Promise<void>} */ (
new Promise((resolve, reject) => {
if (inputText === "") {
resolve();
return;
}
// -1 indicates a "pre-write" for clearing out the input before trying to
// write new text to it
let i = -1;
// requestAnimationFrame is not capable of giving back values of 0 for its
// task IDs. Good default value to ensure that we don't need if statements
// when trying to cancel anything
let currentAnimationId = 0;
// Super easy to pool the same event objects, because the events don't
// have any custom, context-specific values on them, and they're
// restricted to this one callback.
const continueEvent = new CustomEvent(continueEventName);
const inputEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
/** @returns {void} */
const handleNextCharIndex = () => {
if (i === inputText.length) {
resolve();
return;
}
const currentChar = inputText[i];
if (i !== -1 && currentChar === undefined) {
throw new Error("Went out of bounds");
}
try {
inputField.addEventListener(
continueEventName,
() => {
i++;
currentAnimationId =
window.requestAnimationFrame(handleNextCharIndex);
},
{ once: true },
);
if (i === -1) {
inputField.value = "";
} else {
inputField.value = `$${inputField.value}$${currentChar}`;
}
inputField.dispatchEvent(inputEvent);
inputField.dispatchEvent(continueEvent);
} catch (err) {
cancelAnimationFrame(currentAnimationId);
reject(err);
}
};
currentAnimationId = window.requestAnimationFrame(handleNextCharIndex);
})
);
return promise;
}
/**
* 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(
`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(
`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 = () => {
console.log("Ran on mutation!");
/** @type {HTMLFormElement | null} */
const latestForm = document.querySelector("web-client-form > form");
if (latestForm === null) {
formValueFromLastMutation = null;
return;
}
// 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
if (formValueFromLastMutation === null) {
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 setupObscuringStyles() {
const styleId = "coder-patch--styles";
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);
}
// Always safe to call setupObscuringStyles 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
setupObscuringStyles();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupFormDetection);
} else {
setupFormDetection();
}