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.
362 lines
11 KiB
JavaScript
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();
|
|
}
|