/** * @author n1474335 [n1474335@gmail.com] * @author j433866 [j433866@gmail.com] * @copyright Crown Copyright 2016 * @license Apache-2.0 */ import LoaderWorker from "worker-loader?inline=no-fallback!../workers/LoaderWorker.js"; import InputWorker from "worker-loader?inline=no-fallback!../workers/InputWorker.mjs"; import Utils, {debounce} from "../../core/Utils.mjs"; import {toBase64} from "../../core/lib/Base64.mjs"; import cptable from "codepage"; import { EditorView, keymap, highlightSpecialChars, drawSelection, rectangularSelection, crosshairCursor, dropCursor } from "@codemirror/view"; import { EditorState, Compartment } from "@codemirror/state"; import { defaultKeymap, insertTab, insertNewline, history, historyKeymap } from "@codemirror/commands"; import { bracketMatching } from "@codemirror/language"; import { search, searchKeymap, highlightSelectionMatches } from "@codemirror/search"; import {statusBar} from "../utils/statusBar.mjs"; import {fileDetailsPanel} from "../utils/fileDetails.mjs"; import {eolCodeToSeq, eolCodeToName, renderSpecialChar} from "../utils/editorUtils.mjs"; /** * Waiter to handle events related to the input. */ class InputWaiter { /** * InputWaiter constructor. * * @param {App} app - The main view object for CyberChef. * @param {Manager} manager - The CyberChef event manager. */ constructor(app, manager) { this.app = app; this.manager = manager; this.inputTextEl = document.getElementById("input-text"); this.inputChrEnc = 0; this.eolState = 0; // 0 = unset, 1 = detected, 2 = manual this.encodingState = 0; // 0 = unset, 1 = detected, 2 = manual this.initEditor(); this.inputWorker = null; this.loaderWorkers = []; this.workerId = 0; this.maxTabs = this.manager.tabs.calcMaxTabs(); this.callbacks = {}; this.callbackID = 0; this.fileDetails = {}; this.maxWorkers = 1; if (navigator.hardwareConcurrency !== undefined && navigator.hardwareConcurrency > 1) { // Subtract 1 from hardwareConcurrency value to avoid using // the entire available resources this.maxWorkers = navigator.hardwareConcurrency - 1; } } /** * Sets up the CodeMirror Editor */ initEditor() { // Mutable extensions this.inputEditorConf = { eol: new Compartment, lineWrapping: new Compartment, fileDetailsPanel: new Compartment }; const self = this; const initialState = EditorState.create({ doc: null, extensions: [ // Editor extensions history(), highlightSpecialChars({ render: renderSpecialChar // Custom character renderer to handle special cases }), drawSelection(), rectangularSelection(), crosshairCursor(), dropCursor(), bracketMatching(), highlightSelectionMatches(), search({top: true}), EditorState.allowMultipleSelections.of(true), // Custom extensions statusBar({ label: "Input", eolHandler: this.eolChange.bind(this), chrEncHandler: this.chrEncChange.bind(this), chrEncGetter: this.getChrEnc.bind(this), getEncodingState: this.getEncodingState.bind(this), getEOLState: this.getEOLState.bind(this) }), // Mutable state this.inputEditorConf.fileDetailsPanel.of([]), this.inputEditorConf.lineWrapping.of(EditorView.lineWrapping), this.inputEditorConf.eol.of(EditorState.lineSeparator.of("\n")), // Keymap keymap.of([ // Explicitly insert a tab rather than indenting the line { key: "Tab", run: insertTab }, // Explicitly insert a new line (using the current EOL char) rather // than messing around with indenting, which does not respect EOL chars { key: "Enter", run: insertNewline }, ...historyKeymap, ...defaultKeymap, ...searchKeymap ]), // Event listeners EditorView.updateListener.of(e => { if (e.selectionSet) this.manager.highlighter.selectionChange("input", e); if (e.docChanged && !this.silentInputChange) this.inputChange(e); this.silentInputChange = false; }), // Event handlers EditorView.domEventHandlers({ paste(event, view) { setTimeout(() => { self.afterPaste(event); }); } }) ] }); if (this.inputEditorView) this.inputEditorView.destroy(); this.inputEditorView = new EditorView({ state: initialState, parent: this.inputTextEl }); } /** * Handler for EOL change events * Sets the line separator * @param {string} eol * @param {boolean} [manual=false] */ eolChange(eol, manual=false) { const eolVal = eolCodeToSeq[eol]; if (eolVal === undefined) return; this.eolState = manual ? 2 : this.eolState; if (this.eolState < 2 && eolVal === this.getEOLSeq()) return; if (this.eolState === 1) { // Alert this.app.alert(`Input end of line separator has been detected and changed to ${eolCodeToName[eol]}`, 5000); } // Update the EOL value const oldInputVal = this.getInput(); this.inputEditorView.dispatch({ effects: this.inputEditorConf.eol.reconfigure(EditorState.lineSeparator.of(eolVal)) }); // Reset the input so that lines are recalculated, preserving the old EOL values this.setInput(oldInputVal); } /** * Getter for the input EOL sequence * @returns {string} */ getEOLSeq() { return this.inputEditorView.state.lineBreak; } /** * Returns whether the input EOL sequence was set manually or has been detected automatically * @returns {number} - 0 = unset, 1 = detected, 2 = manual */ getEOLState() { return this.eolState; } /** * Handler for Chr Enc change events * Sets the input character encoding * @param {number} chrEncVal * @param {boolean} [manual=false] */ chrEncChange(chrEncVal, manual=false, internal=false) { if (typeof chrEncVal !== "number") return; this.inputChrEnc = chrEncVal; this.encodingState = manual ? 2 : this.encodingState; if (!internal) { this.inputChange(); } } /** * Getter for the input character encoding * @returns {number} */ getChrEnc() { return this.inputChrEnc; } /** * Returns whether the input character encoding was set manually or has been detected automatically * @returns {number} - 0 = unset, 1 = detected, 2 = manual */ getEncodingState() { return this.encodingState; } /** * Sets word wrap on the input editor * @param {boolean} wrap */ setWordWrap(wrap) { this.inputEditorView.dispatch({ effects: this.inputEditorConf.lineWrapping.reconfigure( wrap ? EditorView.lineWrapping : [] ) }); } /** * Gets the value of the current input * @returns {string} */ getInput() { const doc = this.inputEditorView.state.doc; const eol = this.getEOLSeq(); return doc.sliceString(0, doc.length, eol); } /** * Sets the value of the current input * @param {string} data * @param {boolean} [silent=false] */ setInput(data, silent=false) { const lineLengthThreshold = 131072; // 128KB let wrap = this.app.options.wordWrap; if (data.length > lineLengthThreshold) { const lines = data.split(this.getEOLSeq()); const longest = lines.reduce((a, b) => a > b.length ? a : b.length, 0 ); if (longest > lineLengthThreshold) { // If we are exceeding the max line length, turn off word wrap wrap = false; this.app.alert("Maximum line length exceeded. Word wrap will be temporarily disabled to improve performance.", 20000); } } // If turning word wrap off, do it before we populate the editor for performance reasons if (!wrap) this.setWordWrap(wrap); // We use setTimeout here to delay the editor dispatch until the next event cycle, // ensuring all async actions have completed before attempting to set the contents // of the editor. This is mainly with the above call to setWordWrap() in mind. setTimeout(() => { // Insert data into editor, overwriting any previous contents this.silentInputChange = silent; this.inputEditorView.dispatch({ changes: { from: 0, to: this.inputEditorView.state.doc.length, insert: data } }); // If turning word wrap on, do it after we populate the editor if (wrap) setTimeout(() => { this.setWordWrap(wrap); }); }); } /** * Calculates the maximum number of tabs to display */ calcMaxTabs() { const numTabs = this.manager.tabs.calcMaxTabs(); if (this.inputWorker && this.maxTabs !== numTabs) { this.maxTabs = numTabs; this.inputWorker.postMessage({ action: "updateMaxTabs", data: { maxTabs: numTabs, activeTab: this.manager.tabs.getActiveTab("input") } }); } } /** * Terminates any existing workers and sets up a new InputWorker and LoaderWorker */ setupInputWorker() { if (this.inputWorker !== null) { this.inputWorker.terminate(); this.inputWorker = null; } for (let i = this.loaderWorkers.length - 1; i >= 0; i--) { this.removeLoaderWorker(this.loaderWorkers[i]); } log.debug("Adding new InputWorker"); this.inputWorker = new InputWorker(); this.inputWorker.postMessage({ action: "setLogLevel", data: log.getLevel() }); this.inputWorker.postMessage({ action: "updateMaxWorkers", data: this.maxWorkers }); this.inputWorker.postMessage({ action: "updateMaxTabs", data: { maxTabs: this.maxTabs, activeTab: this.manager.tabs.getActiveTab("input") } }); this.inputWorker.addEventListener("message", this.handleInputWorkerMessage.bind(this)); } /** * Activates a loaderWorker and sends it to the InputWorker */ activateLoaderWorker() { const workerIdx = this.addLoaderWorker(); if (workerIdx === -1) return; const workerObj = this.loaderWorkers[workerIdx]; this.inputWorker.postMessage({ action: "loaderWorkerReady", data: { id: workerObj.id } }); } /** * Adds a new loaderWorker * * @returns {number} - The index of the created worker */ addLoaderWorker() { if (this.loaderWorkers.length === this.maxWorkers) { return -1; } log.debug(`Adding new LoaderWorker (${this.loaderWorkers.length + 1}/${this.maxWorkers}).`); const newWorker = new LoaderWorker(); const workerId = this.workerId++; newWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); newWorker.postMessage({ action: "setLogLevel", data: log.getLevel() }); newWorker.postMessage({ action: "setID", data: { id: workerId } }); const newWorkerObj = { worker: newWorker, id: workerId }; this.loaderWorkers.push(newWorkerObj); return this.loaderWorkers.indexOf(newWorkerObj); } /** * Removes a loaderworker * * @param {Object} workerObj - Object containing the loaderWorker and its id * @param {LoaderWorker} workerObj.worker - The actual loaderWorker * @param {number} workerObj.id - The ID of the loaderWorker */ removeLoaderWorker(workerObj) { const idx = this.loaderWorkers.indexOf(workerObj); if (idx === -1) { return; } log.debug(`Terminating worker ${this.loaderWorkers[idx].id}`); this.loaderWorkers[idx].worker.terminate(); this.loaderWorkers.splice(idx, 1); } /** * Finds and returns the object for the loaderWorker of a given id * * @param {number} id - The ID of the loaderWorker to find * @returns {object} */ getLoaderWorker(id) { const idx = this.getLoaderWorkerIndex(id); if (idx === -1) return; return this.loaderWorkers[idx]; } /** * Gets the index for the loaderWorker of a given id * * @param {number} id - The ID of hte loaderWorker to find * @returns {number} The current index of the loaderWorker in the array */ getLoaderWorkerIndex(id) { for (let i = 0; i < this.loaderWorkers.length; i++) { if (this.loaderWorkers[i].id === id) { return i; } } return -1; } /** * Sends an input to be loaded to the loaderWorker * * @param {object} inputData - Object containing the input to be loaded * @param {File} inputData.file - The actual file object to load * @param {number} inputData.inputNum - The inputNum for the file object * @param {number} inputData.workerId - The ID of the loaderWorker that will load it */ loadInput(inputData) { const idx = this.getLoaderWorkerIndex(inputData.workerId); if (idx === -1) return; this.loaderWorkers[idx].worker.postMessage({ action: "loadFile", data: { file: inputData.file, inputNum: inputData.inputNum } }); } /** * Handler for messages sent back by the loaderWorker * Sends the message straight to the inputWorker to be handled there. * * @param {MessageEvent} e */ handleLoaderMessage(e) { const r = e.data; if (Object.prototype.hasOwnProperty.call(r, "progress") && Object.prototype.hasOwnProperty.call(r, "inputNum")) { this.manager.tabs.updateTabProgress(r.inputNum, r.progress, 100, "input"); } const transferable = Object.prototype.hasOwnProperty.call(r, "fileBuffer") ? [r.fileBuffer] : undefined; this.inputWorker.postMessage({ action: "loaderWorkerMessage", data: r }, transferable); } /** * Handler for messages sent back by the InputWorker * * @param {MessageEvent} e */ handleInputWorkerMessage(e) { const r = e.data; if (!("action" in r)) { log.error("A message was received from the InputWorker with no action property. Ignoring message."); return; } log.debug(`Receiving '${r.action}' from InputWorker.`); switch (r.action) { case "activateLoaderWorker": this.activateLoaderWorker(); break; case "loadInput": this.loadInput(r.data); break; case "terminateLoaderWorker": this.removeLoaderWorker(this.getLoaderWorker(r.data)); break; case "refreshTabs": this.refreshTabs(r.data.nums, r.data.activeTab, r.data.tabsLeft, r.data.tabsRight); break; case "changeTab": this.changeTab(r.data, this.app.options.syncTabs); break; case "updateTabHeader": this.manager.tabs.updateTabHeader(r.data.inputNum, r.data.input, "input"); break; case "loadingInfo": this.showLoadingInfo(r.data, true); break; case "setInput": this.set(r.data.inputNum, r.data.inputObj, r.data.silent); break; case "inputAdded": this.inputAdded(r.data.changeTab, r.data.inputNum); break; case "queueInput": this.manager.worker.queueInput(r.data); break; case "queueInputError": this.manager.worker.queueInputError(r.data); break; case "bakeInputs": this.manager.worker.bakeInputs(r.data); break; case "displayTabSearchResults": this.displayTabSearchResults(r.data); break; case "filterTabError": this.app.handleError(r.data); break; case "setUrl": this.app.updateURL(r.data.includeInput, r.data.input); break; case "getInput": case "getInputNums": this.callbacks[r.data.id](r.data); break; case "removeChefWorker": this.removeChefWorker(); break; case "fileLoaded": this.fileLoaded(r.data.inputNum); break; default: log.error(`Unknown action ${r.action}.`); } } /** * Sends a message to the inputWorker to bake all inputs */ bakeAll() { this.app.progress = 0; debounce(this.manager.controls.toggleBakeButtonFunction, 20, "toggleBakeButton", this, ["loading"]); this.inputWorker.postMessage({ action: "bakeAll" }); } /** * Sets the input in the input area * * @param {number} inputNum * @param {Object} inputData - Object containing the input and its metadata * @param {string} type * @param {ArrayBuffer} buffer * @param {string} stringSample * @param {Object} file * @param {string} file.name * @param {number} file.size * @param {string} file.type * @param {string} status * @param {number} progress * @param {number} encoding * @param {string} eolSequence * @param {boolean} [silent=false] - If false, fires the manager statechange event */ async set(inputNum, inputData, silent=false) { return new Promise(function(resolve, reject) { const activeTab = this.manager.tabs.getActiveTab("input"); if (inputNum !== activeTab) { this.changeTab(inputNum, this.app.options.syncTabs); return; } // Update current character encoding this.inputChrEnc = inputData.encoding; // Update current eol sequence this.inputEditorView.dispatch({ effects: this.inputEditorConf.eol.reconfigure( EditorState.lineSeparator.of(inputData.eolSequence) ) }); // Handle file previews if (inputData.file) { this.setFile(inputNum, inputData); } else { this.clearFile(inputNum); } // Decode the data to a string this.manager.timing.recordTime("inputEncodingStart", inputNum); let inputVal; if (this.getChrEnc() > 0) { inputVal = cptable.utils.decode(this.inputChrEnc, new Uint8Array(inputData.buffer)); } else { inputVal = Utils.arrayBufferToStr(inputData.buffer); } this.manager.timing.recordTime("inputEncodingEnd", inputNum); // Populate the input editor this.setInput(inputVal, silent); // Set URL to current input if (inputVal.length >= 0 && inputVal.length <= 51200) { const inputStr = toBase64(inputVal, "A-Za-z0-9+/"); this.app.updateURL(true, inputStr); } // Trigger a state change if (!silent) window.dispatchEvent(this.manager.statechange); }.bind(this)); } /** * Displays file details * * @param {number} inputNum * @param {Object} inputData - Object containing the input and its metadata * @param {string} type * @param {ArrayBuffer} buffer * @param {string} stringSample * @param {Object} file * @param {string} file.name * @param {number} file.size * @param {string} file.type * @param {string} status * @param {number} progress */ setFile(inputNum, inputData) { const activeTab = this.manager.tabs.getActiveTab("input"); if (inputNum !== activeTab) return; // Create file details panel this.fileDetails = { fileDetails: inputData.file, progress: inputData.progress, status: inputData.status, buffer: inputData.buffer, renderPreview: this.app.options.imagePreview, toggleHandler: this.toggleFileDetails.bind(this), hidden: false }; this.inputEditorView.dispatch({ effects: this.inputEditorConf.fileDetailsPanel.reconfigure( fileDetailsPanel(this.fileDetails) ) }); } /** * Clears the file details panel * * @param {number} inputNum */ clearFile(inputNum) { const activeTab = this.manager.tabs.getActiveTab("input"); if (inputNum !== activeTab) return; // Clear file details panel this.inputEditorView.dispatch({ effects: this.inputEditorConf.fileDetailsPanel.reconfigure([]) }); } /** * Handler for file details toggle clicks * @param {event} e */ toggleFileDetails(e) { $("[data-toggle='tooltip']").tooltip("hide"); this.fileDetails.hidden = !this.fileDetails.hidden; this.inputEditorView.dispatch({ effects: this.inputEditorConf.fileDetailsPanel.reconfigure( fileDetailsPanel(this.fileDetails) ) }); } /** * Update file details when a file completes loading * * @param {number} inputNum - The inputNum of the input which has finished loading */ fileLoaded(inputNum) { this.manager.tabs.updateTabProgress(inputNum, 100, 100, "input"); const activeTab = this.manager.tabs.getActiveTab("input"); if (activeTab !== inputNum) return; this.inputWorker.postMessage({ action: "setInput", data: { inputNum: inputNum, silent: false } }); this.updateFileProgress(inputNum, 100); } /** * Updates the displayed load progress for a file * * @param {number} inputNum * @param {number | string} progress - Either a number or "error" */ updateFileProgress(inputNum, progress) { const activeTab = this.manager.tabs.getActiveTab("input"); if (inputNum !== activeTab) return; this.fileDetails.progress = progress; if (progress === "error") this.fileDetails.status = "error"; this.inputEditorView.dispatch({ effects: this.inputEditorConf.fileDetailsPanel.reconfigure( fileDetailsPanel(this.fileDetails) ) }); } /** * Updates the stored value for the specified inputNum * * @param {number} inputNum * @param {string | ArrayBuffer} value */ updateInputValue(inputNum, value, force=false) { // Prepare the value as a buffer (full value) and a string sample (up to 4096 bytes) let buffer; let stringSample = ""; // If value is a string, interpret it using the specified character encoding const tabNum = this.manager.tabs.getActiveTab("input"); this.manager.timing.recordTime("inputEncodingStart", tabNum); if (typeof value === "string") { stringSample = value.slice(0, 4096); if (this.getChrEnc() > 0) { buffer = cptable.utils.encode(this.getChrEnc(), value); buffer = new Uint8Array(buffer).buffer; } else { buffer = Utils.strToArrayBuffer(value); } } else { buffer = value; stringSample = Utils.arrayBufferToStr(value.slice(0, 4096)); } this.manager.timing.recordTime("inputEncodingEnd", tabNum); // Update the deep link const recipeStr = buffer.byteLength < 51200 ? toBase64(buffer, "A-Za-z0-9+/") : ""; // B64 alphabet with no padding const includeInput = recipeStr.length > 0 && buffer.byteLength < 51200; this.app.updateURL(includeInput, recipeStr); // Post new value to the InputWorker const transferable = [buffer]; this.inputWorker.postMessage({ action: "updateInputValue", data: { inputNum: inputNum, buffer: buffer, stringSample: stringSample, encoding: this.getChrEnc(), eolSequence: this.getEOLSeq() } }, transferable); } /** * Get the input value for the specified input * * @param {number} inputNum - The inputNum of the input to retrieve from the inputWorker * @returns {ArrayBuffer | string} */ async getInputValue(inputNum) { return await new Promise(resolve => { this.getInputFromWorker(inputNum, false, r => { resolve(r.data); }); }); } /** * Get the input object for the specified input * * @param {number} inputNum - The inputNum of the input to retrieve from the inputWorker * @returns {object} */ async getInputObj(inputNum) { return await new Promise(resolve => { this.getInputFromWorker(inputNum, true, r => { resolve(r.data); }); }); } /** * Gets the specified input from the inputWorker * * @param {number} inputNum - The inputNum of the data to get * @param {boolean} getObj - If true, get the actual data object of the input instead of just the value * @param {Function} callback - The callback to execute when the input is returned * @returns {ArrayBuffer | string | object} */ getInputFromWorker(inputNum, getObj, callback) { const id = this.callbackID++; this.callbacks[id] = callback; this.inputWorker.postMessage({ action: "getInput", data: { inputNum: inputNum, getObj: getObj, id: id } }); } /** * Gets the number of inputs from the inputWorker * * @returns {object} */ async getInputNums() { return await new Promise(resolve => { this.getNums(r => { resolve(r); }); }); } /** * Gets a list of inputNums from the inputWorker, and sends * them back to the specified callback */ getNums(callback) { const id = this.callbackID++; this.callbacks[id] = callback; this.inputWorker.postMessage({ action: "getInputNums", data: id }); } /** * Handler for input change events. * Updates the value stored in the inputWorker * Debounces the input so we don't call autobake too often. * * @param {event} e * * @fires Manager#statechange */ inputChange(e) { // Change debounce delay based on input length const inputLength = this.inputEditorView.state.doc.length; let delay; if (inputLength < 10000) delay = 20; else if (inputLength < 100000) delay = 50; else if (inputLength < 1000000) delay = 200; else delay = 500; debounce(function(e) { const value = this.getInput(); const activeTab = this.manager.tabs.getActiveTab("input"); this.updateInputValue(activeTab, value); this.inputWorker.postMessage({ action: "updateTabHeader", data: activeTab }); // Fire the statechange event as the input has been modified window.dispatchEvent(this.manager.statechange); }, delay, "inputChange", this, [e])(); } /** * Handler that fires just after input paste events. * Checks whether the EOL separator or character encoding should be updated. * * @param {event} e */ afterPaste(e) { // If EOL has been fixed, skip this. if (this.eolState > 1) return; const inputText = this.getInput(); // Detect most likely EOL sequence const eolCharCounts = { "LF": inputText.count("\u000a"), "VT": inputText.count("\u000b"), "FF": inputText.count("\u000c"), "CR": inputText.count("\u000d"), "CRLF": inputText.count("\u000d\u000a"), "NEL": inputText.count("\u0085"), "LS": inputText.count("\u2028"), "PS": inputText.count("\u2029") }; // If all zero, leave alone const total = Object.values(eolCharCounts).reduce((acc, curr) => { return acc + curr; }, 0); if (total === 0) return; // Find most prevalent line ending sequence const highest = Object.entries(eolCharCounts).reduce((acc, curr) => { return curr[1] > acc[1] ? curr : acc; }, ["LF", 0]); let choice = highest[0]; // If CRLF not zero and more than half the highest alternative, choose CRLF if ((eolCharCounts.CRLF * 2) > highest[1]) { choice = "CRLF"; } const eolVal = eolCodeToSeq[choice]; if (eolVal === this.getEOLSeq()) return; // Setting automatically this.eolState = 1; this.eolChange(choice); } /** * Handler for input dragover events. * Gives the user a visual cue to show that items can be dropped here. * * @param {event} e */ inputDragover(e) { // This will be set if we're dragging an operation if (e.dataTransfer.effectAllowed === "move") return false; e.stopPropagation(); e.preventDefault(); e.target.closest("#input-text").classList.add("dropping-file"); } /** * Handler for input dragleave events. * Removes the visual cue. * * @param {event} e */ inputDragleave(e) { e.stopPropagation(); e.preventDefault(); // Dragleave often fires when moving between lines in the editor. // If the from element is within the input-text element, we are still on target. if (!this.inputTextEl.contains(e.fromElement)) { e.target.closest("#input-text").classList.remove("dropping-file"); } } /** * Handler for input drop events. * Loads the dragged data. * * @param {event} e */ async inputDrop(e) { // This will be set if we're dragging an operation if (e.dataTransfer.effectAllowed === "move") return false; e.stopPropagation(); e.preventDefault(); e.target.closest("#input-text").classList.remove("dropping-file"); // Dropped text is handled by the editor itself if (e.dataTransfer.getData("Text")) return; // Dropped files if (e?.dataTransfer?.files?.length > 0) { let files = []; // Handling the files as FileSystemEntry objects allows us to open directories, // but relies on a function that may be deprecated in future. if (Object.prototype.hasOwnProperty.call(DataTransferItem.prototype, "webkitGetAsEntry")) { const fileEntries = await this.getAllFileEntries(e.dataTransfer.items); // Read all FileEntry objects into File objects files = await Promise.all(fileEntries.map(async fe => await this.getFile(fe))); } else { files = e.dataTransfer.files; } this.loadUIFiles(files); } } /** * * @param {DataTransferItemList} dataTransferItemList * @returns {FileSystemEntry[]} */ async getAllFileEntries(dataTransferItemList) { const fileEntries = []; // Use BFS to traverse entire directory/file structure const queue = []; // Unfortunately dataTransferItemList is not iterable i.e. no forEach for (let i = 0; i < dataTransferItemList.length; i++) { // Note webkitGetAsEntry a non-standard feature and may change // Usage is necessary for handling directories queue.push(dataTransferItemList[i].webkitGetAsEntry()); } while (queue.length > 0) { const entry = queue.shift(); if (entry.isFile) { fileEntries.push(entry); } else if (entry.isDirectory) { queue.push(...await this.readAllDirectoryEntries(entry.createReader())); } } return fileEntries; } /** * Get all the entries (files or sub-directories) in a directory by calling * readEntries until it returns empty array * * @param {FileSystemDirectoryReader} directoryReader * @returns {FileSystemEntry[]} */ async readAllDirectoryEntries(directoryReader) { const entries = []; let readEntries = await this.readEntriesPromise(directoryReader); while (readEntries.length > 0) { entries.push(...readEntries); readEntries = await this.readEntriesPromise(directoryReader); } return entries; } /** * Wrap readEntries in a promise to make working with readEntries easier. * readEntries will return only some of the entries in a directory * e.g. Chrome returns at most 100 entries at a time * * @param {FileSystemDirectoryReader} directoryReader * @returns {Promise} */ async readEntriesPromise(directoryReader) { try { return await new Promise((resolve, reject) => { directoryReader.readEntries(resolve, reject); }); } catch (err) { log.error(err); } } /** * Reads a FileEntry and returns it as a File object * @param {FileEntry} fileEntry * @returns {File} */ async getFile(fileEntry) { try { return new Promise((resolve, reject) => fileEntry.file(resolve, reject)); } catch (err) { log.error(err); } } /** * Handler for open input button events * Loads the opened data into the input textarea * * @param {event} e */ inputOpen(e) { e.preventDefault(); if (e.target.files.length > 0) { this.loadUIFiles(e.target.files); e.target.value = ""; } } /** * Handler for open input button click. * Opens the open file dialog. */ inputOpenClick() { document.getElementById("open-file").click(); } /** * Handler for open folder button click * Opens the open folder dialog. */ folderOpenClick() { document.getElementById("open-folder").click(); } /** * Load files from the UI into the inputWorker * * @param {FileList} files - The list of files to be loaded */ loadUIFiles(files) { const numFiles = files.length; const activeTab = this.manager.tabs.getActiveTab("input"); log.debug(`Loading ${numFiles} files.`); // Display the number of files as pending so the user // knows that we've received the files. this.showLoadingInfo({ pending: numFiles, loading: 0, loaded: 0, total: numFiles, activeProgress: { inputNum: activeTab, progress: 0 } }, false); this.inputWorker.postMessage({ action: "loadUIFiles", data: { files: files, activeTab: activeTab } }); } /** * Display the loaded files information in the input header. * Also, sets the background of the Input header to be a progress bar * @param {object} loadedData - Object containing the loading information * @param {number} loadedData.pending - How many files are pending (not loading / loaded) * @param {number} loadedData.loading - How many files are being loaded * @param {number} loadedData.loaded - How many files have been loaded * @param {number} loadedData.total - The total number of files * @param {object} loadedData.activeProgress - Object containing data about the active tab * @param {number} loadedData.activeProgress.inputNum - The inputNum of the input the progress is for * @param {number} loadedData.activeProgress.progress - The loading progress of the active input * @param {boolean} autoRefresh - If true, automatically refreshes the loading info by sending a message to the inputWorker after 100ms */ showLoadingInfo(loadedData, autoRefresh) { const pending = loadedData.pending; const loading = loadedData.loading; const loaded = loadedData.loaded; const total = loadedData.total; let width = total.toLocaleString().length; width = width < 2 ? 2 : width; const totalStr = total.toLocaleString().padStart(width, " ").replace(/ /g, " "); let msg = "total: " + totalStr; const loadedStr = loaded.toLocaleString().padStart(width, " ").replace(/ /g, " "); msg += "
loaded: " + loadedStr; if (pending > 0) { const pendingStr = pending.toLocaleString().padStart(width, " ").replace(/ /g, " "); msg += "
pending: " + pendingStr; } else if (loading > 0) { const loadingStr = loading.toLocaleString().padStart(width, " ").replace(/ /g, " "); msg += "
loading: " + loadingStr; } const inFiles = document.getElementById("input-files-info"); if (total > 1) { inFiles.innerHTML = msg; inFiles.style.display = ""; } else { inFiles.style.display = "none"; } this.updateFileProgress(loadedData.activeProgress.inputNum, loadedData.activeProgress.progress); const inputTitle = document.getElementById("input").firstElementChild; if (loaded < total) { const percentComplete = loaded / total * 100; inputTitle.style.background = `linear-gradient(to right, var(--title-background-colour) ${percentComplete}%, var(--primary-background-colour) ${percentComplete}%)`; } else { inputTitle.style.background = ""; } if (loaded < total && autoRefresh) { setTimeout(function() { this.inputWorker.postMessage({ action: "getLoadProgress", data: this.manager.tabs.getActiveTab("input") }); }.bind(this), 100); } } /** * Change to a different tab. * * @param {number} inputNum - The inputNum of the tab to change to * @param {boolean} [changeOutput=false] - If true, also changes the output */ changeTab(inputNum, changeOutput=false) { if (this.manager.tabs.getTabItem(inputNum, "input") !== null) { this.manager.tabs.changeTab(inputNum, "input"); this.inputWorker.postMessage({ action: "setInput", data: { inputNum: inputNum, silent: true } }); } else { const minNum = Math.min(...this.manager.tabs.getTabList("input")); let direction = "right"; if (inputNum < minNum) { direction = "left"; } this.inputWorker.postMessage({ action: "refreshTabs", data: { inputNum: inputNum, direction: direction } }); } if (changeOutput) { this.manager.output.changeTab(inputNum, false); } // Set cursor focus to current tab this.inputEditorView.focus(); } /** * Handler for clicking on a tab * * @param {event} mouseEvent */ changeTabClick(mouseEvent) { if (!mouseEvent.target) return; const tabNum = mouseEvent.target.parentElement.getAttribute("inputNum"); if (tabNum >= 0) { this.changeTab(parseInt(tabNum, 10), this.app.options.syncTabs); } } /** * Handler for clear all IO events. * Resets the input, output and info areas, and creates a new inputWorker */ clearAllIoClick() { this.manager.worker.cancelBake(true, true); this.manager.worker.loaded = false; this.manager.output.removeAllOutputs(); this.manager.output.terminateZipWorker(); this.eolState = 0; this.encodingState = 0; this.manager.output.eolState = 0; this.manager.output.encodingState = 0; this.initEditor(); this.manager.output.initEditor(); const tabsList = document.getElementById("input-tabs"); const tabsListChildren = tabsList.children; tabsList.classList.remove("tabs-left"); tabsList.classList.remove("tabs-right"); for (let i = tabsListChildren.length - 1; i >= 0; i--) { tabsListChildren.item(i).remove(); } this.showLoadingInfo({ pending: 0, loading: 0, loaded: 1, total: 1, activeProgress: { inputNum: 1, progress: 100 } }); this.setupInputWorker(); this.manager.worker.setupChefWorker(); this.addInput(true); } /** * Sets the console log level in the workers. */ setLogLevel() { this.loaderWorkers.forEach(w => { w.postMessage({ action: "setLogLevel", data: log.getLevel() }); }); if (!this.inputWorker) return; this.inputWorker.postMessage({ action: "setLogLevel", data: log.getLevel() }); } /** * Sends a message to the inputWorker to add a new input. * @param {boolean} [changeTab=false] - If true, changes the tab to the new input */ addInput(changeTab=false) { if (!this.inputWorker) return; this.inputWorker.postMessage({ action: "addInput", data: changeTab }); } /** * Handler for add input button clicked. */ addInputClick() { this.addInput(true); } /** * Handler for when the inputWorker adds a new input * * @param {boolean} changeTab - Whether or not to change to the new input tab * @param {number} inputNum - The new inputNum */ inputAdded(changeTab, inputNum) { this.addTab(inputNum, changeTab); this.manager.output.addOutput(inputNum, changeTab); this.manager.worker.addChefWorker(); } /** * Remove a chefWorker from the workerWaiter if we remove an input */ removeChefWorker() { const workerIdx = this.manager.worker.getInactiveChefWorker(true); const worker = this.manager.worker.chefWorkers[workerIdx]; this.manager.worker.removeChefWorker(worker); } /** * Adds a new input tab. * * @param {number} inputNum - The inputNum of the new tab * @param {boolean} [changeTab=true] - If true, changes to the new tab once it's been added */ addTab(inputNum, changeTab=true) { const tabsWrapper = document.getElementById("input-tabs"), numTabs = tabsWrapper.children.length; if (!this.manager.tabs.getTabItem(inputNum, "input") && numTabs < this.maxTabs) { const newTab = this.manager.tabs.createTabElement(inputNum, changeTab, "input"); tabsWrapper.appendChild(newTab); if (numTabs > 0) { this.manager.tabs.showTabBar(); } else { this.manager.tabs.hideTabBar(); } this.inputWorker.postMessage({ action: "updateTabHeader", data: inputNum }); } else if (numTabs === this.maxTabs) { // Can't create a new tab document.getElementById("input-tabs").lastElementChild.classList.add("tabs-right"); } if (changeTab) this.changeTab(inputNum, false); } /** * Refreshes the input tabs, and changes to activeTab * * @param {number[]} nums - The inputNums to be displayed as tabs * @param {number} activeTab - The tab to change to * @param {boolean} tabsLeft - True if there are input tabs to the left of the displayed tabs * @param {boolean} tabsRight - True if there are input tabs to the right of the displayed tabs */ refreshTabs(nums, activeTab, tabsLeft, tabsRight) { this.manager.tabs.refreshTabs(nums, activeTab, tabsLeft, tabsRight, "input"); this.inputWorker.postMessage({ action: "setInput", data: { inputNum: activeTab, silent: true } }); } /** * Sends a message to the inputWorker to remove an input. * If the input tab is on the screen, refreshes the tabs * * @param {number} inputNum - The inputNum of the tab to be removed */ removeInput(inputNum) { let refresh = false; if (this.manager.tabs.getTabItem(inputNum, "input") !== null) { refresh = true; } this.inputWorker.postMessage({ action: "removeInput", data: { inputNum: inputNum, refreshTabs: refresh, removeChefWorker: true } }); this.manager.output.removeTab(inputNum); } /** * Handler for clicking on a remove tab button * * @param {event} mouseEvent */ removeTabClick(mouseEvent) { if (!mouseEvent.target) { return; } const tabNum = mouseEvent.target.closest("button").parentElement.getAttribute("inputNum"); if (tabNum) { this.removeInput(parseInt(tabNum, 10)); } } /** * Handler for scrolling on the input tabs area * * @param {event} wheelEvent */ scrollTab(wheelEvent) { wheelEvent.preventDefault(); if (wheelEvent.deltaY > 0) { this.changeTabLeft(); } else if (wheelEvent.deltaY < 0) { this.changeTabRight(); } } /** * Handler for mouse down on the next tab button */ nextTabClick() { this.mousedown = true; this.changeTabRight(); const time = 200; const func = function(time) { if (this.mousedown) { this.changeTabRight(); const newTime = (time > 50) ? time - 10 : 50; setTimeout(func.bind(this, [newTime]), newTime); } }; this.tabTimeout = setTimeout(func.bind(this, [time]), time); } /** * Handler for mouse down on the previous tab button */ previousTabClick() { this.mousedown = true; this.changeTabLeft(); const time = 200; const func = function(time) { if (this.mousedown) { this.changeTabLeft(); const newTime = (time > 50) ? time - 10 : 50; setTimeout(func.bind(this, [newTime]), newTime); } }; this.tabTimeout = setTimeout(func.bind(this, [time]), time); } /** * Handler for mouse up event on the tab buttons */ tabMouseUp() { this.mousedown = false; clearTimeout(this.tabTimeout); this.tabTimeout = null; } /** * Changes to the next (right) tab */ changeTabRight() { const activeTab = this.manager.tabs.getActiveTab("input"); if (activeTab === -1) return; this.inputWorker.postMessage({ action: "changeTabRight", data: { activeTab: activeTab } }); } /** * Changes to the previous (left) tab */ changeTabLeft() { const activeTab = this.manager.tabs.getActiveTab("input"); if (activeTab === -1) return; this.inputWorker.postMessage({ action: "changeTabLeft", data: { activeTab: activeTab } }); } /** * Handler for go to tab button clicked */ async goToTab() { const inputNums = await this.getInputNums(); let tabNum = window.prompt(`Enter tab number (${inputNums.min} - ${inputNums.max}):`, this.manager.tabs.getActiveTab("input").toString()); if (tabNum === null) return; tabNum = parseInt(tabNum, 10); this.changeTab(tabNum, this.app.options.syncTabs); } /** * Handler for find tab button clicked */ findTab() { this.filterTabSearch(); $("#input-tab-modal").modal(); } /** * Sends a message to the inputWorker to search the inputs */ filterTabSearch() { const showPending = document.getElementById("input-show-pending").checked; const showLoading = document.getElementById("input-show-loading").checked; const showLoaded = document.getElementById("input-show-loaded").checked; const filter = document.getElementById("input-filter").value; const filterType = document.getElementById("input-filter-button").innerText; const numResults = parseInt(document.getElementById("input-num-results").value, 10); this.inputWorker.postMessage({ action: "filterTabs", data: { showPending: showPending, showLoading: showLoading, showLoaded: showLoaded, filter: filter, filterType: filterType, numResults: numResults } }); } /** * Handle when an option in the filter drop down box is clicked * * @param {event} mouseEvent */ filterOptionClick(mouseEvent) { document.getElementById("input-filter-button").innerText = mouseEvent.target.innerText; this.filterTabSearch(); } /** * Displays the results of a tab search in the find tab box * * @param {object[]} results - List of results objects * */ displayTabSearchResults(results) { const resultsList = document.getElementById("input-search-results"); for (let i = resultsList.children.length - 1; i >= 0; i--) { resultsList.children.item(i).remove(); } for (let i = 0; i < results.length; i++) { const newListItem = document.createElement("li"); newListItem.classList.add("input-filter-result"); newListItem.setAttribute("inputNum", results[i].inputNum); newListItem.innerText = `${results[i].inputNum}: ${results[i].textDisplay}`; resultsList.appendChild(newListItem); } } /** * Handler for clicking on a filter result * * @param {event} e */ filterItemClick(e) { if (!e.target) return; const inputNum = parseInt(e.target.getAttribute("inputNum"), 10); if (inputNum <= 0) return; $("#input-tab-modal").modal("hide"); this.changeTab(inputNum, this.app.options.syncTabs); } } export default InputWaiter;