diff --git a/package.json b/package.json index 16ab97e..fdba547 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "build": "webpack --mode production && rm -rf dist/*.LICENSE.*", - "dev": "webpack watch --mode development" + "dev": "webpack watch --mode production" }, "keywords": [ "xterm.js", diff --git a/src/Command.js b/src/Command.js new file mode 100644 index 0000000..795636b --- /dev/null +++ b/src/Command.js @@ -0,0 +1,17 @@ +export default class Command { + static PIPE = Symbol("PIPE") + static STDOUT = Symbol("STDOUT") + static STDERR = Symbol("STDERR") + + name + argv + + constructor(name, argv, io = null) { + this.name = name + this.argv = argv + + this.stdout = io?.stdout ?? Command.STDOUT + this.stderr = io?.stderr ?? Command.STDERR + this.append = io?.append ?? false + } +} diff --git a/src/Errors.js b/src/Errors.js new file mode 100644 index 0000000..1803d7d --- /dev/null +++ b/src/Errors.js @@ -0,0 +1,38 @@ +export class CommandNotFoundError extends Error { + constructor(...params) { + super(...params) + + // Maintains proper stack trace for where our error was thrown (non-standard) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CommandNotFoundError) + } + + this.name = "CommandNotFoundError" + } +} + +export class KeyboardInterruptError extends Error { + constructor(...params) { + super(...params) + + // Maintains proper stack trace for where our error was thrown (non-standard) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, KeyboardInterruptError) + } + + this.name = "KeyboardInterruptError" + } +} + +export class CommandParserError extends Error { + constructor(...params) { + super(...params) + + // Maintains proper stack trace for where our error was thrown (non-standard) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CommandParserError) + } + + this.name = "CommandParserError" + } +} diff --git a/src/WasmWebTerm.js b/src/WasmWebTerm.js index 141344c..ef3d893 100644 --- a/src/WasmWebTerm.js +++ b/src/WasmWebTerm.js @@ -1,12 +1,9 @@ import { proxy, wrap } from "comlink" import { FitAddon } from "xterm-addon-fit" -import XtermEchoAddon from "local-echo" -import parse from "shell-quote/parse" import { inflate } from "pako" // fallback for DecompressionStream API (free as its used in WapmFetchUtil) import LineBuffer from "./LineBuffer" -import History from "./History" import WasmWorkerRAW from "./runners/WasmWorker" // will be prebuilt using webpack import { default as PromptsFallback, @@ -14,6 +11,10 @@ import { } from "./runners/WasmRunner" import WapmFetchUtil from "./WapmFetchUtil" +import { CommandNotFoundError, KeyboardInterruptError } from "./Errors" +import Command from "./Command" +import WasmShell from "./shell/WasmShell" + class WasmWebTerm { isRunningCommand @@ -25,8 +26,8 @@ class WasmWebTerm { onCommandRunFinish _xterm - _xtermEcho - _xtermPrompt + _shell + _prompt _worker _wasmRunner // prompts fallback @@ -37,6 +38,8 @@ class WasmWebTerm { _stdoutBuffer _stderrBuffer + _stdoutClosed + _stderrClosed _outputBuffer _lastOutputTime @@ -54,6 +57,9 @@ class WasmWebTerm { this._outputBuffer = "" // buffers outputs to determine if it ended with line break this._lastOutputTime = 0 // can be used for guessing if output is complete on stdin calls + this._stdoutClosed = false + this._stderrClosed = false + this.onActivated = () => {} // can be overwritten to know when activation is complete this.onDisposed = () => {} // can be overwritten to know when disposition is complete @@ -66,14 +72,15 @@ class WasmWebTerm { ![typeof Worker, typeof SharedArrayBuffer, typeof Atomics].includes( "undefined" ) - ) + ) { // if yes, initialize worker this._initWorker() - // if no support -> use prompts as fallback - else this._wasmRunner = new PromptsFallback() + } else { + // if no support -> use prompts as fallback + this._wasmRunner = new PromptsFallback() + } this._suppressOutputs = false - window.term = this // todo: debug } /* xterm.js addon life cycle */ @@ -83,29 +90,22 @@ class WasmWebTerm { // create xterm addon to fit size this._xtermFitAddon = new FitAddon() - this._xtermFitAddon.activate(this._xterm) + this._xtermFitAddon.activate(xterm) // fit xterm size to container setTimeout(() => this._xtermFitAddon.fit(), 1) // handle container resize - window.addEventListener("resize", () => { - this._xtermFitAddon.fit() - }) + window.addEventListener("resize", () => this._xtermFitAddon.fit()) // handle module drag and drop setTimeout(() => this._initWasmModuleDragAndDrop(), 1) - // set xterm prompt - this._xtermPrompt = async () => "$ " - // async to be able to fetch sth here - - // create xterm local echo addon - this._xtermEcho = new XtermEchoAddon(null, { historySize: 1000 }) - this._xtermEcho.activate(this._xterm) - - // patch history controller - this._xtermEcho.history = new History(this._xtermEcho.history.size || 10) + // initialize shell interface + this._shell = new WasmShell(this.runCommands.bind(this)) + if (typeof this._prompt === "function") this._shell.prompt = this._prompt + else if (this._prompt != null) this._shell.prompt = () => this._prompt + this._shell.activate(xterm) // register available js commands this.registerJsCommand("help", async function* (argv) { @@ -141,7 +141,7 @@ class WasmWebTerm { // callback for when welcome message was printed () => { // start REPL - this.repl() + this._shell.repl() // focus terminal cursor setTimeout(() => this._xterm.focus(), 1) @@ -150,9 +150,9 @@ class WasmWebTerm { } async dispose() { - await this._xtermEcho.dispose() await this._xtermFitAddon.dispose() if (this._worker) this._terminateWorker() + await this._shell.dispose() await this.onDisposed() } @@ -171,189 +171,129 @@ class WasmWebTerm { return this._jsCommands } - /* read eval print loop */ - - async repl() { - try { - // read - const prompt = await this._xtermPrompt() - const line = await this._xtermEcho.read(prompt) - - // empty input -> prompt again - if (line.trim() == "") return this.repl() - - // give user possibility to exec sth before run - await this.onBeforeCommandRun() - - // print newline before - this._xterm.write("\r\n") - - // eval and print - await this.runLine(line) - - // print extra newline if output does not end with one - if (this._outputBuffer.slice(-1) != "\n") this._xterm.write("\u23CE\r\n") - - // print newline after - this._xterm.write("\r\n") - - // give user possibility to run sth after exec - await this.onCommandRunFinish() - - // loop - this.repl() - } catch (e) { - /* console.error("Error during REPL:", e) */ - } + async runLine(input) { + return await this._shell.injectCommand(input) } - /* parse line as commands and handle them */ - _parseCommands(line) { - let usesEnvironmentVars = false - let usesBashFeatures = false - - // parse line into tokens (respect escaped spaces and quotation marks) - const commandLine = parse(line, (_key) => { - usesEnvironmentVars = true - return undefined - }) - - const commands = [] - let cmd = [] - - splitter: { - for (let idx = 0; idx < commandLine.length; ++idx) { - const item = commandLine[idx] - - if (typeof item === "string") { - if (cmd.length === 0 && item.match(/^\w+=.*$/)) { - usesEnvironmentVars = true - continue - } else { - cmd.push(item) - } - } else { - switch (item.op) { - case "|": - commands.push(cmd) - cmd = [] - break - default: - usesBashFeatures = true - console.error("Unsupported shell operator:", item.op) - break splitter - } - } - } - } - commands.push(cmd) - - if (usesEnvironmentVars) { - this._stderr( - "\x1b[1m[\x1b[33mWARN\x1b[39m]\x1b[0m Environment variables are not supported!\n" - ) - } - if (usesBashFeatures) { - this._stderr( - "\x1b[1m[\x1b[33mWARN\x1b[39m]\x1b[0m Advanced bash features are not supported! Only the pipe '|' works for now.\n" - ) - } - - return commands - } + /* execute list of commands */ + async runCommands(commands) { + // give user possibility to run sth before exec + await this.onBeforeCommandRun() - async runLine(line) { try { let stdinPreset = null this._suppressOutputs = false - const commandsInLine = this._parseCommands(line) - for (const [index, argv] of commandsInLine.entries()) { + for (const [index, command] of commands.entries()) { + const isLast = index === commands.length - 1 + // split into command name and argv - const commandName = argv.shift() - const command = this._jsCommands.get(commandName) + const { name: commandName, argv, stdout, stderr } = command // try user registered js commands first - if (typeof command?.callback == "function") { - // todo: move this to a method like "runJsCommand"? - - // call registered user function - const result = command.callback(argv, stdinPreset) - let output // where user function outputs are stored - - /** - * user functions are another word for custom js - * commands and can pass outputs in various ways: - * - * 1) return value normally via "return" - * 2) pass value through promise resolve() / async - * 3) yield values via generator functions - */ - - // await promises if any (2) - if (result.then) output = ((await result) || "").toString() - // await yielding generator functions (3) - else if (result.next) - for await (let data of result) - output = output == null ? data : output + data - // default: when functions return "normally" (1) - else output = result.toString() + try { + const output = await this.runJsCommand(commandName, argv, stdinPreset) // if is last command in pipe -> print output to xterm - if (index == commandsInLine.length - 1) this._stdout(output) + if (isLast) this._stdout(output || "") else stdinPreset = output || null // else -> use output as stdinPreset + } catch (e) { + if (!(e instanceof CommandNotFoundError)) throw e - // todo: make it possible for user functions to use stdERR. - // exceptions? they end function execution.. - } - - // otherwise try wasm commands - else if (command == undefined) { - // if is not last command in pipe - if (index < commandsInLine.length - 1) { + // otherwise try wasm commands + if (stdout === Command.PIPE || stderr === Command.PIPE) { + // capture command output const output = await this.runWasmCommandHeadless( commandName, argv, stdinPreset ) - stdinPreset = output.stdout // apply last stdout to next stdin - } - - // if is last command -> run normally and reset stdinPreset - else { + if (stdout === Command.PIPE && stderr === Command.PIPE) + stdinPreset = output.output + else if (stdout === Command.PIPE) stdinPreset = output.stdout + else if (stderr === Command.PIPE) stdinPreset = output.stderr + } else { + // don't capture output await this.runWasmCommand(commandName, argv, stdinPreset) - stdinPreset = null } } + } + } finally { + // print extra newline if output does not end with one + if (this._outputBuffer.slice(-1) !== "\n") this._xterm.write("\u23CE\r\n") + + // give user possibility to run sth after exec + await this.onCommandRunFinish() + } + } - // command is defined but has no function -> can not handle - else - console.error("command is defined but has no function:", commandName) + /* running single js commands */ + + async runJsCommand(programName, argv, stdinPreset) { + if (this.isRunningCommand) throw "WasmWebTerm is already running a command" + else this.isRunningCommand = true + + // enable outputs if they were suppressed + this._suppressOutputs = false + this._outputBuffer = "" + + try { + const command = this._jsCommands.get(programName) + if (command == null) { + throw new CommandNotFoundError() + } + if (typeof command?.callback !== "function") { + throw new Error( + `Command '${programName}' is defined but has no function` + ) } - } catch (e) { - // catch errors (print to terminal and developer console) - if (this._outputBuffer.slice(-1) != "\n") this._stderr("\n") - this._stderr(`\x1b[1m[\x1b[31mERROR\x1b[39m]\x1b[0m ${e.toString()}\n`) - console.error("Error running line:", e) + + // call registered user function + const result = command.callback(argv, stdinPreset) + let output // where user function outputs are stored + + /** + * user functions are another word for custom js + * commands and can pass outputs in various ways: + * + * 1) return value normally via "return" + * 2) pass value through promise resolve() / async + * 3) yield values via generator functions + */ + + // await promises if any (2) + if (result.then) output = ((await result) || "").toString() + // await yielding generator functions (3) + else if (result.next) + for await (let data of result) + output = output == null ? data : output + data + // default: when functions return "normally" (1) + else output = result.toString() + + // todo: make it possible for user functions to use stdERR. + // exceptions? they end function execution.. + + return output + } finally { + // enable commands to run again + this.isRunningCommand = false } } /* running single wasm commands */ runWasmCommand(programName, argv, stdinPreset, onFinishCallback) { - console.log("called runWasmCommand:", programName, argv) - if (this.isRunningCommand) throw "WasmWebTerm is already running a command" else this.isRunningCommand = true // enable outputs if they were suppressed this._suppressOutputs = false this._outputBuffer = "" + this._stdoutClosed = false + this._stderrClosed = false // define callback for when command has finished const onFinish = proxy(async (files) => { - console.log("command finished:", programName, argv) - // enable commands to run again this.isRunningCommand = false @@ -362,7 +302,7 @@ class WasmWebTerm { await this.onFileSystemUpdate(this._wasmFsFiles) // wait until outputs are rendered - await this._waitForOutputPause() + await this._waitForOutputClose() // flush out any pending outputs this._stdoutBuffer.flush() @@ -371,7 +311,7 @@ class WasmWebTerm { // wait until the rest is rendered this._waitForOutputPause().then(() => { // notify caller that command run is over - if (typeof onFinishCallback == "function") onFinishCallback() + if (typeof onFinishCallback === "function") onFinishCallback() // resolve await from shell this._runWasmCommandPromise?.resolve() @@ -382,50 +322,34 @@ class WasmWebTerm { const onError = proxy((value) => this._stderr(value + "\n")) // get or initialize wasm module - this._stdout("loading web assembly ...") + this._xterm.write( + "\x1b[1m[\x1b[32mWasmWebTerm\x1b[39m]\x1b[0m Loading web assembly ..." + ) this._getOrFetchWasmModule(programName) .then((wasmModule) => { // clear last line this._xterm.write("\x1b[2K\r") - // check if we can run on worker - if (this._worker) - // delegate command execution to worker thread - this._worker.runCommand( - programName, - wasmModule.module, - wasmModule.type, - argv, - this._stdinProxy, - this._stdoutProxy, - this._stderrProxy, - this._wasmFsFiles, - onFinish, - onError, - null, - stdinPreset, - wasmModule.runtime, - wasmModule.linkedName - ) - // if not -> fallback with prompts + // check if we can run on worker, else use fallback with prompts + const runner = this._worker || this._wasmRunner + // delegate command execution to worker thread or // start execution on the MAIN thread (freezes terminal) - else - this._wasmRunner.runCommand( - programName, - wasmModule.module, - wasmModule.type, - argv, - null, - this._stdoutProxy, - this._stderrProxy, - this._wasmFsFiles, - onFinish, - onError, - null, - stdinPreset, - wasmModule.runtime, - wasmModule.linkedName - ) + runner.runCommand( + programName, + wasmModule.module, + wasmModule.type, + argv, + this._worker ? this._stdinProxy : null, + this._stdoutProxy, + this._stderrProxy, + this._wasmFsFiles, + onFinish, + onError, + null, + stdinPreset, + wasmModule.runtime, + wasmModule.linkedName + ) }) // catch errors (command not running anymore + reject (returns to shell)) @@ -457,7 +381,7 @@ class WasmWebTerm { this._stderrBuffer.flush() // call on finish callback - if (typeof onFinishCallback == "function") onFinishCallback(outBuffers) + if (typeof onFinishCallback === "function") onFinishCallback(outBuffers) // resolve promise runWasmCommandHeadlessPromise.resolve(outBuffers) @@ -472,39 +396,23 @@ class WasmWebTerm { // get or initialize wasm module this._getOrFetchWasmModule(programName) .then((wasmModule) => { - if (this._worker) - // check if we can run on worker - - // delegate command execution to worker thread - this._worker.runCommandHeadless( - programName, - wasmModule.module, - wasmModule.type, - argv, - this._wasmFsFiles, - onFinish, - onError, - onSuccess, - stdinPreset, - wasmModule.runtime, - wasmModule.linkedName - ) - // if not -> use fallback + // check if we can run on worker, else use fallback with prompts + const runner = this._worker || this._wasmRunner + // delegate command execution to worker thread or // start execution on the MAIN thread (freezes terminal) - else - this._wasmRunner.runCommandHeadless( - programName, - wasmModule.module, - wasmModule.type, - argv, - this._wasmFsFiles, - onFinish, - onError, - onSuccess, - stdinPreset, - wasmModule.runtime, - wasmModule.linkedName - ) + runner.runCommandHeadless( + programName, + wasmModule.module, + wasmModule.type, + argv, + this._wasmFsFiles, + onFinish, + onError, + onSuccess, + stdinPreset, + wasmModule.runtime, + wasmModule.linkedName + ) }) // catch errors (command not running anymore + reject promise) @@ -521,15 +429,60 @@ class WasmWebTerm { /* wasm module handling */ + async _fetchWasmModule(programName) { + // create wasm module object + const wasmModule = { + name: programName, + type: "emscripten", + module: undefined, + } + + // fetch wasm binary + const wasmPath = this.wasmBinaryPath + "/" + programName + ".wasm" + const response = await fetch(wasmPath) + const wasmBinary = await response.arrayBuffer() + + // validate if response contains a wasm binary + if (response?.ok && WebAssembly.validate(wasmBinary)) { + // try to fetch emscripten js runtime + const jsRuntimeResponse = await fetch( + this.wasmBinaryPath + "/" + programName + ".js" + ) + if (jsRuntimeResponse?.ok) { + // read js runtime from response + const jsRuntimeCode = await jsRuntimeResponse.arrayBuffer() + + // check if the first char of the response is not "<" + // (because dumb parcel does not return http errors but an html page) + const firstChar = String.fromCharCode( + new Uint8Array(jsRuntimeCode, 0, 1)[0] + ) + if (firstChar !== "<") + // set this module's runtime + wasmModule.runtime = jsRuntimeCode + } + + // if no valid js runtime was found -> it's considered a wasmer binary + if (!wasmModule.runtime) wasmModule.type = "wasmer" + + // compile fetched bytes into wasm module + wasmModule.module = await WebAssembly.compile(wasmBinary) + + return wasmModule + } + + // not a valid wasm binary + throw new Error(`Cannot load wasm binary at ${wasmPath}`) + } + _getOrFetchWasmModule(programName) { return new Promise(async (resolve, reject) => { let wasmModule, - wasmBinary, localBinaryFound = false // check if there is an initialized module already this._wasmModules.forEach((moduleObj) => { - if (moduleObj.name == programName) wasmModule = moduleObj + if (moduleObj.name === programName) wasmModule = moduleObj }) // if a module was found -> resolve @@ -538,116 +491,57 @@ class WasmWebTerm { try { // if none is found -> initialize a new one - // create wasm module object (to resolve and to store) - wasmModule = { - name: programName, - type: "emscripten", - module: undefined, - } - // try to find local wasm binary // (only if wasmBinaryPath is provided, otherwise use wapm directly) - if (this.wasmBinaryPath != undefined) { + if (this.wasmBinaryPath != null) { // try to fetch local wasm binaries first - const localBinaryResponse = await fetch( - this.wasmBinaryPath + "/" + programName + ".wasm" - ) - wasmBinary = await localBinaryResponse.arrayBuffer() - - // validate if localBinaryResponse contains a wasm binary - if (localBinaryResponse?.ok && WebAssembly.validate(wasmBinary)) { - // try to fetch emscripten js runtime - const jsRuntimeResponse = await fetch( - this.wasmBinaryPath + "/" + programName + ".js" - ) - if (jsRuntimeResponse?.ok) { - // read js runtime from response - const jsRuntimeCode = await jsRuntimeResponse.arrayBuffer() - - // check if the first char of the response is not "<" - // (because dumb parcel does not return http errors but an html page) - const firstChar = String.fromCharCode( - new Uint8Array(jsRuntimeCode).subarray(0, 1).toString() - ) - if (firstChar != "<") - // set this module's runtime - wasmModule.runtime = jsRuntimeCode - } - - // if no valid js runtime was found -> it's considered a wasmer binary - if (!wasmModule.runtime) wasmModule.type = "wasmer" + try { + wasmModule = await this._fetchWasmModule(programName) // local binary was found -> do not fetch wapm localBinaryFound = true - } - - // if none was found or it was invalid -> try for a .lnk file - else { + } catch { + // if none was found or it was invalid -> try for a .lnk file // explanation: .lnk files can contain a different module/runtime name. // this enables `echo` and `ls` to both use `coreutils.wasm`, for example. // try to fetch .lnk file - const linkResponse = await fetch( - this.wasmBinaryPath + "/" + programName + ".lnk" - ) - if (linkResponse?.ok) { - // read new program name from .lnk file - const linkedProgramName = (await linkResponse.text()).trim() - const linkDestination = - this.wasmBinaryPath + "/" + linkedProgramName + ".wasm" - - // try to fetch the new binary - const linkedBinaryResponse = await fetch(linkDestination) - if (linkedBinaryResponse?.ok) { - // read binary from response - wasmBinary = await linkedBinaryResponse.arrayBuffer() - - // validate if linkedBinaryResponse contains a wasm binary - if (WebAssembly.validate(wasmBinary)) { - // try to fetch emscripten js runtime - const jsRuntimeResponse = await fetch( - this.wasmBinaryPath + "/" + linkedProgramName + ".js" - ) - if (jsRuntimeResponse?.ok) { - // todo: note that this code is redundant, maybe use a function? - - // read js runtime from response - const jsRuntimeCode = - await jsRuntimeResponse.arrayBuffer() - - // check if the first char of the response is not "<" - // (because dumb parcel does not return http errors but an html page) - const firstChar = String.fromCharCode( - new Uint8Array(jsRuntimeCode).subarray(0, 1).toString() - ) - if (firstChar != "<") { - // set this module's runtime - wasmModule.runtime = jsRuntimeCode - // save the linked name to find the runtime - wasmModule.linkedName = linkedProgramName - } - } - - // if no valid js runtime was found -> it's considered a wasmer binary - if (!wasmModule.runtime) wasmModule.type = "wasmer" - - // local binary was found -> do not fetch wapm - localBinaryFound = true + try { + const linkPath = + this.wasmBinaryPath + "/" + programName + ".lnk" + const linkResponse = await fetch(linkPath) + if (linkResponse?.ok) { + // read new program name from .lnk file + const linkedProgramName = (await linkResponse.text()).trim() + // fetch the the linked module or use already initialized one + const linkedModule = + await this._getOrFetchWasmModule(linkedProgramName) + // save the linked name to find the runtime + wasmModule = { + ...linkedModule, + name: programName, + linkedName: linkedProgramName, } + + // local binary was found -> do not fetch wapm + localBinaryFound = true } - } + } catch {} } } // if no local binary was found -> fetch from wapm.io if (!localBinaryFound) { - wasmBinary = + const wasmBinary = await WapmFetchUtil.getWasmBinaryFromCommand(programName) - wasmModule.type = "wasmer" - } - // compile fetched bytes into wasm module - wasmModule.module = await WebAssembly.compile(wasmBinary) + // create wasm module object (to resolve and to store) + wasmModule = { + name: programName, + type: "wasmer", + module: await WebAssembly.compile(wasmBinary), + } + } // store compiled module this._wasmModules.push(wasmModule) @@ -684,15 +578,16 @@ class WasmWebTerm { e.preventDefault() let files = [] - if (e.dataTransfer.items) + if (e.dataTransfer.items) { // read files from .items for (let i = 0; i < e.dataTransfer.items.length; i++) - if (e.dataTransfer.items[i].kind == "file") + if (e.dataTransfer.items[i].kind === "file") files.push(e.dataTransfer.items[i].getAsFile()) - // read files from .files (other browsers) - else - for (let i = 0; i < e.dataTransfer.files.length; i++) - files.push(e.dataTransfer.files[i]) + } else { + // read files from .files (other browsers) + for (let i = 0; i < e.dataTransfer.files.length; i++) + files.push(e.dataTransfer.files[i]) + } // parse dropped files into modules for (let i = 0; i < files.length; i++) { @@ -704,14 +599,14 @@ class WasmWebTerm { // remove existing modules with that name this._wasmModules = this._wasmModules.filter( - (mod) => mod.name != programName + (mod) => mod.name !== programName ) // if has .js file -> it's an emscripten binary - if (files.some((f) => f.name == programName + ".js")) { + if (files.some((f) => f.name === programName + ".js")) { // load emscripten js runtime and compile emscripten wasm binary const emscrJsRuntime = files.find( - (f) => f.name == programName + ".js" + (f) => f.name === programName + ".js" ) const emscrWasmModule = await WebAssembly.compile( await file.arrayBuffer() @@ -785,7 +680,7 @@ class WasmWebTerm { let dependencies = [WasmRunnerID] // start with the `WasmRunner` module for ( let dep = dependencies.shift(); - dep != undefined; + dep != null; dep = dependencies.shift() ) { // put the module into the list of modules to forward @@ -864,6 +759,25 @@ class WasmWebTerm { }) } + _waitForOutputClose(interval = 20, timeout = 1000) { + // note: additional timeout to wait for the last call to the output buffers + // which should be blocking until everything has been output. + const start = Date.now() + return new Promise((resolve) => { + const wait = () => { + setTimeout(() => { + // if both buffers are closed -> resolve + if (this._stdoutClosed && this._stderrClosed) resolve() + // if wasn't closed in timeout -> resolve anyway + else if (start + timeout < Date.now()) resolve() + // -> wait until closed or timeout + else wait() + }, interval) + } + wait() + }) + } + /* input output handling -> web worker */ _setStdinBuffer(string) { @@ -873,53 +787,12 @@ class WasmWebTerm { _stdinProxy = proxy((message) => { this._waitForOutputPause().then(async () => { - console.log("called _stdinProxy", message) - // flush outputs (to show the prompt) this._stdoutBuffer.flush() this._stderrBuffer.flush() - // read input until RETURN (LF), CTRL+D (EOF), or CTRL+C - const input = await new Promise((resolve, reject) => { - let buffer = "" - const handler = this._xterm.onData((data) => { - // CTRL + C -> return without data - if (data == "\x03") { - this._xterm.write("^C") - handler.dispose() - return resolve("") - } - // CTRL + D -> return input buffer - else if (data == "\x04") { - handler.dispose() - return resolve(buffer) - } - - // map return to '\n' - if (data == "\r") data = "\n" - // map backspace to CTRL+H - else if (data == "\x7f") data = "\x08" - - // add character or delete last one - if (data == "\x08") buffer = buffer.slice(0, -1) - else buffer += data - - // line complete -> return the input buffer - if (data == "\n") { - // only echo the linebreak when there is no prompt (i.e. we assume multi-line input) - if (!message) this._xterm.write("\r\n") - - handler.dispose() - return resolve(buffer) - } - - // echo input back (special handling for backspace and escape sequences) - if (data == "\x08") this._xterm.write("^H") - else if (data.charCodeAt(0) == 0x1b) - this._xterm.write("^[" + data.slice(1)) - else this._xterm.write(data) - }) - }) + // read input line + const input = await this._shell.readLine(message) // pass value to webworker this._setStdinBuffer(input) @@ -927,12 +800,14 @@ class WasmWebTerm { }) }) - _stdoutProxy = proxy((value) => { + _stdoutProxy = proxy((value, close = false) => { this._lastOutputTime = Date.now() // keep track of time + if (close) this._stdoutClosed = true this._stdoutBuffer.write(value) }) - _stderrProxy = proxy((value) => { + _stderrProxy = proxy((value, close = false) => { this._lastOutputTime = Date.now() // keep track of time + if (close) this._stderrClosed = true this._stderrBuffer.write(value) }) @@ -942,12 +817,10 @@ class WasmWebTerm { _stderrBuffer = new LineBuffer(this._stderr.bind(this)) _stdout(value) { - // string or char code - if (this._suppressOutputs) return // used for Ctrl+C // numbers are interpreted as char codes -> convert to string - if (typeof value == "number") value = String.fromCharCode(value) + if (typeof value === "number") value = String.fromCharCode(value) // avoid offsets with line breaks value = value.replace(/\n/g, "\r\n") @@ -1009,14 +882,16 @@ class WasmWebTerm { ) } + // custom handler for Ctrl+C (webworker only) _onXtermData(data) { - if (data == "\x03") { - // custom handler for Ctrl+C (webworker only) + if (data === "\x03") { if (this._worker) { this._suppressOutputs = true + this._stdoutClosed = true + this._stderrClosed = true this._terminateWorker() this._initWorker() // reinit - this._runWasmCommandPromise?.reject("Ctrl + C") + this._runWasmCommandPromise?.reject(new KeyboardInterruptError()) this.isRunningCommand = false } } diff --git a/src/WasmWebTerm.md b/src/WasmWebTerm.md index 9d77e20..7d13439 100644 --- a/src/WasmWebTerm.md +++ b/src/WasmWebTerm.md @@ -18,7 +18,7 @@ let term = new Terminal() let wasmterm = new WasmWebTerm() wasmterm.printWelcomeMessage = () => "Hello world shell \r\n" -wasmterm._xtermPrompt = () => "custom> " +wasmterm._prompt = () => "custom> " term.loadAddon(wasmterm) term.open(document.getElementById("terminal")) @@ -38,9 +38,6 @@ wasmterm.runWasmCommand("cowsay", ["hi"]) ### Public Methods -* #### async `repl()` - Starts a [Read Eval Print Loop](https://en.wikipedia.org/wiki/Read–eval–print_loop). It reads a line from the terminal, calls `onBeforeCommandRun()`, calls `runLine(line)` (which evaluates the line and runs the contained command), calls `onCommandRunFinish()`, and then recursively calls itself again (loop). - * #### async `runLine(line)` Gets a string (line), splits it into single commands (separated by `|`), and iterates over them. It then checks, if there is a JS function defined in [`_jsCommands`](#_jscommands) with the given command name. If there is, it'll execute it. See [defining custom JS commands](#defining-custom-js-commands) for more details. Otherwise, it will interpret the command name as the name of a WebAssembly binary and delegate to `runWasmCommand(..)` and `runWasmCommandHeadless(..)`. @@ -95,12 +92,8 @@ The following methods are called on specific events. You can overwrite them to c * #### `_xterm` The local instance of xterm.js `Terminal` which the addon is attached to. -* #### `_xtermEcho` - The local instance of `local-echo`, which provides the possibility to read from the Terminal. - > It also makes sense to look at the underlying [local-echo](https://github.com/wavesoft/local-echo). For example, its API offers the possibility to `.abortRead(reason)`, which exits the REPL. - -* #### `_xtermPrompt` - An async function that returns what is shown as prompt in the Terminal. Default is `$`. +* #### `_prompt` + An async function that returns what is shown as prompt in the Terminal. Default is `$ `. * #### `_jsCommands` ES6 [`Map()`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map) containing JS commands in the form of `["command" => function(argv, stdin)]` (simplified). There is a getter called [`jsCommands`](#jscommands), so you don't need the underscore. This can be mutated by using [`registerJsCommand(..)`](#registerjscommandname-callback-autocomplete) and [`unregisterJsCommand(..)`]((#unregisterjscommand)). @@ -169,7 +162,7 @@ The following methods are called on specific events. You can overwrite them to c Sets the value of `_stdinBuffer` to a given string, which can then be read from the Worker. * #### `_stdinProxy(message)` - Comlink Proxy which will be passed to the Worker thread. It will be called when the wasm binary reads from `/dev/stdin` or `/dev/tty`. It then reads a line from the xterm.js Terminal by using `local-echo`, sets the `_stdinBuffer` accordingly, and resumes the Worker. + Comlink Proxy which will be passed to the Worker thread. It will be called when the wasm binary reads from `/dev/stdin` or `/dev/tty`. It then reads a line from the xterm.js Terminal, sets the `_stdinBuffer` accordingly, and resumes the Worker. * #### `_stdoutProxy(value)` and `_stderrProxy(value)` Comlink proxies that map to `_stdout(value)` and `_stderr(value)`. They're proxies so that we can pass them to the Worker. But they can also be called directly, so we can also pass them to the `WasmRunner` Prompts fallback. diff --git a/src/runnables/EmscriptenRunnable.js b/src/runnables/EmscriptenRunnable.js index 3ec3b02..38e3785 100644 --- a/src/runnables/EmscriptenRunnable.js +++ b/src/runnables/EmscriptenRunnable.js @@ -65,10 +65,7 @@ class EmscrWasmRunnable { stdout(val) }, flush: (tty) => (tty.output = []), - fsync: (tty) => - console.log( - "fsynced stdout (EmscriptenRunnable does nothing in this case)" - ), + fsync: (_tty) => /* NOOP */ undefined, }) emscrModule.TTY.register(emscrModule.FS.makedev(6, 0), { get_char: (tty) => stdin(tty), @@ -77,10 +74,7 @@ class EmscrWasmRunnable { stderr(val) }, flush: (tty) => (tty.output = []), - fsync: (tty) => - console.log( - "fsynced stderr (EmscriptenRunnable does nothing in this case)" - ), + fsync: (_tty) => /* NOOP */ undefined, }) }, ], @@ -100,11 +94,11 @@ class EmscrWasmRunnable { } } - let filesPostRun // instantiate emscripten module and call main - this.#emscrJsRuntime(emscrModule) - .then((instance) => { - // emscr module instance - + // instantiate emscripten module and call main + this.#emscrJsRuntime(emscrModule).then((instance) => { + // emscr module instance + let filesPostRun + try { // write submitted files to wasm this._writeFilesToFS(instance, files) @@ -116,10 +110,14 @@ class EmscrWasmRunnable { // success callback onSuccess(filesPostRun) - }) - - .catch((error) => onError(error)) - .finally(() => onFinish(filesPostRun || files)) + } catch (e) { + onError(e.message) + } finally { + stdout("", true) + stderr("", true) + onFinish(filesPostRun || files) + } + }) } /** @@ -136,9 +134,7 @@ class EmscrWasmRunnable { if (typeof onFinish != "function") onFinish = () => {} // stdin is not needed - const stdin = () => { - console.log("called runHeadless stdin") - } + const stdin = () => console.warn("called runHeadless stdin") // output is redirected into buffer let outputBuffer = "", @@ -229,8 +225,6 @@ class EmscrWasmRunnable { let blob = new Blob([jsRuntime], { type: "application/javascript" }) importScripts(URL.createObjectURL(blob)) - console.log(jsRuntime, blob) - // read emscripten Module from js runtime this.#emscrJsRuntime = self[emscrJsModuleName] || self["_createPyodideModule"] diff --git a/src/runnables/WasmerRunnable.js b/src/runnables/WasmerRunnable.js index 8683249..acc44f7 100644 --- a/src/runnables/WasmerRunnable.js +++ b/src/runnables/WasmerRunnable.js @@ -78,6 +78,8 @@ class WasmerRunnable { } } + const decoder = new TextDecoder("utf-8") + // set /dev/stdout to stdout function wasmFs.volume.fds[1].node.write = ( stdoutBuffer, @@ -85,7 +87,7 @@ class WasmerRunnable { length, position ) => { - stdout(new TextDecoder("utf-8").decode(stdoutBuffer)) + stdout(decoder.decode(stdoutBuffer)) return stdoutBuffer.length } @@ -96,7 +98,7 @@ class WasmerRunnable { length, position ) => { - stderr(new TextDecoder("utf-8").decode(stderrBuffer)) + stderr(decoder.decode(stderrBuffer)) return stderrBuffer.length } @@ -144,6 +146,8 @@ class WasmerRunnable { } catch (e) { onError(e.message) } finally { + stdout("", true) + stderr("", true) onFinish(filesPostRun || files) } } @@ -158,7 +162,7 @@ class WasmerRunnable { // stdin is not needed const stdin = () => { - console.log("called runHeadless stdin") + console.warn("called runHeadless stdin") return 0 } diff --git a/src/runners/WasmRunner.js b/src/runners/WasmRunner.js index 6c2fb9d..79a8a7a 100644 --- a/src/runners/WasmRunner.js +++ b/src/runners/WasmRunner.js @@ -40,13 +40,13 @@ class WasmRunner { this.outputBuffer += typeof value == "number" ? String.fromCharCode(value) : value } - const stdoutHandler = (value) => { + const stdoutHandler = (value, close) => { bufferOutputs(value) - return stdoutProxy(value) + return stdoutProxy(value, close) } - const stderrHandler = (value) => { + const stderrHandler = (value, close) => { bufferOutputs(value) - return stderrProxy(value) + return stderrProxy(value, close) } if (wasmModuleType == "emscripten") { @@ -149,6 +149,7 @@ class WasmRunner { ) } else if (wasmModuleType == "wasmer") { // instantiate new wasmer runnable + console.log("wasm runner creates new wasmer runnable") let wasmerExe = new WasmerRunnable(programName, wasmModule) // run command on it diff --git a/src/History.js b/src/shell/History.js similarity index 83% rename from src/History.js rename to src/shell/History.js index e9375f2..ba56693 100644 --- a/src/History.js +++ b/src/shell/History.js @@ -20,7 +20,10 @@ export default class History { if (entry.trim() === "") return // skip duplicate entries const last = this.#entries[this.#entries.length - 1] - if (entry === last) return + if (entry === last) { + this.#cursor = this.#entries.length + return + } this.#entries.push(entry) if (this.#entries.length > this.size) { @@ -35,6 +38,12 @@ export default class History { this.#cursor = this.#entries.length } + // move cursor to first entry and return it + getFirst() { + this.#cursor = 0 + return this.#entries[this.#cursor] + } + // move cursor to the previous entry and return it getPrevious() { this.#cursor = Math.max(0, this.#cursor - 1) diff --git a/src/shell/WasmShell.js b/src/shell/WasmShell.js new file mode 100644 index 0000000..62df75c --- /dev/null +++ b/src/shell/WasmShell.js @@ -0,0 +1,433 @@ +import { KeyboardInterruptError, CommandParserError } from "../Errors" +import { parseCommands } from "./shell-utils" + +import { isIncompleteInput } from "./shell-utils" +import WasmTTY from "./WasmTTY" +import History from "./History" + +/** + * WASM Shell interface + * + * A shell is an interactive command-line interface to start other programs. + * Its purpose is to handle: + * - Interpret tty input to launch processes + * - parameter handling + * - job control, chaining + * - Handle Control Sequences (job control and line editing) + * - Output text to the tty + */ +class WasmShell { + #tty + #history + + #execute + + prompt + + #activePrompt + #isActive + #inHistory + #partialInput // input buffer before navigating history + + constructor(executeFn, options) { + if (typeof executeFn !== "function") + throw new Error("`executeFn` parameter must be a function") + this.#execute = executeFn + + // create new command history + this.#history = new History(options?.historySize ?? 1000) + + this.prompt = this._defaultPrompt.bind(this) // default prompt + + this.#isActive = false // start in disabled mode + this.#inHistory = false + } + + activate(xterm) { + this.#tty = new WasmTTY() // internal tty device + + // activate the tty device + this.#tty.activate(xterm) + + // register data input handler + xterm.onData((data) => this.handleDataInput(data)) + } + + dispose() { + this.#isActive = false + + // stop any pending prompt + if (this.#activePrompt?.reject) { + this.#activePrompt.reject("disposed") + this.#activePrompt = undefined + } + + // deactivate the tty device + this.#tty.dispose() + this.#tty = undefined + } + + /** Main read-eval-print-loop */ + async repl() { + // do nothing if already running + if (this.#activePrompt) return + + try { + // read line from tty + const prompt = await this.prompt() + + if (prompt instanceof Array) { + this.#activePrompt = this.#tty.read(prompt[0], prompt[1]) + } else { + this.#activePrompt = this.#tty.read(prompt) + } + this.#isActive = true + + const line = (await this.#activePrompt.promise).trim() + + // empty input -> prompt again + if (line === "") { + setTimeout(() => this.repl()) + return + } + + // eval + await this._evalLine(line) + + // loop again + setTimeout(() => this.repl()) + } catch (e) { + // break loop when disposed + if (e === "disposed") return + + // loop again + setTimeout(() => this.repl()) + } + } + + /** Evaluate a single line of input (drop any existing input) */ + async injectCommand(input) { + if (typeof input !== "string") + throw new TypeError("can only execute strings") + if (input.trim() === "") return + + let restartRepl = false + + // stop any pending prompt + if (this.#activePrompt?.reject) { + this.#activePrompt.reject("disposed") + this.#activePrompt = undefined + restartRepl = true + } + + // set prompt + this.#tty.input = input + this._handleReadComplete() + // evaluate input line + await this._evalLine(this.#tty.input) + + // restart repl if we stopped it + if (restartRepl) setTimeout(() => this.repl()) + } + + /** The default prompt */ + async _defaultPrompt() { + return ["$ ", "> "] + } + + /** Read line-buffered input from the terminal */ + async readLine(message) { + return new Promise((resolve) => { + // read input until RETURN (LF), CTRL+D (EOF), or CTRL+C + let buffer = "" + const handler = this.#tty.onData((data) => { + // CTRL + C -> return without data + if (data === "\x03") { + this.#tty.write("^C") + handler.dispose() + return resolve("") + } + + // CTRL + D -> return input buffer + else if (data === "\x04") { + handler.dispose() + return resolve(buffer) + } + + // map return to '\n' + if (data === "\r") data = "\n" + // map backspace to CTRL+H + else if (data === "\x7f") data = "\x08" + + // add character or delete last one + if (data === "\x08") buffer = buffer.slice(0, -1) + else buffer += data + + // line complete -> return the input buffer + if (data === "\n") { + // only echo the linebreak when there is no prompt (i.e. we assume multi-line input) + if (!message) this.#tty.write("\r\n") + + handler.dispose() + return resolve(buffer) + } + + // echo input back (special handling for backspace and escape sequences) + if (data === "\x08") this.#tty.write("^H") + else if (data.charCodeAt(0) === 0x1b) + this.#tty.write("^[" + data.slice(1)) + else this.#tty.write(data) + }) + }) + } + + /* ======== COMMAND EXECUTION ======== */ + + _handleReadComplete() { + // push to history + this.#history.push(this.#tty.input) + this.#inHistory = false + this.#partialInput = undefined + + if (this.#activePrompt?.resolve) { + // resolve the pending prompt with the current input + this.#activePrompt.resolve(this.#tty.input) + this.#activePrompt = undefined + } + + // print terminating newline + this.#tty.print("\r\n") + this.#isActive = false + } + + async _evalLine(line) { + try { + // print extra newline before + this.#tty.write("\r\n") + + try { + // eval and print + const commands = parseCommands(line, this.#tty.println.bind(this.#tty)) + await this.#execute(commands) + } finally { + // print extra newline after + this.#tty.write("\r\n") + } + } catch (e) { + // print error message and run again + console.error("Error while executing commands:", e) + if (!(e instanceof KeyboardInterruptError)) { + this.#tty.println( + `\x1b[1m[\x1b[31mERROR\x1b[39m]\x1b[0m ${e.toString()}\n` + ) + } + } + } + + /* ======== INPUT HANDLING ======== */ + + /** Move cursor in specified direction */ + _handleCursorMove(direction) { + if (direction > 0) { + // move at most to the end of the current input + const columns = Math.min( + direction, + this.#tty.input.length - this.#tty.cursor + ) + this.#tty.moveCursorTo(this.#tty.cursor + columns) + } else if (direction < 0) { + // move at most to the beginning of the input + const columns = Math.max(direction, -this.#tty.cursor) + this.#tty.moveCursorTo(this.#tty.cursor + columns) + } + } + + /** Insert data at current cursor position */ + _handleCursorInsert(data) { + // insert data into old input at current cursor position + const newInput = + this.#tty.input.substring(0, this.#tty.cursor) + + data + + this.#tty.input.substring(this.#tty.cursor) + + // move cursor after the inserted data and set new input + this.#tty.moveCursorTo(this.#tty.cursor + data.length) + this.#tty.input = newInput + } + + _handleCursorErase(backspace = true) { + if (backspace) { + if (this.#tty.cursor <= 0) return + + // remove character at the cursor position + const newInput = + this.#tty.input.substring(0, this.#tty.cursor - 1) + + this.#tty.input.substring(this.#tty.cursor) + // move cursor back by one position + const newCursor = this.#tty.cursor - 1 + + this.#tty.input = newInput + this.#tty.cursor = newCursor + } else { + if (this.#tty.cursor >= this.#tty.input.length) return + + // remove character behind the cursor position + const newInput = + this.#tty.input.substring(0, this.#tty.cursor) + + this.#tty.input.substring(this.#tty.cursor + 1) + + this.#tty.input = newInput + } + } + + /** Handle a single logical tty input (key-press, escape-sequence, ...) */ + _handleData(data) { + const ord = data.charCodeAt(0) + + // handle ANSI escape sequences + if (ord === 0x1b) { + // escape character `^[` + switch (data.substring(1)) { + // History + case "[A": { + // Arrow Up + const value = this.#history.getPrevious() + if (value) { + if (!this.#inHistory) { + // save current input + this.#inHistory = true + this.#partialInput = this.#tty.input + } + this.#tty.moveCursorTo(value.length) + this.#tty.input = value + } + break + } + case "[B": { + // Arrow Down + const value = this.#history.getNext() + if (value) { + this.#tty.moveCursorTo(value.length) + this.#tty.input = value + } else if (this.#inHistory) { + // reached end of history, restore input buffer + this.#tty.input = this.#partialInput ?? "" + this.#tty.cursor = this.#tty.input.length + this.#inHistory = false + this.#partialInput = undefined + } + break + } + case "[5~": { + // Page Up + const value = this.#history.getFirst() + if (value) { + if (!this.#inHistory) { + // save current input + this.#inHistory = true + this.#partialInput = this.#tty.input + } + this.#tty.moveCursorTo(value.length) + this.#tty.input = value + } + break + } + case "[6~": + // Page Down + if (this.#inHistory) { + // reached end of history, restore input buffer + this.#tty.input = this.#partialInput ?? "" + this.#tty.cursor = this.#tty.input.length + this.#history.rewind() + this.#inHistory = false + this.#partialInput = undefined + } + break + + // Navigation + case "[D": // Arrow Left + this._handleCursorMove(-1) + break + case "[C": // Arrow Right + this._handleCursorMove(1) + break + case "[H": // Home + this.#tty.cursor = 0 + break + case "[F": // End + this.#tty.cursor = this.#tty.input.length + break + + // Other + case "[3~": // Delete + this._handleCursorErase(false) + + default: + console.debug("Escape Sequence:", data.substring(1)) + } + } + + // handle special characters + else if (ord < 0x20 || ord === 0x7f) { + // below 0x20 (+ 0x7f) are control characters + switch (data) { + case "\x03": // CTRL + C + this.#tty.input = "" + this.#history.rewind() + break + case "\r": // Enter + case "\x0a": // CTRL + J + case "\x0d": // CTRL + M + if (isIncompleteInput(this.#tty.input)) { + this._handleCursorInsert("\n") + } else { + this._handleReadComplete() + } + break + + case "\t": // Tab + // TODO: Handle autocomplete + break + + case "\x01": // CTRL + A + this.#tty.cursor = 0 + break + case "\x05": // CTRL + E + this.#tty.cursor = this.#tty.input.length + break + case "\x02": // CTRL + B + this._handleCursorMove(-1) + break + case "\x06": // CTRL + F + this._handleCursorMove(1) + break + + case "\x7f": // Backspace + case "\x08": // CTRL + H + case "\x04": // CTRL + D + this._handleCursorErase(true) + break + + case "\x0c": // CTRL + L + this.#tty.clearTty() + break + + default: + console.debug("Control Character:", ord, data) + } + } + + // handle standard printable characters + else this._handleCursorInsert(data) + } + + /** Handle input events comming from the terminal */ + handleDataInput(data) { + // Only pass CTRL + C when not active + if (!this.#isActive && data !== "\x03") return + + this._handleData(data) + } +} + +export default WasmShell diff --git a/src/shell/WasmTTY.js b/src/shell/WasmTTY.js new file mode 100644 index 0000000..a4f2242 --- /dev/null +++ b/src/shell/WasmTTY.js @@ -0,0 +1,307 @@ +import { offsetToColRow, countLines } from "./tty-utils" + +/** + * WASM TTY interface + * + * A tty is a virtual device file that handles the interaction between shell + * and terminal, providing an interface for writing/reading from the terminal. + */ +class WasmTTY { + #xterm + #termSize + + #promptPrefix + #continuationPromptPrefix + + #cursor + #inputBuffer + + constructor() { + this.#promptPrefix = "" // current prompt prefix + this.#continuationPromptPrefix = "" // current continuation prompt prefix + + this.#cursor = 0 // track current cursor position (for insert / delete) + this.#inputBuffer = "" // track current user input + } + + activate(xterm) { + this.#xterm = xterm // keep reference to the underlying terminal + + this.#termSize = { + cols: this.#xterm.cols, // get current number of columns fitting the screen + rows: this.#xterm.rows, // get current number of rows fitting the screen + } + + // register resize handler + this.#xterm.onResize((data) => this.handleTermResize(data)) + } + + dispose() {} + + onData(fn) { + return this.#xterm.onData(fn) + } + + /** Generate a deconstructed readPromise */ + _getAsyncRead() { + let readResolve + let readReject + + const readPromise = new Promise((resolve, reject) => { + readResolve = (response) => { + // reset prompt prefixes + this.#promptPrefix = "" + this.#continuationPromptPrefix = "" + + resolve(response) + } + readReject = reject + }) + + return { promise: readPromise, resolve: readResolve, reject: readReject } + } + + read(promptPrefix, continuationPromptPrefix) { + if (promptPrefix?.length > 0) this._printLines(promptPrefix) + + // set prompt prefixes + this.#promptPrefix = promptPrefix + this.#continuationPromptPrefix = continuationPromptPrefix ?? "> " + + // reset input and cursor position + this.#inputBuffer = "" + this.#cursor = 0 + + return this._getAsyncRead() + } + + /* ======== OUTPUT HANDLING ======== */ + + /** Write raw bytes to the terminal */ + write(bytes) { + this.#xterm.write(bytes) + } + + /** Print a message to the terminal */ + print(message) { + // normalize line endings + const normalized = message.replace(/(\r\n)+/g, "\n") + this.#xterm.write(normalized.replace(/\n/g, "\r\n")) + } + + /** Print a message with a trailing newline to the terminal */ + println(message) { + this.print(message + "\n") + } + + /** Print message line-by-line and add extra linebreak if line ends in last column */ + _printLines(message) { + // normalize line endings + const normalized = message.replace(/(\r\n)+/g, "\n") + + const maxCols = this.size.cols + + // print each line individually + let lineStart = 0 + while (lineStart < normalized.length) { + // search for next linebreak + const linebreak = normalized.indexOf("\n", lineStart) + let line + + // no linebreak -> last line + if (linebreak === -1) { + line = normalized.slice(lineStart, normalized.length) + lineStart = normalized.length + + // print extra newline if line ends on last column + if (line.length > 0 && line.length % maxCols === 0) { + line = line + "\n" + } + } + + // found linebreak, print line including its trailing newline + else { + const lineEnd = linebreak + 1 + line = normalized.slice(lineStart, lineEnd) + lineStart = lineEnd + + // print extra newline if line (excluding its trailing newline) ends on last column + if (line.length > 0 && line.length % maxCols === 1) { + line = line + "\n" + } + } + + // print actual line + this.#xterm.write(line.replace(/\n/g, "\r\n")) + } + } + + /** Clear the already printed lines of the input buffer from the terminal */ + _clearInput() { + const currentPrompt = this._applyPrompts(this.input) + + // get number of lines for the current input + const numRows = countLines(currentPrompt, this.size.cols) + + // get row we are currently at + const promptCursor = this._applyPromptsOffset(this.input, this.cursor) + const { row } = offsetToColRow(currentPrompt, promptCursor, this.size.cols) + + // move to the last line + const moveRows = numRows - row - 1 + if (moveRows > 0) this.#xterm.write("\x1b[E".repeat(moveRows)) // move line down + + // clear all input line(s) + this.#xterm.write("\r\x1b[K") // clear line + for (let i = 1; i < numRows; ++i) this.#xterm.write("\r\x1b[F\x1b[K") // move line up and clear line + } + + /** Clear the entire terminal and move cursor to the top-left corner */ + clearTty() { + this.#xterm.write("\x1b[2J") // Clear the whole screen + this.#xterm.write("\x1b[0;0H") // move cursor to 0,0 + + // re-print current prompt + this._setInput(this.input) + } + + /** Add the current prompts to the input */ + _applyPrompts(input) { + return ( + this.#promptPrefix + + input.replace(/\n/g, "\n" + this.#continuationPromptPrefix) + ) + } + + /** Advance the offset by the amount added by the prompt additions to the input */ + _applyPromptsOffset(input, offset) { + const inputWithPrompts = this._applyPrompts(input.substring(0, offset)) + return inputWithPrompts.length + } + + /* ======== INPUT CURSOR MOVEMENT ======== */ + + /** Get the current cursor position in the input buffer */ + get cursor() { + return this.#cursor + } + + /** Set the current cursor position in the input buffer */ + set cursor(newCursor) { + // clamp new position to point inside the input buffer + if (newCursor < 0) newCursor = 0 + if (newCursor > this.input.length) newCursor = this.input.length + + this.moveCursorTo(newCursor) + } + + /** Directly move the cursor to the updated position */ + moveCursorTo(newCursor) { + // apply current prompts to the input + const inputWithPrompt = this._applyPrompts(this.input) + + // estimate the current physical cursor position + const currentPromptOffset = this._applyPromptsOffset( + this.input, + this.cursor + ) + const { col: currentCol, row: currentRow } = offsetToColRow( + inputWithPrompt, + currentPromptOffset, + this.size.cols + ) + + // estimate the new physical cursor position + const newPromptOffset = this._applyPromptsOffset(this.input, newCursor) + const { col: newCol, row: newRow } = offsetToColRow( + inputWithPrompt, + newPromptOffset, + this.size.cols + ) + + // adjust for vertical difference + const rowDiff = newRow - currentRow + if (rowDiff > 0) { + this.#xterm.write("\x1b[B".repeat(rowDiff)) // move down + } else if (rowDiff < 0) { + this.#xterm.write("\x1b[A".repeat(-rowDiff)) // move up + } + + // adjust for horizontal difference + const colDiff = newCol - currentCol + if (colDiff > 0) { + this.#xterm.write("\x1b[C".repeat(colDiff)) // move right + } else if (colDiff < 0) { + this.#xterm.write("\x1b[D".repeat(-colDiff)) // move left + } + + // save new cursor offset + this.#cursor = newCursor + } + + /* ======== INPUT HANDLING ======== */ + + /** Get the currently buffered input data */ + get input() { + return this.#inputBuffer + } + + /** Set new input data to be printed to the terminal */ + set input(newInput) { + // clear current prompt, move cursor to start position + this._clearInput() + + // actually update the input + this._setInput(newInput) + } + + /** Update input buffer and print to the terminal */ + _setInput(newInput) { + // write new input lines, including the current prompts + const inputWithPrompt = this._applyPrompts(newInput) + this._printLines(inputWithPrompt) + + // trim cursor overflow + if (this.#cursor > newInput.length) this.#cursor = newInput.length + + // get new cursor position, taking the prompts into account + const newCursor = this._applyPromptsOffset(newInput, this.cursor) + let newRows = countLines(inputWithPrompt, this.size.cols) + let { col, row } = offsetToColRow( + inputWithPrompt, + newCursor, + this.size.cols + ) + + // move cursor back to the current column and row + const moveUpRows = newRows - row - 1 + this.#xterm.write("\r") // move to column 0 + if (moveUpRows > 0) this.#xterm.write("\x1b[F".repeat(moveUpRows)) // move line up + if (col > 0) this.#xterm.write("\x1b[C".repeat(col)) // move right + + // replace input buffer + this.#inputBuffer = newInput + } + + /* ======== TERMINAL RESIZING ======== */ + + /** Get the current size of the underlying terminal */ + get size() { + return this.#termSize + } + + /** Handle resize events comming from the terminal */ + handleTermResize({ rows, cols }) { + // clear old prompt + this._clearInput() + + // update terminal size + this.#termSize.cols = cols + this.#termSize.rows = rows + + // re-print current input with the updated size + this._setInput(this.input) + } +} + +export default WasmTTY diff --git a/src/shell/shell-utils.js b/src/shell/shell-utils.js new file mode 100644 index 0000000..16b020c --- /dev/null +++ b/src/shell/shell-utils.js @@ -0,0 +1,187 @@ +import parse from "shell-quote/parse" + +import { CommandParserError } from "../Errors" +import Command from "../Command" + +/** + * Checks if there is an incomplete input + * + * An incomplete input is considered: + * - An input that contains unterminated single quotes + * - An input that contains unterminated double quotes + * - An input that ends with "\" + * - An input that has an incomplete boolean shell expression (&& and ||) + * - An incomplete pipe expression (|) + */ +export function isIncompleteInput(input) { + // empty input is not incomplete + if (input.trim() === "") return false + + // check for dangling single-quote strings + if ((input.match(/'/g) || []).length % 2 !== 0) return true + + // Check for dangling double-quote strings + if ((input.match(/"/g) || []).length % 2 !== 0) return true + + // Check for dangling boolean or pipe operations + if ( + input + .split(/(\|\||\||&&)/g) + .pop() + .trim() === "" + ) + return true + + // Check for tailing slash + if (input.endsWith("\\") && !input.endsWith("\\\\")) return true + + return false +} + +const SUPPORTED_FEATURES = [";", "|" /*">", ">>", "<"*/] +const SUPPORTED_FEATURES_STR = + SUPPORTED_FEATURES.slice(0, -1) + .map((feat) => `'${feat}'`) + .join(", ") + ` and '${SUPPORTED_FEATURES[SUPPORTED_FEATURES.length - 1]}'` + +/** + * Parse input line into commands with its input and output redirects + */ +export function parseCommands(line, println = console.warn) { + let usesEnvironmentVars = false + let usesBashFeatures = false + + // parse line into tokens (respect escaped spaces and quotation marks) + const commandLine = parse(line, (_key) => { + usesEnvironmentVars = true + return undefined + }) + + const commands = [] + let buf = [] + let io = null + + splitter: { + for (let idx = 0; idx < commandLine.length; ++idx) { + const item = commandLine[idx] + + if (typeof item === "string") { + // normal word + if (buf.length === 0 && item.match(/^\w+=.*$/)) { + usesEnvironmentVars = true + continue + } else { + buf.push(item) + } + } else { + // shell operator + switch (item.op) { + // command separator + case ";": + if (buf.length === 0) break + commands.push(new Command(buf[0], buf.slice(1), io)) + buf = [] + io = null + break + case "|": { + if (buf.length === 0) break + const cmd = new Command(buf[0], buf.slice(1), io) + // Redirect stdout through the pipe if not already redirected + if (cmd.stdout === Command.STDOUT) cmd.stdout = Command.PIPE + if (cmd.stderr === Command.STDOUT) cmd.stderr = Command.PIPE + commands.push(cmd) + buf = [] + io = null + break + } + //// io redirection + //case ">": + //case ">>": { + // const prev = commandLine[idx - 1] + // let file = commandLine[++idx] + // if (file?.op === "|") file = commandLine[++idx] + // if (file == null || typeof file !== "string" || file === "") + // throw new CommandParserError( + // `Missing file for \`${item.op}\` redirect` + // ) + + // io = io ?? {} + // if (prev === "2") { + // buf.pop() + // io.stderr = file + // } else if (prev === "1") { + // buf.pop() + // io.stdout = file + // } else if (prev.op === "&") { + // io.stderr = file + // io.stdout = file + // } else { + // io.stdout = file + // } + // if (item.op === ">>") io.append = true + // break + //} + //case ">&": { + // const prev = commandLine[idx - 1] + // const fd = commandLine[++idx] + // if (fd == null || typeof fd !== "string" || fd === "") + // throw new CommandParserError( + // "Missing file descriptor for `>&` redirect" + // ) + + // io = io ?? {} + // const target = + // fd === "2" ? Command.STDERR : fd === "1" ? Command.STDOUT : null + // if (prev === "2") { + // buf.pop() + // io.stderr = target + // } else if (prev === "1") { + // buf.pop() + // io.stdout = target + // } else { + // io.stdout = target + // } + // break + //} + //case "<": { + // let file = commandLine[++idx] + // if (file?.op === ">" || file?.op === "<") { + // usesBashFeatures = true + // console.error("Unsupported shell operator:", item.op + file.op) + // break splitter + // } + // if (file == null || typeof file !== "string" || file === "") + // throw new CommandParserError("Missing file for `<` redirect") + + // io = io ?? {} + // io.stdin = file + // break + //} + //// background processing (unsupported) or `&>` redirect + //case "&": { + // // allow `&` followed by `>` for stdout+stderr redirect + // const next = commandLine[idx + 1] + // if (next?.op === ">") break + // // fall through + //} + default: + usesBashFeatures = true + console.error("Unsupported shell operator:", item.op) + break splitter + } + } + } + } + if (buf[0]) commands.push(new Command(buf[0], buf.slice(1), io)) + + if (usesEnvironmentVars) + println( + "\x1b[1m[\x1b[33mWARN\x1b[39m]\x1b[0m Environment variables are not supported!" + ) + if (usesBashFeatures) + println( + `\x1b[1m[\x1b[33mWARN\x1b[39m]\x1b[0m Advanced bash features are not supported! Only ${SUPPORTED_FEATURES_STR} work for now.` + ) + + return commands +} diff --git a/src/shell/tty-utils.js b/src/shell/tty-utils.js new file mode 100644 index 0000000..fd103e3 --- /dev/null +++ b/src/shell/tty-utils.js @@ -0,0 +1,41 @@ +/** Convert offset at the given input to col/row location */ +export function offsetToColRow(input, offset, maxCols) { + let row = 0 + let col = 1 + + // strip out escape sequences + input = input.replace(/\x1b\[[^m]*?m/g, "") + + let lineStart = 0 + while (true) { + // search for next line break + const linebreak = input.indexOf("\n", lineStart) + + // no line break left or after the target offset -> stop + if (linebreak === -1 || linebreak >= offset) { + // get length of the current line + const lineLength = offset - lineStart + // how many rows does this line span (when wrapped after maxCols) + row += Math.floor(lineLength / maxCols) + // how many columns are left in the last wrapped row + col = lineLength % maxCols + break + } + + // explicit line break + const lineLength = linebreak - lineStart + + // how many rows does this line span (when wrapped after maxCols) + next line + row += Math.floor(lineLength / maxCols) + 1 + + // search for the next line + lineStart = linebreak + 1 + } + + return { row, col } +} + +/** Count the lines in the given input */ +export function countLines(input, maxCols) { + return offsetToColRow(input, input.length, maxCols).row + 1 +} diff --git a/worker.loader.js b/worker.loader.js index 7573773..1d26e95 100644 --- a/worker.loader.js +++ b/worker.loader.js @@ -28,6 +28,7 @@ module.exports = function (source) { // create webpack compiler let compiler = webpack({ + mode: "production", entry: inputFilename, output: { path: "/",