'use strict'; TABS.cli = { lineDelayMs: 15, profileSwitchDelayMs: 100, outputHistory: "", cliBuffer: "", startProcessing: false, GUI: { snippetPreviewWindow: null, copyButton: null, windowWrapper: null, }, }; function removePromptHash(promptText) { return promptText.replace(/^# /, ''); } function cliBufferCharsToDelete(command, buffer) { let commonChars = 0; for (let i = 0; i < buffer.length; i++) { if (command[i] === buffer[i]) { commonChars++; } else { break; } } return buffer.length - commonChars; } function commandWithBackSpaces(command, buffer, noOfCharsToDelete) { const backspace = String.fromCharCode(127); return backspace.repeat(noOfCharsToDelete) + command.substring(buffer.length - noOfCharsToDelete, command.length); } function getCliCommand(command, cliBuffer) { const buffer = removePromptHash(cliBuffer); const bufferRegex = new RegExp(`^${buffer}`, 'g'); if (command.match(bufferRegex)) { return command.replace(bufferRegex, ''); } const noOfCharsToDelete = cliBufferCharsToDelete(command, buffer); return commandWithBackSpaces(command, buffer, noOfCharsToDelete); } function copyToClipboard(text) { function onCopySuccessful() { analytics.sendEvent(analytics.EVENT_CATEGORIES.FLIGHT_CONTROLLER, 'CliCopyToClipboard', text.length); const button = TABS.cli.GUI.copyButton; const origText = button.text(); const origWidth = button.css("width"); button.text(i18n.getMessage("cliCopySuccessful")); button.css({ width: origWidth, textAlign: "center", }); setTimeout(() => { button.text(origText); button.css({ width: "", textAlign: "", }); }, 1500); } function onCopyFailed(ex) { console.warn(ex); } Clipboard.writeText(text, onCopySuccessful, onCopyFailed); } TABS.cli.initialize = function (callback) { const self = this; if (GUI.active_tab !== 'cli') { GUI.active_tab = 'cli'; } self.outputHistory = ""; self.cliBuffer = ""; self.startProcessing = false; const enterKeyCode = 13; function executeCommands(outString) { self.history.add(outString.trim()); const outputArray = outString.split("\n"); Promise.reduce(outputArray, function(delay, line, index) { return new Promise(function (resolve) { GUI.timeout_add('CLI_send_slowly', function () { let processingDelay = self.lineDelayMs; line = line.trim(); if (line.toLowerCase().startsWith('profile')) { processingDelay = self.profileSwitchDelayMs; } const isLastCommand = outputArray.length === index + 1; if (isLastCommand && self.cliBuffer) { line = getCliCommand(line, self.cliBuffer); } self.sendLine(line, function () { resolve(processingDelay); }); }, delay); }); }, 0); } $('#content').load("./tabs/cli.html", function () { // translate to user-selected language i18n.localizePage(); TABS.cli.adaptPhones(); CONFIGURATOR.cliActive = true; self.GUI.copyButton = $('.tab-cli .copy'); self.GUI.windowWrapper = $('.tab-cli .window .wrapper'); const textarea = $('.tab-cli textarea[name="commands"]'); CliAutoComplete.initialize(textarea, self.sendLine.bind(self), writeToOutput); $(CliAutoComplete).on('build:start', function() { textarea .val('') .attr('placeholder', i18n.getMessage('cliInputPlaceholderBuilding')) .prop('disabled', true); }); $(CliAutoComplete).on('build:stop', function() { textarea .attr('placeholder', i18n.getMessage('cliInputPlaceholder')) .prop('disabled', false) .focus(); }); $('.tab-cli .save').click(function() { const prefix = 'cli'; const suffix = 'txt'; const filename = generateFilename(prefix, suffix); const accepts = [{ description: `${suffix.toUpperCase()} files`, extensions: [suffix], }]; chrome.fileSystem.chooseEntry({type: 'saveFile', suggestedName: filename, accepts: accepts}, function(entry) { if (checkChromeRuntimeError()) { return; } if (!entry) { console.log('No file selected'); return; } entry.createWriter(function (writer) { writer.onerror = function (){ console.error('Failed to write file'); }; writer.onwriteend = function () { if (self.outputHistory.length > 0 && writer.length === 0) { writer.write(new Blob([self.outputHistory], {type: 'text/plain'})); } else { analytics.sendEvent(analytics.EVENT_CATEGORIES.FLIGHT_CONTROLLER, 'CliSave', self.outputHistory.length); console.log('write complete'); } }; writer.truncate(0); }, function (){ console.error('Failed to get file writer'); }); }); }); $('.tab-cli .clear').click(function() { self.outputHistory = ""; self.GUI.windowWrapper.empty(); }); if (Clipboard.available) { self.GUI.copyButton.click(function() { copyToClipboard(self.outputHistory); }); } else { self.GUI.copyButton.hide(); } $('.tab-cli .load').click(function() { const accepts = [ { description: 'Config files', extensions: ["txt", "config"], }, { description: 'All files', }, ]; chrome.fileSystem.chooseEntry({type: 'openFile', accepts: accepts}, function(entry) { if (checkChromeRuntimeError()) { return; } const previewArea = $("#snippetpreviewcontent textarea#preview"); function executeSnippet(fileName) { const commands = previewArea.val(); analytics.sendEvent(analytics.EVENT_CATEGORIES.FLIGHT_CONTROLLER, 'CliExecuteFromFile', fileName); executeCommands(commands); self.GUI.snippetPreviewWindow.close(); } function previewCommands(result, fileName) { if (!self.GUI.snippetPreviewWindow) { self.GUI.snippetPreviewWindow = new jBox("Modal", { id: "snippetPreviewWindow", width: 'auto', height: 'auto', closeButton: 'title', animation: false, isolateScroll: false, title: i18n.getMessage("cliConfirmSnippetDialogTitle", { fileName: fileName }), content: $('#snippetpreviewcontent'), onCreated: () => $("#snippetpreviewcontent a.confirm").click(() => executeSnippet(fileName)) , }); } previewArea.val(result); self.GUI.snippetPreviewWindow.open(); } entry.file((file) => { const reader = new FileReader(); reader.onload = () => previewCommands(reader.result, file.name); reader.onerror = () => console.error(reader.error); reader.readAsText(file); }); }); }); // Tab key detection must be on keydown, // `keypress`/`keyup` happens too late, as `textarea` will have already lost focus. textarea.keydown(function (event) { const tabKeyCode = 9; if (event.which === tabKeyCode) { // prevent default tabbing behaviour event.preventDefault(); if (!CliAutoComplete.isEnabled()) { // Native FC autoComplete const outString = textarea.val(); const lastCommand = outString.split("\n").pop(); const command = getCliCommand(lastCommand, self.cliBuffer); if (command) { self.sendNativeAutoComplete(command); textarea.val(''); } } else if (!CliAutoComplete.isOpen() && !CliAutoComplete.isBuilding()) { // force show autocomplete on Tab CliAutoComplete.openLater(true); } } }); textarea.keypress(function (event) { if (event.which === enterKeyCode) { event.preventDefault(); // prevent the adding of new line if (CliAutoComplete.isBuilding()) { return; // silently ignore commands if autocomplete is still building } const outString = textarea.val(); executeCommands(outString); textarea.val(''); } }); textarea.keyup(function (event) { const keyUp = {38: true}; const keyDown = {40: true}; if (CliAutoComplete.isOpen()) { return; // disable history keys if autocomplete is open } if (event.keyCode in keyUp) { textarea.val(self.history.prev()); } if (event.keyCode in keyDown) { textarea.val(self.history.next()); } }); // give input element user focus textarea.focus(); GUI.timeout_add('enter_cli', function enter_cli() { // Enter CLI mode const bufferOut = new ArrayBuffer(1); const bufView = new Uint8Array(bufferOut); bufView[0] = 0x23; // # serial.send(bufferOut); }, 250); GUI.content_ready(callback); }); }; TABS.cli.adaptPhones = function() { if ($(window).width() < 575) { const backdropHeight = $('.note').height() + 22 + 38; $('.backdrop').css('height', `calc(100% - ${backdropHeight}px)`); } if (GUI.isCordova()) { UI_PHONES.initToolbar(); } }; TABS.cli.history = { history: [], index: 0, }; TABS.cli.history.add = function (str) { this.history.push(str); this.index = this.history.length; }; TABS.cli.history.prev = function () { if (this.index > 0) { this.index -= 1; } return this.history[this.index]; }; TABS.cli.history.next = function () { if (this.index < this.history.length) { this.index += 1; } return this.history[this.index - 1]; }; const backspaceCode = 8; const lineFeedCode = 10; const carriageReturnCode = 13; function writeToOutput(text) { TABS.cli.GUI.windowWrapper.append(text); const cliWindow = $('.tab-cli .window'); cliWindow.scrollTop(cliWindow.prop("scrollHeight")); } function writeLineToOutput(text) { if (CliAutoComplete.isBuilding()) { CliAutoComplete.builderParseLine(text); return; // suppress output if in building state } if (text.startsWith("###ERROR")) { writeToOutput(`${text}
`); } else { writeToOutput(`${text}
`); } } function setPrompt(text) { $('.tab-cli textarea').val(text); } TABS.cli.read = function (readInfo) { /* Some info about handling line feeds and carriage return line feed = LF = \n = 0x0A = 10 carriage return = CR = \r = 0x0D = 13 MAC only understands CR Linux and Unix only understand LF Windows understands (both) CRLF Chrome OS currently unknown */ const data = new Uint8Array(readInfo.data); let validateText = ""; let sequenceCharsToSkip = 0; for (let i = 0; i < data.length; i++) { const currentChar = String.fromCharCode(data[i]); const isCRLF = currentChar.charCodeAt() === lineFeedCode || currentChar.charCodeAt() === carriageReturnCode; if (!CONFIGURATOR.cliValid && (isCRLF || this.startProcessing)) { // try to catch part of valid CLI enter message (firmware message starts with CRLF) this.startProcessing = true; validateText += currentChar; writeToOutput(currentChar); continue; } const escapeSequenceCode = 27; const escapeSequenceCharLength = 3; if (data[i] === escapeSequenceCode && !sequenceCharsToSkip) { // ESC + other sequenceCharsToSkip = escapeSequenceCharLength; } if (sequenceCharsToSkip) { sequenceCharsToSkip--; continue; } if (CONFIGURATOR.cliValid) { switch (data[i]) { case lineFeedCode: if (GUI.operating_system === "Windows") { writeLineToOutput(this.cliBuffer); this.cliBuffer = ""; } break; case carriageReturnCode: if (GUI.operating_system !== "Windows") { writeLineToOutput(this.cliBuffer); this.cliBuffer = ""; } break; case 60: this.cliBuffer += '<'; break; case 62: this.cliBuffer += '>'; break; case backspaceCode: this.cliBuffer = this.cliBuffer.slice(0, -1); this.outputHistory = this.outputHistory.slice(0, -1); continue; default: this.cliBuffer += currentChar; } } if (!CliAutoComplete.isBuilding()) { // do not include the building dialog into the history this.outputHistory += currentChar; } if (this.cliBuffer === 'Rebooting') { CONFIGURATOR.cliActive = false; CONFIGURATOR.cliValid = false; GUI.log(i18n.getMessage('cliReboot')); reinitializeConnection(self); } } if (!CONFIGURATOR.cliValid && validateText.indexOf('CLI') !== -1) { GUI.log(i18n.getMessage('cliEnter')); CONFIGURATOR.cliValid = true; // begin output history with the prompt (last line of welcome message) // this is to match the content of the history with what the user sees on this tab const lastLine = validateText.split("\n").pop(); this.outputHistory = lastLine; if (CliAutoComplete.isEnabled() && !CliAutoComplete.isBuilding()) { // start building autoComplete CliAutoComplete.builderStart(); } } // fallback to native autocomplete if (!CliAutoComplete.isEnabled()) { setPrompt(removePromptHash(this.cliBuffer)); } }; TABS.cli.sendLine = function (line, callback) { this.send(`${line}\n`, callback); }; TABS.cli.sendNativeAutoComplete = function (line, callback) { this.send(`${line}\t`, callback); }; TABS.cli.send = function (line, callback) { const bufferOut = new ArrayBuffer(line.length); const bufView = new Uint8Array(bufferOut); for (let cKey = 0; cKey < line.length; cKey++) { bufView[cKey] = line.charCodeAt(cKey); } serial.send(bufferOut, callback); }; TABS.cli.cleanup = function (callback) { if (TABS.cli.GUI.snippetPreviewWindow) { TABS.cli.GUI.snippetPreviewWindow.destroy(); TABS.cli.GUI.snippetPreviewWindow = null; } if (!(CONFIGURATOR.connectionValid && CONFIGURATOR.cliValid && CONFIGURATOR.cliActive)) { if (callback) { callback(); } return; } this.send(getCliCommand('exit\r', this.cliBuffer), function () { // we could handle this "nicely", but this will do for now // (another approach is however much more complicated): // we can setup an interval asking for data lets say every 200ms, when data arrives, callback will be triggered and tab switched // we could probably implement this someday if (callback) { callback(); } CONFIGURATOR.cliActive = false; CONFIGURATOR.cliValid = false; }); CliAutoComplete.cleanup(); $(CliAutoComplete).off(); };