// @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>} 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} */ function setInputValue(inputField, inputText) { const continueEventName = "coder-patch--continue"; const promise = /** @type {Promise} */ ( 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} */ 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(); }