diff --git a/README.md b/README.md index e6d835a8..4029fc23 100755 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ You can use as many operations as you like in simple or complex ways. Some examp - Drag and drop - Operations can be dragged in and out of the recipe list, or reorganised. - - Files up to 500MB can be dragged over the input box to load them directly into the browser. + - Files up to 2GB can be dragged over the input box to load them directly into the browser. - Auto Bake - Whenever you modify the input or the recipe, CyberChef will automatically "bake" for you and produce the output immediately. - This can be turned off and operated manually if it is affecting performance (if the input is very large, for instance). @@ -67,7 +67,7 @@ You can use as many operations as you like in simple or complex ways. Some examp - Highlighting - When you highlight text in the input or output, the offset and length values will be displayed and, if possible, the corresponding data will be highlighted in the output or input respectively (example: [highlight the word 'question' in the input to see where it appears in the output][11]). - Save to file and load from file - - You can save the output to a file at any time or load a file by dragging and dropping it into the input field. Files up to around 500MB are supported (depending on your browser), however some operations may take a very long time to run over this much data. + - You can save the output to a file at any time or load a file by dragging and dropping it into the input field. Files up to around 2GB are supported (depending on your browser), however some operations may take a very long time to run over this much data. - CyberChef is entirely client-side - It should be noted that none of your recipe configuration or input (either text or files) is ever sent to the CyberChef web server - all processing is carried out within your browser, on your own computer. - Due to this feature, CyberChef can be compiled into a single HTML file. You can download this file and drop it into a virtual machine, share it with other people, or use it independently on your local machine. diff --git a/src/core/Chef.mjs b/src/core/Chef.mjs index 33b7acbe..0fcee7f5 100755 --- a/src/core/Chef.mjs +++ b/src/core/Chef.mjs @@ -28,8 +28,6 @@ class Chef { * @param {Object[]} recipeConfig - The recipe configuration object * @param {Object} options - The options object storing various user choices * @param {boolean} options.attempHighlight - Whether or not to attempt highlighting - * @param {number} progress - The position in the recipe to start from - * @param {number} [step] - Whether to only execute one operation in the recipe * * @returns {Object} response * @returns {string} response.result - The output of the recipe @@ -38,46 +36,20 @@ class Chef { * @returns {number} response.duration - The number of ms it took to execute the recipe * @returns {number} response.error - The error object thrown by a failed operation (false if no error) */ - async bake(input, recipeConfig, options, progress, step) { + async bake(input, recipeConfig, options) { log.debug("Chef baking"); const startTime = new Date().getTime(), recipe = new Recipe(recipeConfig), containsFc = recipe.containsFlowControl(), notUTF8 = options && options.hasOwnProperty("treatAsUtf8") && !options.treatAsUtf8; - let error = false; + let error = false, + progress = 0; if (containsFc && ENVIRONMENT_IS_WORKER()) self.setOption("attemptHighlight", false); - // Clean up progress - if (progress >= recipeConfig.length) { - progress = 0; - } - - if (step) { - // Unset breakpoint on this step - recipe.setBreakpoint(progress, false); - // Set breakpoint on next step - recipe.setBreakpoint(progress + 1, true); - } - - // If the previously run operation presented a different value to its - // normal output, we need to recalculate it. - if (recipe.lastOpPresented(progress)) { - progress = 0; - } - - // If stepping with flow control, we have to start from the beginning - // but still want to skip all previous breakpoints - if (progress > 0 && containsFc) { - recipe.removeBreaksUpTo(progress); - progress = 0; - } - - // If starting from scratch, load data - if (progress === 0) { - const type = input instanceof ArrayBuffer ? Dish.ARRAY_BUFFER : Dish.STRING; - this.dish.set(input, type); - } + // Load data + const type = input instanceof ArrayBuffer ? Dish.ARRAY_BUFFER : Dish.STRING; + this.dish.set(input, type); try { progress = await recipe.execute(this.dish, progress); @@ -196,6 +168,18 @@ class Chef { return await newDish.get(type); } + /** + * Gets the title of a dish and returns it + * + * @param {Dish} dish + * @param {number} [maxLength=100] + * @returns {string} + */ + async getDishTitle(dish, maxLength=100) { + const newDish = new Dish(dish); + return await newDish.getTitle(maxLength); + } + } export default Chef; diff --git a/src/core/ChefWorker.js b/src/core/ChefWorker.js index ad498936..90b9e76b 100644 --- a/src/core/ChefWorker.js +++ b/src/core/ChefWorker.js @@ -25,6 +25,8 @@ self.chef = new Chef(); self.OpModules = OpModules; self.OperationConfig = OperationConfig; +self.inputNum = -1; + // Tell the app that the worker has loaded and is ready to operate self.postMessage({ @@ -35,6 +37,9 @@ self.postMessage({ /** * Respond to message from parent thread. * + * inputNum is optional and only used for baking multiple inputs. + * Defaults to -1 when one isn't sent with the bake message. + * * Messages should have the following format: * { * action: "bake" | "silentBake", @@ -43,8 +48,9 @@ self.postMessage({ * recipeConfig: {[Object]}, * options: {Object}, * progress: {number}, - * step: {boolean} - * } | undefined + * step: {boolean}, + * [inputNum=-1]: {number} + * } * } */ self.addEventListener("message", function(e) { @@ -62,6 +68,9 @@ self.addEventListener("message", function(e) { case "getDishAs": getDishAs(r.data); break; + case "getDishTitle": + getDishTitle(r.data); + break; case "docURL": // Used to set the URL of the current document so that scripts can be // imported into an inline worker. @@ -91,30 +100,35 @@ self.addEventListener("message", function(e) { async function bake(data) { // Ensure the relevant modules are loaded self.loadRequiredModules(data.recipeConfig); - try { + self.inputNum = (data.inputNum !== undefined) ? data.inputNum : -1; const response = await self.chef.bake( data.input, // The user's input data.recipeConfig, // The configuration of the recipe - data.options, // Options set by the user - data.progress, // The current position in the recipe - data.step // Whether or not to take one step or execute the whole recipe + data.options // Options set by the user ); + const transferable = (data.input instanceof ArrayBuffer) ? [data.input] : undefined; self.postMessage({ action: "bakeComplete", data: Object.assign(response, { - id: data.id + id: data.id, + inputNum: data.inputNum, + bakeId: data.bakeId }) - }); + }, transferable); + } catch (err) { self.postMessage({ action: "bakeError", - data: Object.assign(err, { - id: data.id - }) + data: { + error: err.message || err, + id: data.id, + inputNum: data.inputNum + } }); } + self.inputNum = -1; } @@ -136,13 +150,33 @@ function silentBake(data) { */ async function getDishAs(data) { const value = await self.chef.getDishAs(data.dish, data.type); - + const transferable = (data.type === "ArrayBuffer") ? [value] : undefined; self.postMessage({ action: "dishReturned", data: { value: value, id: data.id } + }, transferable); +} + + +/** + * Gets the dish title + * + * @param {object} data + * @param {Dish} data.dish + * @param {number} data.maxLength + * @param {number} data.id + */ +async function getDishTitle(data) { + const title = await self.chef.getDishTitle(data.dish, data.maxLength); + self.postMessage({ + action: "dishReturned", + data: { + value: title, + id: data.id + } }); } @@ -193,7 +227,28 @@ self.loadRequiredModules = function(recipeConfig) { self.sendStatusMessage = function(msg) { self.postMessage({ action: "statusMessage", - data: msg + data: { + message: msg, + inputNum: self.inputNum + } + }); +}; + + +/** + * Send progress update to the app. + * + * @param {number} progress + * @param {number} total + */ +self.sendProgressMessage = function(progress, total) { + self.postMessage({ + action: "progressMessage", + data: { + progress: progress, + total: total, + inputNum: self.inputNum + } }); }; diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 96aea716..dd3c8c2f 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -8,6 +8,7 @@ import Utils from "./Utils"; import DishError from "./errors/DishError"; import BigNumber from "bignumber.js"; +import {detectFileType} from "./lib/FileType"; import log from "loglevel"; /** @@ -141,6 +142,54 @@ class Dish { } + /** + * Detects the MIME type of the current dish + * @returns {string} + */ + async detectDishType() { + const data = new Uint8Array(this.value.slice(0, 2048)), + types = detectFileType(data); + + if (!types.length || !types[0].mime || !types[0].mime === "text/plain") { + return null; + } else { + return types[0].mime; + } + } + + + /** + * Returns the title of the data up to the specified length + * + * @param {number} maxLength - The maximum title length + * @returns {string} + */ + async getTitle(maxLength) { + let title = ""; + let cloned; + + switch (this.type) { + case Dish.FILE: + title = this.value.name; + break; + case Dish.LIST_FILE: + title = `${this.value.length} file(s)`; + break; + case Dish.ARRAY_BUFFER: + case Dish.BYTE_ARRAY: + title = await this.detectDishType(); + if (title !== null) break; + // fall through if no mime type was detected + default: + cloned = this.clone(); + cloned.value = cloned.value.slice(0, 256); + title = await cloned.get(Dish.STRING); + } + + return title.slice(0, maxLength); + } + + /** * Translates the data to the given type format. * diff --git a/src/core/Recipe.mjs b/src/core/Recipe.mjs index a11b4d02..bed6845c 100755 --- a/src/core/Recipe.mjs +++ b/src/core/Recipe.mjs @@ -200,7 +200,12 @@ class Recipe { try { input = await dish.get(op.inputType); - log.debug("Executing operation"); + log.debug(`Executing operation '${op.name}'`); + + if (ENVIRONMENT_IS_WORKER()) { + self.sendStatusMessage(`Baking... (${i+1}/${this.opList.length})`); + self.sendProgressMessage(i + 1, this.opList.length); + } if (op.flowControl) { // Package up the current state diff --git a/src/web/App.mjs b/src/web/App.mjs index 868684de..db530cf0 100755 --- a/src/web/App.mjs +++ b/src/web/App.mjs @@ -41,6 +41,7 @@ class App { this.autoBakePause = false; this.progress = 0; this.ingId = 0; + this.timeouts = {}; } @@ -87,7 +88,10 @@ class App { setTimeout(function() { document.getElementById("loader-wrapper").remove(); document.body.classList.remove("loaded"); - }, 1000); + + // Bake initial input + this.manager.input.bakeAll(); + }.bind(this), 1000); // Clear the loading message interval clearInterval(window.loadingMsgsInt); @@ -96,6 +100,9 @@ class App { window.removeEventListener("error", window.loadingErrorHandler); document.dispatchEvent(this.manager.apploaded); + + this.manager.input.calcMaxTabs(); + this.manager.output.calcMaxTabs(); } @@ -128,7 +135,6 @@ class App { this.manager.recipe.updateBreakpointIndicator(false); this.manager.worker.bake( - this.getInput(), // The user's input this.getRecipeConfig(), // The configuration of the recipe this.options, // Options set by the user this.progress, // The current position in the recipe @@ -148,13 +154,46 @@ class App { if (this.autoBake_ && !this.baking) { log.debug("Auto-baking"); - this.bake(); + this.manager.input.inputWorker.postMessage({ + action: "autobake", + data: { + activeTab: this.manager.tabs.getActiveInputTab() + } + }); } else { this.manager.controls.showStaleIndicator(); } } + /** + * Executes the next step of the recipe. + */ + step() { + if (this.baking) return; + + // Reset status using cancelBake + this.manager.worker.cancelBake(true, false); + + const activeTab = this.manager.tabs.getActiveInputTab(); + if (activeTab === -1) return; + + let progress = 0; + if (this.manager.output.outputs[activeTab].progress !== false) { + log.error(this.manager.output.outputs[activeTab]); + progress = this.manager.output.outputs[activeTab].progress; + } + + this.manager.input.inputWorker.postMessage({ + action: "step", + data: { + activeTab: activeTab, + progress: progress + 1 + } + }); + } + + /** * Runs a silent bake, forcing the browser to load and cache all the relevant JavaScript code needed * to do a real bake. @@ -175,24 +214,25 @@ class App { } - /** - * Gets the user's input data. - * - * @returns {string} - */ - getInput() { - return this.manager.input.get(); - } - - /** * Sets the user's input data. * * @param {string} input - The string to set the input to - * @param {boolean} [silent=false] - Suppress statechange event */ - setInput(input, silent=false) { - this.manager.input.set(input, silent); + setInput(input) { + // Get the currently active tab. + // If there isn't one, assume there are no inputs so use inputNum of 1 + let inputNum = this.manager.tabs.getActiveInputTab(); + if (inputNum === -1) inputNum = 1; + this.manager.input.updateInputValue(inputNum, input); + + this.manager.input.inputWorker.postMessage({ + action: "setInput", + data: { + inputNum: inputNum, + silent: true + } + }); } @@ -255,9 +295,11 @@ class App { minSize: minimise ? [0, 0, 0] : [240, 310, 450], gutterSize: 4, expandToMin: true, - onDrag: function() { + onDrag: this.debounce(function() { this.manager.recipe.adjustWidth(); - }.bind(this) + this.manager.input.calcMaxTabs(); + this.manager.output.calcMaxTabs(); + }, 50, "dragSplitter", this, []) }); this.ioSplitter = Split(["#input", "#output"], { @@ -391,11 +433,12 @@ class App { this.manager.recipe.initialiseOperationDragNDrop(); } - /** - * Checks for input and recipe in the URI parameters and loads them if present. + * Gets the URI params from the window and parses them to extract the actual values. + * + * @returns {object} */ - loadURIParams() { + getURIParams() { // Load query string or hash from URI (depending on which is populated) // We prefer getting the hash by splitting the href rather than referencing // location.hash as some browsers (Firefox) automatically URL decode it, @@ -403,8 +446,20 @@ class App { const params = window.location.search || window.location.href.split("#")[1] || window.location.hash; - this.uriParams = Utils.parseURIParams(params); + const parsedParams = Utils.parseURIParams(params); + return parsedParams; + } + + /** + * Searches the URI parameters for recipe and input parameters. + * If recipe is present, replaces the current recipe with the recipe provided in the URI. + * If input is present, decodes and sets the input to the one provided in the URI. + * + * @fires Manager#statechange + */ + loadURIParams() { this.autoBakePause = true; + this.uriParams = this.getURIParams(); // Read in recipe from URI params if (this.uriParams.recipe) { @@ -433,7 +488,7 @@ class App { if (this.uriParams.input) { try { const inputData = fromBase64(this.uriParams.input); - this.setInput(inputData, true); + this.setInput(inputData); } catch (err) {} } @@ -522,6 +577,8 @@ class App { this.columnSplitter.setSizes([20, 30, 50]); this.ioSplitter.setSizes([50, 50]); this.manager.recipe.adjustWidth(); + this.manager.input.calcMaxTabs(); + this.manager.output.calcMaxTabs(); } @@ -656,6 +713,17 @@ class App { this.progress = 0; this.autoBake(); + this.updateTitle(false, null, true); + } + + /** + * Update the page title to contain the new recipe + * + * @param {boolean} includeInput + * @param {string} input + * @param {boolean} [changeUrl=true] + */ + updateTitle(includeInput, input, changeUrl=true) { // Set title const recipeConfig = this.getRecipeConfig(); let title = "CyberChef"; @@ -674,8 +742,8 @@ class App { document.title = title; // Update the current history state (not creating a new one) - if (this.options.updateUrl) { - this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, recipeConfig); + if (this.options.updateUrl && changeUrl) { + this.lastStateUrl = this.manager.controls.generateStateUrl(true, includeInput, input, recipeConfig); window.history.replaceState({}, title, this.lastStateUrl); } } @@ -691,6 +759,29 @@ class App { this.loadURIParams(); } + + /** + * Debouncer to stop functions from being executed multiple times in a + * short space of time + * https://davidwalsh.name/javascript-debounce-function + * + * @param {function} func - The function to be executed after the debounce time + * @param {number} wait - The time (ms) to wait before executing the function + * @param {string} id - Unique ID to reference the timeout for the function + * @param {object} scope - The object to bind to the debounced function + * @param {array} args - Array of arguments to be passed to func + * @returns {function} + */ + debounce(func, wait, id, scope, args) { + return function() { + const later = function() { + func.apply(scope, args); + }; + clearTimeout(this.timeouts[id]); + this.timeouts[id] = setTimeout(later, wait); + }.bind(this); + } + } export default App; diff --git a/src/web/InputWaiter.mjs b/src/web/InputWaiter.mjs deleted file mode 100755 index 17b48b6f..00000000 --- a/src/web/InputWaiter.mjs +++ /dev/null @@ -1,354 +0,0 @@ -/** - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - */ - -import LoaderWorker from "worker-loader?inline&fallback=false!./LoaderWorker"; -import Utils from "../core/Utils"; - - -/** - * 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; - - // Define keys that don't change the input so we don't have to autobake when they are pressed - this.badKeys = [ - 16, //Shift - 17, //Ctrl - 18, //Alt - 19, //Pause - 20, //Caps - 27, //Esc - 33, 34, 35, 36, //PgUp, PgDn, End, Home - 37, 38, 39, 40, //Directional - 44, //PrntScrn - 91, 92, //Win - 93, //Context - 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, //F1-12 - 144, //Num - 145, //Scroll - ]; - - this.loaderWorker = null; - this.fileBuffer = null; - } - - - /** - * Gets the user's input from the input textarea. - * - * @returns {string} - */ - get() { - return this.fileBuffer || document.getElementById("input-text").value; - } - - - /** - * Sets the input in the input area. - * - * @param {string|File} input - * @param {boolean} [silent=false] - Suppress statechange event - * - * @fires Manager#statechange - */ - set(input, silent=false) { - const inputText = document.getElementById("input-text"); - if (input instanceof File) { - this.setFile(input); - inputText.value = ""; - this.setInputInfo(input.size, null); - } else { - inputText.value = input; - this.closeFile(); - if (!silent) window.dispatchEvent(this.manager.statechange); - const lines = input.length < (this.app.options.ioDisplayThreshold * 1024) ? - input.count("\n") + 1 : null; - this.setInputInfo(input.length, lines); - } - } - - - /** - * Shows file details. - * - * @param {File} file - */ - setFile(file) { - // Display file overlay in input area with details - const fileOverlay = document.getElementById("input-file"), - fileName = document.getElementById("input-file-name"), - fileSize = document.getElementById("input-file-size"), - fileType = document.getElementById("input-file-type"), - fileLoaded = document.getElementById("input-file-loaded"); - - this.fileBuffer = new ArrayBuffer(); - fileOverlay.style.display = "block"; - fileName.textContent = file.name; - fileSize.textContent = file.size.toLocaleString() + " bytes"; - fileType.textContent = file.type || "unknown"; - fileLoaded.textContent = "0%"; - } - - - /** - * Displays information about the input. - * - * @param {number} length - The length of the current input string - * @param {number} lines - The number of the lines in the current input string - */ - setInputInfo(length, lines) { - let width = length.toString().length; - width = width < 2 ? 2 : width; - - const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); - let msg = "length: " + lengthStr; - - if (typeof lines === "number") { - const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); - msg += "
lines: " + linesStr; - } - - document.getElementById("input-info").innerHTML = msg; - } - - - /** - * Handler for input change events. - * - * @param {event} e - * - * @fires Manager#statechange - */ - inputChange(e) { - // Ignore this function if the input is a File - if (this.fileBuffer) return; - - // Remove highlighting from input and output panes as the offsets might be different now - this.manager.highlighter.removeHighlights(); - - // Reset recipe progress as any previous processing will be redundant now - this.app.progress = 0; - - // Update the input metadata info - const inputText = this.get(); - const lines = inputText.length < (this.app.options.ioDisplayThreshold * 1024) ? - inputText.count("\n") + 1 : null; - - this.setInputInfo(inputText.length, lines); - - if (e && this.badKeys.indexOf(e.keyCode) < 0) { - // Fire the statechange event as the input has been modified - window.dispatchEvent(this.manager.statechange); - } - } - - - /** - * Handler for input paste events. - * Checks that the size of the input is below the display limit, otherwise treats it as a file/blob. - * - * @param {event} e - */ - inputPaste(e) { - const pastedData = e.clipboardData.getData("Text"); - - if (pastedData.length < (this.app.options.ioDisplayThreshold * 1024)) { - this.inputChange(e); - } else { - e.preventDefault(); - e.stopPropagation(); - - const file = new File([pastedData], "PastedData", { - type: "text/plain", - lastModified: Date.now() - }); - - this.loaderWorker = new LoaderWorker(); - this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); - this.loaderWorker.postMessage({"file": file}); - this.set(file); - return false; - } - } - - - /** - * 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,#input-file").classList.add("dropping-file"); - } - - - /** - * Handler for input dragleave events. - * Removes the visual cue. - * - * @param {event} e - */ - inputDragleave(e) { - e.stopPropagation(); - e.preventDefault(); - document.getElementById("input-text").classList.remove("dropping-file"); - document.getElementById("input-file").classList.remove("dropping-file"); - } - - - /** - * Handler for input drop events. - * Loads the dragged data into the input textarea. - * - * @param {event} e - */ - inputDrop(e) { - // This will be set if we're dragging an operation - if (e.dataTransfer.effectAllowed === "move") - return false; - - e.stopPropagation(); - e.preventDefault(); - - const file = e.dataTransfer.files[0]; - const text = e.dataTransfer.getData("Text"); - - document.getElementById("input-text").classList.remove("dropping-file"); - document.getElementById("input-file").classList.remove("dropping-file"); - - if (text) { - this.closeFile(); - this.set(text); - return; - } - - if (file) { - this.loadFile(file); - } - } - - /** - * Handler for open input button events - * Loads the opened data into the input textarea - * - * @param {event} e - */ - inputOpen(e) { - e.preventDefault(); - const file = e.srcElement.files[0]; - this.loadFile(file); - } - - - /** - * Handler for messages sent back by the LoaderWorker. - * - * @param {MessageEvent} e - */ - handleLoaderMessage(e) { - const r = e.data; - if (r.hasOwnProperty("progress")) { - const fileLoaded = document.getElementById("input-file-loaded"); - fileLoaded.textContent = r.progress + "%"; - } - - if (r.hasOwnProperty("error")) { - this.app.alert(r.error, 10000); - } - - if (r.hasOwnProperty("fileBuffer")) { - log.debug("Input file loaded"); - this.fileBuffer = r.fileBuffer; - this.displayFilePreview(); - window.dispatchEvent(this.manager.statechange); - } - } - - - /** - * Shows a chunk of the file in the input behind the file overlay. - */ - displayFilePreview() { - const inputText = document.getElementById("input-text"), - fileSlice = this.fileBuffer.slice(0, 4096); - - inputText.style.overflow = "hidden"; - inputText.classList.add("blur"); - inputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); - if (this.fileBuffer.byteLength > 4096) { - inputText.value += "[truncated]..."; - } - } - - - /** - * Handler for file close events. - */ - closeFile() { - if (this.loaderWorker) this.loaderWorker.terminate(); - this.fileBuffer = null; - document.getElementById("input-file").style.display = "none"; - const inputText = document.getElementById("input-text"); - inputText.style.overflow = "auto"; - inputText.classList.remove("blur"); - } - - - /** - * Loads a file into the input. - * - * @param {File} file - */ - loadFile(file) { - if (file) { - this.closeFile(); - this.loaderWorker = new LoaderWorker(); - this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); - this.loaderWorker.postMessage({"file": file}); - this.set(file); - } - } - - - /** - * Handler for clear IO events. - * Resets the input, output and info areas. - * - * @fires Manager#statechange - */ - clearIoClick() { - this.closeFile(); - this.manager.output.closeFile(); - this.manager.highlighter.removeHighlights(); - document.getElementById("input-text").value = ""; - document.getElementById("output-text").value = ""; - document.getElementById("input-info").innerHTML = ""; - document.getElementById("output-info").innerHTML = ""; - document.getElementById("input-selection-info").innerHTML = ""; - document.getElementById("output-selection-info").innerHTML = ""; - window.dispatchEvent(this.manager.statechange); - } - -} - -export default InputWaiter; diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs index 5fa0e8c1..2486f65d 100755 --- a/src/web/Manager.mjs +++ b/src/web/Manager.mjs @@ -4,18 +4,19 @@ * @license Apache-2.0 */ -import WorkerWaiter from "./WorkerWaiter"; -import WindowWaiter from "./WindowWaiter"; -import ControlsWaiter from "./ControlsWaiter"; -import RecipeWaiter from "./RecipeWaiter"; -import OperationsWaiter from "./OperationsWaiter"; -import InputWaiter from "./InputWaiter"; -import OutputWaiter from "./OutputWaiter"; -import OptionsWaiter from "./OptionsWaiter"; -import HighlighterWaiter from "./HighlighterWaiter"; -import SeasonalWaiter from "./SeasonalWaiter"; -import BindingsWaiter from "./BindingsWaiter"; -import BackgroundWorkerWaiter from "./BackgroundWorkerWaiter"; +import WorkerWaiter from "./waiters/WorkerWaiter"; +import WindowWaiter from "./waiters/WindowWaiter"; +import ControlsWaiter from "./waiters/ControlsWaiter"; +import RecipeWaiter from "./waiters/RecipeWaiter"; +import OperationsWaiter from "./waiters/OperationsWaiter"; +import InputWaiter from "./waiters/InputWaiter"; +import OutputWaiter from "./waiters/OutputWaiter"; +import OptionsWaiter from "./waiters/OptionsWaiter"; +import HighlighterWaiter from "./waiters/HighlighterWaiter"; +import SeasonalWaiter from "./waiters/SeasonalWaiter"; +import BindingsWaiter from "./waiters/BindingsWaiter"; +import BackgroundWorkerWaiter from "./waiters/BackgroundWorkerWaiter"; +import TabWaiter from "./waiters/TabWaiter"; /** @@ -63,6 +64,7 @@ class Manager { this.controls = new ControlsWaiter(this.app, this); this.recipe = new RecipeWaiter(this.app, this); this.ops = new OperationsWaiter(this.app, this); + this.tabs = new TabWaiter(this.app, this); this.input = new InputWaiter(this.app, this); this.output = new OutputWaiter(this.app, this); this.options = new OptionsWaiter(this.app, this); @@ -82,7 +84,9 @@ class Manager { * Sets up the various components and listeners. */ setup() { - this.worker.registerChefWorker(); + this.input.setupInputWorker(); + this.input.addInput(true); + this.worker.setupChefWorker(); this.recipe.initialiseOperationDragNDrop(); this.controls.initComponents(); this.controls.autoBakeChange(); @@ -142,11 +146,11 @@ class Manager { this.addDynamicListener("textarea.arg", "drop", this.recipe.textArgDrop, this.recipe); // Input - this.addMultiEventListener("#input-text", "keyup", this.input.inputChange, this.input); + this.addMultiEventListener("#input-text", "keyup", this.input.debounceInputChange, this.input); this.addMultiEventListener("#input-text", "paste", this.input.inputPaste, this.input); document.getElementById("reset-layout").addEventListener("click", this.app.resetLayout.bind(this.app)); - document.getElementById("clr-io").addEventListener("click", this.input.clearIoClick.bind(this.input)); - this.addListeners("#open-file", "change", this.input.inputOpen, this.input); + this.addListeners("#clr-io,#btn-close-all-tabs", "click", this.input.clearAllIoClick, this.input); + this.addListeners("#open-file,#open-folder", "change", this.input.inputOpen, this.input); this.addListeners("#input-text,#input-file", "dragover", this.input.inputDragover, this.input); this.addListeners("#input-text,#input-file", "dragleave", this.input.inputDragleave, this.input); this.addListeners("#input-text,#input-file", "drop", this.input.inputDrop, this.input); @@ -155,9 +159,31 @@ class Manager { document.getElementById("input-text").addEventListener("mousemove", this.highlighter.inputMousemove.bind(this.highlighter)); this.addMultiEventListener("#input-text", "mousedown dblclick select", this.highlighter.inputMousedown, this.highlighter); document.querySelector("#input-file .close").addEventListener("click", this.input.clearIoClick.bind(this.input)); + document.getElementById("btn-new-tab").addEventListener("click", this.input.addInputClick.bind(this.input)); + document.getElementById("btn-previous-input-tab").addEventListener("mousedown", this.input.previousTabClick.bind(this.input)); + document.getElementById("btn-next-input-tab").addEventListener("mousedown", this.input.nextTabClick.bind(this.input)); + this.addListeners("#btn-next-input-tab,#btn-previous-input-tab", "mouseup", this.input.tabMouseUp, this.input); + this.addListeners("#btn-next-input-tab,#btn-previous-input-tab", "mouseout", this.input.tabMouseUp, this.input); + document.getElementById("btn-go-to-input-tab").addEventListener("click", this.input.goToTab.bind(this.input)); + document.getElementById("btn-find-input-tab").addEventListener("click", this.input.findTab.bind(this.input)); + this.addDynamicListener("#input-tabs li .input-tab-content", "click", this.input.changeTabClick, this.input); + document.getElementById("input-show-pending").addEventListener("change", this.input.filterTabSearch.bind(this.input)); + document.getElementById("input-show-loading").addEventListener("change", this.input.filterTabSearch.bind(this.input)); + document.getElementById("input-show-loaded").addEventListener("change", this.input.filterTabSearch.bind(this.input)); + this.addListeners("#input-filter-content,#input-filter-filename", "click", this.input.filterOptionClick, this.input); + document.getElementById("input-filter").addEventListener("change", this.input.filterTabSearch.bind(this.input)); + document.getElementById("input-filter").addEventListener("keyup", this.input.filterTabSearch.bind(this.input)); + document.getElementById("input-num-results").addEventListener("change", this.input.filterTabSearch.bind(this.input)); + document.getElementById("input-num-results").addEventListener("keyup", this.input.filterTabSearch.bind(this.input)); + document.getElementById("input-filter-refresh").addEventListener("click", this.input.filterTabSearch.bind(this.input)); + this.addDynamicListener(".input-filter-result", "click", this.input.filterItemClick, this.input); + document.getElementById("btn-open-file").addEventListener("click", this.input.inputOpenClick.bind(this.input)); + document.getElementById("btn-open-folder").addEventListener("click", this.input.folderOpenClick.bind(this.input)); + // Output document.getElementById("save-to-file").addEventListener("click", this.output.saveClick.bind(this.output)); + document.getElementById("save-all-to-file").addEventListener("click", this.output.saveAllClick.bind(this.output)); document.getElementById("copy-output").addEventListener("click", this.output.copyClick.bind(this.output)); document.getElementById("switch").addEventListener("click", this.output.switchClick.bind(this.output)); document.getElementById("undo-switch").addEventListener("click", this.output.undoSwitchClick.bind(this.output)); @@ -174,6 +200,25 @@ class Manager { this.addDynamicListener("#output-file-slice i", "click", this.output.displayFileSlice, this.output); document.getElementById("show-file-overlay").addEventListener("click", this.output.showFileOverlayClick.bind(this.output)); this.addDynamicListener(".extract-file,.extract-file i", "click", this.output.extractFileClick, this.output); + this.addDynamicListener("#output-tabs-wrapper #output-tabs li .output-tab-content", "click", this.output.changeTabClick, this.output); + document.getElementById("btn-previous-output-tab").addEventListener("mousedown", this.output.previousTabClick.bind(this.output)); + document.getElementById("btn-next-output-tab").addEventListener("mousedown", this.output.nextTabClick.bind(this.output)); + this.addListeners("#btn-next-output-tab,#btn-previous-output-tab", "mouseup", this.output.tabMouseUp, this.output); + this.addListeners("#btn-next-output-tab,#btn-previous-output-tab", "mouseout", this.output.tabMouseUp, this.output); + document.getElementById("btn-go-to-output-tab").addEventListener("click", this.output.goToTab.bind(this.output)); + document.getElementById("btn-find-output-tab").addEventListener("click", this.output.findTab.bind(this.output)); + document.getElementById("output-show-pending").addEventListener("change", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-show-baking").addEventListener("change", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-show-baked").addEventListener("change", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-show-stale").addEventListener("change", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-show-errored").addEventListener("change", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-content-filter").addEventListener("change", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-content-filter").addEventListener("keyup", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-num-results").addEventListener("change", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-num-results").addEventListener("keyup", this.output.filterTabSearch.bind(this.output)); + document.getElementById("output-filter-refresh").addEventListener("click", this.output.filterTabSearch.bind(this.output)); + this.addDynamicListener(".output-filter-result", "click", this.output.filterItemClick, this.output); + // Options document.getElementById("options").addEventListener("click", this.options.optionsClick.bind(this.options)); @@ -186,6 +231,7 @@ class Manager { this.addDynamicListener(".option-item select", "change", this.options.selectChange, this.options); document.getElementById("theme").addEventListener("change", this.options.themeChange.bind(this.options)); document.getElementById("logLevel").addEventListener("change", this.options.logLevelChange.bind(this.options)); + document.getElementById("imagePreview").addEventListener("change", this.input.renderFileThumb.bind(this.input)); // Misc window.addEventListener("keydown", this.bindings.parseInput.bind(this.bindings)); @@ -307,7 +353,6 @@ class Manager { } } } - } export default Manager; diff --git a/src/web/OutputWaiter.mjs b/src/web/OutputWaiter.mjs deleted file mode 100755 index eaafeb8b..00000000 --- a/src/web/OutputWaiter.mjs +++ /dev/null @@ -1,547 +0,0 @@ -/** - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - */ - -import Utils from "../core/Utils"; -import FileSaver from "file-saver"; - - -/** - * Waiter to handle events related to the output. - */ -class OutputWaiter { - - /** - * OutputWaiter 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.dishBuffer = null; - this.dishStr = null; - } - - - /** - * Gets the output string from the output textarea. - * - * @returns {string} - */ - get() { - return document.getElementById("output-text").value; - } - - - /** - * Sets the output in the output textarea. - * - * @param {string|ArrayBuffer} data - The output string/HTML/ArrayBuffer - * @param {string} type - The data type of the output - * @param {number} duration - The length of time (ms) it took to generate the output - * @param {boolean} [preserveBuffer=false] - Whether to preserve the dishBuffer - */ - async set(data, type, duration, preserveBuffer) { - log.debug("Output type: " + type); - const outputText = document.getElementById("output-text"); - const outputHtml = document.getElementById("output-html"); - const outputFile = document.getElementById("output-file"); - const outputHighlighter = document.getElementById("output-highlighter"); - const inputHighlighter = document.getElementById("input-highlighter"); - let scriptElements, lines, length; - - if (!preserveBuffer) { - this.closeFile(); - this.dishStr = null; - document.getElementById("show-file-overlay").style.display = "none"; - } - - switch (type) { - case "html": - outputText.style.display = "none"; - outputHtml.style.display = "block"; - outputFile.style.display = "none"; - outputHighlighter.display = "none"; - inputHighlighter.display = "none"; - - outputText.value = ""; - outputHtml.innerHTML = data; - - // Execute script sections - scriptElements = outputHtml.querySelectorAll("script"); - for (let i = 0; i < scriptElements.length; i++) { - try { - eval(scriptElements[i].innerHTML); // eslint-disable-line no-eval - } catch (err) { - log.error(err); - } - } - - await this.getDishStr(); - length = this.dishStr.length; - lines = this.dishStr.count("\n") + 1; - break; - case "ArrayBuffer": - outputText.style.display = "block"; - outputHtml.style.display = "none"; - outputHighlighter.display = "none"; - inputHighlighter.display = "none"; - - outputText.value = ""; - outputHtml.innerHTML = ""; - length = data.byteLength; - - this.setFile(data); - break; - case "string": - default: - outputText.style.display = "block"; - outputHtml.style.display = "none"; - outputFile.style.display = "none"; - outputHighlighter.display = "block"; - inputHighlighter.display = "block"; - - outputText.value = Utils.printable(data, true); - outputHtml.innerHTML = ""; - - lines = data.count("\n") + 1; - length = data.length; - this.dishStr = data; - break; - } - - this.manager.highlighter.removeHighlights(); - this.setOutputInfo(length, lines, duration); - this.backgroundMagic(); - } - - - /** - * Shows file details. - * - * @param {ArrayBuffer} buf - */ - setFile(buf) { - this.dishBuffer = buf; - const file = new File([buf], "output.dat"); - - // Display file overlay in output area with details - const fileOverlay = document.getElementById("output-file"), - fileSize = document.getElementById("output-file-size"); - - fileOverlay.style.display = "block"; - fileSize.textContent = file.size.toLocaleString() + " bytes"; - - // Display preview slice in the background - const outputText = document.getElementById("output-text"), - fileSlice = this.dishBuffer.slice(0, 4096); - - outputText.classList.add("blur"); - outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); - } - - - /** - * Removes the output file and nulls its memory. - */ - closeFile() { - this.dishBuffer = null; - document.getElementById("output-file").style.display = "none"; - document.getElementById("output-text").classList.remove("blur"); - } - - - /** - * Handler for file download events. - */ - async downloadFile() { - this.filename = window.prompt("Please enter a filename:", this.filename || "download.dat"); - await this.getDishBuffer(); - const file = new File([this.dishBuffer], this.filename); - if (this.filename) FileSaver.saveAs(file, this.filename, false); - } - - - /** - * Handler for file slice display events. - */ - displayFileSlice() { - const startTime = new Date().getTime(), - showFileOverlay = document.getElementById("show-file-overlay"), - sliceFromEl = document.getElementById("output-file-slice-from"), - sliceToEl = document.getElementById("output-file-slice-to"), - sliceFrom = parseInt(sliceFromEl.value, 10), - sliceTo = parseInt(sliceToEl.value, 10), - str = Utils.arrayBufferToStr(this.dishBuffer.slice(sliceFrom, sliceTo)); - - document.getElementById("output-text").classList.remove("blur"); - showFileOverlay.style.display = "block"; - this.set(str, "string", new Date().getTime() - startTime, true); - } - - - /** - * Handler for show file overlay events. - * - * @param {Event} e - */ - showFileOverlayClick(e) { - const outputFile = document.getElementById("output-file"), - showFileOverlay = e.target; - - document.getElementById("output-text").classList.add("blur"); - outputFile.style.display = "block"; - showFileOverlay.style.display = "none"; - this.setOutputInfo(this.dishBuffer.byteLength, null, 0); - } - - - /** - * Displays information about the output. - * - * @param {number} length - The length of the current output string - * @param {number} lines - The number of the lines in the current output string - * @param {number} duration - The length of time (ms) it took to generate the output - */ - setOutputInfo(length, lines, duration) { - let width = length.toString().length; - width = width < 4 ? 4 : width; - - const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); - const timeStr = (duration.toString() + "ms").padStart(width, " ").replace(/ /g, " "); - - let msg = "time: " + timeStr + "
length: " + lengthStr; - - if (typeof lines === "number") { - const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); - msg += "
lines: " + linesStr; - } - - document.getElementById("output-info").innerHTML = msg; - document.getElementById("input-selection-info").innerHTML = ""; - document.getElementById("output-selection-info").innerHTML = ""; - } - - - /** - * Handler for save click events. - * Saves the current output to a file. - */ - saveClick() { - this.downloadFile(); - } - - - /** - * Handler for copy click events. - * Copies the output to the clipboard. - */ - async copyClick() { - await this.getDishStr(); - - // Create invisible textarea to populate with the raw dish string (not the printable version that - // contains dots instead of the actual bytes) - const textarea = document.createElement("textarea"); - textarea.style.position = "fixed"; - textarea.style.top = 0; - textarea.style.left = 0; - textarea.style.width = 0; - textarea.style.height = 0; - textarea.style.border = "none"; - - textarea.value = this.dishStr; - document.body.appendChild(textarea); - - // Select and copy the contents of this textarea - let success = false; - try { - textarea.select(); - success = this.dishStr && document.execCommand("copy"); - } catch (err) { - success = false; - } - - if (success) { - this.app.alert("Copied raw output successfully.", 2000); - } else { - this.app.alert("Sorry, the output could not be copied.", 3000); - } - - // Clean up - document.body.removeChild(textarea); - } - - - /** - * Handler for switch click events. - * Moves the current output into the input textarea. - */ - async switchClick() { - this.switchOrigData = this.manager.input.get(); - document.getElementById("undo-switch").disabled = false; - if (this.dishBuffer) { - this.manager.input.setFile(new File([this.dishBuffer], "output.dat")); - this.manager.input.handleLoaderMessage({ - data: { - progress: 100, - fileBuffer: this.dishBuffer - } - }); - } else { - await this.getDishStr(); - this.app.setInput(this.dishStr); - } - } - - - /** - * Handler for undo switch click events. - * Removes the output from the input and replaces the input that was removed. - */ - undoSwitchClick() { - this.app.setInput(this.switchOrigData); - const undoSwitch = document.getElementById("undo-switch"); - undoSwitch.disabled = true; - $(undoSwitch).tooltip("hide"); - } - - - /** - * Handler for maximise output click events. - * Resizes the output frame to be as large as possible, or restores it to its original size. - */ - maximiseOutputClick(e) { - const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode; - - if (el.getAttribute("data-original-title").indexOf("Maximise") === 0) { - this.app.initialiseSplitter(true); - this.app.columnSplitter.collapse(0); - this.app.columnSplitter.collapse(1); - this.app.ioSplitter.collapse(0); - - $(el).attr("data-original-title", "Restore output pane"); - el.querySelector("i").innerHTML = "fullscreen_exit"; - } else { - $(el).attr("data-original-title", "Maximise output pane"); - el.querySelector("i").innerHTML = "fullscreen"; - this.app.initialiseSplitter(false); - this.app.resetLayout(); - } - } - - - /** - * Save bombe object then remove it from the DOM so that it does not cause performance issues. - */ - saveBombe() { - this.bombeEl = document.getElementById("bombe"); - this.bombeEl.parentNode.removeChild(this.bombeEl); - } - - - /** - * Shows or hides the output loading screen. - * The animated Bombe SVG, whilst quite aesthetically pleasing, is reasonably CPU - * intensive, so we remove it from the DOM when not in use. We only show it if the - * recipe is taking longer than 200ms. We add it to the DOM just before that so that - * it is ready to fade in without stuttering. - * - * @param {boolean} value - true == show loader - */ - toggleLoader(value) { - clearTimeout(this.appendBombeTimeout); - clearTimeout(this.outputLoaderTimeout); - - const outputLoader = document.getElementById("output-loader"), - outputElement = document.getElementById("output-text"), - animation = document.getElementById("output-loader-animation"); - - if (value) { - this.manager.controls.hideStaleIndicator(); - - // Start a timer to add the Bombe to the DOM just before we make it - // visible so that there is no stuttering - this.appendBombeTimeout = setTimeout(function() { - animation.appendChild(this.bombeEl); - }.bind(this), 150); - - // Show the loading screen - this.outputLoaderTimeout = setTimeout(function() { - outputElement.disabled = true; - outputLoader.style.visibility = "visible"; - outputLoader.style.opacity = 1; - this.manager.controls.toggleBakeButtonFunction(true); - }.bind(this), 200); - } else { - // Remove the Bombe from the DOM to save resources - this.outputLoaderTimeout = setTimeout(function () { - try { - animation.removeChild(this.bombeEl); - } catch (err) {} - }.bind(this), 500); - outputElement.disabled = false; - outputLoader.style.opacity = 0; - outputLoader.style.visibility = "hidden"; - this.manager.controls.toggleBakeButtonFunction(false); - this.setStatusMsg(""); - } - } - - - /** - * Sets the baking status message value. - * - * @param {string} msg - */ - setStatusMsg(msg) { - const el = document.querySelector("#output-loader .loading-msg"); - - el.textContent = msg; - } - - - /** - * Returns true if the output contains carriage returns - * - * @returns {boolean} - */ - async containsCR() { - await this.getDishStr(); - return this.dishStr.indexOf("\r") >= 0; - } - - - /** - * Retrieves the current dish as a string, returning the cached version if possible. - * - * @returns {string} - */ - async getDishStr() { - if (this.dishStr) return this.dishStr; - - this.dishStr = await new Promise(resolve => { - this.manager.worker.getDishAs(this.app.dish, "string", r => { - resolve(r.value); - }); - }); - return this.dishStr; - } - - - /** - * Retrieves the current dish as an ArrayBuffer, returning the cached version if possible. - * - * @returns {ArrayBuffer} - */ - async getDishBuffer() { - if (this.dishBuffer) return this.dishBuffer; - - this.dishBuffer = await new Promise(resolve => { - this.manager.worker.getDishAs(this.app.dish, "ArrayBuffer", r => { - resolve(r.value); - }); - }); - return this.dishBuffer; - } - - - /** - * Triggers the BackgroundWorker to attempt Magic on the current output. - */ - backgroundMagic() { - this.hideMagicButton(); - if (!this.app.options.autoMagic) return; - - const sample = this.dishStr ? this.dishStr.slice(0, 1000) : - this.dishBuffer ? this.dishBuffer.slice(0, 1000) : ""; - - if (sample.length) { - this.manager.background.magic(sample); - } - } - - - /** - * Handles the results of a background Magic call. - * - * @param {Object[]} options - */ - backgroundMagicResult(options) { - if (!options.length || - !options[0].recipe.length) - return; - - const currentRecipeConfig = this.app.getRecipeConfig(); - const newRecipeConfig = currentRecipeConfig.concat(options[0].recipe); - const opSequence = options[0].recipe.map(o => o.op).join(", "); - - this.showMagicButton(opSequence, options[0].data, newRecipeConfig); - } - - - /** - * Handler for Magic click events. - * - * Loads the Magic recipe. - * - * @fires Manager#statechange - */ - magicClick() { - const magicButton = document.getElementById("magic"); - this.app.setRecipeConfig(JSON.parse(magicButton.getAttribute("data-recipe"))); - window.dispatchEvent(this.manager.statechange); - this.hideMagicButton(); - } - - - /** - * Displays the Magic button with a title and adds a link to a complete recipe. - * - * @param {string} opSequence - * @param {string} result - * @param {Object[]} recipeConfig - */ - showMagicButton(opSequence, result, recipeConfig) { - const magicButton = document.getElementById("magic"); - magicButton.setAttribute("data-original-title", `${opSequence} will produce "${Utils.escapeHtml(Utils.truncate(result), 30)}"`); - magicButton.setAttribute("data-recipe", JSON.stringify(recipeConfig), null, ""); - magicButton.classList.remove("hidden"); - } - - - /** - * Hides the Magic button and resets its values. - */ - hideMagicButton() { - const magicButton = document.getElementById("magic"); - magicButton.classList.add("hidden"); - magicButton.setAttribute("data-recipe", ""); - magicButton.setAttribute("data-original-title", "Magic!"); - } - - - /** - * Handler for extract file events. - * - * @param {Event} e - */ - async extractFileClick(e) { - e.preventDefault(); - e.stopPropagation(); - - const el = e.target.nodeName === "I" ? e.target.parentNode : e.target; - const blobURL = el.getAttribute("blob-url"); - const fileName = el.getAttribute("file-name"); - - const blob = await fetch(blobURL).then(r => r.blob()); - this.manager.input.loadFile(new File([blob], fileName, {type: blob.type})); - } - -} - -export default OutputWaiter; diff --git a/src/web/WorkerWaiter.mjs b/src/web/WorkerWaiter.mjs deleted file mode 100755 index 7ef72263..00000000 --- a/src/web/WorkerWaiter.mjs +++ /dev/null @@ -1,239 +0,0 @@ -/** - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2017 - * @license Apache-2.0 - */ - -import ChefWorker from "worker-loader?inline&fallback=false!../core/ChefWorker"; - -/** - * Waiter to handle conversations with the ChefWorker. - */ -class WorkerWaiter { - - /** - * WorkerWaiter 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.callbacks = {}; - this.callbackID = 0; - } - - - /** - * Sets up the ChefWorker and associated listeners. - */ - registerChefWorker() { - log.debug("Registering new ChefWorker"); - this.chefWorker = new ChefWorker(); - this.chefWorker.addEventListener("message", this.handleChefMessage.bind(this)); - this.setLogLevel(); - - let docURL = document.location.href.split(/[#?]/)[0]; - const index = docURL.lastIndexOf("/"); - if (index > 0) { - docURL = docURL.substring(0, index); - } - this.chefWorker.postMessage({"action": "docURL", "data": docURL}); - } - - - /** - * Handler for messages sent back by the ChefWorker. - * - * @param {MessageEvent} e - */ - handleChefMessage(e) { - const r = e.data; - log.debug("Receiving '" + r.action + "' from ChefWorker"); - - switch (r.action) { - case "bakeComplete": - this.bakingComplete(r.data); - break; - case "bakeError": - this.app.handleError(r.data); - this.setBakingStatus(false); - break; - case "dishReturned": - this.callbacks[r.data.id](r.data); - break; - case "silentBakeComplete": - break; - case "workerLoaded": - this.app.workerLoaded = true; - log.debug("ChefWorker loaded"); - this.app.loaded(); - break; - case "statusMessage": - this.manager.output.setStatusMsg(r.data); - break; - case "optionUpdate": - log.debug(`Setting ${r.data.option} to ${r.data.value}`); - this.app.options[r.data.option] = r.data.value; - break; - case "setRegisters": - this.manager.recipe.setRegisters(r.data.opIndex, r.data.numPrevRegisters, r.data.registers); - break; - case "highlightsCalculated": - this.manager.highlighter.displayHighlights(r.data.pos, r.data.direction); - break; - default: - log.error("Unrecognised message from ChefWorker", e); - break; - } - } - - - /** - * Updates the UI to show if baking is in process or not. - * - * @param {bakingStatus} - */ - setBakingStatus(bakingStatus) { - this.app.baking = bakingStatus; - - this.manager.output.toggleLoader(bakingStatus); - } - - - /** - * Cancels the current bake by terminating the ChefWorker and creating a new one. - */ - cancelBake() { - this.chefWorker.terminate(); - this.registerChefWorker(); - this.setBakingStatus(false); - this.manager.controls.showStaleIndicator(); - } - - - /** - * Handler for completed bakes. - * - * @param {Object} response - */ - bakingComplete(response) { - this.setBakingStatus(false); - - if (!response) return; - - if (response.error) { - this.app.handleError(response.error); - } - - this.app.progress = response.progress; - this.app.dish = response.dish; - this.manager.recipe.updateBreakpointIndicator(response.progress); - this.manager.output.set(response.result, response.type, response.duration); - log.debug("--- Bake complete ---"); - } - - - /** - * Asks the ChefWorker to bake the current input using the current recipe. - * - * @param {string} input - * @param {Object[]} recipeConfig - * @param {Object} options - * @param {number} progress - * @param {boolean} step - */ - bake(input, recipeConfig, options, progress, step) { - this.setBakingStatus(true); - - this.chefWorker.postMessage({ - action: "bake", - data: { - input: input, - recipeConfig: recipeConfig, - options: options, - progress: progress, - step: step - } - }); - } - - - /** - * Asks the ChefWorker to run a silent bake, forcing the browser to load and cache all the relevant - * JavaScript code needed to do a real bake. - * - * @param {Object[]} [recipeConfig] - */ - silentBake(recipeConfig) { - this.chefWorker.postMessage({ - action: "silentBake", - data: { - recipeConfig: recipeConfig - } - }); - } - - - /** - * Asks the ChefWorker to calculate highlight offsets if possible. - * - * @param {Object[]} recipeConfig - * @param {string} direction - * @param {Object} pos - The position object for the highlight. - * @param {number} pos.start - The start offset. - * @param {number} pos.end - The end offset. - */ - highlight(recipeConfig, direction, pos) { - this.chefWorker.postMessage({ - action: "highlight", - data: { - recipeConfig: recipeConfig, - direction: direction, - pos: pos - } - }); - } - - - /** - * Asks the ChefWorker to return the dish as the specified type - * - * @param {Dish} dish - * @param {string} type - * @param {Function} callback - */ - getDishAs(dish, type, callback) { - const id = this.callbackID++; - this.callbacks[id] = callback; - this.chefWorker.postMessage({ - action: "getDishAs", - data: { - dish: dish, - type: type, - id: id - } - }); - } - - - /** - * Sets the console log level in the worker. - * - * @param {string} level - */ - setLogLevel(level) { - if (!this.chefWorker) return; - - this.chefWorker.postMessage({ - action: "setLogLevel", - data: log.getLevel() - }); - } - -} - - -export default WorkerWaiter; diff --git a/src/web/html/index.html b/src/web/html/index.html index 350c4a37..7fcb7415 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -218,9 +218,16 @@
- + + +
-
+ +
- -
-
+ +
+
- +
Name:
@@ -257,13 +289,16 @@
+ - +
-
-
-
- - -
-
-
-
- -
- Size:
- -
- - - - -
to
- +
+ +
+
+
+ + +
+
+
+
+ +
+ Size:
+ +
+ + + + +
to
+ +
-
-
-
- +
+
+ +
+
-
@@ -425,6 +484,8 @@ + +
@@ -498,6 +559,20 @@ Attempt to detect encoded data automagically
+ +
+ +
+ +
+ +
+ + + diff --git a/src/web/index.js b/src/web/index.js index 4f34e2c2..38361078 100755 --- a/src/web/index.js +++ b/src/web/index.js @@ -52,6 +52,8 @@ function main() { ioDisplayThreshold: 512, logLevel: "info", autoMagic: true, + imagePreview: true, + syncTabs: true }; document.removeEventListener("DOMContentLoaded", main, false); diff --git a/src/web/stylesheets/components/_operation.css b/src/web/stylesheets/components/_operation.css index bb3e9b5d..a4255fc3 100755 --- a/src/web/stylesheets/components/_operation.css +++ b/src/web/stylesheets/components/_operation.css @@ -82,7 +82,43 @@ div.toggle-string { .operation .is-focused [class*=' bmd-label'], .operation .is-focused label, .operation .checkbox label:hover { - color: #1976d2; + color: var(--input-highlight-colour); +} + +.ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check, +.ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check::before { + border-color: var(--input-border-colour); + color: var(--input-highlight-colour); +} + +.ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check, +.ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check::before { + border-color: var(--input-highlight-colour); + color: var(--input-highlight-colour); +} + +.disabled .ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check, +.disabled .ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check::before, +.disabled .ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check, +.disabled .ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check::before { + border-color: var(--disabled-font-colour); + color: var(--disabled-font-colour); +} + +.break .ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check, +.break .ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check::before, +.break .ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check, +.break .ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check::before { + border-color: var(--breakpoint-font-colour); + color: var(--breakpoint-font-colour); +} + +.flow-control-op.break .ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check, +.flow-control-op.break .ingredients .checkbox label input[type=checkbox]+.checkbox-decorator .check::before, +.flow-control-op.break .ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check, +.flow-control-op.break .ingredients .checkbox label input[type=checkbox]:checked+.checkbox-decorator .check::before { + border-color: var(--fc-breakpoint-operation-font-colour); + color: var(--fc-breakpoint-operation-font-colour); } .operation .form-control { @@ -97,7 +133,7 @@ div.toggle-string { .operation .form-control:hover { background-image: - linear-gradient(to top, #1976d2 2px, rgba(25, 118, 210, 0) 2px), + linear-gradient(to top, var(--input-highlight-colour) 2px, rgba(25, 118, 210, 0) 2px), linear-gradient(to top, rgba(0, 0, 0, 0.26) 1px, rgba(0, 0, 0, 0) 1px); filter: brightness(97%); } @@ -105,7 +141,7 @@ div.toggle-string { .operation .form-control:focus { background-color: var(--arg-background); background-image: - linear-gradient(to top, #1976d2 2px, rgba(25, 118, 210, 0) 2px), + linear-gradient(to top, var(--input-highlight-colour) 2px, rgba(25, 118, 210, 0) 2px), linear-gradient(to top, rgba(0, 0, 0, 0.26) 1px, rgba(0, 0, 0, 0) 1px); filter: brightness(100%); } @@ -205,19 +241,19 @@ div.toggle-string { } .disable-icon { - color: #9e9e9e; + color: var(--disable-icon-colour); } .disable-icon-selected { - color: #f44336; + color: var(--disable-icon-selected-colour); } .breakpoint { - color: #9e9e9e; + color: var(--breakpoint-icon-colour); } .breakpoint-selected { - color: #f44336; + color: var(--breakpoint-icon-selected-colour); } .break { diff --git a/src/web/stylesheets/components/_pane.css b/src/web/stylesheets/components/_pane.css index f98e2f3f..9ee8f46f 100755 --- a/src/web/stylesheets/components/_pane.css +++ b/src/web/stylesheets/components/_pane.css @@ -8,6 +8,7 @@ :root { --title-height: 48px; + --tab-height: 40px; } .title { @@ -52,6 +53,7 @@ line-height: 30px; background-color: var(--primary-background-colour); flex-direction: row; + padding-left: 10px; } .io-card.card:hover { @@ -60,10 +62,16 @@ .io-card.card>img { float: left; - width: 128px; - height: 128px; - margin-left: 10px; - margin-top: 11px; + width: auto; + height: auto; + max-width: 128px; + max-height: 128px; + margin-left: auto; + margin-top: auto; + margin-right: auto; + margin-bottom: auto; + padding: 0px; + } .io-card.card .card-body .close { diff --git a/src/web/stylesheets/index.css b/src/web/stylesheets/index.css index b5463a5b..ef35d54f 100755 --- a/src/web/stylesheets/index.css +++ b/src/web/stylesheets/index.css @@ -10,6 +10,8 @@ @import "./themes/_classic.css"; @import "./themes/_dark.css"; @import "./themes/_geocities.css"; +@import "./themes/_solarizedDark.css"; +@import "./themes/_solarizedLight.css"; /* Utilities */ @import "./utils/_overrides.css"; diff --git a/src/web/stylesheets/layout/_banner.css b/src/web/stylesheets/layout/_banner.css index 220ae914..59856958 100755 --- a/src/web/stylesheets/layout/_banner.css +++ b/src/web/stylesheets/layout/_banner.css @@ -22,6 +22,10 @@ padding-right: 10px; } +#banner a { + color: var(--banner-url-colour); +} + #notice-wrapper { text-align: center; overflow: hidden; diff --git a/src/web/stylesheets/layout/_controls.css b/src/web/stylesheets/layout/_controls.css index 8f4cdf0b..d231133a 100755 --- a/src/web/stylesheets/layout/_controls.css +++ b/src/web/stylesheets/layout/_controls.css @@ -40,6 +40,12 @@ cursor: pointer; } +#auto-bake-label .check, +#auto-bake-label .check::before { + border-color: var(--input-highlight-colour); + color: var(--input-highlight-colour); +} + #auto-bake-label .checkbox-decorator { position: relative; } @@ -51,3 +57,15 @@ #controls .btn { border-radius: 30px; } + +.spin { + animation-name: spin; + animation-duration: 3s; + animation-iteration-count: infinite; + animation-timing-function: linear; +} + +@keyframes spin { + 0% {transform: rotate(0deg);} + 100% {transform: rotate(360deg);} +} diff --git a/src/web/stylesheets/layout/_io.css b/src/web/stylesheets/layout/_io.css index 2578b57d..d7d628cb 100755 --- a/src/web/stylesheets/layout/_io.css +++ b/src/web/stylesheets/layout/_io.css @@ -24,18 +24,172 @@ word-wrap: break-word; } +#output-wrapper{ + margin: 0; + padding: 0; +} + +#output-wrapper .textarea-wrapper { + width: 100%; + height: 100%; + box-sizing: border-box; + overflow: hidden; + pointer-events: auto; +} + + #output-html { display: none; overflow-y: auto; -moz-padding-start: 1px; /* Fixes bug in Firefox */ } -.textarea-wrapper { - position: absolute; - top: var(--title-height); - bottom: 0; +#input-tabs-wrapper #input-tabs, +#output-tabs-wrapper #output-tabs { + list-style: none; + background-color: var(--title-background-colour); + padding: 0; + margin: 0; + overflow-x: auto; + overflow-y: hidden; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--primary-border-colour); + border-left: 1px solid var(--primary-border-colour); + height: var(--tab-height); + clear: none; +} + +#input-tabs li, +#output-tabs li { + display: flex; + flex-direction: row; width: 100%; + min-width: 120px; + float: left; + padding: 0px; + text-align: center; + border-right: 1px solid var(--primary-border-colour); + height: var(--tab-height); + vertical-align: middle; +} + +#input-tabs li:hover, +#output-tabs li:hover { + cursor: pointer; + background-color: var(--primary-background-colour); +} + +.active-input-tab, +.active-output-tab { + font-weight: bold; + background-color: var(--primary-background-colour); +} + +.input-tab-content+.btn-close-tab { + display: block; + margin-top: auto; + margin-bottom: auto; + margin-right: 2px; +} + +.input-tab-content+.btn-close-tab i { + font-size: 0.8em; +} + +.input-tab-buttons, +.output-tab-buttons { + width: 25px; + text-align: center; + margin: 0; + height: var(--tab-height); + line-height: var(--tab-height); + font-weight: bold; + background-color: var(--title-background-colour); + border-bottom: 1px solid var(--primary-border-colour); +} + +.input-tab-buttons:hover, +.output-tab-buttons:hover { + cursor: pointer; + background-color: var(--primary-background-colour); +} + + +#btn-next-input-tab, +#btn-input-tab-dropdown, +#btn-next-output-tab, +#btn-output-tab-dropdown { + float: right; +} + +#btn-previous-input-tab, +#btn-previous-output-tab { + float: left; +} + +#btn-close-all-tabs { + color: var(--breakpoint-font-colour) !important; +} + +.input-tab-content, +.output-tab-content { + width: 100%; + max-width: 100%; + padding-left: 5px; + padding-right: 5px; + padding-top: 10px; + padding-bottom: 10px; + height: var(--tab-height); + vertical-align: middle; overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.btn-close-tab { + height: var(--tab-height); + vertical-align: middle; + width: fit-content; +} + +.tabs-left > li:first-child { + box-shadow: 15px 0px 15px -15px var(--primary-border-colour) inset; +} + +.tabs-right > li:last-child { + box-shadow: -15px 0px 15px -15px var(--primary-border-colour) inset; +} + +#input-wrapper, +#output-wrapper, +#input-wrapper > * , +#output-wrapper > .textarea-wrapper > div, +#output-wrapper > .textarea-wrapper > textarea { + height: calc(100% - var(--title-height)); +} + +#input-wrapper.show-tabs, +#input-wrapper.show-tabs > *, +#output-wrapper.show-tabs, +#output-wrapper.show-tabs > .textarea-wrapper > div, +#output-wrapper.show-tabs > .textarea-wrapper > textarea { + height: calc(100% - var(--tab-height) - var(--title-height)); +} + +#output-wrapper > .textarea-wrapper > #output-html { + height: 100%; +} + +#show-file-overlay { + height: 32px; +} + +.input-wrapper.textarea-wrapper { + width: 100%; + box-sizing: border-box; + overflow: hidden; + pointer-events: auto; } .textarea-wrapper textarea, @@ -49,9 +203,8 @@ #output-highlighter { position: absolute; left: 0; - top: 0; + bottom: 0; width: 100%; - height: 100%; padding: 3px; margin: 0; overflow: hidden; @@ -61,14 +214,14 @@ color: #fff; background-color: transparent; border: none; + pointer-events: none; } #output-loader { position: absolute; - top: 0; + bottom: 0; left: 0; width: 100%; - height: 100%; margin: 0; background-color: var(--primary-background-colour); visibility: hidden; @@ -105,9 +258,8 @@ #output-file { position: absolute; left: 0; - top: 0; + bottom: 0; width: 100%; - height: 100%; display: none; } @@ -122,7 +274,7 @@ #show-file-overlay { position: absolute; right: 15px; - top: 15px; + top: calc(var(--title-height) + 10px); cursor: pointer; display: none; } @@ -147,7 +299,6 @@ .dropping-file { border: 5px dashed var(--drop-file-border-colour) !important; - margin: -5px; } #stale-indicator { @@ -185,3 +336,103 @@ #magic svg path { fill: var(--primary-font-colour); } + +#input-find-options, +#output-find-options { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 100%; +} + +#input-tab-body .form-group.input-group, +#output-tab-body .form-group.input-group { + width: 70%; + float: left; + margin-bottom: 2rem; +} + +.input-find-option .toggle-string { + width: 70%; + display: inline-block; +} + +.input-find-option-append button { + border-top-right-radius: 4px; + background-color: var(--arg-background) !important; + margin: unset; +} + +.input-find-option-append button:hover { + filter: brightness(97%); +} + +.form-group.output-find-option { + width: 70%; + float: left; +} + +#input-num-results-container, +#output-num-results-container { + width: 20%; + float: right; + margin: 0; + margin-left: 10%; +} + +#input-find-options-checkboxes, +#output-find-options-checkboxes { + list-style: none; + padding: 0; + margin: auto; + overflow-x: auto; + overflow-y: hidden; + text-align: center; + width: fit-content; +} + +#input-find-options-checkboxes li, +#output-find-options-checkboxes li { + display: flex; + flex-direction: row; + float: left; + padding: 10px; + text-align: center; +} + + +#input-search-results, +#output-search-results { + list-style: none; + width: 75%; + min-width: 200px; + margin-left: auto; + margin-right: auto; +} + +#input-search-results li, +#output-search-results li { + padding-left: 5px; + padding-right: 5px; + padding-top: 10px; + padding-bottom: 10px; + text-align: center; + width: 100%; + color: var(--op-list-operation-font-colour); + background-color: var(--op-list-operation-bg-colour); + border-bottom: 2px solid var(--op-list-operation-border-colour); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +#input-search-results li:first-of-type, +#output-search-results li:first-of-type { + border-top: 2px solid var(--op-list-operation-border-colour); +} + +#input-search-results li:hover, +#output-search-results li:hover { + cursor: pointer; + filter: brightness(98%); +} diff --git a/src/web/stylesheets/layout/_modals.css b/src/web/stylesheets/layout/_modals.css index a49c579d..c1745eeb 100755 --- a/src/web/stylesheets/layout/_modals.css +++ b/src/web/stylesheets/layout/_modals.css @@ -77,3 +77,34 @@ padding: 20px; border-left: 2px solid var(--primary-border-colour); } + +.checkbox label input[type=checkbox]+.checkbox-decorator .check, +.checkbox label input[type=checkbox]+.checkbox-decorator .check::before { + border-color: var(--input-border-colour); + color: var(--input-highlight-colour); +} + +.checkbox label input[type=checkbox]:checked+.checkbox-decorator .check, +.checkbox label input[type=checkbox]:checked+.checkbox-decorator .check::before { + border-color: var(--input-highlight-colour); + color: var(--input-highlight-colour); +} + +.bmd-form-group.is-focused .option-item label { + color: var(--input-highlight-colour); +} + +.bmd-form-group.is-focused [class^='bmd-label'], +.bmd-form-group.is-focused [class*=' bmd-label'], +.bmd-form-group.is-focused [class^='bmd-label'], +.bmd-form-group.is-focused [class*=' bmd-label'], +.bmd-form-group.is-focused label, +.checkbox label:hover { + color: var(--input-highlight-colour); +} + +.bmd-form-group.option-item label+.form-control{ + background-image: + linear-gradient(to top, var(--input-highlight-colour) 2px, rgba(0, 0, 0, 0) 2px), + linear-gradient(to top, var(--primary-border-colour) 1px, rgba(0, 0, 0, 0) 1px); +} \ No newline at end of file diff --git a/src/web/stylesheets/layout/_operations.css b/src/web/stylesheets/layout/_operations.css index e5e4c887..b73dfa84 100755 --- a/src/web/stylesheets/layout/_operations.css +++ b/src/web/stylesheets/layout/_operations.css @@ -16,7 +16,7 @@ padding-left: 10px; padding-right: 10px; background-image: - linear-gradient(to top, #1976d2 2px, rgba(25, 118, 210, 0) 2px), + linear-gradient(to top, var(--input-highlight-colour) 2px, rgba(0, 0, 0, 0) 2px), linear-gradient(to top, var(--primary-border-colour) 1px, rgba(0, 0, 0, 0) 1px); } @@ -33,7 +33,7 @@ } #categories a { - color: #1976d2; + color: var(--category-list-font-colour); cursor: pointer; } diff --git a/src/web/stylesheets/preloader.css b/src/web/stylesheets/preloader.css index 288ffc28..01e6d3d2 100755 --- a/src/web/stylesheets/preloader.css +++ b/src/web/stylesheets/preloader.css @@ -6,14 +6,14 @@ * @license Apache-2.0 */ -#loader-wrapper { + #loader-wrapper { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; - background-color: var(--secondary-border-colour); + background-color: var(--loader-background-colour); } .loader { @@ -26,7 +26,7 @@ margin: -75px 0 0 -75px; border: 3px solid transparent; - border-top-color: #3498db; + border-top-color: var(--loader-outer-colour); border-radius: 50%; animation: spin 2s linear infinite; @@ -45,7 +45,7 @@ left: 5px; right: 5px; bottom: 5px; - border-top-color: #e74c3c; + border-top-color: var(--loader-middle-colour); animation: spin 3s linear infinite; } @@ -54,7 +54,7 @@ left: 13px; right: 13px; bottom: 13px; - border-top-color: #f9c922; + border-top-color: var(--loader-inner-colour); animation: spin 1.5s linear infinite; } diff --git a/src/web/stylesheets/themes/_classic.css b/src/web/stylesheets/themes/_classic.css index 5d6bd3e8..f89d9dce 100755 --- a/src/web/stylesheets/themes/_classic.css +++ b/src/web/stylesheets/themes/_classic.css @@ -35,6 +35,14 @@ --banner-font-colour: #468847; --banner-bg-colour: #dff0d8; + --banner-url-colour: #1976d2; + + --category-list-font-colour: #1976d2; + + --loader-background-colour: var(--secondary-border-colour); + --loader-outer-colour: #3498db; + --loader-middle-colour: #e74c3c; + --loader-inner-colour: #f9c922; /* Operation colours */ @@ -76,6 +84,13 @@ --arg-label-colour: #388e3c; + /* Operation buttons */ + --disable-icon-colour: #9e9e9e; + --disable-icon-selected-colour: #f44336; + --breakpoint-icon-colour: #9e9e9e; + --breakpoint-icon-selected-colour: #f44336; + + /* Buttons */ --btn-default-font-colour: #333; --btn-default-bg-colour: #fff; @@ -114,4 +129,6 @@ --popover-border-colour: #ccc; --code-background: #f9f2f4; --code-font-colour: #c7254e; + --input-highlight-colour: #1976d2; + --input-border-colour: #424242; } diff --git a/src/web/stylesheets/themes/_dark.css b/src/web/stylesheets/themes/_dark.css index 62d91808..ff2217fb 100755 --- a/src/web/stylesheets/themes/_dark.css +++ b/src/web/stylesheets/themes/_dark.css @@ -31,6 +31,14 @@ --banner-font-colour: #c5c5c5; --banner-bg-colour: #252525; + --banner-url-colour: #1976d2; + + --category-list-font-colour: #1976d2; + + --loader-background-colour: var(--secondary-border-colour); + --loader-outer-colour: #3498db; + --loader-middle-colour: #e74c3c; + --loader-inner-colour: #f9c922; /* Operation colours */ @@ -72,6 +80,13 @@ --arg-label-colour: rgb(25, 118, 210); + /* Operation buttons */ + --disable-icon-colour: #9e9e9e; + --disable-icon-selected-colour: #f44336; + --breakpoint-icon-colour: #9e9e9e; + --breakpoint-icon-selected-colour: #f44336; + + /* Buttons */ --btn-default-font-colour: #c5c5c5; --btn-default-bg-colour: #2d2d2d; @@ -110,4 +125,6 @@ --popover-border-colour: #555; --code-background: #0e639c; --code-font-colour: #fff; + --input-highlight-colour: #1976d2; + --input-border-colour: #424242; } diff --git a/src/web/stylesheets/themes/_geocities.css b/src/web/stylesheets/themes/_geocities.css index 12936e21..230638b1 100755 --- a/src/web/stylesheets/themes/_geocities.css +++ b/src/web/stylesheets/themes/_geocities.css @@ -31,6 +31,14 @@ --banner-font-colour: white; --banner-bg-colour: maroon; + --banner-url-colour: yellow; + + --category-list-font-colour: yellow; + + --loader-background-colour: #00f; + --loader-outer-colour: #0f0; + --loader-middle-colour: red; + --loader-inner-colour: yellow; /* Operation colours */ @@ -72,6 +80,13 @@ --arg-label-colour: red; + /* Operation buttons */ + --disable-icon-colour: #0f0; + --disable-icon-selected-colour: yellow; + --breakpoint-icon-colour: #0f0; + --breakpoint-icon-selected-colour: yellow; + + /* Buttons */ --btn-default-font-colour: black; --btn-default-bg-colour: white; @@ -110,4 +125,6 @@ --popover-border-colour: violet; --code-background: black; --code-font-colour: limegreen; + --input-highlight-colour: limegreen; + --input-border-colour: limegreen; } diff --git a/src/web/stylesheets/themes/_solarizedDark.css b/src/web/stylesheets/themes/_solarizedDark.css new file mode 100755 index 00000000..1d46a9bd --- /dev/null +++ b/src/web/stylesheets/themes/_solarizedDark.css @@ -0,0 +1,147 @@ +/** + * Solarized dark theme definitions + * + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +:root.solarizedDark { + --base03: #002b36; + --base02: #073642; + --base01: #586e75; + --base00: #657b83; + --base0: #839496; + --base1: #93a1a1; + --base2: #eee8d5; + --base3: #fdf6e3; + --sol-yellow: #b58900; + --sol-orange: #cb4b16; + --sol-red: #dc322f; + --sol-magenta: #d33682; + --sol-violet: #6c71c4; + --sol-blue: #268bd2; + --sol-cyan: #2aa198; + --sol-green: #859900; + + --primary-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; + --primary-font-colour: var(--base0); + --primary-font-size: 14px; + --primary-line-height: 20px; + + --fixed-width-font-family: SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + --fixed-width-font-colour: inherit; + --fixed-width-font-size: inherit; + + --subtext-font-colour: var(--base01); + --subtext-font-size: 13px; + + --primary-background-colour: var(--base03); + --secondary-background-colour: var(--base02); + + --primary-border-colour: var(--base00); + --secondary-border-colour: var(--base01); + + --title-colour: var(--base1); + --title-weight: bold; + --title-background-colour: var(--base02); + + --banner-font-colour: var(--base0); + --banner-bg-colour: var(--base03); + --banner-url-colour: var(--base1); + + --category-list-font-colour: var(--base1); + + --loader-background-colour: var(--base03); + --loader-outer-colour: var(--base1); + --loader-middle-colour: var(--base0); + --loader-inner-colour: var(--base00); + + + /* Operation colours */ + --op-list-operation-font-colour: var(--base0); + --op-list-operation-bg-colour: var(--base03); + --op-list-operation-border-colour: var(--base02); + + --rec-list-operation-font-colour: var(--base0); + --rec-list-operation-bg-colour: var(--base02); + --rec-list-operation-border-colour: var(--base01); + + --selected-operation-font-color: var(--base1); + --selected-operation-bg-colour: var(--base02); + --selected-operation-border-colour: var(--base01); + + --breakpoint-font-colour: var(--sol-red); + --breakpoint-bg-colour: var(--base02); + --breakpoint-border-colour: var(--base00); + + --disabled-font-colour: var(--base01); + --disabled-bg-colour: var(--base03); + --disabled-border-colour: var(--base02); + + --fc-operation-font-colour: var(--base1); + --fc-operation-bg-colour: var(--base02); + --fc-operation-border-colour: var(--base01); + + --fc-breakpoint-operation-font-colour: var(--sol-orange); + --fc-breakpoint-operation-bg-colour: var(--base02); + --fc-breakpoint-operation-border-colour: var(--base00); + + + /* Operation arguments */ + --op-title-font-weight: bold; + --arg-font-colour: var(--base0); + --arg-background: var(--base03); + --arg-border-colour: var(--base00); + --arg-disabled-background: var(--base03); + --arg-label-colour: var(--base1); + + + /* Operation buttons */ + --disable-icon-colour: var(--base00); + --disable-icon-selected-colour: var(--sol-red); + --breakpoint-icon-colour: var(--base00); + --breakpoint-icon-selected-colour: var(--sol-red); + + /* Buttons */ + --btn-default-font-colour: var(--base0); + --btn-default-bg-colour: var(--base02); + --btn-default-border-colour: var(--base01); + + --btn-default-hover-font-colour: var(--base1); + --btn-default-hover-bg-colour: var(--base01); + --btn-default-hover-border-colour: var(--base00); + + --btn-success-font-colour: var(--base0); + --btn-success-bg-colour: var(--base03); + --btn-success-border-colour: var(--base00); + + --btn-success-hover-font-colour: var(--base1); + --btn-success-hover-bg-colour: var(--base01); + --btn-success-hover-border-colour: var(--base00); + + /* Highlighter colours */ + --hl1: var(--base01); + --hl2: var(--sol-blue); + --hl3: var(--sol-magenta); + --hl4: var(--sol-yellow); + --hl5: var(--sol-green); + + + /* Scrollbar */ + --scrollbar-track: var(--base03); + --scrollbar-thumb: var(--base00); + --scrollbar-hover: var(--base01); + + + /* Misc. */ + --drop-file-border-colour: var(--base01); + --popover-background: var(--base02); + --popover-border-colour: var(--base01); + --code-background: var(--base03); + --code-font-colour: var(--base1); + --input-highlight-colour: var(--base1); + --input-border-colour: var(--base0); +} diff --git a/src/web/stylesheets/themes/_solarizedLight.css b/src/web/stylesheets/themes/_solarizedLight.css new file mode 100755 index 00000000..46f8bf1c --- /dev/null +++ b/src/web/stylesheets/themes/_solarizedLight.css @@ -0,0 +1,149 @@ +/** + * Solarized light theme definitions + * + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +:root.solarizedLight { + --base03: #002b36; + --base02: #073642; + --base01: #586e75; + --base00: #657b83; + --base0: #839496; + --base1: #93a1a1; + --base2: #eee8d5; + --base3: #fdf6e3; + --sol-yellow: #b58900; + --sol-orange: #cb4b16; + --sol-red: #dc322f; + --sol-magenta: #d33682; + --sol-violet: #6c71c4; + --sol-blue: #268bd2; + --sol-cyan: #2aa198; + --sol-green: #859900; + + --primary-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; + --primary-font-colour: var(--base00); + --primary-font-size: 14px; + --primary-line-height: 20px; + + --fixed-width-font-family: SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + --fixed-width-font-colour: inherit; + --fixed-width-font-size: inherit; + + --subtext-font-colour: var(--base1); + --subtext-font-size: 13px; + + --primary-background-colour: var(--base3); + --secondary-background-colour: var(--base2); + + --primary-border-colour: var(--base0); + --secondary-border-colour: var(--base1); + + --title-colour: var(--base01); + --title-weight: bold; + --title-background-colour: var(--base2); + + --banner-font-colour: var(--base00); + --banner-bg-colour: var(--base3); + --banner-url-colour: var(--base01); + + --category-list-font-colour: var(--base01); + + --loader-background-colour: var(--base3); + --loader-outer-colour: var(--base01); + --loader-middle-colour: var(--base00); + --loader-inner-colour: var(--base0); + + + /* Operation colours */ + --op-list-operation-font-colour: var(--base00); + --op-list-operation-bg-colour: var(--base3); + --op-list-operation-border-colour: var(--base2); + + --rec-list-operation-font-colour: var(--base00); + --rec-list-operation-bg-colour: var(--base2); + --rec-list-operation-border-colour: var(--base1); + + --selected-operation-font-color: var(--base01); + --selected-operation-bg-colour: var(--base2); + --selected-operation-border-colour: var(--base1); + + --breakpoint-font-colour: var(--sol-red); + --breakpoint-bg-colour: var(--base2); + --breakpoint-border-colour: var(--base0); + + --disabled-font-colour: var(--base1); + --disabled-bg-colour: var(--base3); + --disabled-border-colour: var(--base2); + + --fc-operation-font-colour: var(--base01); + --fc-operation-bg-colour: var(--base2); + --fc-operation-border-colour: var(--base1); + + --fc-breakpoint-operation-font-colour: var(--base02); + --fc-breakpoint-operation-bg-colour: var(--base1); + --fc-breakpoint-operation-border-colour: var(--base0); + + + /* Operation arguments */ + --op-title-font-weight: bold; + --arg-font-colour: var(--base00); + --arg-background: var(--base3); + --arg-border-colour: var(--base0); + --arg-disabled-background: var(--base3); + --arg-label-colour: var(--base01); + + + /* Operation buttons */ + --disable-icon-colour: #9e9e9e; + --disable-icon-selected-colour: #f44336; + --breakpoint-icon-colour: #9e9e9e; + --breakpoint-icon-selected-colour: #f44336; + + + /* Buttons */ + --btn-default-font-colour: var(--base00); + --btn-default-bg-colour: var(--base2); + --btn-default-border-colour: var(--base1); + + --btn-default-hover-font-colour: var(--base01); + --btn-default-hover-bg-colour: var(--base1); + --btn-default-hover-border-colour: var(--base0); + + --btn-success-font-colour: var(--base00); + --btn-success-bg-colour: var(--base3); + --btn-success-border-colour: var(--base0); + + --btn-success-hover-font-colour: var(--base01); + --btn-success-hover-bg-colour: var(--base1); + --btn-success-hover-border-colour: var(--base0); + + + /* Highlighter colours */ + --hl1: var(--base1); + --hl2: var(--sol-blue); + --hl3: var(--sol-magenta); + --hl4: var(--sol-yellow); + --hl5: var(--sol-green); + + + /* Scrollbar */ + --scrollbar-track: var(--base3); + --scrollbar-thumb: var(--base1); + --scrollbar-hover: var(--base0); + + + /* Misc. */ + --drop-file-border-colour: var(--base1); + --popover-background: var(--base2); + --popover-border-colour: var(--base1); + --code-background: var(--base3); + --code-font-colour: var(--base01); + --input-highlight-colour: var(--base01); + --input-border-colour: var(--base00); +} diff --git a/src/web/stylesheets/utils/_overrides.css b/src/web/stylesheets/utils/_overrides.css index 014da99a..129b840e 100755 --- a/src/web/stylesheets/utils/_overrides.css +++ b/src/web/stylesheets/utils/_overrides.css @@ -104,8 +104,11 @@ select.form-control:not([size]):not([multiple]), select.custom-file-control:not( color: var(--primary-font-colour); } -.form-control { - background-image: linear-gradient(to top, rgb(25, 118, 210) 2px, rgba(25, 118, 210, 0) 2px), linear-gradient(to top, var(--primary-border-colour) 1px, rgba(0, 0, 0, 0) 1px); +.form-control, +.is-focused .form-control { + background-image: + linear-gradient(to top, var(--input-highlight-colour) 2px, rgba(0, 0, 0, 0) 2px), + linear-gradient(to top, var(--primary-border-colour) 1px, rgba(0, 0, 0, 0) 1px); } code { diff --git a/src/web/BackgroundWorkerWaiter.mjs b/src/web/waiters/BackgroundWorkerWaiter.mjs similarity index 97% rename from src/web/BackgroundWorkerWaiter.mjs rename to src/web/waiters/BackgroundWorkerWaiter.mjs index b7b259be..a7f9b83a 100644 --- a/src/web/BackgroundWorkerWaiter.mjs +++ b/src/web/waiters/BackgroundWorkerWaiter.mjs @@ -4,7 +4,7 @@ * @license Apache-2.0 */ -import ChefWorker from "worker-loader?inline&fallback=false!../core/ChefWorker"; +import ChefWorker from "worker-loader?inline&fallback=false!../../core/ChefWorker"; /** * Waiter to handle conversations with a ChefWorker in the background. @@ -68,6 +68,7 @@ class BackgroundWorkerWaiter { break; case "optionUpdate": case "statusMessage": + case "progressMessage": // Ignore these messages break; default: diff --git a/src/web/BindingsWaiter.mjs b/src/web/waiters/BindingsWaiter.mjs similarity index 85% rename from src/web/BindingsWaiter.mjs rename to src/web/waiters/BindingsWaiter.mjs index 74262c61..79c2903b 100755 --- a/src/web/BindingsWaiter.mjs +++ b/src/web/waiters/BindingsWaiter.mjs @@ -98,11 +98,11 @@ class BindingsWaiter { break; case "Space": // Bake e.preventDefault(); - this.app.bake(); + this.manager.controls.bakeClick(); break; case "Quote": // Step through e.preventDefault(); - this.app.bake(true); + this.manager.controls.stepClick(); break; case "KeyC": // Clear recipe e.preventDefault(); @@ -120,6 +120,22 @@ class BindingsWaiter { e.preventDefault(); this.manager.output.switchClick(); break; + case "KeyT": // New tab + e.preventDefault(); + this.manager.input.addInputClick(); + break; + case "KeyW": // Close tab + e.preventDefault(); + this.manager.input.removeInput(this.manager.tabs.getActiveInputTab()); + break; + case "ArrowLeft": // Go to previous tab + e.preventDefault(); + this.manager.input.changeTabLeft(); + break; + case "ArrowRight": // Go to next tab + e.preventDefault(); + this.manager.input.changeTabRight(); + break; default: if (e.code.match(/Digit[0-9]/g)) { // Select nth operation e.preventDefault(); @@ -216,6 +232,26 @@ class BindingsWaiter { Ctrl+${modWinLin}+m Ctrl+${modMac}+m + + Create a new tab + Ctrl+${modWinLin}+t + Ctrl+${modMac}+t + + + Close the current tab + Ctrl+${modWinLin}+w + Ctrl+${modMac}+w + + + Go to next tab + Ctrl+${modWinLin}+RightArrow + Ctrl+${modMac}+RightArrow + + + Go to previous tab + Ctrl+${modWinLin}+LeftArrow + Ctrl+${modMac}+LeftArrow + `; } diff --git a/src/web/ControlsWaiter.mjs b/src/web/waiters/ControlsWaiter.mjs similarity index 83% rename from src/web/ControlsWaiter.mjs rename to src/web/waiters/ControlsWaiter.mjs index bcebb1f3..1a7b0684 100755 --- a/src/web/ControlsWaiter.mjs +++ b/src/web/waiters/ControlsWaiter.mjs @@ -4,8 +4,7 @@ * @license Apache-2.0 */ -import Utils from "../core/Utils"; -import {toBase64} from "../core/lib/Base64"; +import Utils from "../../core/Utils"; /** @@ -57,10 +56,11 @@ class ControlsWaiter { * Handler to trigger baking. */ bakeClick() { - if (document.getElementById("bake").textContent.indexOf("Bake") > 0) { - this.app.bake(); - } else { - this.manager.worker.cancelBake(); + const btnBake = document.getElementById("bake"); + if (btnBake.textContent.indexOf("Bake") > 0) { + this.app.manager.input.bakeAll(); + } else if (btnBake.textContent.indexOf("Cancel") > 0) { + this.manager.worker.cancelBake(false, true); } } @@ -69,7 +69,7 @@ class ControlsWaiter { * Handler for the 'Step through' command. Executes the next step of the recipe. */ stepClick() { - this.app.bake(true); + this.app.step(); } @@ -90,7 +90,7 @@ class ControlsWaiter { /** - * Populates the save disalog box with a URL incorporating the recipe and input. + * Populates the save dialog box with a URL incorporating the recipe and input. * * @param {Object[]} [recipeConfig] - The recipe configuration object array. */ @@ -112,26 +112,33 @@ class ControlsWaiter { * * @param {boolean} includeRecipe - Whether to include the recipe in the URL. * @param {boolean} includeInput - Whether to include the input in the URL. + * @param {string} input * @param {Object[]} [recipeConfig] - The recipe configuration object array. * @param {string} [baseURL] - The CyberChef URL, set to the current URL if not included * @returns {string} */ - generateStateUrl(includeRecipe, includeInput, recipeConfig, baseURL) { + generateStateUrl(includeRecipe, includeInput, input, recipeConfig, baseURL) { recipeConfig = recipeConfig || this.app.getRecipeConfig(); const link = baseURL || window.location.protocol + "//" + window.location.host + window.location.pathname; const recipeStr = Utils.generatePrettyRecipe(recipeConfig); - const inputStr = toBase64(this.app.getInput(), "A-Za-z0-9+/"); // B64 alphabet with no padding includeRecipe = includeRecipe && (recipeConfig.length > 0); - // Only inlcude input if it is less than 50KB (51200 * 4/3 as it is Base64 encoded) - includeInput = includeInput && (inputStr.length > 0) && (inputStr.length <= 68267); + + // If we don't get passed an input, get it from the current URI + if (input === null) { + const params = this.app.getURIParams(); + if (params.input) { + includeInput = true; + input = params.input; + } + } const params = [ includeRecipe ? ["recipe", recipeStr] : undefined, - includeInput ? ["input", inputStr] : undefined, + includeInput ? ["input", input] : undefined, ]; const hash = params @@ -335,7 +342,7 @@ class ControlsWaiter { e.preventDefault(); const reportBugInfo = document.getElementById("report-bug-info"); - const saveLink = this.generateStateUrl(true, true, null, "https://gchq.github.io/CyberChef/"); + const saveLink = this.generateStateUrl(true, true, null, null, "https://gchq.github.io/CyberChef/"); if (reportBugInfo) { reportBugInfo.innerHTML = `* Version: ${PKG_VERSION} @@ -370,22 +377,34 @@ ${navigator.userAgent} /** - * Switches the Bake button between 'Bake' and 'Cancel' functions. + * Switches the Bake button between 'Bake', 'Cancel' and 'Loading' functions. * - * @param {boolean} cancel - Whether to change to cancel or not + * @param {string} func - The function to change to. Either "cancel", "loading" or "bake" */ - toggleBakeButtonFunction(cancel) { + toggleBakeButtonFunction(func) { const bakeButton = document.getElementById("bake"), btnText = bakeButton.querySelector("span"); - if (cancel) { - btnText.innerText = "Cancel"; - bakeButton.classList.remove("btn-success"); - bakeButton.classList.add("btn-danger"); - } else { - btnText.innerText = "Bake!"; - bakeButton.classList.remove("btn-danger"); - bakeButton.classList.add("btn-success"); + switch (func) { + case "cancel": + btnText.innerText = "Cancel"; + bakeButton.classList.remove("btn-success"); + bakeButton.classList.remove("btn-warning"); + bakeButton.classList.add("btn-danger"); + break; + case "loading": + bakeButton.style.background = ""; + btnText.innerText = "Loading..."; + bakeButton.classList.remove("btn-success"); + bakeButton.classList.remove("btn-danger"); + bakeButton.classList.add("btn-warning"); + break; + default: + bakeButton.style.background = ""; + btnText.innerText = "Bake!"; + bakeButton.classList.remove("btn-danger"); + bakeButton.classList.remove("btn-warning"); + bakeButton.classList.add("btn-success"); } } diff --git a/src/web/HighlighterWaiter.mjs b/src/web/waiters/HighlighterWaiter.mjs similarity index 99% rename from src/web/HighlighterWaiter.mjs rename to src/web/waiters/HighlighterWaiter.mjs index 99ae10b1..95050556 100755 --- a/src/web/HighlighterWaiter.mjs +++ b/src/web/waiters/HighlighterWaiter.mjs @@ -378,6 +378,8 @@ class HighlighterWaiter { displayHighlights(pos, direction) { if (!pos) return; + if (this.manager.tabs.getActiveInputTab() !== this.manager.tabs.getActiveOutputTab()) return; + const io = direction === "forward" ? "output" : "input"; document.getElementById(io + "-selection-info").innerHTML = this.selectionInfo(pos[0].start, pos[0].end); diff --git a/src/web/waiters/InputWaiter.mjs b/src/web/waiters/InputWaiter.mjs new file mode 100644 index 00000000..90f52bbb --- /dev/null +++ b/src/web/waiters/InputWaiter.mjs @@ -0,0 +1,1371 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import LoaderWorker from "worker-loader?inline&fallback=false!../workers/LoaderWorker"; +import InputWorker from "worker-loader?inline&fallback=false!../workers/InputWorker"; +import Utils from "../../core/Utils"; +import { toBase64 } from "../../core/lib/Base64"; +import { isImage } from "../../core/lib/FileType"; + + +/** + * 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; + + // Define keys that don't change the input so we don't have to autobake when they are pressed + this.badKeys = [ + 16, //Shift + 17, //Ctrl + 18, //Alt + 19, //Pause + 20, //Caps + 27, //Esc + 33, 34, 35, 36, //PgUp, PgDn, End, Home + 37, 38, 39, 40, //Directional + 44, //PrntScrn + 91, 92, //Win + 93, //Context + 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, //F1-12 + 144, //Num + 145, //Scroll + ]; + + this.inputWorker = null; + this.loaderWorkers = []; + this.workerId = 0; + this.maxTabs = this.manager.tabs.calcMaxTabs(); + this.callbacks = {}; + this.callbackID = 0; + + 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; + } + } + + /** + * 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.getActiveInputTab() + } + }); + } + } + + /** + * 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: "updateMaxWorkers", + data: this.maxWorkers + }); + this.inputWorker.postMessage({ + action: "updateMaxTabs", + data: { + maxTabs: this.maxTabs, + activeTab: this.manager.tabs.getActiveInputTab() + } + }); + this.inputWorker.postMessage({ + action: "setLogLevel", + data: log.getLevel() + }); + 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."); + const newWorker = new LoaderWorker(); + const workerId = this.workerId++; + newWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); + newWorker.postMessage({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({ + 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 (r.hasOwnProperty("progress") && r.hasOwnProperty("inputNum")) { + this.manager.tabs.updateInputTabProgress(r.inputNum, r.progress, 100); + } else if (r.hasOwnProperty("fileBuffer")) { + this.manager.tabs.updateInputTabProgress(r.inputNum, 100, 100); + } + + const transferable = r.hasOwnProperty("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 (!r.hasOwnProperty("action")) { + 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.updateInputTabHeader(r.data.inputNum, r.data.input); + break; + case "loadingInfo": + this.showLoadingInfo(r.data, true); + break; + case "setInput": + this.app.debounce(this.set, 50, "setInput", this, [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 "bakeAllInputs": + this.manager.worker.bakeAllInputs(r.data); + break; + case "displayTabSearchResults": + this.displayTabSearchResults(r.data); + break; + case "filterTabError": + this.app.handleError(r.data); + break; + case "setUrl": + this.setUrl(r.data); + break; + case "inputSwitch": + this.manager.output.inputSwitch(r.data); + break; + case "getInput": + case "getInputNums": + this.callbacks[r.data.id](r.data); + break; + case "removeChefWorker": + this.removeChefWorker(); + break; + default: + log.error(`Unknown action ${r.action}.`); + } + } + + /** + * Sends a message to the inputWorker to bake all inputs + */ + bakeAll() { + this.app.progress = 0; + this.app.debounce(this.manager.controls.toggleBakeButtonFunction, 20, "toggleBakeButton", this, ["loading"]); + this.inputWorker.postMessage({ + action: "bakeAll" + }); + } + + /** + * Sets the input in the input area + * + * @param {object} inputData - Object containing the input and its metadata + * @param {number} inputData.inputNum - The unique inputNum for the selected input + * @param {string | object} inputData.input - The actual input data + * @param {string} inputData.name - The name of the input file + * @param {number} inputData.size - The size in bytes of the input file + * @param {string} inputData.type - The MIME type of the input file + * @param {number} inputData.progress - The load progress of the input file + * @param {boolean} [silent=false] - If true, fires the manager statechange event + */ + async set(inputData, silent=false) { + return new Promise(function(resolve, reject) { + const activeTab = this.manager.tabs.getActiveInputTab(); + if (inputData.inputNum !== activeTab) return; + + const inputText = document.getElementById("input-text"); + + if (typeof inputData.input === "string") { + inputText.value = inputData.input; + const fileOverlay = document.getElementById("input-file"), + fileName = document.getElementById("input-file-name"), + fileSize = document.getElementById("input-file-size"), + fileType = document.getElementById("input-file-type"), + fileLoaded = document.getElementById("input-file-loaded"); + + fileOverlay.style.display = "none"; + fileName.textContent = ""; + fileSize.textContent = ""; + fileType.textContent = ""; + fileLoaded.textContent = ""; + + inputText.style.overflow = "auto"; + inputText.classList.remove("blur"); + inputText.scroll(0, 0); + + const lines = inputData.input.length < (this.app.options.ioDisplayThreshold * 1024) ? + inputData.input.count("\n") + 1 : null; + this.setInputInfo(inputData.input.length, lines); + + // Set URL to current input + const inputStr = toBase64(inputData.input, "A-Za-z0-9+/"); + if (inputStr.length > 0 && inputStr.length <= 68267) { + this.setUrl({ + includeInput: true, + input: inputStr + }); + } + + if (!silent) window.dispatchEvent(this.manager.statechange); + } else { + this.setFile(inputData); + } + + }.bind(this)); + } + + /** + * Displays file details + * + * @param {object} inputData - Object containing the input and its metadata + * @param {number} inputData.inputNum - The unique inputNum for the selected input + * @param {string | object} inputData.input - The actual input data + * @param {string} inputData.name - The name of the input file + * @param {number} inputData.size - The size in bytes of the input file + * @param {string} inputData.type - The MIME type of the input file + * @param {number} inputData.progress - The load progress of the input file + */ + setFile(inputData) { + const activeTab = this.manager.tabs.getActiveInputTab(); + if (inputData.inputNum !== activeTab) return; + + const fileOverlay = document.getElementById("input-file"), + fileName = document.getElementById("input-file-name"), + fileSize = document.getElementById("input-file-size"), + fileType = document.getElementById("input-file-type"), + fileLoaded = document.getElementById("input-file-loaded"); + + fileOverlay.style.display = "block"; + fileName.textContent = inputData.name; + fileSize.textContent = inputData.size + " bytes"; + fileType.textContent = inputData.type; + if (inputData.status === "error") { + fileLoaded.textContent = "Error"; + fileLoaded.style.color = "#FF0000"; + } else { + fileLoaded.style.color = ""; + fileLoaded.textContent = inputData.progress + "%"; + } + + this.setInputInfo(inputData.size, null); + this.displayFilePreview(inputData); + } + + /** + * Render the input thumbnail + */ + async renderFileThumb() { + const activeTab = this.manager.tabs.getActiveInputTab(), + input = await this.getInputValue(activeTab), + fileThumb = document.getElementById("input-file-thumbnail"); + + if (typeof input === "string" || + !this.app.options.imagePreview) { + this.resetFileThumb(); + return; + } + + const inputArr = new Uint8Array(input), + type = isImage(inputArr); + + if (type && type !== "image/tiff" && inputArr.byteLength <= 512000) { + // Most browsers don't support displaying TIFFs, so ignore them + // Don't render images over 512000 bytes + const blob = new Blob([inputArr], {type: type}), + url = URL.createObjectURL(blob); + fileThumb.src = url; + } else { + this.resetFileThumb(); + } + + } + + /** + * Reset the input thumbnail to the default icon + */ + resetFileThumb() { + const fileThumb = document.getElementById("input-file-thumbnail"); + fileThumb.src = require("../static/images/file-128x128.png"); + } + + /** + * Shows a chunk of the file in the input behind the file overlay + * + * @param {Object} inputData - Object containing the input data + * @param {number} inputData.inputNum - The inputNum of the file being displayed + * @param {ArrayBuffer} inputData.input - The actual input to display + */ + displayFilePreview(inputData) { + const activeTab = this.manager.tabs.getActiveInputTab(), + input = inputData.input, + inputText = document.getElementById("input-text"); + if (inputData.inputNum !== activeTab) return; + inputText.style.overflow = "hidden"; + inputText.classList.add("blur"); + inputText.value = Utils.printable(Utils.arrayBufferToStr(input.slice(0, 4096))); + + this.renderFileThumb(); + + } + + /** + * 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.getActiveInputTab(); + if (inputNum !== activeTab) return; + + const fileLoaded = document.getElementById("input-file-loaded"); + let oldProgress = fileLoaded.textContent; + if (oldProgress !== "Error") { + oldProgress = parseInt(oldProgress.replace("%", ""), 10); + } + if (progress === "error") { + fileLoaded.textContent = "Error"; + fileLoaded.style.color = "#FF0000"; + } else { + fileLoaded.textContent = progress + "%"; + fileLoaded.style.color = ""; + } + + if (progress === 100 && progress !== oldProgress) { + // Don't set the input if the progress hasn't changed + this.inputWorker.postMessage({ + action: "setInput", + data: { + inputNum: inputNum, + silent: false + } + }); + window.dispatchEvent(this.manager.statechange); + + } + } + + /** + * Updates the stored value for the specified inputNum + * + * @param {number} inputNum + * @param {string | ArrayBuffer} value + */ + updateInputValue(inputNum, value) { + let includeInput = false; + const recipeStr = toBase64(value, "A-Za-z0-9+/"); // B64 alphabet with no padding + if (recipeStr.length > 0 && recipeStr.length <= 68267) { + includeInput = true; + } + this.setUrl({ + includeInput: includeInput, + input: recipeStr + }); + + // Value is either a string set by the input or an ArrayBuffer from a LoaderWorker, + // so is safe to use typeof === "string" + const transferable = (typeof value !== "string") ? [value] : undefined; + this.inputWorker.postMessage({ + action: "updateInputValue", + data: { + inputNum: inputNum, + value: value + } + }, transferable); + } + + /** + * Updates the .data property for the input of the specified inputNum. + * Used for switching the output into the input + * + * @param {number} inputNum - The inputNum of the input we're changing + * @param {object} inputData - The new data object + */ + updateInputObj(inputNum, inputData) { + const transferable = (typeof inputData !== "string") ? [inputData.fileBuffer] : undefined; + this.inputWorker.postMessage({ + action: "updateInputObj", + data: { + inputNum: inputNum, + data: inputData + } + }, 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.getInput(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.getInput(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} + */ + getInput(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 + }); + } + + + /** + * Displays information about the input. + * + * @param {number} length - The length of the current input string + * @param {number} lines - The number of the lines in the current input string + */ + setInputInfo(length, lines) { + let width = length.toString().length.toLocaleString(); + width = width < 2 ? 2 : width; + + const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); + let msg = "length: " + lengthStr; + + if (typeof lines === "number") { + const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); + msg += "
lines: " + linesStr; + } + + document.getElementById("input-info").innerHTML = msg; + + } + + /** + * Handler for input change events. + * Debounces the input so we don't call autobake too often. + * + * @param {event} e + */ + debounceInputChange(e) { + this.app.debounce(this.inputChange, 50, "inputChange", this, [e])(); + } + + /** + * Handler for input change events. + * Updates the value stored in the inputWorker + * + * @param {event} e + * + * @fires Manager#statechange + */ + inputChange(e) { + // Ignore this function if the input is a file + const fileOverlay = document.getElementById("input-file"); + if (fileOverlay.style.display === "block") return; + + // Remove highlighting from input and output panes as the offsets might be different now + this.manager.highlighter.removeHighlights(); + + const textArea = document.getElementById("input-text"); + const value = (textArea.value !== undefined) ? textArea.value : ""; + const activeTab = this.manager.tabs.getActiveInputTab(); + + this.app.progress = 0; + + const lines = value.length < (this.app.options.ioDisplayThreshold * 1024) ? + (value.count("\n") + 1) : null; + this.setInputInfo(value.length, lines); + this.updateInputValue(activeTab, value); + this.manager.tabs.updateInputTabHeader(activeTab, value.replace(/[\n\r]/g, "").slice(0, 100)); + + if (e && this.badKeys.indexOf(e.keyCode) < 0) { + // Fire the statechange event as the input has been modified + window.dispatchEvent(this.manager.statechange); + } + } + + /** + * Handler for input paste events + * Checks that the size of the input is below the display limit, otherwise treats it as a file/blob + * + * @param {event} e + */ + inputPaste(e) { + const pastedData = e.clipboardData.getData("Text"); + if (pastedData.length < (this.app.options.ioDisplayThreshold * 1024)) { + // Pasting normally fires the inputChange() event before + // changing the value, so instead change it here ourselves + // and manually fire inputChange() + e.preventDefault(); + const inputText = document.getElementById("input-text"); + const selStart = inputText.selectionStart; + const selEnd = inputText.selectionEnd; + const startVal = inputText.value.slice(0, selStart); + const endVal = inputText.value.slice(selEnd); + + inputText.value = startVal + pastedData + endVal; + inputText.setSelectionRange(selStart + pastedData.length, selStart + pastedData.length); + this.debounceInputChange(e); + } else { + e.preventDefault(); + e.stopPropagation(); + + const file = new File([pastedData], "PastedData", { + type: "text/plain", + lastModified: Date.now() + }); + + this.loadUIFiles([file]); + return false; + } + } + + + /** + * 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,#input-file").classList.add("dropping-file"); + } + + /** + * Handler for input dragleave events. + * Removes the visual cue. + * + * @param {event} e + */ + inputDragleave(e) { + e.stopPropagation(); + e.preventDefault(); + e.target.closest("#input-text,#input-file").classList.remove("dropping-file"); + } + + /** + * Handler for input drop events. + * Loads the dragged data. + * + * @param {event} e + */ + inputDrop(e) { + // This will be set if we're dragging an operation + if (e.dataTransfer.effectAllowed === "move") + return false; + + e.stopPropagation(); + e.preventDefault(); + + const text = e.dataTransfer.getData("Text"); + + e.target.closest("#input-text,#input-file").classList.remove("dropping-file"); + + if (text) { + // Append the text to the current input and fire inputChange() + document.getElementById("input-text").value += text; + this.inputChange(e); + return; + } + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + this.loadUIFiles(e.dataTransfer.files); + } + } + + /** + * 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 = ""; + } + } + + /** + * 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.getActiveInputTab(); + 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 + } + }); + } + + /** + * 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(); + } + + /** + * 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.getActiveInputTab() + }); + }.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) { + if (this.manager.tabs.getInputTabItem(inputNum) !== null) { + this.manager.tabs.changeInputTab(inputNum); + this.inputWorker.postMessage({ + action: "setInput", + data: { + inputNum: inputNum, + silent: true + } + }); + } else { + const minNum = Math.min(...this.manager.tabs.getInputTabList()); + 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); + } + } + + /** + * 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.manager.highlighter.removeHighlights(); + getSelection().removeAllRanges(); + + const tabsList = document.getElementById("input-tabs").children; + for (let i = tabsList.length - 1; i >= 0; i--) { + tabsList.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); + this.bakeAll(); + } + + /** + * Handler for clear IO click event. + * Resets the input for the current tab + */ + clearIoClick() { + const inputNum = this.manager.tabs.getActiveInputTab(); + if (inputNum === -1) return; + + this.manager.highlighter.removeHighlights(); + getSelection().removeAllRanges(); + + this.updateInputValue(inputNum, ""); + + this.set({ + inputNum: inputNum, + input: "" + }); + + this.manager.tabs.updateInputTabHeader(inputNum, ""); + } + + /** + * Sets the console log level in the worker. + * + * @param {string} level + */ + setLogLevel(level) { + 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.getInputTabItem(inputNum) && numTabs < this.maxTabs) { + const newTab = this.manager.tabs.createInputTabElement(inputNum, changeTab); + 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.style.boxShadow = "-15px 0px 15px -15px var(--primary-border-colour) inset"; + } + + 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.refreshInputTabs(nums, activeTab, tabsLeft, tabsRight); + + 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.getInputTabItem(inputNum) !== 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 = 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 = 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.getActiveInputTab(); + if (activeTab === -1) return; + this.inputWorker.postMessage({ + action: "changeTabRight", + data: { + activeTab: activeTab + } + }); + } + + /** + * Changes to the previous (left) tab + */ + changeTabLeft() { + const activeTab = this.manager.tabs.getActiveInputTab(); + 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.getActiveInputTab().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); + } + + /** + * Update the input URL to the new value + * + * @param {object} urlData - Object containing the URL data + * @param {boolean} urlData.includeInput - If true, the input is included in the title + * @param {string} urlData.input - The input data to be included + */ + setUrl(urlData) { + this.app.updateTitle(urlData.includeInput, urlData.input, true); + } + + +} + +export default InputWaiter; diff --git a/src/web/OperationsWaiter.mjs b/src/web/waiters/OperationsWaiter.mjs similarity index 99% rename from src/web/OperationsWaiter.mjs rename to src/web/waiters/OperationsWaiter.mjs index decc49d6..ffd28374 100755 --- a/src/web/OperationsWaiter.mjs +++ b/src/web/waiters/OperationsWaiter.mjs @@ -4,7 +4,7 @@ * @license Apache-2.0 */ -import HTMLOperation from "./HTMLOperation"; +import HTMLOperation from "../HTMLOperation"; import Sortable from "sortablejs"; diff --git a/src/web/OptionsWaiter.mjs b/src/web/waiters/OptionsWaiter.mjs similarity index 99% rename from src/web/OptionsWaiter.mjs rename to src/web/waiters/OptionsWaiter.mjs index 3f08b91b..eb6bac18 100755 --- a/src/web/OptionsWaiter.mjs +++ b/src/web/waiters/OptionsWaiter.mjs @@ -168,6 +168,7 @@ OptionsWaiter.prototype.logLevelChange = function (e) { const level = e.target.value; log.setLevel(level, false); this.manager.worker.setLogLevel(); + this.manager.input.setLogLevel(); }; export default OptionsWaiter; diff --git a/src/web/waiters/OutputWaiter.mjs b/src/web/waiters/OutputWaiter.mjs new file mode 100755 index 00000000..bc0e202d --- /dev/null +++ b/src/web/waiters/OutputWaiter.mjs @@ -0,0 +1,1417 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import Utils from "../../core/Utils"; +import FileSaver from "file-saver"; +import ZipWorker from "worker-loader?inline&fallback=false!../workers/ZipWorker"; + +/** + * Waiter to handle events related to the output + */ +class OutputWaiter { + + /** + * OutputWaiter 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.outputs = {}; + this.zipWorker = null; + this.maxTabs = this.manager.tabs.calcMaxTabs(); + this.tabTimeout = null; + } + + /** + * Calculates the maximum number of tabs to display + */ + calcMaxTabs() { + const numTabs = this.manager.tabs.calcMaxTabs(); + if (numTabs !== this.maxTabs) { + this.maxTabs = numTabs; + this.refreshTabs(this.manager.tabs.getActiveOutputTab(), "right"); + } + } + + /** + * Gets the output for the specified input number + * + * @param {number} inputNum - The input to get the output for + * @param {boolean} [raw=true] - If true, returns the raw data instead of the presented result. + * @returns {string | ArrayBuffer} + */ + getOutput(inputNum, raw=true) { + if (!this.outputExists(inputNum)) return -1; + + if (this.outputs[inputNum].data === null) return ""; + + if (raw) { + let data = this.outputs[inputNum].data.dish.value; + if (Array.isArray(data)) { + data = new Uint8Array(data.length); + + for (let i = 0; i < data.length; i++) { + data[i] = this.outputs[inputNum].data.dish.value[i]; + } + + data = data.buffer; + } else if (typeof data !== "object" && typeof data !== "string") { + data = String(data); + } + return data; + } else if (typeof this.outputs[inputNum].data.result === "string") { + return this.outputs[inputNum].data.result; + } else { + return this.outputs[inputNum].data.result || ""; + } + } + + /** + * Gets the dish object for an output. + * + * @param inputNum - The inputNum of the output to get the dish of + * @returns {Dish} + */ + getOutputDish(inputNum) { + if (this.outputExists(inputNum) && + this.outputs[inputNum].data && + this.outputs[inputNum].data.dish) { + return this.outputs[inputNum].data.dish; + } + return null; + } + + /** + * Checks if an output exists in the output dictionary + * + * @param {number} inputNum - The number of the output we're looking for + * @returns {boolean} + */ + outputExists(inputNum) { + if (this.outputs[inputNum] === undefined || + this.outputs[inputNum] === null) { + return false; + } + return true; + } + + /** + * Gets the output string or FileBuffer for the active input + * + * @param {boolean} [raw=true] - If true, returns the raw data instead of the presented result. + * @returns {string | ArrayBuffer} + */ + getActive(raw=true) { + return this.getOutput(this.manager.tabs.getActiveOutputTab(), raw); + } + + /** + * Adds a new output to the output array. + * Creates a new tab if we have less than maxtabs tabs open + * + * @param {number} inputNum - The inputNum of the new output + * @param {boolean} [changeTab=true] - If true, change to the new output + */ + addOutput(inputNum, changeTab = true) { + // Remove the output (will only get removed if it already exists) + this.removeOutput(inputNum); + + const newOutput = { + data: null, + inputNum: inputNum, + statusMessage: `Input ${inputNum} has not been baked yet.`, + error: null, + status: "inactive", + bakeId: -1, + progress: false + }; + + this.outputs[inputNum] = newOutput; + + this.addTab(inputNum, changeTab); + } + + /** + * Updates the value for the output in the output array. + * If this is the active output tab, updates the output textarea + * + * @param {ArrayBuffer | String} data + * @param {number} inputNum + * @param {boolean} set + */ + updateOutputValue(data, inputNum, set=true) { + if (!this.outputExists(inputNum)) { + this.addOutput(inputNum); + } + + this.outputs[inputNum].data = data; + + const tabItem = this.manager.tabs.getOutputTabItem(inputNum); + if (tabItem) tabItem.style.background = ""; + + if (set) this.set(inputNum); + } + + /** + * Updates the status message for the output in the output array. + * If this is the active output tab, updates the output textarea + * + * @param {string} statusMessage + * @param {number} inputNum + * @param {boolean} [set=true] + */ + updateOutputMessage(statusMessage, inputNum, set=true) { + if (!this.outputExists(inputNum)) return; + this.outputs[inputNum].statusMessage = statusMessage; + if (set) this.set(inputNum); + } + + /** + * Updates the error value for the output in the output array. + * If this is the active output tab, calls app.handleError. + * Otherwise, the error will be handled when the output is switched to + * + * @param {Error} error + * @param {number} inputNum + * @param {number} [progress=0] + */ + updateOutputError(error, inputNum, progress=0) { + if (!this.outputExists(inputNum)) return; + + const errorString = error.displayStr || error.toString(); + + this.outputs[inputNum].error = errorString; + this.outputs[inputNum].progress = progress; + this.updateOutputStatus("error", inputNum); + } + + /** + * Updates the status value for the output in the output array + * + * @param {string} status + * @param {number} inputNum + */ + updateOutputStatus(status, inputNum) { + if (!this.outputExists(inputNum)) return; + this.outputs[inputNum].status = status; + + if (status !== "error") { + delete this.outputs[inputNum].error; + } + + this.displayTabInfo(inputNum); + this.set(inputNum); + } + + /** + * Updates the stored bake ID for the output in the ouptut array + * + * @param {number} bakeId + * @param {number} inputNum + */ + updateOutputBakeId(bakeId, inputNum) { + if (!this.outputExists(inputNum)) return; + this.outputs[inputNum].bakeId = bakeId; + } + + /** + * Updates the stored progress value for the output in the output array + * + * @param {number} progress + * @param {number} total + * @param {number} inputNum + */ + updateOutputProgress(progress, total, inputNum) { + if (!this.outputExists(inputNum)) return; + this.outputs[inputNum].progress = progress; + + if (progress !== false) { + this.manager.tabs.updateOutputTabProgress(inputNum, progress, total); + } + + } + + /** + * Removes an output from the output array. + * + * @param {number} inputNum + */ + removeOutput(inputNum) { + if (!this.outputExists(inputNum)) return; + + delete (this.outputs[inputNum]); + } + + /** + * Removes all output tabs + */ + removeAllOutputs() { + this.outputs = {}; + const tabs = document.getElementById("output-tabs").children; + for (let i = tabs.length - 1; i >= 0; i--) { + tabs.item(i).remove(); + } + } + + /** + * Sets the output in the output textarea. + * + * @param {number} inputNum + */ + async set(inputNum) { + if (inputNum !== this.manager.tabs.getActiveOutputTab()) return; + this.toggleLoader(true); + + return new Promise(async function(resolve, reject) { + const output = this.outputs[inputNum], + activeTab = this.manager.tabs.getActiveOutputTab(); + if (output === undefined || output === null) return; + if (typeof inputNum !== "number") inputNum = parseInt(inputNum, 10); + + const outputText = document.getElementById("output-text"); + const outputHtml = document.getElementById("output-html"); + const outputFile = document.getElementById("output-file"); + const outputHighlighter = document.getElementById("output-highlighter"); + const inputHighlighter = document.getElementById("input-highlighter"); + // If pending or baking, show loader and status message + // If error, style the tab and handle the error + // If done, display the output if it's the active tab + // If inactive, show the last bake value (or blank) + if (output.status === "inactive" || + output.status === "stale" || + (output.status === "baked" && output.bakeId < this.manager.worker.bakeId)) { + this.manager.controls.showStaleIndicator(); + } else { + this.manager.controls.hideStaleIndicator(); + } + + if (output.progress !== undefined && !this.app.baking) { + this.manager.recipe.updateBreakpointIndicator(output.progress); + } else { + this.manager.recipe.updateBreakpointIndicator(false); + } + + document.getElementById("show-file-overlay").style.display = "none"; + + if (output.status === "pending" || output.status === "baking") { + // show the loader and the status message if it's being shown + // otherwise don't do anything + document.querySelector("#output-loader .loading-msg").textContent = output.statusMessage; + } else if (output.status === "error") { + // style the tab if it's being shown + this.toggleLoader(false); + outputText.style.display = "block"; + outputText.classList.remove("blur"); + outputHtml.style.display = "none"; + outputFile.style.display = "none"; + outputHighlighter.display = "none"; + inputHighlighter.display = "none"; + + if (output.error) { + outputText.value = output.error; + } else { + outputText.value = output.data.result; + } + outputHtml.innerHTML = ""; + } else if (output.status === "baked" || output.status === "inactive") { + document.querySelector("#output-loader .loading-msg").textContent = `Loading output ${inputNum}`; + this.closeFile(); + let scriptElements, lines, length; + + if (output.data === null) { + outputText.style.display = "block"; + outputHtml.style.display = "none"; + outputFile.style.display = "none"; + outputHighlighter.display = "block"; + inputHighlighter.display = "block"; + + outputText.value = ""; + outputHtml.innerHTML = ""; + + lines = 0; + length = 0; + this.toggleLoader(false); + return; + } + + switch (output.data.type) { + case "html": + outputText.style.display = "none"; + outputHtml.style.display = "block"; + outputFile.style.display = "none"; + outputHighlighter.style.display = "none"; + inputHighlighter.style.display = "none"; + + outputText.value = ""; + outputHtml.innerHTML = output.data.result; + + // Execute script sections + scriptElements = outputHtml.querySelectorAll("script"); + for (let i = 0; i < scriptElements.length; i++) { + try { + eval(scriptElements[i].innerHTML); // eslint-disable-line no-eval + } catch (err) { + log.error(err); + } + } + break; + case "ArrayBuffer": + outputText.style.display = "block"; + outputHtml.style.display = "none"; + outputHighlighter.display = "none"; + inputHighlighter.display = "none"; + + outputText.value = ""; + outputHtml.innerHTML = ""; + + length = output.data.result.byteLength; + this.setFile(await this.getDishBuffer(output.data.dish), activeTab); + break; + case "string": + default: + outputText.style.display = "block"; + outputHtml.style.display = "none"; + outputFile.style.display = "none"; + outputHighlighter.display = "block"; + inputHighlighter.display = "block"; + + outputText.value = Utils.printable(output.data.result, true); + outputHtml.innerHTML = ""; + + lines = output.data.result.count("\n") + 1; + length = output.data.result.length; + break; + } + this.toggleLoader(false); + + if (output.data.type === "html") { + const dishStr = await this.getDishStr(output.data.dish); + length = dishStr.length; + lines = dishStr.count("\n") + 1; + } + + this.setOutputInfo(length, lines, output.data.duration); + this.backgroundMagic(); + } + }.bind(this)); + } + + /** + * Shows file details + * + * @param {ArrayBuffer} buf + * @param {number} activeTab + */ + setFile(buf, activeTab) { + if (activeTab !== this.manager.tabs.getActiveOutputTab()) return; + // Display file overlay in output area with details + const fileOverlay = document.getElementById("output-file"), + fileSize = document.getElementById("output-file-size"), + outputText = document.getElementById("output-text"), + fileSlice = buf.slice(0, 4096); + + fileOverlay.style.display = "block"; + fileSize.textContent = buf.byteLength.toLocaleString() + " bytes"; + + outputText.classList.add("blur"); + outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); + } + + /** + * Clears output file details + */ + closeFile() { + document.getElementById("output-file").style.display = "none"; + document.getElementById("output-text").classList.remove("blur"); + } + + /** + * Retrieves the dish as a string, returning the cached version if possible. + * + * @param {Dish} dish + * @returns {string} + */ + async getDishStr(dish) { + return await new Promise(resolve => { + this.manager.worker.getDishAs(dish, "string", r => { + resolve(r.value); + }); + }); + } + + /** + * Retrieves the dish as an ArrayBuffer, returning the cached version if possible. + * + * @param {Dish} dish + * @returns {ArrayBuffer} + */ + async getDishBuffer(dish) { + return await new Promise(resolve => { + this.manager.worker.getDishAs(dish, "ArrayBuffer", r => { + resolve(r.value); + }); + }); + } + + /** + * Retrieves the title of the Dish as a string + * + * @param {Dish} dish + * @param {number} maxLength + * @returns {string} + */ + async getDishTitle(dish, maxLength) { + return await new Promise(resolve => { + this.manager.worker.getDishTitle(dish, maxLength, r => { + resolve(r.value); + }); + }); + } + + /** + * Save bombe object then remove it from the DOM so that it does not cause performance issues. + */ + saveBombe() { + this.bombeEl = document.getElementById("bombe"); + this.bombeEl.parentNode.removeChild(this.bombeEl); + } + + /** + * Shows or hides the output loading screen. + * The animated Bombe SVG, whilst quite aesthetically pleasing, is reasonably CPU + * intensive, so we remove it from the DOM when not in use. We only show it if the + * recipe is taking longer than 200ms. We add it to the DOM just before that so that + * it is ready to fade in without stuttering. + * + * @param {boolean} value - If true, show the loader + */ + toggleLoader(value) { + clearTimeout(this.appendBombeTimeout); + clearTimeout(this.outputLoaderTimeout); + + const outputLoader = document.getElementById("output-loader"), + outputElement = document.getElementById("output-text"), + animation = document.getElementById("output-loader-animation"); + + if (value) { + this.manager.controls.hideStaleIndicator(); + + // Don't add the bombe if it's already there! + if (animation.children.length > 0) return; + + // Start a timer to add the Bombe to the DOM just before we make it + // visible so that there is no stuttering + this.appendBombeTimeout = setTimeout(function() { + animation.appendChild(this.bombeEl); + }.bind(this), 150); + + // Show the loading screen + this.outputLoaderTimeout = setTimeout(function() { + outputElement.disabled = true; + outputLoader.style.visibility = "visible"; + outputLoader.style.opacity = 1; + }, 200); + } else { + // Remove the Bombe from the DOM to save resources + this.outputLoaderTimeout = setTimeout(function () { + try { + animation.removeChild(this.bombeEl); + } catch (err) {} + }.bind(this), 500); + outputElement.disabled = false; + outputLoader.style.opacity = 0; + outputLoader.style.visibility = "hidden"; + } + } + + /** + * Handler for save click events. + * Saves the current output to a file. + */ + saveClick() { + this.downloadFile(); + } + + /** + * Handler for file download events. + */ + async downloadFile() { + let fileName = window.prompt("Please enter a filename: ", "download.dat"); + + if (fileName === null) fileName = "download.dat"; + + const file = new File([this.getActive(true)], fileName); + FileSaver.saveAs(file, fileName, false); + } + + /** + * Handler for save all click event + * Saves all outputs to a single archvie file + */ + saveAllClick() { + const downloadButton = document.getElementById("save-all-to-file"); + if (downloadButton.firstElementChild.innerHTML === "archive") { + this.downloadAllFiles(); + } else if (window.confirm("Cancel zipping of outputs?")) { + this.terminateZipWorker(); + } + } + + + /** + * Spawns a new ZipWorker and sends it the outputs so that they can + * be zipped for download + */ + async downloadAllFiles() { + return new Promise(resolve => { + const inputNums = Object.keys(this.outputs); + for (let i = 0; i < inputNums.length; i++) { + const iNum = inputNums[i]; + if (this.outputs[iNum].status !== "baked" || + this.outputs[iNum].bakeId !== this.manager.worker.bakeId) { + if (window.confirm("Not all outputs have been baked yet. Continue downloading outputs?")) { + break; + } else { + return; + } + } + } + + let fileName = window.prompt("Please enter a filename: ", "download.zip"); + + if (fileName === null || fileName === "") { + // Don't zip the files if there isn't a filename + this.app.alert("No filename was specified.", 3000); + return; + } + + if (!fileName.match(/.zip$/)) { + fileName += ".zip"; + } + + let fileExt = window.prompt("Please enter a file extension for the files, or leave blank to detect automatically.", ""); + + if (fileExt === null) fileExt = ""; + + if (this.zipWorker !== null) { + this.terminateZipWorker(); + } + + const downloadButton = document.getElementById("save-all-to-file"); + + downloadButton.classList.add("spin"); + downloadButton.title = `Zipping ${inputNums.length} files...`; + downloadButton.setAttribute("data-original-title", `Zipping ${inputNums.length} files...`); + + downloadButton.firstElementChild.innerHTML = "autorenew"; + + log.debug("Creating ZipWorker"); + this.zipWorker = new ZipWorker(); + this.zipWorker.postMessage({ + outputs: this.outputs, + filename: fileName, + fileExtension: fileExt + }); + this.zipWorker.addEventListener("message", this.handleZipWorkerMessage.bind(this)); + }); + } + + /** + * Terminate the ZipWorker + */ + terminateZipWorker() { + if (this.zipWorker === null) return; // Already terminated + + log.debug("Terminating ZipWorker."); + + this.zipWorker.terminate(); + this.zipWorker = null; + + const downloadButton = document.getElementById("save-all-to-file"); + + downloadButton.classList.remove("spin"); + downloadButton.title = "Save all outputs to a zip file"; + downloadButton.setAttribute("data-original-title", "Save all outputs to a zip file"); + + downloadButton.firstElementChild.innerHTML = "archive"; + + } + + + /** + * Handle messages sent back by the ZipWorker + */ + handleZipWorkerMessage(e) { + const r = e.data; + if (!r.hasOwnProperty("zippedFile")) { + log.error("No zipped file was sent in the message."); + this.terminateZipWorker(); + return; + } + if (!r.hasOwnProperty("filename")) { + log.error("No filename was sent in the message."); + this.terminateZipWorker(); + return; + } + + const file = new File([r.zippedFile], r.filename); + FileSaver.saveAs(file, r.filename, false); + + this.terminateZipWorker(); + } + + /** + * Adds a new output tab. + * + * @param {number} inputNum + * @param {boolean} [changeTab=true] + */ + addTab(inputNum, changeTab = true) { + const tabsWrapper = document.getElementById("output-tabs"); + const numTabs = tabsWrapper.children.length; + + if (!this.manager.tabs.getOutputTabItem(inputNum) && numTabs < this.maxTabs) { + // Create a new tab element + const newTab = this.manager.tabs.createOutputTabElement(inputNum, changeTab); + tabsWrapper.appendChild(newTab); + } else if (numTabs === this.maxTabs) { + // Can't create a new tab + document.getElementById("output-tabs").lastElementChild.style.boxShadow = "-15px 0px 15px -15px var(--primary-border-colour) inset"; + } + + this.displayTabInfo(inputNum); + + if (changeTab) { + this.changeTab(inputNum, false); + } + } + + /** + * Changes the active tab + * + * @param {number} inputNum + * @param {boolean} [changeInput = false] + */ + changeTab(inputNum, changeInput = false) { + if (!this.outputExists(inputNum)) return; + const currentNum = this.manager.tabs.getActiveOutputTab(); + + this.hideMagicButton(); + + this.manager.highlighter.removeHighlights(); + getSelection().removeAllRanges(); + + if (!this.manager.tabs.changeOutputTab(inputNum)) { + let direction = "right"; + if (currentNum > inputNum) { + direction = "left"; + } + const newOutputs = this.getNearbyNums(inputNum, direction); + + const tabsLeft = (newOutputs[0] !== this.getSmallestInputNum()); + const tabsRight = (newOutputs[newOutputs.length - 1] !== this.getLargestInputNum()); + + this.manager.tabs.refreshOutputTabs(newOutputs, inputNum, tabsLeft, tabsRight); + + for (let i = 0; i < newOutputs.length; i++) { + this.displayTabInfo(newOutputs[i]); + } + } + + this.app.debounce(this.set, 50, "setOutput", this, [inputNum])(); + + document.getElementById("output-html").scroll(0, 0); + document.getElementById("output-text").scroll(0, 0); + + if (changeInput) { + this.manager.input.changeTab(inputNum, false); + } + } + + /** + * Handler for changing tabs event + * + * @param {event} mouseEvent + */ + changeTabClick(mouseEvent) { + if (!mouseEvent.target) return; + const tabNum = mouseEvent.target.parentElement.getAttribute("inputNum"); + if (tabNum) { + this.changeTab(parseInt(tabNum, 10), this.app.options.syncTabs); + } + } + + /** + * Handler for scrolling on the output 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 = 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 = 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; + } + + /** + * Handler for changing to the left tab + */ + changeTabLeft() { + const currentTab = this.manager.tabs.getActiveOutputTab(); + this.changeTab(this.getPreviousInputNum(currentTab), this.app.options.syncTabs); + } + + /** + * Handler for changing to the right tab + */ + changeTabRight() { + const currentTab = this.manager.tabs.getActiveOutputTab(); + this.changeTab(this.getNextInputNum(currentTab), this.app.options.syncTabs); + } + + /** + * Handler for go to tab button clicked + */ + goToTab() { + const min = this.getSmallestInputNum(), + max = this.getLargestInputNum(); + + let tabNum = window.prompt(`Enter tab number (${min} - ${max}):`, this.manager.tabs.getActiveOutputTab().toString()); + if (tabNum === null) return; + tabNum = parseInt(tabNum, 10); + + if (this.outputExists(tabNum)) { + this.changeTab(tabNum, this.app.options.syncTabs); + } + } + + /** + * Generates a list of the nearby inputNums + * @param inputNum + * @param direction + */ + getNearbyNums(inputNum, direction) { + const nums = []; + for (let i = 0; i < this.maxTabs; i++) { + let newNum; + if (i === 0 && this.outputs[inputNum] !== undefined) { + newNum = inputNum; + } else { + switch (direction) { + case "left": + newNum = this.getNextInputNum(nums[i - 1]); + if (newNum === nums[i - 1]) { + direction = "right"; + newNum = this.getPreviousInputNum(nums[0]); + } + break; + case "right": + newNum = this.getPreviousInputNum(nums[i - 1]); + if (newNum === nums[i - 1]) { + direction = "left"; + newNum = this.getNextInputNum(nums[0]); + } + } + } + if (!nums.includes(newNum) && (newNum > 0)) { + nums.push(newNum); + } + } + nums.sort(function(a, b) { + return a - b; + }); + return nums; + } + + /** + * Gets the largest inputNum + * + * @returns {number} + */ + getLargestInputNum() { + const inputNums = Object.keys(this.outputs); + if (inputNums.length === 0) return -1; + return Math.max(...inputNums); + } + + /** + * Gets the smallest inputNum + * + * @returns {number} + */ + getSmallestInputNum() { + const inputNums = Object.keys(this.outputs); + if (inputNums.length === 0) return -1; + return Math.min(...inputNums); + } + + /** + * Gets the previous inputNum + * + * @param {number} inputNum - The current input number + * @returns {number} + */ + getPreviousInputNum(inputNum) { + const inputNums = Object.keys(this.outputs); + if (inputNums.length === 0) return -1; + let num = Math.min(...inputNums); + for (let i = 0; i < inputNums.length; i++) { + const iNum = parseInt(inputNums[i], 10); + if (iNum < inputNum) { + if (iNum > num) { + num = iNum; + } + } + } + return num; + } + + /** + * Gets the next inputNum + * + * @param {number} inputNum - The current input number + * @returns {number} + */ + getNextInputNum(inputNum) { + const inputNums = Object.keys(this.outputs); + if (inputNums.length === 0) return -1; + let num = Math.max(...inputNums); + for (let i = 0; i < inputNums.length; i++) { + const iNum = parseInt(inputNums[i], 10); + if (iNum > inputNum) { + if (iNum < num) { + num = iNum; + } + } + } + return num; + } + + /** + * Removes a tab and it's corresponding output + * + * @param {number} inputNum + */ + removeTab(inputNum) { + if (!this.outputExists(inputNum)) return; + + const tabElement = this.manager.tabs.getOutputTabItem(inputNum); + + this.removeOutput(inputNum); + + if (tabElement !== null) { + this.refreshTabs(this.getPreviousInputNum(inputNum), "left"); + } + } + + /** + * Redraw the entire tab bar to remove any outdated tabs + * @param {number} activeTab + * @param {string} direction - Either "left" or "right" + */ + refreshTabs(activeTab, direction) { + const newNums = this.getNearbyNums(activeTab, direction), + tabsLeft = (newNums[0] !== this.getSmallestInputNum()), + tabsRight = (newNums[newNums.length - 1] !== this.getLargestInputNum()); + + this.manager.tabs.refreshOutputTabs(newNums, activeTab, tabsLeft, tabsRight); + + for (let i = 0; i < newNums.length; i++) { + this.displayTabInfo(newNums[i]); + } + + } + + /** + * Display output information in the tab header + * + * @param {number} inputNum + */ + async displayTabInfo(inputNum) { + if (!this.outputExists(inputNum)) return; + + const dish = this.getOutputDish(inputNum); + let tabStr = ""; + + if (dish !== null) { + tabStr = await this.getDishTitle(this.getOutputDish(inputNum), 100); + tabStr = tabStr.replace(/[\n\r]/g, ""); + } + this.manager.tabs.updateOutputTabHeader(inputNum, tabStr); + if (this.manager.worker.recipeConfig !== undefined) { + this.manager.tabs.updateOutputTabProgress(inputNum, this.outputs[inputNum].progress, this.manager.worker.recipeConfig.length); + } + + const tabItem = this.manager.tabs.getOutputTabItem(inputNum); + if (tabItem) { + if (this.outputs[inputNum].status === "error") { + tabItem.style.color = "#FF0000"; + } else { + tabItem.style.color = ""; + } + } + } + + /** + * Displays information about the output. + * + * @param {number} length - The length of the current output string + * @param {number} lines - The number of the lines in the current output string + * @param {number} duration - The length of time (ms) it took to generate the output + */ + setOutputInfo(length, lines, duration) { + if (!length) return; + let width = length.toString().length; + width = width < 4 ? 4 : width; + + const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); + const timeStr = (duration.toString() + "ms").padStart(width, " ").replace(/ /g, " "); + + let msg = "time: " + timeStr + "
length: " + lengthStr; + + if (typeof lines === "number") { + const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); + msg += "
lines: " + linesStr; + } + + document.getElementById("output-info").innerHTML = msg; + document.getElementById("input-selection-info").innerHTML = ""; + document.getElementById("output-selection-info").innerHTML = ""; + } + + /** + * Triggers the BackgroundWorker to attempt Magic on the current output. + */ + async backgroundMagic() { + this.hideMagicButton(); + if (!this.app.options.autoMagic || !this.getActive(true)) return; + const dish = this.outputs[this.manager.tabs.getActiveOutputTab()].data.dish; + const buffer = await this.getDishBuffer(dish); + const sample = buffer.slice(0, 1000) || ""; + + if (sample.length || sample.byteLength) { + this.manager.background.magic(sample); + } + } + + /** + * Handles the results of a background Magic call. + * + * @param {Object[]} options + */ + backgroundMagicResult(options) { + if (!options.length || + !options[0].recipe.length) + return; + + const currentRecipeConfig = this.app.getRecipeConfig(); + const newRecipeConfig = currentRecipeConfig.concat(options[0].recipe); + const opSequence = options[0].recipe.map(o => o.op).join(", "); + + this.showMagicButton(opSequence, options[0].data, newRecipeConfig); + } + + /** + * Handler for Magic click events. + * + * Loads the Magic recipe. + * + * @fires Manager#statechange + */ + magicClick() { + const magicButton = document.getElementById("magic"); + this.app.setRecipeConfig(JSON.parse(magicButton.getAttribute("data-recipe"))); + window.dispatchEvent(this.manager.statechange); + this.hideMagicButton(); + } + + /** + * Displays the Magic button with a title and adds a link to a complete recipe. + * + * @param {string} opSequence + * @param {string} result + * @param {Object[]} recipeConfig + */ + showMagicButton(opSequence, result, recipeConfig) { + const magicButton = document.getElementById("magic"); + magicButton.setAttribute("data-original-title", `${opSequence} will produce "${Utils.escapeHtml(Utils.truncate(result), 30)}"`); + magicButton.setAttribute("data-recipe", JSON.stringify(recipeConfig), null, ""); + magicButton.classList.remove("hidden"); + } + + + /** + * Hides the Magic button and resets its values. + */ + hideMagicButton() { + const magicButton = document.getElementById("magic"); + magicButton.classList.add("hidden"); + magicButton.setAttribute("data-recipe", ""); + magicButton.setAttribute("data-original-title", "Magic!"); + } + + + /** + * Handler for file slice display events. + */ + async displayFileSlice() { + document.querySelector("#output-loader .loading-msg").textContent = "Loading file slice..."; + this.toggleLoader(true); + const outputText = document.getElementById("output-text"), + outputHtml = document.getElementById("output-html"), + outputFile = document.getElementById("output-file"), + outputHighlighter = document.getElementById("output-highlighter"), + inputHighlighter = document.getElementById("input-highlighter"), + showFileOverlay = document.getElementById("show-file-overlay"), + sliceFromEl = document.getElementById("output-file-slice-from"), + sliceToEl = document.getElementById("output-file-slice-to"), + sliceFrom = parseInt(sliceFromEl.value, 10), + sliceTo = parseInt(sliceToEl.value, 10), + output = this.outputs[this.manager.tabs.getActiveOutputTab()].data; + + let str; + if (output.type === "ArrayBuffer") { + str = Utils.arrayBufferToStr(output.result.slice(sliceFrom, sliceTo)); + } else { + str = Utils.arrayBufferToStr(await this.getDishBuffer(output.dish).slice(sliceFrom, sliceTo)); + } + + outputText.classList.remove("blur"); + showFileOverlay.style.display = "block"; + outputText.value = Utils.printable(str, true); + + + outputText.style.display = "block"; + outputHtml.style.display = "none"; + outputFile.style.display = "none"; + outputHighlighter.display = "block"; + inputHighlighter.display = "block"; + + this.toggleLoader(false); + } + + /** + * Handler for show file overlay events + * + * @param {Event} e + */ + showFileOverlayClick(e) { + const showFileOverlay = e.target; + + document.getElementById("output-text").classList.add("blur"); + showFileOverlay.style.display = "none"; + this.set(this.manager.tabs.getActiveOutputTab()); + } + + /** + * Handler for extract file events. + * + * @param {Event} e + */ + async extractFileClick(e) { + e.preventDefault(); + e.stopPropagation(); + + const el = e.target.nodeName === "I" ? e.target.parentNode : e.target; + const blobURL = el.getAttribute("blob-url"); + const fileName = el.getAttribute("file-name"); + + const blob = await fetch(blobURL).then(r => r.blob()); + this.manager.input.loadUIFiles([new File([blob], fileName, {type: blob.type})]); + } + + + /** + * Handler for copy click events. + * Copies the output to the clipboard + */ + copyClick() { + let output = this.getActive(true); + + if (typeof output !== "string") { + output = Utils.arrayBufferToStr(output); + } + + // Create invisible textarea to populate with the raw dish string (not the printable version that + // contains dots instead of the actual bytes) + const textarea = document.createElement("textarea"); + textarea.style.position = "fixed"; + textarea.style.top = 0; + textarea.style.left = 0; + textarea.style.width = 0; + textarea.style.height = 0; + textarea.style.border = "none"; + + textarea.value = output; + document.body.appendChild(textarea); + + let success = false; + try { + textarea.select(); + success = output && document.execCommand("copy"); + } catch (err) { + success = false; + } + + if (success) { + this.app.alert("Copied raw output successfully.", 2000); + } else { + this.app.alert("Sorry, the output could not be copied.", 3000); + } + + // Clean up + document.body.removeChild(textarea); + } + + /** + * Returns true if the output contains carriage returns + * + * @returns {boolean} + */ + containsCR() { + return this.getActive(false).indexOf("\r") >= 0; + } + + /** + * Handler for switch click events. + * Moves the current output into the input textarea. + */ + async switchClick() { + const active = await this.getDishBuffer(this.getOutputDish(this.manager.tabs.getActiveOutputTab())); + this.manager.input.inputWorker.postMessage({ + action: "inputSwitch", + data: { + inputNum: this.manager.tabs.getActiveInputTab(), + outputData: active + } + }, [active]); + } + + /** + * Handler for when the inputWorker has switched the inputs. + * Stores the old input + * + * @param {object} switchData + * @param {number} switchData.inputNum + * @param {string | object} switchData.data + * @param {ArrayBuffer} switchData.data.fileBuffer + * @param {number} switchData.data.size + * @param {string} switchData.data.type + * @param {string} switchData.data.name + */ + inputSwitch(switchData) { + this.switchOrigData = switchData; + document.getElementById("undo-switch").disabled = false; + } + + /** + * Handler for undo switch click events. + * Removes the output from the input and replaces the input that was removed. + */ + undoSwitchClick() { + this.manager.input.updateInputObj(this.switchOrigData.inputNum, this.switchOrigData.data); + const undoSwitch = document.getElementById("undo-switch"); + undoSwitch.disabled = true; + $(undoSwitch).tooltip("hide"); + + this.manager.input.inputWorker.postMessage({ + action: "setInput", + data: { + inputNum: this.switchOrigData.inputNum, + silent: false + } + }); + } + + /** + * Handler for maximise output click events. + * Resizes the output frame to be as large as possible, or restores it to its original size. + */ + maximiseOutputClick(e) { + const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode; + + if (el.getAttribute("data-original-title").indexOf("Maximise") === 0) { + this.app.initialiseSplitter(true); + this.app.columnSplitter.collapse(0); + this.app.columnSplitter.collapse(1); + this.app.ioSplitter.collapse(0); + + $(el).attr("data-original-title", "Restore output pane"); + el.querySelector("i").innerHTML = "fullscreen_exit"; + } else { + $(el).attr("data-original-title", "Maximise output pane"); + el.querySelector("i").innerHTML = "fullscreen"; + this.app.initialiseSplitter(false); + this.app.resetLayout(); + } + } + + /** + * Handler for find tab button clicked + */ + findTab() { + this.filterTabSearch(); + $("#output-tab-modal").modal(); + } + + /** + * Searches the outputs using the filter settings and displays the results + */ + filterTabSearch() { + const showPending = document.getElementById("output-show-pending").checked, + showBaking = document.getElementById("output-show-baking").checked, + showBaked = document.getElementById("output-show-baked").checked, + showStale = document.getElementById("output-show-stale").checked, + showErrored = document.getElementById("output-show-errored").checked, + contentFilter = document.getElementById("output-content-filter").value, + resultsList = document.getElementById("output-search-results"), + numResults = parseInt(document.getElementById("output-num-results").value, 10), + inputNums = Object.keys(this.outputs), + results = []; + + let contentFilterExp; + try { + contentFilterExp = new RegExp(contentFilter, "i"); + } catch (error) { + this.app.handleError(error); + return; + } + + // Search through the outputs for matching output results + for (let i = 0; i < inputNums.length; i++) { + const iNum = inputNums[i], + output = this.outputs[iNum]; + + if (output.status === "pending" && showPending || + output.status === "baking" && showBaking || + output.status === "error" && showErrored || + output.status === "stale" && showStale || + output.status === "inactive" && showStale) { + const outDisplay = { + "pending": "Not baked yet", + "baking": "Baking", + "error": output.error || "Errored", + "stale": "Stale (output is out of date)", + "inactive": "Not baked yet" + }; + results.push({ + inputNum: iNum, + textDisplay: outDisplay[output.status] + }); + } else if (output.status === "baked" && showBaked && output.progress === false) { + let data = this.getOutput(iNum, false).slice(0, 4096); + if (typeof data !== "string") { + data = Utils.arrayBufferToStr(data); + } + data = data.replace(/[\r\n]/g, ""); + if (contentFilterExp.test(data)) { + results.push({ + inputNum: iNum, + textDisplay: data.slice(0, 100) + }); + } + } else if (output.progress !== false && showErrored) { + let data = this.getOutput(iNum, false).slice(0, 4096); + if (typeof data !== "string") { + data = Utils.arrayBufferToStr(data); + } + data = data.replace(/[\r\n]/g, ""); + if (contentFilterExp.test(data)) { + results.push({ + inputNum: iNum, + textDisplay: data.slice(0, 100) + }); + } + } + + if (results.length >= numResults) { + break; + } + } + + 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("output-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. + * Changes to the clicked output + * + * @param {event} e + */ + filterItemClick(e) { + if (!e.target) return; + const inputNum = parseInt(e.target.getAttribute("inputNum"), 10); + if (inputNum <= 0) return; + + $("#output-tab-modal").modal("hide"); + this.changeTab(inputNum, this.app.options.syncTabs); + } +} + +export default OutputWaiter; diff --git a/src/web/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs similarity index 99% rename from src/web/RecipeWaiter.mjs rename to src/web/waiters/RecipeWaiter.mjs index b408beb0..b7cf582a 100755 --- a/src/web/RecipeWaiter.mjs +++ b/src/web/waiters/RecipeWaiter.mjs @@ -4,9 +4,9 @@ * @license Apache-2.0 */ -import HTMLOperation from "./HTMLOperation"; +import HTMLOperation from "../HTMLOperation"; import Sortable from "sortablejs"; -import Utils from "../core/Utils"; +import Utils from "../../core/Utils"; /** diff --git a/src/web/SeasonalWaiter.mjs b/src/web/waiters/SeasonalWaiter.mjs similarity index 99% rename from src/web/SeasonalWaiter.mjs rename to src/web/waiters/SeasonalWaiter.mjs index f894e951..43e29ebf 100755 --- a/src/web/SeasonalWaiter.mjs +++ b/src/web/waiters/SeasonalWaiter.mjs @@ -5,8 +5,8 @@ */ import clippy from "clippyjs"; -import "./static/clippy_assets/agents/Clippy/agent.js"; -import clippyMap from "./static/clippy_assets/agents/Clippy/map.png"; +import "../static/clippy_assets/agents/Clippy/agent.js"; +import clippyMap from "../static/clippy_assets/agents/Clippy/map.png"; /** * Waiter to handle seasonal events and easter eggs. diff --git a/src/web/waiters/TabWaiter.mjs b/src/web/waiters/TabWaiter.mjs new file mode 100644 index 00000000..384b1ab7 --- /dev/null +++ b/src/web/waiters/TabWaiter.mjs @@ -0,0 +1,428 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +/** + * Waiter to handle events related to the input and output tabs + */ +class TabWaiter { + + /** + * TabWaiter 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; + } + + /** + * Calculates the maximum number of tabs to display + * + * @returns {number} + */ + calcMaxTabs() { + let numTabs = Math.floor((document.getElementById("IO").offsetWidth - 75) / 120); + numTabs = (numTabs > 1) ? numTabs : 2; + + return numTabs; + } + + /** + * Gets the currently active input or active tab number + * + * @param {string} io - Either "input" or "output" + * @returns {number} - The currently active tab or -1 + */ + getActiveTab(io) { + const activeTabs = document.getElementsByClassName(`active-${io}-tab`); + if (activeTabs.length > 0) { + if (!activeTabs.item(0).hasAttribute("inputNum")) return -1; + const tabNum = activeTabs.item(0).getAttribute("inputNum"); + return parseInt(tabNum, 10); + } + return -1; + } + + /** + * Gets the currently active input tab number + * + * @returns {number} + */ + getActiveInputTab() { + return this.getActiveTab("input"); + } + + /** + * Gets the currently active output tab number + * + * @returns {number} + */ + getActiveOutputTab() { + return this.getActiveTab("output"); + } + + /** + * Gets the li element for the tab of a given input number + * + * @param {number} inputNum - The inputNum of the tab we're trying to get + * @param {string} io - Either "input" or "output" + * @returns {Element} + */ + getTabItem(inputNum, io) { + const tabs = document.getElementById(`${io}-tabs`).children; + for (let i = 0; i < tabs.length; i++) { + if (parseInt(tabs.item(i).getAttribute("inputNum"), 10) === inputNum) { + return tabs.item(i); + } + } + return null; + } + + /** + * Gets the li element for an input tab of the given input number + * + * @param {inputNum} - The inputNum of the tab we're trying to get + * @returns {Element} + */ + getInputTabItem(inputNum) { + return this.getTabItem(inputNum, "input"); + } + + /** + * Gets the li element for an output tab of the given input number + * + * @param {number} inputNum + * @returns {Element} + */ + getOutputTabItem(inputNum) { + return this.getTabItem(inputNum, "output"); + } + + /** + * Gets a list of tab numbers for the currently displayed tabs + * + * @param {string} io - Either "input" or "output" + * @returns {number[]} + */ + getTabList(io) { + const nums = [], + tabs = document.getElementById(`${io}-tabs`).children; + + for (let i = 0; i < tabs.length; i++) { + nums.push(parseInt(tabs.item(i).getAttribute("inputNum"), 10)); + } + + return nums; + } + + /** + * Gets a list of tab numbers for the currently displayed input tabs + * + * @returns {number[]} + */ + getInputTabList() { + return this.getTabList("input"); + } + + /** + * Gets a list of tab numbers for the currently displayed output tabs + * + * @returns {number[]} + */ + getOutputTabList() { + return this.getTabList("output"); + } + + /** + * Creates a new tab element for the tab bar + * + * @param {number} inputNum - The inputNum of the new tab + * @param {boolean} active - If true, sets the tab to active + * @param {string} io - Either "input" or "output" + * @returns {Element} + */ + createTabElement(inputNum, active, io) { + const newTab = document.createElement("li"); + newTab.setAttribute("inputNum", inputNum.toString()); + + if (active) newTab.classList.add(`active-${io}-tab`); + + const newTabContent = document.createElement("div"); + newTabContent.classList.add(`${io}-tab-content`); + + newTabContent.innerText = `Tab ${inputNum.toString()}`; + + newTabContent.addEventListener("wheel", this.manager[io].scrollTab.bind(this.manager[io]), {passive: false}); + + newTab.appendChild(newTabContent); + + if (io === "input") { + const newTabButton = document.createElement("button"), + newTabButtonIcon = document.createElement("i"); + newTabButton.type = "button"; + newTabButton.className = "btn btn-primary bmd-btn-icon btn-close-tab"; + + newTabButtonIcon.classList.add("material-icons"); + newTabButtonIcon.innerText = "clear"; + + newTabButton.appendChild(newTabButtonIcon); + + newTabButton.addEventListener("click", this.manager.input.removeTabClick.bind(this.manager.input)); + + newTab.appendChild(newTabButton); + } + + return newTab; + } + + /** + * Creates a new tab element for the input tab bar + * + * @param {number} inputNum - The inputNum of the new input tab + * @param {boolean} [active=false] - If true, sets the tab to active + * @returns {Element} + */ + createInputTabElement(inputNum, active=false) { + return this.createTabElement(inputNum, active, "input"); + } + + /** + * Creates a new tab element for the output tab bar + * + * @param {number} inputNum - The inputNum of the new output tab + * @param {boolean} [active=false] - If true, sets the tab to active + * @returns {Element} + */ + createOutputTabElement(inputNum, active=false) { + return this.createTabElement(inputNum, active, "output"); + } + + /** + * Displays the tab bar for both the input and output + */ + showTabBar() { + document.getElementById("input-tabs-wrapper").style.display = "block"; + document.getElementById("output-tabs-wrapper").style.display = "block"; + + document.getElementById("input-wrapper").classList.add("show-tabs"); + document.getElementById("output-wrapper").classList.add("show-tabs"); + + document.getElementById("save-all-to-file").style.display = "inline-block"; + } + + /** + * Hides the tab bar for both the input and output + */ + hideTabBar() { + document.getElementById("input-tabs-wrapper").style.display = "none"; + document.getElementById("output-tabs-wrapper").style.display = "none"; + + document.getElementById("input-wrapper").classList.remove("show-tabs"); + document.getElementById("output-wrapper").classList.remove("show-tabs"); + + document.getElementById("save-all-to-file").style.display = "none"; + } + + /** + * Redraws the tab bar with an updated list of tabs, then changes to activeTab + * + * @param {number[]} nums - The inputNums of the tab bar to be drawn + * @param {number} activeTab - The inputNum of the activeTab + * @param {boolean} tabsLeft - True if there are tabs to the left of the displayed tabs + * @param {boolean} tabsRight - True if there are tabs to the right of the displayed tabs + * @param {string} io - Either "input" or "output" + */ + refreshTabs(nums, activeTab, tabsLeft, tabsRight, io) { + const tabsList = document.getElementById(`${io}-tabs`); + + // Remove existing tab elements + for (let i = tabsList.children.length - 1; i >= 0; i--) { + tabsList.children.item(i).remove(); + } + + // Create and add new tab elements + for (let i = 0; i < nums.length; i++) { + const active = (nums[i] === activeTab); + tabsList.appendChild(this.createTabElement(nums[i], active, io)); + } + + // Display shadows if there are tabs left / right of the displayed tabs + if (tabsLeft) { + tabsList.classList.add("tabs-left"); + } else { + tabsList.classList.remove("tabs-left"); + } + if (tabsRight) { + tabsList.classList.add("tabs-right"); + } else { + tabsList.classList.remove("tabs-right"); + } + + // Show or hide the tab bar depending on how many tabs we have + if (nums.length > 1) { + this.showTabBar(); + } else { + this.hideTabBar(); + } + } + + /** + * 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 + */ + refreshInputTabs(nums, activeTab, tabsLeft, tabsRight) { + this.refreshTabs(nums, activeTab, tabsLeft, tabsRight, "input"); + } + + /** + * Refreshes the output 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 output tabs to the left of the displayed tabs + * @param {boolean} tabsRight - True if there are output tabs to the right of the displayed tabs + */ + refreshOutputTabs(nums, activeTab, tabsLeft, tabsRight) { + this.refreshTabs(nums, activeTab, tabsLeft, tabsRight, "output"); + } + + /** + * Changes the active tab to a different tab + * + * @param {number} inputNum - The inputNum of the tab to change to + * @param {string} io - Either "input" or "output" + * @return {boolean} - False if the tab is not currently being displayed + */ + changeTab(inputNum, io) { + const tabsList = document.getElementById(`${io}-tabs`); + + this.manager.highlighter.removeHighlights(); + getSelection().removeAllRanges(); + + let found = false; + for (let i = 0; i < tabsList.children.length; i++) { + const tabNum = parseInt(tabsList.children.item(i).getAttribute("inputNum"), 10); + if (tabNum === inputNum) { + tabsList.children.item(i).classList.add(`active-${io}-tab`); + found = true; + } else { + tabsList.children.item(i).classList.remove(`active-${io}-tab`); + } + } + + return found; + } + + /** + * Changes the active input tab to a different tab + * + * @param {number} inputNum + * @returns {boolean} - False if the tab is not currently being displayed + */ + changeInputTab(inputNum) { + return this.changeTab(inputNum, "input"); + } + + /** + * Changes the active output tab to a different tab + * + * @param {number} inputNum + * @returns {boolean} - False if the tab is not currently being displayed + */ + changeOutputTab(inputNum) { + return this.changeTab(inputNum, "output"); + } + + /** + * Updates the tab header to display a preview of the tab contents + * + * @param {number} inputNum - The inputNum of the tab to update the header of + * @param {string} data - The data to display in the tab header + * @param {string} io - Either "input" or "output" + */ + updateTabHeader(inputNum, data, io) { + const tab = this.getTabItem(inputNum, io); + if (tab === null) return; + + let headerData = `Tab ${inputNum}`; + if (data.length > 0) { + headerData = data.slice(0, 100); + headerData = `${inputNum}: ${headerData}`; + } + tab.firstElementChild.innerText = headerData; + } + + /** + * Updates the input tab header to display a preview of the tab contents + * + * @param {number} inputNum - The inputNum of the tab to update the header of + * @param {string} data - The data to display in the tab header + */ + updateInputTabHeader(inputNum, data) { + this.updateTabHeader(inputNum, data, "input"); + } + + /** + * Updates the output tab header to display a preview of the tab contents + * + * @param {number} inputNum - The inputNum of the tab to update the header of + * @param {string} data - The data to display in the tab header + */ + updateOutputTabHeader(inputNum, data) { + this.updateTabHeader(inputNum, data, "output"); + } + + /** + * Updates the tab background to display the progress of the current tab + * + * @param {number} inputNum - The inputNum of the tab + * @param {number} progress - The current progress + * @param {number} total - The total which the progress is a percent of + * @param {string} io - Either "input" or "output" + */ + updateTabProgress(inputNum, progress, total, io) { + const tabItem = this.getTabItem(inputNum, io); + if (tabItem === null) return; + + const percentComplete = (progress / total) * 100; + if (percentComplete >= 100 || progress === false) { + tabItem.style.background = ""; + } else { + tabItem.style.background = `linear-gradient(to right, var(--title-background-colour) ${percentComplete}%, var(--primary-background-colour) ${percentComplete}%)`; + } + } + + /** + * Updates the input tab background to display its progress + * + * @param {number} inputNum + * @param {number} progress + * @param {number} total + */ + updateInputTabProgress(inputNum, progress, total) { + this.updateTabProgress(inputNum, progress, total, "input"); + } + + /** + * Updates the output tab background to display its progress + * + * @param {number} inputNum + * @param {number} progress + * @param {number} total + */ + updateOutputTabProgress(inputNum, progress, total) { + this.updateTabProgress(inputNum, progress, total, "output"); + } + +} + +export default TabWaiter; diff --git a/src/web/WindowWaiter.mjs b/src/web/waiters/WindowWaiter.mjs similarity index 92% rename from src/web/WindowWaiter.mjs rename to src/web/waiters/WindowWaiter.mjs index a8e124f5..5b44ff98 100755 --- a/src/web/WindowWaiter.mjs +++ b/src/web/waiters/WindowWaiter.mjs @@ -25,8 +25,7 @@ class WindowWaiter { * continuous resetting). */ windowResize() { - clearTimeout(this.resetLayoutTimeout); - this.resetLayoutTimeout = setTimeout(this.app.resetLayout.bind(this.app), 200); + this.app.debounce(this.app.resetLayout, 200, "windowResize", this.app, [])(); } diff --git a/src/web/waiters/WorkerWaiter.mjs b/src/web/waiters/WorkerWaiter.mjs new file mode 100644 index 00000000..ec2a8f64 --- /dev/null +++ b/src/web/waiters/WorkerWaiter.mjs @@ -0,0 +1,817 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import ChefWorker from "worker-loader?inline&fallback=false!../../core/ChefWorker"; +import DishWorker from "worker-loader?inline&fallback=false!../workers/DishWorker"; + +/** + * Waiter to handle conversations with the ChefWorker + */ +class WorkerWaiter { + + /** + * WorkerWaiter 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.loaded = false; + this.chefWorkers = []; + this.inputs = []; + this.inputNums = []; + this.totalOutputs = 0; + this.loadingOutputs = 0; + this.bakeId = 0; + this.callbacks = {}; + this.callbackID = 0; + + this.maxWorkers = 1; + if (navigator.hardwareConcurrency !== undefined && + navigator.hardwareConcurrency > 1) { + this.maxWorkers = navigator.hardwareConcurrency - 1; + } + + // Store dishWorker action (getDishAs or getDishTitle) + this.dishWorker = { + worker: null, + currentAction: "" + }; + this.dishWorkerQueue = []; + } + + /** + * Terminates any existing ChefWorkers and sets up a new worker + */ + setupChefWorker() { + for (let i = this.chefWorkers.length - 1; i >= 0; i--) { + this.removeChefWorker(this.chefWorkers[i]); + } + + this.addChefWorker(); + this.setupDishWorker(); + } + + /** + * Sets up a DishWorker to be used for performing Dish operations + */ + setupDishWorker() { + if (this.dishWorker.worker !== null) { + this.dishWorker.worker.terminate(); + this.dishWorker.currentAction = ""; + } + log.debug("Adding new DishWorker"); + + this.dishWorker.worker = new DishWorker(); + this.dishWorker.worker.addEventListener("message", this.handleDishMessage.bind(this)); + + if (this.dishWorkerQueue.length > 0) { + this.postDishMessage(this.dishWorkerQueue.splice(0, 1)[0]); + } + } + + /** + * Adds a new ChefWorker + * + * @returns {number} The index of the created worker + */ + addChefWorker() { + if (this.chefWorkers.length === this.maxWorkers) { + // Can't create any more workers + return -1; + } + + log.debug("Adding new ChefWorker"); + + // Create a new ChefWorker and send it the docURL + const newWorker = new ChefWorker(); + newWorker.addEventListener("message", this.handleChefMessage.bind(this)); + let docURL = document.location.href.split(/[#?]/)[0]; + const index = docURL.lastIndexOf("/"); + if (index > 0) { + docURL = docURL.substring(0, index); + } + + newWorker.postMessage({"action": "docURL", "data": docURL}); + newWorker.postMessage({ + action: "setLogLevel", + data: log.getLevel() + }); + + // Store the worker, whether or not it's active, and the inputNum as an object + const newWorkerObj = { + worker: newWorker, + active: false, + inputNum: -1 + }; + + this.chefWorkers.push(newWorkerObj); + return this.chefWorkers.indexOf(newWorkerObj); + } + + /** + * Gets an inactive ChefWorker to be used for baking + * + * @param {boolean} [setActive=true] - If true, set the worker status to active + * @returns {number} - The index of the ChefWorker + */ + getInactiveChefWorker(setActive=true) { + for (let i = 0; i < this.chefWorkers.length; i++) { + if (!this.chefWorkers[i].active) { + this.chefWorkers[i].active = setActive; + return i; + } + } + return -1; + } + + /** + * Removes a ChefWorker + * + * @param {Object} workerObj + */ + removeChefWorker(workerObj) { + const index = this.chefWorkers.indexOf(workerObj); + if (index === -1) { + return; + } + + if (this.chefWorkers.length > 1 || this.chefWorkers[index].active) { + log.debug(`Removing ChefWorker at index ${index}`); + this.chefWorkers[index].worker.terminate(); + this.chefWorkers.splice(index, 1); + } + + // There should always be a ChefWorker loaded + if (this.chefWorkers.length === 0) { + this.addChefWorker(); + } + } + + /** + * Finds and returns the object for the ChefWorker of a given inputNum + * + * @param {number} inputNum + */ + getChefWorker(inputNum) { + for (let i = 0; i < this.chefWorkers.length; i++) { + if (this.chefWorkers[i].inputNum === inputNum) { + return this.chefWorkers[i]; + } + } + } + + /** + * Handler for messages sent back by the ChefWorkers + * + * @param {MessageEvent} e + */ + handleChefMessage(e) { + const r = e.data; + let inputNum = 0; + log.debug(`Receiving ${r.action} from ChefWorker.`); + + if (r.data.hasOwnProperty("inputNum")) { + inputNum = r.data.inputNum; + } + + const currentWorker = this.getChefWorker(inputNum); + + switch (r.action) { + case "bakeComplete": + log.debug(`Bake ${inputNum} complete.`); + + if (r.data.error) { + this.app.handleError(r.data.error); + this.manager.output.updateOutputError(r.data.error, inputNum, r.data.progress); + } else { + this.updateOutput(r.data, r.data.inputNum, r.data.bakeId, r.data.progress); + } + + this.app.progress = r.data.progress; + + if (r.data.progress === this.recipeConfig.length) { + this.step = false; + } + + this.workerFinished(currentWorker); + break; + case "bakeError": + this.app.handleError(r.data.error); + this.manager.output.updateOutputError(r.data.error, inputNum, r.data.progress); + this.app.progress = r.data.progress; + this.workerFinished(currentWorker); + break; + case "dishReturned": + this.callbacks[r.data.id](r.data); + break; + case "silentBakeComplete": + break; + case "workerLoaded": + this.app.workerLoaded = true; + log.debug("ChefWorker loaded."); + if (!this.loaded) { + this.app.loaded(); + this.loaded = true; + } else { + this.bakeNextInput(this.getInactiveChefWorker(false)); + } + break; + case "statusMessage": + this.manager.output.updateOutputMessage(r.data.message, r.data.inputNum, true); + break; + case "progressMessage": + this.manager.output.updateOutputProgress(r.data.progress, r.data.total, r.data.inputNum); + break; + case "optionUpdate": + log.debug(`Setting ${r.data.option} to ${r.data.value}`); + this.app.options[r.data.option] = r.data.value; + break; + case "setRegisters": + this.manager.recipe.setRegisters(r.data.opIndex, r.data.numPrevRegisters, r.data.registers); + break; + case "highlightsCalculated": + this.manager.highlighter.displayHighlights(r.data.pos, r.data.direction); + break; + default: + log.error("Unrecognised message from ChefWorker", e); + break; + } + } + + /** + * Update the value of an output + * + * @param {Object} data + * @param {number} inputNum + * @param {number} bakeId + * @param {number} progress + */ + updateOutput(data, inputNum, bakeId, progress) { + this.manager.output.updateOutputBakeId(bakeId, inputNum); + if (progress === this.recipeConfig.length) { + progress = false; + } + this.manager.output.updateOutputProgress(progress, this.recipeConfig.length, inputNum); + this.manager.output.updateOutputValue(data, inputNum, false); + + if (progress !== false) { + this.manager.output.updateOutputStatus("error", inputNum); + + if (inputNum === this.manager.tabs.getActiveInputTab()) { + this.manager.recipe.updateBreakpointIndicator(progress); + } + + } else { + this.manager.output.updateOutputStatus("baked", inputNum); + } + } + + /** + * Updates the UI to show if baking is in progress or not. + * + * @param {boolean} bakingStatus + */ + setBakingStatus(bakingStatus) { + this.app.baking = bakingStatus; + this.app.debounce(this.manager.controls.toggleBakeButtonFunction, 20, "toggleBakeButton", this, [bakingStatus ? "cancel" : "bake"])(); + + if (bakingStatus) this.manager.output.hideMagicButton(); + } + + /** + * Get the progress of the ChefWorkers + */ + getBakeProgress() { + const pendingInputs = this.inputNums.length + this.loadingOutputs + this.inputs.length; + let bakingInputs = 0; + + for (let i = 0; i < this.chefWorkers.length; i++) { + if (this.chefWorkers[i].active) { + bakingInputs++; + } + } + + const total = this.totalOutputs; + const bakedInputs = total - pendingInputs - bakingInputs; + + return { + total: total, + pending: pendingInputs, + baking: bakingInputs, + baked: bakedInputs + }; + } + + /** + * Cancels the current bake by terminating and removing all ChefWorkers + * + * @param {boolean} [silent=false] - If true, don't set the output + * @param {boolean} killAll - If true, kills all chefWorkers regardless of status + */ + cancelBake(silent, killAll) { + for (let i = this.chefWorkers.length - 1; i >= 0; i--) { + if (this.chefWorkers[i].active || killAll) { + const inputNum = this.chefWorkers[i].inputNum; + this.removeChefWorker(this.chefWorkers[i]); + this.manager.output.updateOutputStatus("inactive", inputNum); + } + } + this.setBakingStatus(false); + + for (let i = 0; i < this.inputs.length; i++) { + this.manager.output.updateOutputStatus("inactive", this.inputs[i].inputNum); + } + + for (let i = 0; i < this.inputNums.length; i++) { + this.manager.output.updateOutputStatus("inactive", this.inputNums[i]); + } + + const tabList = this.manager.tabs.getOutputTabList(); + for (let i = 0; i < tabList.length; i++) { + this.manager.tabs.getOutputTabItem(tabList[i]).style.background = ""; + } + + this.inputs = []; + this.inputNums = []; + this.totalOutputs = 0; + this.loadingOutputs = 0; + if (!silent) this.manager.output.set(this.manager.tabs.getActiveOutputTab()); + } + + /** + * Handle a worker completing baking + * + * @param {object} workerObj - Object containing the worker information + * @param {ChefWorker} workerObj.worker - The actual worker object + * @param {number} workerObj.inputNum - The inputNum of the input being baked by the worker + * @param {boolean} workerObj.active - If true, the worker is currrently baking an input + */ + workerFinished(workerObj) { + const workerIdx = this.chefWorkers.indexOf(workerObj); + this.chefWorkers[workerIdx].active = false; + if (this.inputs.length > 0) { + this.bakeNextInput(workerIdx); + } else if (this.inputNums.length === 0 && this.loadingOutputs === 0) { + // The ChefWorker is no longer needed + log.debug("No more inputs to bake."); + const progress = this.getBakeProgress(); + if (progress.total === progress.baked) { + this.bakingComplete(); + } + } + } + + /** + * Handler for completed bakes + */ + bakingComplete() { + this.setBakingStatus(false); + let duration = new Date().getTime() - this.bakeStartTime; + duration = duration.toLocaleString() + "ms"; + const progress = this.getBakeProgress(); + + if (progress.total > 1) { + let width = progress.total.toLocaleString().length; + if (duration.length > width) { + width = duration.length; + } + width = width < 2 ? 2 : width; + + const totalStr = progress.total.toLocaleString().padStart(width, " ").replace(/ /g, " "); + const durationStr = duration.padStart(width, " ").replace(/ /g, " "); + + const inputNums = Object.keys(this.manager.output.outputs); + let avgTime = 0, + numOutputs = 0; + for (let i = 0; i < inputNums.length; i++) { + const output = this.manager.output.outputs[inputNums[i]]; + if (output.status === "baked") { + numOutputs++; + avgTime += output.data.duration; + } + } + avgTime = Math.round(avgTime / numOutputs).toLocaleString() + "ms"; + avgTime = avgTime.padStart(width, " ").replace(/ /g, " "); + + const msg = `total: ${totalStr}
time: ${durationStr}
average: ${avgTime}`; + + const bakeInfo = document.getElementById("bake-info"); + bakeInfo.innerHTML = msg; + bakeInfo.style.display = ""; + } else { + document.getElementById("bake-info").style.display = "none"; + } + + document.getElementById("bake").style.background = ""; + this.totalOutputs = 0; // Reset for next time + log.debug("--- Bake complete ---"); + } + + /** + * Bakes the next input and tells the inputWorker to load the next input + * + * @param {number} workerIdx - The index of the worker to bake with + */ + bakeNextInput(workerIdx) { + if (this.inputs.length === 0) return; + if (workerIdx === -1) return; + if (!this.chefWorkers[workerIdx]) return; + this.chefWorkers[workerIdx].active = true; + const nextInput = this.inputs.splice(0, 1)[0]; + if (typeof nextInput.inputNum === "string") nextInput.inputNum = parseInt(nextInput.inputNum, 10); + + log.debug(`Baking input ${nextInput.inputNum}.`); + this.manager.output.updateOutputMessage(`Baking input ${nextInput.inputNum}...`, nextInput.inputNum, false); + this.manager.output.updateOutputStatus("baking", nextInput.inputNum); + + this.chefWorkers[workerIdx].inputNum = nextInput.inputNum; + const input = nextInput.input, + recipeConfig = this.recipeConfig; + + if (this.step) { + // Remove all breakpoints from the recipe up to progress + if (nextInput.progress !== false) { + for (let i = 0; i < nextInput.progress; i++) { + if (recipeConfig[i].hasOwnProperty("breakpoint")) { + delete recipeConfig[i].breakpoint; + } + } + } + + // Set a breakpoint at the next operation so we stop baking there + if (recipeConfig[this.app.progress]) recipeConfig[this.app.progress].breakpoint = true; + } + + let transferable; + if (input instanceof ArrayBuffer || ArrayBuffer.isView(input)) { + transferable = [input]; + } + this.chefWorkers[workerIdx].worker.postMessage({ + action: "bake", + data: { + input: input, + recipeConfig: recipeConfig, + options: this.options, + inputNum: nextInput.inputNum, + bakeId: this.bakeId + } + }, transferable); + + if (this.inputNums.length > 0) { + this.manager.input.inputWorker.postMessage({ + action: "bakeNext", + data: { + inputNum: this.inputNums.splice(0, 1)[0], + bakeId: this.bakeId + } + }); + this.loadingOutputs++; + } + } + + /** + * Bakes the current input using the current recipe. + * + * @param {Object[]} recipeConfig + * @param {Object} options + * @param {number} progress + * @param {boolean} step + */ + bake(recipeConfig, options, progress, step) { + this.setBakingStatus(true); + this.manager.recipe.updateBreakpointIndicator(false); + this.bakeStartTime = new Date().getTime(); + this.bakeId++; + this.recipeConfig = recipeConfig; + this.options = options; + this.progress = progress; + this.step = step; + + this.displayProgress(); + } + + /** + * Queues an input ready to be baked + * + * @param {object} inputData + * @param {string | ArrayBuffer} inputData.input + * @param {number} inputData.inputNum + * @param {number} inputData.bakeId + */ + queueInput(inputData) { + this.loadingOutputs--; + if (this.app.baking && inputData.bakeId === this.bakeId) { + this.inputs.push(inputData); + this.bakeNextInput(this.getInactiveChefWorker(true)); + } + } + + /** + * Handles if an error is thrown by QueueInput + * + * @param {object} inputData + * @param {number} inputData.inputNum + * @param {number} inputData.bakeId + */ + queueInputError(inputData) { + this.loadingOutputs--; + if (this.app.baking && inputData.bakeId === this.bakeId) { + this.manager.output.updateOutputError("Error queueing the input for a bake.", inputData.inputNum, 0); + + if (this.inputNums.length === 0) return; + + // Load the next input + this.manager.input.inputWorker.postMessage({ + action: "bakeNext", + data: { + inputNum: this.inputNums.splice(0, 1)[0], + bakeId: this.bakeId + } + }); + this.loadingOutputs++; + + } + } + + /** + * Queues a list of inputNums to be baked by ChefWorkers, and begins baking + * + * @param {object} inputData + * @param {number[]} inputData.nums - The inputNums to be queued for baking + * @param {boolean} inputData.step - If true, only execute the next operation in the recipe + * @param {number} inputData.progress - The current progress through the recipe. Used when stepping + */ + async bakeAllInputs(inputData) { + return await new Promise(resolve => { + if (this.app.baking) return; + const inputNums = inputData.nums; + const step = inputData.step; + + // Use cancelBake to clear out the inputs + this.cancelBake(true, false); + + this.inputNums = inputNums; + this.totalOutputs = inputNums.length; + this.app.progress = inputData.progress; + + let inactiveWorkers = 0; + for (let i = 0; i < this.chefWorkers.length; i++) { + if (!this.chefWorkers[i].active) { + inactiveWorkers++; + } + } + + for (let i = 0; i < inputNums.length - inactiveWorkers; i++) { + if (this.addChefWorker() === -1) break; + } + + this.app.bake(step); + + for (let i = 0; i < this.inputNums.length; i++) { + this.manager.output.updateOutputMessage(`Input ${inputNums[i]} has not been baked yet.`, inputNums[i], false); + this.manager.output.updateOutputStatus("pending", inputNums[i]); + } + + let numBakes = this.chefWorkers.length; + if (this.inputNums.length < numBakes) { + numBakes = this.inputNums.length; + } + for (let i = 0; i < numBakes; i++) { + this.manager.input.inputWorker.postMessage({ + action: "bakeNext", + data: { + inputNum: this.inputNums.splice(0, 1)[0], + bakeId: this.bakeId + } + }); + this.loadingOutputs++; + } + }); + } + + /** + * Asks the ChefWorker to run a silent bake, forcing the browser to load and cache all the relevant + * JavaScript code needed to do a real bake. + * + * @param {Object[]} [recipeConfig] + */ + silentBake(recipeConfig) { + // If there aren't any active ChefWorkers, try to add one + let workerId = this.getInactiveChefWorker(); + if (workerId === -1) { + workerId = this.addChefWorker(); + } + if (workerId === -1) return; + this.chefWorkers[workerId].worker.postMessage({ + action: "silentBake", + data: { + recipeConfig: recipeConfig + } + }); + } + + /** + * Handler for messages sent back from DishWorker + * + * @param {MessageEvent} e + */ + handleDishMessage(e) { + const r = e.data; + log.debug(`Receiving ${r.action} from DishWorker`); + + switch (r.action) { + case "dishReturned": + this.dishWorker.currentAction = ""; + this.callbacks[r.data.id](r.data); + + if (this.dishWorkerQueue.length > 0) { + this.postDishMessage(this.dishWorkerQueue.splice(0, 1)[0]); + } + + break; + default: + log.error("Unrecognised message from DishWorker", e); + break; + } + } + + /** + * Asks the ChefWorker to return the dish as the specified type + * + * @param {Dish} dish + * @param {string} type + * @param {Function} callback + */ + getDishAs(dish, type, callback) { + const id = this.callbackID++; + + this.callbacks[id] = callback; + + if (this.dishWorker.worker === null) this.setupDishWorker(); + this.postDishMessage({ + action: "getDishAs", + data: { + dish: dish, + type: type, + id: id + } + }); + } + + /** + * Asks the ChefWorker to get the title of the dish + * + * @param {Dish} dish + * @param {number} maxLength + * @param {Function} callback + * @returns {string} + */ + getDishTitle(dish, maxLength, callback) { + const id = this.callbackID++; + + this.callbacks[id] = callback; + + if (this.dishWorker.worker === null) this.setupDishWorker(); + + this.postDishMessage({ + action: "getDishTitle", + data: { + dish: dish, + maxLength: maxLength, + id: id + } + }); + } + + /** + * Queues a message to be sent to the dishWorker + * + * @param {object} message + * @param {string} message.action + * @param {object} message.data + * @param {Dish} message.data.dish + * @param {number} message.data.id + */ + queueDishMessage(message) { + if (message.action === "getDishAs") { + this.dishWorkerQueue = [message].concat(this.dishWorkerQueue); + } else { + this.dishWorkerQueue.push(message); + } + } + + /** + * Sends a message to the DishWorker + * + * @param {object} message + * @param {string} message.action + * @param {object} message.data + */ + postDishMessage(message) { + if (this.dishWorker.currentAction !== "") { + this.queueDishMessage(message); + } else { + this.dishWorker.currentAction = message.action; + this.dishWorker.worker.postMessage(message); + } + } + + /** + * Sets the console log level in the workers. + */ + setLogLevel() { + for (let i = 0; i < this.chefWorkers.length; i++) { + this.chefWorkers[i].worker.postMessage({ + action: "setLogLevel", + data: log.getLevel() + }); + } + } + + /** + * Display the bake progress in the output bar and bake button + */ + displayProgress() { + const progress = this.getBakeProgress(); + if (progress.total === progress.baked) return; + + const percentComplete = ((progress.pending + progress.baking) / progress.total) * 100; + const bakeButton = document.getElementById("bake"); + if (this.app.baking) { + if (percentComplete < 100) { + bakeButton.style.background = `linear-gradient(to left, #fea79a ${percentComplete}%, #f44336 ${percentComplete}%)`; + } else { + bakeButton.style.background = ""; + } + } else { + // not baking + bakeButton.style.background = ""; + } + + const bakeInfo = document.getElementById("bake-info"); + if (progress.total > 1) { + let width = progress.total.toLocaleString().length; + width = width < 2 ? 2 : width; + + const totalStr = progress.total.toLocaleString().padStart(width, " ").replace(/ /g, " "); + const bakedStr = progress.baked.toLocaleString().padStart(width, " ").replace(/ /g, " "); + const pendingStr = progress.pending.toLocaleString().padStart(width, " ").replace(/ /g, " "); + const bakingStr = progress.baking.toLocaleString().padStart(width, " ").replace(/ /g, " "); + + let msg = "total: " + totalStr; + msg += "
baked: " + bakedStr; + + if (progress.pending > 0) { + msg += "
pending: " + pendingStr; + } else if (progress.baking > 0) { + msg += "
baking: " + bakingStr; + } + bakeInfo.innerHTML = msg; + bakeInfo.style.display = ""; + } else { + bakeInfo.style.display = "none"; + } + + if (progress.total !== progress.baked) { + setTimeout(function() { + this.displayProgress(); + }.bind(this), 100); + } + + } + + /** + * Asks the ChefWorker to calculate highlight offsets if possible. + * + * @param {Object[]} recipeConfig + * @param {string} direction + * @param {Object} pos - The position object for the highlight. + * @param {number} pos.start - The start offset. + * @param {number} pos.end - The end offset. + */ + highlight(recipeConfig, direction, pos) { + let workerIdx = this.getInactiveChefWorker(false); + if (workerIdx === -1) { + workerIdx = this.addChefWorker(); + } + if (workerIdx === -1) return; + this.chefWorkers[workerIdx].worker.postMessage({ + action: "highlight", + data: { + recipeConfig: recipeConfig, + direction: direction, + pos: pos + } + }); + } +} + +export default WorkerWaiter; diff --git a/src/web/workers/DishWorker.mjs b/src/web/workers/DishWorker.mjs new file mode 100644 index 00000000..44b0a8c9 --- /dev/null +++ b/src/web/workers/DishWorker.mjs @@ -0,0 +1,69 @@ +/** + * Web worker to handle dish conversion operations. + * + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Dish from "../../core/Dish"; + +self.addEventListener("message", function(e) { + // Handle message from the main thread + const r = e.data; + log.debug(`DishWorker receiving command '${r.action}'`); + + switch (r.action) { + case "getDishAs": + getDishAs(r.data); + break; + case "getDishTitle": + getDishTitle(r.data); + break; + default: + log.error(`DishWorker sent invalid action: '${r.action}'`); + } +}); + +/** + * Translates the dish to a given type + * + * @param {object} data + * @param {Dish} data.dish + * @param {string} data.type + * @param {number} data.id + */ +async function getDishAs(data) { + const newDish = new Dish(data.dish), + value = await newDish.get(data.type), + transferable = (data.type === "ArrayBuffer") ? [value] : undefined; + + self.postMessage({ + action: "dishReturned", + data: { + value: value, + id: data.id + } + }, transferable); +} + +/** + * Gets the title of the given dish + * + * @param {object} data + * @param {Dish} data.dish + * @param {number} data.id + * @param {number} data.maxLength + */ +async function getDishTitle(data) { + const newDish = new Dish(data.dish), + title = await newDish.getTitle(data.maxLength); + + self.postMessage({ + action: "dishReturned", + data: { + value: title, + id: data.id + } + }); +} diff --git a/src/web/workers/InputWorker.mjs b/src/web/workers/InputWorker.mjs new file mode 100644 index 00000000..9f3eb338 --- /dev/null +++ b/src/web/workers/InputWorker.mjs @@ -0,0 +1,1057 @@ +/** + * Web worker to handle the inputs. + * Handles storage, modification and retrieval of the inputs. + * + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Utils from "../../core/Utils"; +import {detectFileType} from "../../core/lib/FileType"; + +// Default max values +// These will be correctly calculated automatically +self.maxWorkers = 4; +self.maxTabs = 1; + +self.pendingFiles = []; +self.inputs = {}; +self.loaderWorkers = []; +self.currentInputNum = 1; +self.numInputs = 0; +self.pendingInputs = 0; +self.loadingInputs = 0; + +/** + * Respond to message from parent thread. + * + * @param {MessageEvent} e + */ +self.addEventListener("message", function(e) { + const r = e.data; + if (!r.hasOwnProperty("action")) { + log.error("No action"); + return; + } + + log.debug(`Receiving ${r.action} from InputWaiter.`); + + switch (r.action) { + case "loadUIFiles": + self.loadFiles(r.data); + break; + case "loaderWorkerReady": + self.loaderWorkerReady(r.data); + break; + case "updateMaxWorkers": + self.maxWorkers = r.data; + break; + case "updateMaxTabs": + self.updateMaxTabs(r.data.maxTabs, r.data.activeTab); + break; + case "updateInputValue": + self.updateInputValue(r.data); + break; + case "updateInputObj": + self.updateInputObj(r.data); + break; + case "updateInputProgress": + self.updateInputProgress(r.data); + break; + case "bakeAll": + self.bakeAllInputs(); + break; + case "bakeNext": + self.bakeInput(r.data.inputNum, r.data.bakeId); + break; + case "getLoadProgress": + self.getLoadProgress(r.data); + break; + case "setInput": + self.setInput(r.data); + break; + case "setLogLevel": + log.setLevel(r.data, false); + break; + case "addInput": + self.addInput(r.data, "string"); + break; + case "refreshTabs": + self.refreshTabs(r.data.inputNum, r.data.direction); + break; + case "removeInput": + self.removeInput(r.data); + break; + case "changeTabRight": + self.changeTabRight(r.data.activeTab); + break; + case "changeTabLeft": + self.changeTabLeft(r.data.activeTab); + break; + case "autobake": + self.autoBake(r.data.activeTab, 0, false); + break; + case "filterTabs": + self.filterTabs(r.data); + break; + case "loaderWorkerMessage": + self.handleLoaderMessage(r.data); + break; + case "inputSwitch": + self.inputSwitch(r.data); + break; + case "updateTabHeader": + self.updateTabHeader(r.data); + break; + case "step": + self.autoBake(r.data.activeTab, r.data.progress, true); + break; + case "getInput": + self.getInput(r.data); + break; + case "getInputNums": + self.getInputNums(r.data); + break; + default: + log.error(`Unknown action '${r.action}'.`); + } +}); + +/** + * Gets the load progress of the input files, and the + * load progress for the input given in inputNum + * + * @param {number} inputNum - The input to get the file loading progress for + */ +self.getLoadProgress = function(inputNum) { + const total = self.numInputs; + const pending = self.pendingFiles.length; + const loading = self.loadingInputs; + const loaded = total - pending - loading; + + self.postMessage({ + action: "loadingInfo", + data: { + pending: pending, + loading: loading, + loaded: loaded, + total: total, + activeProgress: { + inputNum: inputNum, + progress: self.getInputProgress(inputNum) + } + } + }); +}; + +/** + * Fired when an autobake is initiated. + * Queues the active input and sends a bake command. + * + * @param {number} inputNum - The input to be baked + * @param {number} progress - The current progress of the bake through the recipe + * @param {boolean} [step=false] - Set to true if we should only execute one operation instead of the + * whole recipe + */ +self.autoBake = function(inputNum, progress, step=false) { + const input = self.getInputObj(inputNum); + if (input) { + self.postMessage({ + action: "bakeAllInputs", + data: { + nums: [parseInt(inputNum, 10)], + step: step, + progress: progress + } + }); + } +}; + +/** + * Fired when we want to bake all inputs (bake button clicked) + * Sends a list of inputNums to the workerwaiter + */ +self.bakeAllInputs = function() { + const inputNums = Object.keys(self.inputs), + nums = []; + + for (let i = 0; i < inputNums.length; i++) { + if (self.inputs[inputNums[i]].status === "loaded") { + nums.push(parseInt(inputNums[i], 10)); + } + } + self.postMessage({ + action: "bakeAllInputs", + data: { + nums: nums, + step: false, + progress: 0 + } + }); +}; + +/** + * Gets the data for the provided inputNum and sends it to the WorkerWaiter + * + * @param {number} inputNum + * @param {number} bakeId + */ +self.bakeInput = function(inputNum, bakeId) { + const inputObj = self.getInputObj(inputNum); + if (inputObj === null || + inputObj === undefined || + inputObj.status !== "loaded") { + self.postMessage({ + action: "queueInputError", + data: { + inputNum: inputNum, + bakeId: bakeId + } + }); + return; + } + + let inputData = inputObj.data; + if (typeof inputData !== "string") inputData = inputData.fileBuffer; + + self.postMessage({ + action: "queueInput", + data: { + input: inputData, + inputNum: inputNum, + bakeId: bakeId + } + }); +}; + +/** + * Gets the stored object for a specific inputNum + * + * @param {number} inputNum - The input we want to get the object for + * @returns {object} + */ +self.getInputObj = function(inputNum) { + return self.inputs[inputNum]; +}; + +/** + * Gets the stored value for a specific inputNum. + * + * @param {number} inputNum - The input we want to get the value of + * @returns {string | ArrayBuffer} + */ +self.getInputValue = function(inputNum) { + if (self.inputs[inputNum]) { + if (typeof self.inputs[inputNum].data === "string") { + return self.inputs[inputNum].data; + } else { + return self.inputs[inputNum].data.fileBuffer; + } + } + return ""; +}; + +/** + * Gets the stored value or object for a specific inputNum and sends it to the inputWaiter. + * + * @param {object} inputData - Object containing data about the input to retrieve + * @param {number} inputData.inputNum - The inputNum of the input to get + * @param {boolean} inputData.getObj - If true, returns the entire input object instead of just the value + * @param {number} inputData.id - The callback ID for the callback to run when returned to the inputWaiter + */ +self.getInput = function(inputData) { + const inputNum = inputData.inputNum, + data = (inputData.getObj) ? self.getInputObj(inputNum) : self.getInputValue(inputNum); + self.postMessage({ + action: "getInput", + data: { + data: data, + id: inputData.id + } + }); +}; + +/** + * Gets a list of the stored inputNums, along with the minimum and maximum + * + * @param {number} id - The callback ID to be executed when returned to the inputWaiter + */ +self.getInputNums = function(id) { + const inputNums = Object.keys(self.inputs), + min = self.getSmallestInputNum(inputNums), + max = self.getLargestInputNum(inputNums); + + self.postMessage({ + action: "getInputNums", + data: { + inputNums: inputNums, + min: min, + max: max, + id: id + } + }); +}; + +/** + * Gets the load progress for a specific inputNum + * + * @param {number} inputNum - The input we want to get the progress of + * @returns {number | string} - Returns "error" if there was a load error + */ +self.getInputProgress = function(inputNum) { + const inputObj = self.getInputObj(inputNum); + if (inputObj === undefined || inputObj === null) return; + if (inputObj.status === "error") { + return "error"; + } + return inputObj.progress; +}; + +/** + * Gets the largest inputNum of all the inputs + * + * @param {string[]} inputNums - The numbers to find the largest of + * @returns {number} + */ +self.getLargestInputNum = function(inputNums) { + return inputNums.reduce((acc, val) => { + val = parseInt(val, 10); + return val > acc ? val : acc; + }, -1); +}; + +/** + * Gets the smallest inputNum of all the inputs + * + * @param {string[]} inputNums - The numbers to find the smallest of + * @returns {number} + */ +self.getSmallestInputNum = function(inputNums) { + const min = inputNums.reduce((acc, val) => { + val = parseInt(val, 10); + return val < acc ? val : acc; + }, Number.MAX_SAFE_INTEGER); + + // Assume we don't have this many tabs! + if (min === Number.MAX_SAFE_INTEGER) return -1; + + return min; +}; + +/** + * Gets the next smallest inputNum + * + * @param {number} inputNum - The current input number + * @returns {number} + */ +self.getPreviousInputNum = function(inputNum) { + const inputNums = Object.keys(self.inputs); + if (inputNums.length === 0) return -1; + + return inputNums.reduce((acc, val) => { + val = parseInt(val, 10); + return (val < inputNum && val > acc) ? val : acc; + }, self.getSmallestInputNum(inputNums)); +}; + +/** + * Gets the next largest inputNum + * + * @param {number} inputNum - The current input number + * @returns {number} + */ +self.getNextInputNum = function(inputNum) { + const inputNums = Object.keys(self.inputs); + + return inputNums.reduce((acc, val) => { + val = parseInt(val, 10); + return (val > inputNum && val < acc) ? val : acc; + }, self.getLargestInputNum(inputNums)); +}; + +/** + * Gets a list of inputNums starting from the provided inputNum. + * If direction is "left", gets the inputNums higher than the provided number. + * If direction is "right", gets the inputNums lower than the provided number. + * @param {number} inputNum - The inputNum we want to get the neighbours of + * @param {string} direction - Either "left" or "right". Determines which direction we search for nearby numbers in + * @returns {number[]} + */ +self.getNearbyNums = function(inputNum, direction) { + const nums = []; + for (let i = 0; i < self.maxTabs; i++) { + let newNum; + if (i === 0 && self.inputs[inputNum] !== undefined) { + newNum = inputNum; + } else { + switch (direction) { + case "left": + newNum = self.getNextInputNum(nums[i - 1]); + if (newNum === nums[i - 1]) { + direction = "right"; + newNum = self.getPreviousInputNum(nums[0]); + } + break; + case "right": + newNum = self.getPreviousInputNum(nums[i - 1]); + if (newNum === nums[i - 1]) { + direction = "left"; + newNum = self.getNextInputNum(nums[0]); + } + } + } + if (!nums.includes(newNum) && (newNum > 0)) { + nums.push(newNum); + } + } + nums.sort(function(a, b) { + return a - b; + }); + return nums; +}; + +/** + * Gets the data to display in the tab header for an input, and + * posts it back to the inputWaiter + * + * @param {number} inputNum - The inputNum of the tab header + */ +self.updateTabHeader = function(inputNum) { + const input = self.getInputObj(inputNum); + if (input === null || input === undefined) return; + let inputData = input.data; + if (typeof inputData !== "string") { + inputData = input.data.name; + } + inputData = inputData.replace(/[\n\r]/g, ""); + + self.postMessage({ + action: "updateTabHeader", + data: { + inputNum: inputNum, + input: inputData.slice(0, 100) + } + }); +}; + +/** + * Gets the input for a specific inputNum, and posts it to the inputWaiter + * so that it can be displayed in the input area + * + * @param {object} inputData + * @param {number} inputData.inputNum - The input to get the data for + * @param {boolean} inputData.silent - If false, the manager statechange event won't be fired + */ +self.setInput = function(inputData) { + const inputNum = inputData.inputNum; + const silent = inputData.silent; + const input = self.getInputObj(inputNum); + if (input === undefined || input === null) return; + + let inputVal = input.data; + const inputObj = { + inputNum: inputNum, + input: inputVal + }; + if (typeof inputVal !== "string") { + inputObj.name = inputVal.name; + inputObj.size = inputVal.size; + inputObj.type = inputVal.type; + inputObj.progress = input.progress; + inputObj.status = input.status; + inputVal = inputVal.fileBuffer; + const fileSlice = inputVal.slice(0, 512001); + inputObj.input = fileSlice; + + self.postMessage({ + action: "setInput", + data: { + inputObj: inputObj, + silent: silent + } + }, [fileSlice]); + } else { + self.postMessage({ + action: "setInput", + data: { + inputObj: inputObj, + silent: silent + } + }); + } + self.updateTabHeader(inputNum); +}; + +/** + * Gets the nearby inputNums to the provided number, and posts them + * to the inputWaiter to be displayed on the page. + * + * @param {number} inputNum - The inputNum to find the nearby numbers for + * @param {string} direction - The direction to search for inputNums in. Either "left" or "right" + */ +self.refreshTabs = function(inputNum, direction) { + const nums = self.getNearbyNums(inputNum, direction), + inputNums = Object.keys(self.inputs), + tabsLeft = (self.getSmallestInputNum(inputNums) !== nums[0]), + tabsRight = (self.getLargestInputNum(inputNums) !== nums[nums.length - 1]); + + self.postMessage({ + action: "refreshTabs", + data: { + nums: nums, + activeTab: (nums.includes(inputNum)) ? inputNum : self.getNextInputNum(inputNum), + tabsLeft: tabsLeft, + tabsRight: tabsRight + } + }); + + // Update the tab headers for the new tabs + for (let i = 0; i < nums.length; i++) { + self.updateTabHeader(nums[i]); + } +}; + +/** + * Update the stored status for an input + * + * @param {number} inputNum - The input that's having its status changed + * @param {string} status - The status of the input + */ +self.updateInputStatus = function(inputNum, status) { + if (self.inputs[inputNum] !== undefined) { + self.inputs[inputNum].status = status; + } +}; + +/** + * Update the stored load progress of an input + * + * @param {object} inputData + * @param {number} inputData.inputNum - The input that's having its progress updated + * @param {number} inputData.progress - The load progress of the input + */ +self.updateInputProgress = function(inputData) { + const inputNum = inputData.inputNum; + const progress = inputData.progress; + + if (self.inputs[inputNum] !== undefined) { + self.inputs[inputNum].progress = progress; + } +}; + +/** + * Update the stored value of an input. + * + * @param {object} inputData + * @param {number} inputData.inputNum - The input that's having its value updated + * @param {string | ArrayBuffer} inputData.value - The new value of the input + */ +self.updateInputValue = function(inputData) { + const inputNum = inputData.inputNum; + if (inputNum < 1) return; + const value = inputData.value; + if (self.inputs[inputNum] !== undefined) { + if (typeof value === "string") { + self.inputs[inputNum].data = value; + } else { + self.inputs[inputNum].data.fileBuffer = value; + } + self.inputs[inputNum].status = "loaded"; + self.inputs[inputNum].progress = 100; + return; + } + + // If we get to here, an input for inputNum could not be found, + // so create a new one. Only do this if the value is a string, as + // loadFiles will create the input object for files + if (typeof value === "string") { + self.inputs.push({ + inputNum: inputNum, + data: value, + status: "loaded", + progress: 100 + }); + } +}; + +/** + * Update the stored data object for an input. + * Used if we need to change a string to an ArrayBuffer + * + * @param {object} inputData + * @param {number} inputData.inputNum - The number of the input we're updating + * @param {object} inputData.data - The new data object for the input + */ +self.updateInputObj = function(inputData) { + const inputNum = inputData.inputNum; + const data = inputData.data; + + if (self.getInputObj(inputNum) === -1) return; + + self.inputs[inputNum].data = data; +}; + +/** + * Get the index of a loader worker object. + * Returns -1 if the worker could not be found + * + * @param {number} workerId - The ID of the worker we're searching for + * @returns {number} + */ +self.getLoaderWorkerIdx = function(workerId) { + for (let i = 0; i < self.loaderWorkers.length; i++) { + if (self.loaderWorkers[i].id === workerId) { + return i; + } + } + return -1; +}; + +/** + * Fires when a loaderWorker is ready to load files. + * Stores data about the new loaderWorker in the loaderWorkers array, + * and sends the next file to the loaderWorker to be loaded. + * + * @param {object} workerData + * @param {number} workerData.id - The ID of the new loaderWorker + */ +self.loaderWorkerReady = function(workerData) { + const newWorkerObj = { + id: workerData.id, + inputNum: -1, + active: true + }; + self.loaderWorkers.push(newWorkerObj); + self.loadNextFile(self.loaderWorkers.indexOf(newWorkerObj)); +}; + +/** + * Handler for messages sent by loaderWorkers. + * (Messages are sent between the inputWorker and + * loaderWorkers via the main thread) + * + * @param {object} r - The data sent by the loaderWorker + * @param {number} r.inputNum - The inputNum which the message corresponds to + * @param {string} r.error - Present if an error is fired by the loaderWorker. Contains the error message string. + * @param {ArrayBuffer} r.fileBuffer - Present if a file has finished loading. Contains the loaded file buffer. + */ +self.handleLoaderMessage = function(r) { + let inputNum = 0; + + if (r.hasOwnProperty("inputNum")) { + inputNum = r.inputNum; + } + + if (r.hasOwnProperty("error")) { + self.updateInputProgress(r.inputNum, 0); + self.updateInputStatus(r.inputNum, "error"); + + log.error(r.error); + self.loadingInputs--; + + self.terminateLoaderWorker(r.id); + self.activateLoaderWorker(); + + self.setInput({inputNum: inputNum, silent: true}); + return; + } + + if (r.hasOwnProperty("fileBuffer")) { + log.debug(`Input file ${inputNum} loaded.`); + self.loadingInputs--; + self.updateInputValue({ + inputNum: inputNum, + value: r.fileBuffer + }); + + const idx = self.getLoaderWorkerIdx(r.id); + self.loadNextFile(idx); + } else if (r.hasOwnProperty("progress")) { + self.updateInputProgress(r); + } +}; + +/** + * Loads the next file using a loaderWorker + * + * @param {number} - The loaderWorker which will load the file + */ +self.loadNextFile = function(workerIdx) { + if (workerIdx === -1) return; + const id = self.loaderWorkers[workerIdx].id; + if (self.pendingFiles.length === 0) { + const workerObj = self.loaderWorkers.splice(workerIdx, 1)[0]; + self.terminateLoaderWorker(workerObj.id); + return; + } + + const nextFile = self.pendingFiles.splice(0, 1)[0]; + self.loaderWorkers[workerIdx].inputNum = nextFile.inputNum; + self.loadingInputs++; + self.postMessage({ + action: "loadInput", + data: { + file: nextFile.file, + inputNum: nextFile.inputNum, + workerId: id + } + }); +}; + +/** + * Sends a message to the inputWaiter to create a new loaderWorker. + * If there's an inactive loaderWorker that already exists, use that instead. + */ +self.activateLoaderWorker = function() { + for (let i = 0; i < self.loaderWorkers.length; i++) { + if (!self.loaderWorkers[i].active) { + self.loaderWorkers[i].active = true; + self.loadNextFile(i); + return; + } + } + self.postMessage({ + action: "activateLoaderWorker" + }); +}; + +/** + * Sends a message to the inputWaiter to terminate a loaderWorker. + * + * @param {number} id - The ID of the worker to be terminated + */ +self.terminateLoaderWorker = function(id) { + self.postMessage({ + action: "terminateLoaderWorker", + data: id + }); + // If we still have pending files, spawn a worker + if (self.pendingFiles.length > 0) { + self.activateLoaderWorker(); + } +}; + +/** + * Loads files using LoaderWorkers + * + * @param {object} filesData + * @param {FileList} filesData.files - The list of files to be loaded + * @param {number} filesData.activeTab - The active tab in the UI + */ +self.loadFiles = function(filesData) { + const files = filesData.files; + const activeTab = filesData.activeTab; + let lastInputNum = -1; + const inputNums = []; + for (let i = 0; i < files.length; i++) { + if (i === 0 && self.getInputValue(activeTab) === "") { + self.removeInput({ + inputNum: activeTab, + refreshTabs: false, + removeChefWorker: false + }); + lastInputNum = self.addInput(false, "file", { + name: files[i].name, + size: files[i].size.toLocaleString(), + type: files[i].type || "unknown" + }, activeTab); + } else { + lastInputNum = self.addInput(false, "file", { + name: files[i].name, + size: files[i].size.toLocaleString(), + type: files[i].type || "unknown" + }); + } + inputNums.push(lastInputNum); + + self.pendingFiles.push({ + file: files[i], + inputNum: lastInputNum + }); + } + let max = self.maxWorkers; + if (self.pendingFiles.length < self.maxWorkers) max = self.pendingFiles.length; + + // Create loaderWorkers to load the new files + for (let i = 0; i < max; i++) { + self.activateLoaderWorker(); + } + + self.getLoadProgress(); + self.setInput({inputNum: activeTab, silent: false}); +}; + +/** + * Adds an input to the input dictionary + * + * @param {boolean} [changetab=false] - Whether or not to change to the new input + * @param {string} type - Either "string" or "file" + * @param {Object} fileData - Contains information about the file to be added to the input (only used when type is "file") + * @param {string} fileData.name - The filename of the input being added + * @param {number} fileData.size - The file size (in bytes) of the input being added + * @param {string} fileData.type - The MIME type of the input being added + * @param {number} inputNum - Defaults to auto-incrementing self.currentInputNum + */ +self.addInput = function( + changeTab = false, + type, + fileData = { + name: "unknown", + size: "unknown", + type: "unknown" + }, + inputNum = self.currentInputNum++ +) { + self.numInputs++; + const newInputObj = { + inputNum: inputNum + }; + + switch (type) { + case "string": + newInputObj.data = ""; + newInputObj.status = "loaded"; + newInputObj.progress = 100; + break; + case "file": + newInputObj.data = { + fileBuffer: new ArrayBuffer(), + name: fileData.name, + size: fileData.size, + type: fileData.type + }; + newInputObj.status = "pending"; + newInputObj.progress = 0; + break; + default: + log.error(`Invalid type '${type}'.`); + return -1; + } + self.inputs[inputNum] = newInputObj; + + // Tell the inputWaiter we've added an input, so it can create a tab to display it + self.postMessage({ + action: "inputAdded", + data: { + changeTab: changeTab, + inputNum: inputNum + } + }); + + return inputNum; +}; + +/** + * Remove an input from the inputs dictionary + * + * @param {object} removeInputData + * @param {number} removeInputData.inputNum - The number of the input to be removed + * @param {boolean} removeInputData.refreshTabs - If true, refresh the tabs after removing the input + * @param {boolean} removeInputData.removeChefWorker - If true, remove a chefWorker from the WorkerWaiter + */ +self.removeInput = function(removeInputData) { + const inputNum = removeInputData.inputNum; + const refreshTabs = removeInputData.refreshTabs; + self.numInputs--; + + for (let i = 0; i < self.loaderWorkers.length; i++) { + if (self.loaderWorkers[i].inputNum === inputNum) { + // Terminate any loaderWorker that's loading the removed input + self.loadingInputs--; + self.terminateLoaderWorker(self.loaderWorkers[i].id); + break; + } + } + + for (let i = 0; i < self.pendingFiles.length; i++) { + // Remove the input from the pending files list + if (self.pendingFiles[i].inputNum === inputNum) { + self.pendingFiles.splice(i, 1); + break; + } + } + + delete self.inputs[inputNum]; + + if (refreshTabs) { + self.refreshTabs(self.getPreviousInputNum(inputNum), "left"); + } + + if (self.numInputs < self.maxWorkers && removeInputData.removeChefWorker) { + self.postMessage({ + action: "removeChefWorker" + }); + } +}; + +/** + * Change to the next tab. + * + * @param {number} inputNum - The inputNum of the tab to change to + */ +self.changeTabRight = function(inputNum) { + const newInput = self.getNextInputNum(inputNum); + self.postMessage({ + action: "changeTab", + data: newInput + }); +}; + +/** + * Change to the previous tab. + * + * @param {number} inputNum - The inputNum of the tab to change to + */ +self.changeTabLeft = function(inputNum) { + const newInput = self.getPreviousInputNum(inputNum); + self.postMessage({ + action: "changeTab", + data: newInput + }); +}; + +/** + * Updates the maximum number of tabs, and refreshes them if it changes + * + * @param {number} maxTabs - The new max number of tabs + * @param {number} activeTab - The currently selected tab + */ +self.updateMaxTabs = function(maxTabs, activeTab) { + if (self.maxTabs !== maxTabs) { + self.maxTabs = maxTabs; + self.refreshTabs(activeTab, "right"); + } +}; + +/** + * Search the inputs for any that match the filters provided, + * posting the results back to the inputWaiter + * + * @param {object} searchData - Object containing the search filters + * @param {boolean} searchData.showPending - If true, include pending inputs in the results + * @param {boolean} searchData.showLoading - If true, include loading inputs in the results + * @param {boolean} searchData.showLoaded - If true, include loaded inputs in the results + * @param {string} searchData.filter - A regular expression to match the inputs on + * @param {string} searchData.filterType - Either "CONTENT" or "FILENAME". Detemines what should be matched with filter + * @param {number} searchData.numResults - The maximum number of results to be returned + */ +self.filterTabs = function(searchData) { + const showPending = searchData.showPending, + showLoading = searchData.showLoading, + showLoaded = searchData.showLoaded, + filterType = searchData.filterType; + + let filterExp; + try { + filterExp = new RegExp(searchData.filter, "i"); + } catch (error) { + self.postMessage({ + action: "filterTabError", + data: error.message + }); + return; + } + const numResults = searchData.numResults; + + const inputs = []; + const inputNums = Object.keys(self.inputs); + for (let i = 0; i < inputNums.length; i++) { + const iNum = inputNums[i]; + let textDisplay = ""; + let addInput = false; + if (self.inputs[iNum].status === "pending" && showPending || + self.inputs[iNum].status === "loading" && showLoading || + self.inputs[iNum].status === "loaded" && showLoaded) { + try { + if (typeof self.inputs[iNum].data === "string") { + if (filterType.toLowerCase() === "content" && + filterExp.test(self.inputs[iNum].data.slice(0, 4096))) { + textDisplay = self.inputs[iNum].data.slice(0, 4096); + addInput = true; + } + } else { + if ((filterType.toLowerCase() === "filename" && + filterExp.test(self.inputs[iNum].data.name)) || + filterType.toLowerCase() === "content" && + filterExp.test(Utils.arrayBufferToStr(self.inputs[iNum].data.fileBuffer.slice(0, 4096)))) { + textDisplay = self.inputs[iNum].data.name; + addInput = true; + } + } + } catch (error) { + self.postMessage({ + action: "filterTabError", + data: error.message + }); + return; + } + } + + if (addInput) { + if (textDisplay === "" || textDisplay === undefined) { + textDisplay = "New Tab"; + } + const inputItem = { + inputNum: iNum, + textDisplay: textDisplay + }; + inputs.push(inputItem); + } + if (inputs.length >= numResults) { + break; + } + } + + // Send the results back to the inputWaiter + self.postMessage({ + action: "displayTabSearchResults", + data: inputs + }); +}; + +/** + * Swaps the input and outputs, and sends the old input back to the main thread. + * + * @param {object} switchData + * @param {number} switchData.inputNum - The inputNum of the input to be switched to + * @param {string | ArrayBuffer} switchData.outputData - The data to switch to + */ +self.inputSwitch = function(switchData) { + const currentInput = self.getInputObj(switchData.inputNum); + const currentData = currentInput.data; + if (currentInput === undefined || currentInput === null) return; + + if (typeof switchData.outputData === "object") { + const output = new Uint8Array(switchData.outputData), + types = detectFileType(output); + let type = "unknown", + ext = "dat"; + if (types.length) { + type = types[0].mime; + ext = types[0].extension.split(",", 1)[0]; + } + + // ArrayBuffer + currentInput.data = { + fileBuffer: switchData.outputData, + name: `output.${ext}`, + size: switchData.outputData.byteLength.toLocaleString(), + type: type + }; + } else { + // String + currentInput.data = switchData.outputData; + } + + self.postMessage({ + action: "inputSwitch", + data: { + data: currentData, + inputNum: switchData.inputNum + } + }); + + self.setInput({inputNum: switchData.inputNum, silent: false}); + +}; diff --git a/src/web/LoaderWorker.js b/src/web/workers/LoaderWorker.js similarity index 51% rename from src/web/LoaderWorker.js rename to src/web/workers/LoaderWorker.js index 076e0e7d..ce1fbe62 100755 --- a/src/web/LoaderWorker.js +++ b/src/web/workers/LoaderWorker.js @@ -6,14 +6,32 @@ * @license Apache-2.0 */ +self.id = null; + + +self.handleMessage = function(e) { + const r = e.data; + log.debug(`LoaderWorker receiving command '${r.action}'`); + + switch (r.action) { + case "loadInput": + self.loadFile(r.data.file, r.data.inputNum); + break; + } +}; + /** * Respond to message from parent thread. */ self.addEventListener("message", function(e) { const r = e.data; - if (r.hasOwnProperty("file")) { - self.loadFile(r.file); + if (r.hasOwnProperty("file") && (r.hasOwnProperty("inputNum"))) { + self.loadFile(r.file, r.inputNum); + } else if (r.hasOwnProperty("file")) { + self.loadFile(r.file, ""); + } else if (r.hasOwnProperty("id")) { + self.id = r.id; } }); @@ -22,20 +40,24 @@ self.addEventListener("message", function(e) { * Loads a file object into an ArrayBuffer, then transfers it back to the parent thread. * * @param {File} file + * @param {string} inputNum */ -self.loadFile = function(file) { +self.loadFile = function(file, inputNum) { const reader = new FileReader(); + if (file.size >= 256*256*256*128) { + self.postMessage({"error": "File size too large.", "inputNum": inputNum, "id": self.id}); + return; + } const data = new Uint8Array(file.size); let offset = 0; const CHUNK_SIZE = 10485760; // 10MiB const seek = function() { if (offset >= file.size) { - self.postMessage({"progress": 100}); - self.postMessage({"fileBuffer": data.buffer}, [data.buffer]); + self.postMessage({"fileBuffer": data.buffer, "inputNum": inputNum, "id": self.id}, [data.buffer]); return; } - self.postMessage({"progress": Math.round(offset / file.size * 100)}); + self.postMessage({"progress": Math.round(offset / file.size * 100), "inputNum": inputNum}); const slice = file.slice(offset, offset + CHUNK_SIZE); reader.readAsArrayBuffer(slice); }; @@ -47,7 +69,7 @@ self.loadFile = function(file) { }; reader.onerror = function(e) { - self.postMessage({"error": reader.error.message}); + self.postMessage({"error": reader.error.message, "inputNum": inputNum, "id": self.id}); }; seek(); diff --git a/src/web/workers/ZipWorker.mjs b/src/web/workers/ZipWorker.mjs new file mode 100644 index 00000000..58e6db19 --- /dev/null +++ b/src/web/workers/ZipWorker.mjs @@ -0,0 +1,73 @@ +/** + * Web Worker to handle zipping the outputs for download. + * + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import zip from "zlibjs/bin/zip.min"; +import Utils from "../../core/Utils"; +import Dish from "../../core/Dish"; +import {detectFileType} from "../../core/lib/FileType"; + +const Zlib = zip.Zlib; + +/** + * Respond to message from parent thread. + */ +self.addEventListener("message", function(e) { + const r = e.data; + if (!r.hasOwnProperty("outputs")) { + log.error("No files were passed to the ZipWorker."); + return; + } + if (!r.hasOwnProperty("filename")) { + log.error("No filename was passed to the ZipWorker"); + return; + } + + self.zipFiles(r.outputs, r.filename, r.fileExtension); +}); + +self.setOption = function(...args) {}; + +/** + * Compress the files into a zip file and send the zip back + * to the OutputWaiter. + * + * @param {object} outputs + * @param {string} filename + * @param {string} fileExtension + */ +self.zipFiles = async function(outputs, filename, fileExtension) { + const zip = new Zlib.Zip(); + const inputNums = Object.keys(outputs); + + for (let i = 0; i < inputNums.length; i++) { + const iNum = inputNums[i]; + let ext = fileExtension; + + const cloned = new Dish(outputs[iNum].data.dish); + const output = new Uint8Array(await cloned.get(Dish.ARRAY_BUFFER)); + + if (fileExtension === undefined || fileExtension === "") { + // Detect automatically + const types = detectFileType(output); + if (!types.length) { + ext = ".dat"; + } else { + ext = `.${types[0].extension.split(",", 1)[0]}`; + } + } + const name = Utils.strToByteArray(iNum + ext); + + zip.addFile(output, {filename: name}); + } + + const zippedFile = zip.compress(); + self.postMessage({ + zippedFile: zippedFile.buffer, + filename: filename + }, [zippedFile.buffer]); +}; diff --git a/tests/browser/nightwatch.js b/tests/browser/nightwatch.js index 23100d8d..f966791b 100644 --- a/tests/browser/nightwatch.js +++ b/tests/browser/nightwatch.js @@ -82,6 +82,7 @@ module.exports = { browser .useCss() .setValue("#input-text", "Don't Panic.") + .pause(1000) .click("#bake"); // Check output