chore: add script file for overriding Devolutions
							parent
							
								
									b93471a381
								
							
						
					
					
						commit
						20795aa2b6
					
				@ -0,0 +1,361 @@
 | 
				
			|||||||
 | 
					// @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();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
					Loading…
					
					
				
		Reference in New Issue