diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..2238ba3 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,33 @@ +name: Docker Build and Publish + +on: + push: + branches: "master" + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Docker/Dockerfile + push: true + tags: edryslabs/micropython-webrepl-proxy:latest \ No newline at end of file diff --git a/Docker/.dockerignore b/Docker/.dockerignore new file mode 100644 index 0000000..845d09e --- /dev/null +++ b/Docker/.dockerignore @@ -0,0 +1,22 @@ +.git +.gitignore + +README.md +*.md + +index.html +term.js +webrepl.js +FileSaver.js +webrepl_cli.py +make_html_js.py +LICENSE + +.vscode +.idea +*.swp +*.swo +*~ + +.DS_Store +Thumbs.db diff --git a/Docker/Dockerfile b/Docker/Dockerfile new file mode 100644 index 0000000..7a0996f --- /dev/null +++ b/Docker/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +COPY Docker/requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY proxy.py . + +EXPOSE 8765 + +RUN useradd --create-home --shell /bin/bash app && chown -R app:app /app +USER app + +CMD ["python", "proxy.py"] diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml new file mode 100644 index 0000000..e3fe251 --- /dev/null +++ b/Docker/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + micropython-proxy: + build: + context: . + dockerfile: Dockerfile + network_mode: host + restart: unless-stopped + environment: + - PYTHONUNBUFFERED=1 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/Docker/requirements.txt b/Docker/requirements.txt new file mode 100644 index 0000000..65ab3a3 --- /dev/null +++ b/Docker/requirements.txt @@ -0,0 +1 @@ +websockets==11.0.2 diff --git a/FileSaver.js b/FileSaver.js index 239db12..5abca6d 100644 --- a/FileSaver.js +++ b/FileSaver.js @@ -13,176 +13,195 @@ /*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ -var saveAs = saveAs || (function(view) { - "use strict"; - // IE <10 is explicitly unsupported - if (typeof view === "undefined" || typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) { - return; - } - var - doc = view.document - // only get URL when necessary in case Blob.js hasn't overridden it yet - , get_URL = function() { - return view.URL || view.webkitURL || view; - } - , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") - , can_use_save_link = "download" in save_link - , click = function(node) { - var event = new MouseEvent("click"); - node.dispatchEvent(event); - } - , is_safari = /constructor/i.test(view.HTMLElement) - , is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent) - , throw_outside = function(ex) { - (view.setImmediate || view.setTimeout)(function() { - throw ex; - }, 0); - } - , force_saveable_type = "application/octet-stream" - // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to - , arbitrary_revoke_timeout = 1000 * 40 // in ms - , revoke = function(file) { - var revoker = function() { - if (typeof file === "string") { // file is an object URL - get_URL().revokeObjectURL(file); - } else { // file is a File - file.remove(); - } - }; - setTimeout(revoker, arbitrary_revoke_timeout); - } - , dispatch = function(filesaver, event_types, event) { - event_types = [].concat(event_types); - var i = event_types.length; - while (i--) { - var listener = filesaver["on" + event_types[i]]; - if (typeof listener === "function") { - try { - listener.call(filesaver, event || filesaver); - } catch (ex) { - throw_outside(ex); - } - } - } - } - , auto_bom = function(blob) { - // prepend BOM for UTF-8 XML and text/* types (including HTML) - // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF - if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { - return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type}); - } - return blob; - } - , FileSaver = function(blob, name, no_auto_bom) { - if (!no_auto_bom) { - blob = auto_bom(blob); - } - // First try a.download, then web filesystem, then object URLs - var - filesaver = this - , type = blob.type - , force = type === force_saveable_type - , object_url - , dispatch_all = function() { - dispatch(filesaver, "writestart progress write writeend".split(" ")); - } - // on any filesys errors revert to saving with object URLs - , fs_error = function() { - if ((is_chrome_ios || (force && is_safari)) && view.FileReader) { - // Safari doesn't allow downloading of blob urls - var reader = new FileReader(); - reader.onloadend = function() { - var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;'); - var popup = view.open(url, '_blank'); - if(!popup) view.location.href = url; - url=undefined; // release reference before dispatching - filesaver.readyState = filesaver.DONE; - dispatch_all(); - }; - reader.readAsDataURL(blob); - filesaver.readyState = filesaver.INIT; - return; - } - // don't create more object URLs than needed - if (!object_url) { - object_url = get_URL().createObjectURL(blob); - } - if (force) { - view.location.href = object_url; - } else { - var opened = view.open(object_url, "_blank"); - if (!opened) { - // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html - view.location.href = object_url; - } - } - filesaver.readyState = filesaver.DONE; - dispatch_all(); - revoke(object_url); - } - ; - filesaver.readyState = filesaver.INIT; +var saveAs = + saveAs || + (function (view) { + 'use strict' + // IE <10 is explicitly unsupported + if ( + typeof view === 'undefined' || + (typeof navigator !== 'undefined' && + /MSIE [1-9]\./.test(navigator.userAgent)) + ) { + return + } + var doc = view.document, + // only get URL when necessary in case Blob.js hasn't overridden it yet + get_URL = function () { + return view.URL || view.webkitURL || view + }, + save_link = doc.createElementNS('http://www.w3.org/1999/xhtml', 'a'), + can_use_save_link = 'download' in save_link, + click = function (node) { + var event = new MouseEvent('click') + node.dispatchEvent(event) + }, + is_safari = /constructor/i.test(view.HTMLElement), + is_chrome_ios = /CriOS\/[\d]+/.test(navigator.userAgent), + throw_outside = function (ex) { + ;(view.setImmediate || view.setTimeout)(function () { + throw ex + }, 0) + }, + force_saveable_type = 'application/octet-stream', + // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to + arbitrary_revoke_timeout = 1000 * 40, // in ms + revoke = function (file) { + var revoker = function () { + if (typeof file === 'string') { + // file is an object URL + get_URL().revokeObjectURL(file) + } else { + // file is a File + file.remove() + } + } + setTimeout(revoker, arbitrary_revoke_timeout) + }, + dispatch = function (filesaver, event_types, event) { + event_types = [].concat(event_types) + var i = event_types.length + while (i--) { + var listener = filesaver['on' + event_types[i]] + if (typeof listener === 'function') { + try { + listener.call(filesaver, event || filesaver) + } catch (ex) { + throw_outside(ex) + } + } + } + }, + auto_bom = function (blob) { + // prepend BOM for UTF-8 XML and text/* types (including HTML) + // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF + if ( + /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test( + blob.type + ) + ) { + return new Blob([String.fromCharCode(0xfeff), blob], { + type: blob.type, + }) + } + return blob + }, + FileSaver = function (blob, name, no_auto_bom) { + if (!no_auto_bom) { + blob = auto_bom(blob) + } + // First try a.download, then web filesystem, then object URLs + var filesaver = this, + type = blob.type, + force = type === force_saveable_type, + object_url, + dispatch_all = function () { + dispatch(filesaver, 'writestart progress write writeend'.split(' ')) + }, + // on any filesys errors revert to saving with object URLs + fs_error = function () { + if ((is_chrome_ios || (force && is_safari)) && view.FileReader) { + // Safari doesn't allow downloading of blob urls + var reader = new FileReader() + reader.onloadend = function () { + var url = is_chrome_ios + ? reader.result + : reader.result.replace( + /^data:[^;]*;/, + 'data:attachment/file;' + ) + var popup = view.open(url, '_blank') + if (!popup) view.location.href = url + url = undefined // release reference before dispatching + filesaver.readyState = filesaver.DONE + dispatch_all() + } + reader.readAsDataURL(blob) + filesaver.readyState = filesaver.INIT + return + } + // don't create more object URLs than needed + if (!object_url) { + object_url = get_URL().createObjectURL(blob) + } + if (force) { + view.location.href = object_url + } else { + var opened = view.open(object_url, '_blank') + if (!opened) { + // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html + view.location.href = object_url + } + } + filesaver.readyState = filesaver.DONE + dispatch_all() + revoke(object_url) + } + filesaver.readyState = filesaver.INIT - if (can_use_save_link) { - object_url = get_URL().createObjectURL(blob); - setTimeout(function() { - save_link.href = object_url; - save_link.download = name; - click(save_link); - dispatch_all(); - revoke(object_url); - filesaver.readyState = filesaver.DONE; - }); - return; - } + if (can_use_save_link) { + object_url = get_URL().createObjectURL(blob) + setTimeout(function () { + save_link.href = object_url + save_link.download = name + click(save_link) + dispatch_all() + revoke(object_url) + filesaver.readyState = filesaver.DONE + }) + return + } - fs_error(); - } - , FS_proto = FileSaver.prototype - , saveAs = function(blob, name, no_auto_bom) { - return new FileSaver(blob, name || blob.name || "download", no_auto_bom); - } - ; - // IE 10+ (native saveAs) - if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { - return function(blob, name, no_auto_bom) { - name = name || blob.name || "download"; + fs_error() + }, + FS_proto = FileSaver.prototype, + saveAs = function (blob, name, no_auto_bom) { + return new FileSaver(blob, name || blob.name || 'download', no_auto_bom) + } + // IE 10+ (native saveAs) + if (typeof navigator !== 'undefined' && navigator.msSaveOrOpenBlob) { + return function (blob, name, no_auto_bom) { + name = name || blob.name || 'download' - if (!no_auto_bom) { - blob = auto_bom(blob); - } - return navigator.msSaveOrOpenBlob(blob, name); - }; - } + if (!no_auto_bom) { + blob = auto_bom(blob) + } + return navigator.msSaveOrOpenBlob(blob, name) + } + } - FS_proto.abort = function(){}; - FS_proto.readyState = FS_proto.INIT = 0; - FS_proto.WRITING = 1; - FS_proto.DONE = 2; + FS_proto.abort = function () {} + FS_proto.readyState = FS_proto.INIT = 0 + FS_proto.WRITING = 1 + FS_proto.DONE = 2 - FS_proto.error = - FS_proto.onwritestart = - FS_proto.onprogress = - FS_proto.onwrite = - FS_proto.onabort = - FS_proto.onerror = - FS_proto.onwriteend = - null; + FS_proto.error = + FS_proto.onwritestart = + FS_proto.onprogress = + FS_proto.onwrite = + FS_proto.onabort = + FS_proto.onerror = + FS_proto.onwriteend = + null - return saveAs; -}( - typeof self !== "undefined" && self - || typeof window !== "undefined" && window - || this.content -)); + return saveAs + })( + (typeof self !== 'undefined' && self) || + (typeof window !== 'undefined' && window) || + this.content + ) // `self` is undefined in Firefox for Android content script context // while `this` is nsIContentFrameMessageManager // with an attribute `content` that corresponds to the window -if (typeof module !== "undefined" && module.exports) { - module.exports.saveAs = saveAs; -} else if ((typeof define !== "undefined" && define !== null) && (define.amd !== null)) { - define([], function() { - return saveAs; - }); +if (typeof module !== 'undefined' && module.exports) { + module.exports.saveAs = saveAs +} else if ( + typeof define !== 'undefined' && + define !== null && + define.amd !== null +) { + define([], function () { + return saveAs + }) } diff --git a/README.md b/README.md index c2f27ac..a8bf4c3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,53 @@ +# Module MicroPython WebREPL + +This is a fork of the official MicroPython WebREPL client from the [WebREPL client for MicroPython](https://github.com/micropython/webrepl), which enables you to integrate the the WebRepl into a Edrys remote laboratory. + +The original README.md content is included at the end of this file. + +To use this module, you need to have a MicroPython board with WebREPL enabled. You can find the instructions on how to enable WebREPL in the [MicroPython documentation](https://docs.micropython.org/en/latest/esp8266/tutorial/repl.html#webrepl-a-prompt-over-wifi)... and you need to import the following URL into your Edrys modules: + +``` html +https://edrys-labs.github.io/module-micropython-webrepl/ +``` + +You can preset the WebSocket connection with the following station-configuration. +Additionally you can define a topic onto which the module will listen, thus if an external module publishes code under the following topic, then this code will be directly executed. +This is useful when using an editor with an execute procedure. + +``` +websocket: wss?://... +execute: topic +``` + +However, in most cases your MicroPython board will **NOT** be able to offer a secure wss connection, that is why you either need to install a browser-plugin at the Station-Browser. Or your run, the included [`proxy.py`](./proxy.py) script on a local machine, which will forward the WebSocket connection to the MicroPython board. + +You can run the proxy either manually: +``` bash +python3 proxy.py +``` +Or by running the following Docker container: +``` bash +docker run -it --network host edryslabs/micropython-webrepl-proxy:latest +``` + +The proxy will by default listen for incoming connections at `ws://localhost:8765` by adding a path with your MicroPython websocket connection, all incoming requests will be forwarded to this connection. + +``` html +ws://localhost:8765/ws://192.168.2.197:8266 +``` + +This way, you can have multiple stations with connections to different MicroPython boards. + +Initialize a default configuration with the following station-configuration: + +``` yaml +websocket: ws://localhost:8765/ws:// +``` + +--- + +# Original README.md content: + WebREPL client for MicroPython ============================== diff --git a/index.html b/index.html new file mode 100644 index 0000000..a96790b --- /dev/null +++ b/index.html @@ -0,0 +1,425 @@ + + + + MicroPython WebREPL + + + + + + + + + + + + + + + + +
+
+
WebSocket Connection
+ + + +
+
+
Send a file
+ + + +
+ +
+
Get a file
+ + +
+
+
File status
+ (file operation status) +
+
+
Self Charge
+ +
+
+
+
+ Terminal widget should be focused (text cursor visible) to accept + input. Click on it if not. To paste, press Ctrl+A, then Ctrl+V +
+ + + + + diff --git a/proxy.py b/proxy.py new file mode 100644 index 0000000..e362241 --- /dev/null +++ b/proxy.py @@ -0,0 +1,127 @@ +import asyncio +import websockets +import logging +from urllib.parse import urlparse + +# Configure logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger('websocket_proxy') + +async def forward(ws_from, ws_to, name="unknown"): + try: + async for message in ws_from: + if isinstance(message, bytes): + logger.debug(f"{name} forwarding binary message of length {len(message)}") + if len(message) >= 2: + logger.debug(f"First two bytes: {message[:2].hex()}") + await ws_to.send(message) + await asyncio.sleep(0.01) # Small delay after binary messages + else: + logger.debug(f"{name} forwarding text message: {message[:100]}") + await ws_to.send(message) + except websockets.ConnectionClosed as e: + logger.info(f"Connection closed by {name}: {e.code} {e.reason}") + # Ensure the other websocket is also closed + if not ws_to.closed: + await ws_to.close(1000, "Other end closed") + except Exception as e: + logger.error(f"Error in {name} forward: {str(e)}") + if not ws_to.closed: + await ws_to.close(1001, f"Error in forwarding: {str(e)}") + +async def proxy(websocket, path): + target_ws = None + try: + target_uri = path.lstrip('/') + + if not target_uri.startswith('ws://'): + logger.error(f"Invalid target URI: {target_uri}") + await websocket.close(1008, "Invalid target URI. Must start with ws://") + return + + try: + parsed = urlparse(target_uri) + if not parsed.netloc: + raise ValueError("Invalid URI format") + except Exception as e: + logger.error(f"URI parsing error: {str(e)}") + await websocket.close(1008, "Invalid URI format") + return + + logger.info(f"New connection from {websocket.remote_address} to {target_uri}") + + async with websockets.connect( + target_uri, + subprotocols=['binary', 'base64'], + max_size=None, + ping_interval=None, + ping_timeout=None, + close_timeout=10, + max_queue=2**16 + ) as target_ws: + # Create forwarding tasks + forward_tasks = [ + asyncio.create_task(forward(websocket, target_ws, f"client->{target_uri}")), + asyncio.create_task(forward(target_ws, websocket, f"{target_uri}->client")) + ] + + # Wait for any task to complete (which will happen when either connection closes) + done, pending = await asyncio.wait( + forward_tasks, + return_when=asyncio.FIRST_COMPLETED + ) + + # Cancel pending tasks + for task in pending: + task.cancel() + + # Wait for cancellation to complete + if pending: + await asyncio.wait(pending) + + # Ensure both connections are closed + if not websocket.closed: + await websocket.close(1000, "Target connection closed") + if not target_ws.closed: + await target_ws.close(1000, "Client connection closed") + + except websockets.exceptions.WebSocketException as e: + logger.error(f"WebSocket error: {str(e)}") + if not websocket.closed: + await websocket.close(1011, f"WebSocket error: {str(e)}") + except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + if not websocket.closed: + await websocket.close(1011, f"Unexpected error: {str(e)}") + finally: + logger.info(f"Proxy connection closed for {websocket.remote_address}") + +async def main(): + server = await websockets.serve( + proxy, + "localhost", + 8765, + subprotocols=['binary', 'base64'], + max_size=None, + ping_interval=None, + ping_timeout=None, + close_timeout=10, + max_queue=2**16 + ) + + + logger.info("Proxy server listening on ws://localhost:8765") + logger.info("Connect using: ws://localhost:8765/ws://target-host:port") + + try: + await server.wait_closed() + except KeyboardInterrupt: + logger.info("Shutting down proxy server...") + server.close() + await server.wait_closed() + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Proxy server shutdown complete") \ No newline at end of file diff --git a/term.js b/term.js index dc535cc..4128ca6 100644 --- a/term.js +++ b/term.js @@ -30,5981 +30,5920 @@ * other features. */ -;(function() { +;(function () { + /** + * Terminal Emulation References: + * http://vt100.net/ + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + * http://invisible-island.net/vttest/ + * http://www.inwap.com/pdp10/ansicode.txt + * http://linux.die.net/man/4/console_codes + * http://linux.die.net/man/7/urxvt + */ -/** - * Terminal Emulation References: - * http://vt100.net/ - * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt - * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html - * http://invisible-island.net/vttest/ - * http://www.inwap.com/pdp10/ansicode.txt - * http://linux.die.net/man/4/console_codes - * http://linux.die.net/man/7/urxvt - */ + 'use strict' -'use strict'; + /** + * Shared + */ -/** - * Shared - */ + var window = this, + document = this.document -var window = this - , document = this.document; + /** + * EventEmitter + */ -/** - * EventEmitter - */ + function EventEmitter() { + this._events = this._events || {} + } + + EventEmitter.prototype.addListener = function (type, listener) { + this._events[type] = this._events[type] || [] + this._events[type].push(listener) + } + + EventEmitter.prototype.on = EventEmitter.prototype.addListener -function EventEmitter() { - this._events = this._events || {}; -} + EventEmitter.prototype.removeListener = function (type, listener) { + if (!this._events[type]) return -EventEmitter.prototype.addListener = function(type, listener) { - this._events[type] = this._events[type] || []; - this._events[type].push(listener); -}; + var obj = this._events[type], + i = obj.length -EventEmitter.prototype.on = EventEmitter.prototype.addListener; + while (i--) { + if (obj[i] === listener || obj[i].listener === listener) { + obj.splice(i, 1) + return + } + } + } -EventEmitter.prototype.removeListener = function(type, listener) { - if (!this._events[type]) return; + EventEmitter.prototype.off = EventEmitter.prototype.removeListener - var obj = this._events[type] - , i = obj.length; + EventEmitter.prototype.removeAllListeners = function (type) { + if (this._events[type]) delete this._events[type] + } - while (i--) { - if (obj[i] === listener || obj[i].listener === listener) { - obj.splice(i, 1); - return; + EventEmitter.prototype.once = function (type, listener) { + function on() { + var args = Array.prototype.slice.call(arguments) + this.removeListener(type, on) + return listener.apply(this, args) } + on.listener = listener + return this.on(type, on) } -}; -EventEmitter.prototype.off = EventEmitter.prototype.removeListener; + EventEmitter.prototype.emit = function (type) { + if (!this._events[type]) return -EventEmitter.prototype.removeAllListeners = function(type) { - if (this._events[type]) delete this._events[type]; -}; + var args = Array.prototype.slice.call(arguments, 1), + obj = this._events[type], + l = obj.length, + i = 0 -EventEmitter.prototype.once = function(type, listener) { - function on() { - var args = Array.prototype.slice.call(arguments); - this.removeListener(type, on); - return listener.apply(this, args); + for (; i < l; i++) { + obj[i].apply(this, args) + } } - on.listener = listener; - return this.on(type, on); -}; -EventEmitter.prototype.emit = function(type) { - if (!this._events[type]) return; + EventEmitter.prototype.listeners = function (type) { + return (this._events[type] = this._events[type] || []) + } - var args = Array.prototype.slice.call(arguments, 1) - , obj = this._events[type] - , l = obj.length - , i = 0; + /** + * Stream + */ - for (; i < l; i++) { - obj[i].apply(this, args); + function Stream() { + EventEmitter.call(this) } -}; -EventEmitter.prototype.listeners = function(type) { - return this._events[type] = this._events[type] || []; -}; + inherits(Stream, EventEmitter) -/** - * Stream - */ + Stream.prototype.pipe = function (dest, options) { + var src = this, + ondata, + onerror, + onend + + function unbind() { + src.removeListener('data', ondata) + src.removeListener('error', onerror) + src.removeListener('end', onend) + dest.removeListener('error', onerror) + dest.removeListener('close', unbind) + } + + src.on( + 'data', + (ondata = function (data) { + dest.write(data) + }) + ) + + src.on( + 'error', + (onerror = function (err) { + unbind() + if (!this.listeners('error').length) { + throw err + } + }) + ) -function Stream() { - EventEmitter.call(this); -} + src.on( + 'end', + (onend = function () { + dest.end() + unbind() + }) + ) -inherits(Stream, EventEmitter); + dest.on('error', onerror) + dest.on('close', unbind) -Stream.prototype.pipe = function(dest, options) { - var src = this - , ondata - , onerror - , onend; + dest.emit('pipe', src) - function unbind() { - src.removeListener('data', ondata); - src.removeListener('error', onerror); - src.removeListener('end', onend); - dest.removeListener('error', onerror); - dest.removeListener('close', unbind); + return dest } - src.on('data', ondata = function(data) { - dest.write(data); - }); + /** + * States + */ - src.on('error', onerror = function(err) { - unbind(); - if (!this.listeners('error').length) { - throw err; + var normal = 0, + escaped = 1, + csi = 2, + osc = 3, + charset = 4, + dcs = 5, + ignore = 6, + UDK = { type: 'udk' } + + /** + * Terminal + */ + + function Terminal(options) { + var self = this + + if (!(this instanceof Terminal)) { + return new Terminal(arguments[0], arguments[1], arguments[2]) } - }); - src.on('end', onend = function() { - dest.end(); - unbind(); - }); + Stream.call(this) + + if (typeof options === 'number') { + options = { + cols: arguments[0], + rows: arguments[1], + handler: arguments[2], + } + } - dest.on('error', onerror); - dest.on('close', unbind); + options = options || {} - dest.emit('pipe', src); + each(keys(Terminal.defaults), function (key) { + if (options[key] == null) { + options[key] = Terminal.options[key] + // Legacy: + if (Terminal[key] !== Terminal.defaults[key]) { + options[key] = Terminal[key] + } + } + self[key] = options[key] + }) + + if (options.colors.length === 8) { + options.colors = options.colors.concat(Terminal._colors.slice(8)) + } else if (options.colors.length === 16) { + options.colors = options.colors.concat(Terminal._colors.slice(16)) + } else if (options.colors.length === 10) { + options.colors = options.colors + .slice(0, -2) + .concat(Terminal._colors.slice(8, -2), options.colors.slice(-2)) + } else if (options.colors.length === 18) { + options.colors = options.colors + .slice(0, -2) + .concat(Terminal._colors.slice(16, -2), options.colors.slice(-2)) + } + this.colors = options.colors - return dest; -}; + this.options = options -/** - * States - */ + // this.context = options.context || window; + // this.document = options.document || document; + this.parent = + options.body || + options.parent || + (document ? document.getElementsByTagName('body')[0] : null) -var normal = 0 - , escaped = 1 - , csi = 2 - , osc = 3 - , charset = 4 - , dcs = 5 - , ignore = 6 - , UDK = { type: 'udk' }; + this.cols = options.cols || options.geometry[0] + this.rows = options.rows || options.geometry[1] -/** - * Terminal - */ + // Act as though we are a node TTY stream: + this.setRawMode + this.isTTY = true + this.isRaw = true + this.columns = this.cols + this.rows = this.rows -function Terminal(options) { - var self = this; + if (options.handler) { + this.on('data', options.handler) + } - if (!(this instanceof Terminal)) { - return new Terminal(arguments[0], arguments[1], arguments[2]); - } + this.ybase = 0 + this.ydisp = 0 + this.x = 0 + this.y = 0 + this.cursorState = 0 + this.cursorHidden = false + this.convertEol + this.state = 0 + this.queue = '' + this.scrollTop = 0 + this.scrollBottom = this.rows - 1 + + // modes + this.applicationKeypad = false + this.applicationCursor = false + this.originMode = false + this.insertMode = false + this.wraparoundMode = false + this.normal = null + + // select modes + this.prefixMode = false + this.selectMode = false + this.visualMode = false + this.searchMode = false + this.searchDown + this.entry = '' + this.entryPrefix = 'Search: ' + this._real + this._selected + this._textarea + + // charset + this.charset = null + this.gcharset = null + this.glevel = 0 + this.charsets = [null] + + // mouse properties + this.decLocator + this.x10Mouse + this.vt200Mouse + this.vt300Mouse + this.normalMouse + this.mouseEvents + this.sendFocus + this.utfMouse + this.sgrMouse + this.urxvtMouse + + // misc + this.element + this.children + this.refreshStart + this.refreshEnd + this.savedX + this.savedY + this.savedCols + + // stream + this.readable = true + this.writable = true + + this.defAttr = (0 << 18) | (257 << 9) | (256 << 0) + this.curAttr = this.defAttr + + this.params = [] + this.currentParam = 0 + this.prefix = '' + this.postfix = '' + + this.lines = [] + var i = this.rows + while (i--) { + this.lines.push(this.blankLine()) + } - Stream.call(this); + this.tabs + this.setupStops() + } + + inherits(Terminal, Stream) + + /** + * Colors + */ + + // Colors 0-15 + Terminal.tangoColors = [ + // dark: + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + // bright: + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec', + ] + + Terminal.xtermColors = [ + // dark: + '#000000', // black + '#cd0000', // red3 + '#00cd00', // green3 + '#cdcd00', // yellow3 + '#0000ee', // blue2 + '#cd00cd', // magenta3 + '#00cdcd', // cyan3 + '#e5e5e5', // gray90 + // bright: + '#7f7f7f', // gray50 + '#ff0000', // red + '#00ff00', // green + '#ffff00', // yellow + '#5c5cff', // rgb:5c/5c/ff + '#ff00ff', // magenta + '#00ffff', // cyan + '#ffffff', // white + ] + + // Colors 0-15 + 16-255 + // Much thanks to TooTallNate for writing this. + Terminal.colors = (function () { + var colors = Terminal.tangoColors.slice(), + r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff], + i + + // 16-231 + i = 0 + for (; i < 216; i++) { + out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]) + } - if (typeof options === 'number') { - options = { - cols: arguments[0], - rows: arguments[1], - handler: arguments[2] - }; - } + // 232-255 (grey) + i = 0 + for (; i < 24; i++) { + r = 8 + i * 10 + out(r, r, r) + } - options = options || {}; + function out(r, g, b) { + colors.push('#' + hex(r) + hex(g) + hex(b)) + } - each(keys(Terminal.defaults), function(key) { - if (options[key] == null) { - options[key] = Terminal.options[key]; - // Legacy: - if (Terminal[key] !== Terminal.defaults[key]) { - options[key] = Terminal[key]; - } + function hex(c) { + c = c.toString(16) + return c.length < 2 ? '0' + c : c } - self[key] = options[key]; - }); - - if (options.colors.length === 8) { - options.colors = options.colors.concat(Terminal._colors.slice(8)); - } else if (options.colors.length === 16) { - options.colors = options.colors.concat(Terminal._colors.slice(16)); - } else if (options.colors.length === 10) { - options.colors = options.colors.slice(0, -2).concat( - Terminal._colors.slice(8, -2), options.colors.slice(-2)); - } else if (options.colors.length === 18) { - options.colors = options.colors.slice(0, -2).concat( - Terminal._colors.slice(16, -2), options.colors.slice(-2)); - } - this.colors = options.colors; - - this.options = options; - - // this.context = options.context || window; - // this.document = options.document || document; - this.parent = options.body || options.parent - || (document ? document.getElementsByTagName('body')[0] : null); - - this.cols = options.cols || options.geometry[0]; - this.rows = options.rows || options.geometry[1]; - - // Act as though we are a node TTY stream: - this.setRawMode; - this.isTTY = true; - this.isRaw = true; - this.columns = this.cols; - this.rows = this.rows; - - if (options.handler) { - this.on('data', options.handler); - } - - this.ybase = 0; - this.ydisp = 0; - this.x = 0; - this.y = 0; - this.cursorState = 0; - this.cursorHidden = false; - this.convertEol; - this.state = 0; - this.queue = ''; - this.scrollTop = 0; - this.scrollBottom = this.rows - 1; - - // modes - this.applicationKeypad = false; - this.applicationCursor = false; - this.originMode = false; - this.insertMode = false; - this.wraparoundMode = false; - this.normal = null; - - // select modes - this.prefixMode = false; - this.selectMode = false; - this.visualMode = false; - this.searchMode = false; - this.searchDown; - this.entry = ''; - this.entryPrefix = 'Search: '; - this._real; - this._selected; - this._textarea; - - // charset - this.charset = null; - this.gcharset = null; - this.glevel = 0; - this.charsets = [null]; - - // mouse properties - this.decLocator; - this.x10Mouse; - this.vt200Mouse; - this.vt300Mouse; - this.normalMouse; - this.mouseEvents; - this.sendFocus; - this.utfMouse; - this.sgrMouse; - this.urxvtMouse; - - // misc - this.element; - this.children; - this.refreshStart; - this.refreshEnd; - this.savedX; - this.savedY; - this.savedCols; - - // stream - this.readable = true; - this.writable = true; - - this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); - this.curAttr = this.defAttr; - - this.params = []; - this.currentParam = 0; - this.prefix = ''; - this.postfix = ''; - - this.lines = []; - var i = this.rows; - while (i--) { - this.lines.push(this.blankLine()); - } - - this.tabs; - this.setupStops(); -} - -inherits(Terminal, Stream); -/** - * Colors - */ + return colors + })() -// Colors 0-15 -Terminal.tangoColors = [ - // dark: - '#2e3436', - '#cc0000', - '#4e9a06', - '#c4a000', - '#3465a4', - '#75507b', - '#06989a', - '#d3d7cf', - // bright: - '#555753', - '#ef2929', - '#8ae234', - '#fce94f', - '#729fcf', - '#ad7fa8', - '#34e2e2', - '#eeeeec' -]; - -Terminal.xtermColors = [ - // dark: - '#000000', // black - '#cd0000', // red3 - '#00cd00', // green3 - '#cdcd00', // yellow3 - '#0000ee', // blue2 - '#cd00cd', // magenta3 - '#00cdcd', // cyan3 - '#e5e5e5', // gray90 - // bright: - '#7f7f7f', // gray50 - '#ff0000', // red - '#00ff00', // green - '#ffff00', // yellow - '#5c5cff', // rgb:5c/5c/ff - '#ff00ff', // magenta - '#00ffff', // cyan - '#ffffff' // white -]; - -// Colors 0-15 + 16-255 -// Much thanks to TooTallNate for writing this. -Terminal.colors = (function() { - var colors = Terminal.tangoColors.slice() - , r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] - , i; - - // 16-231 - i = 0; - for (; i < 216; i++) { - out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]); - } - - // 232-255 (grey) - i = 0; - for (; i < 24; i++) { - r = 8 + i * 10; - out(r, r, r); - } - - function out(r, g, b) { - colors.push('#' + hex(r) + hex(g) + hex(b)); - } - - function hex(c) { - c = c.toString(16); - return c.length < 2 ? '0' + c : c; - } - - return colors; -})(); - -// Default BG/FG -Terminal.colors[256] = '#000000'; -Terminal.colors[257] = '#f0f0f0'; - -Terminal._colors = Terminal.colors.slice(); - -Terminal.vcolors = (function() { - var out = [] - , colors = Terminal.colors - , i = 0 - , color; - - for (; i < 256; i++) { - color = parseInt(colors[i].substring(1), 16); - out.push([ - (color >> 16) & 0xff, - (color >> 8) & 0xff, - color & 0xff - ]); - } - - return out; -})(); + // Default BG/FG + Terminal.colors[256] = '#000000' + Terminal.colors[257] = '#f0f0f0' -/** - * Options - */ + Terminal._colors = Terminal.colors.slice() -Terminal.defaults = { - colors: Terminal.colors, - convertEol: false, - termName: 'xterm', - geometry: [80, 24], - cursorBlink: true, - visualBell: false, - popOnBell: false, - scrollback: 1000, - screenKeys: false, - debug: false, - useStyle: false - // programFeatures: false, - // focusKeys: false, -}; - -Terminal.options = {}; - -each(keys(Terminal.defaults), function(key) { - Terminal[key] = Terminal.defaults[key]; - Terminal.options[key] = Terminal.defaults[key]; -}); + Terminal.vcolors = (function () { + var out = [], + colors = Terminal.colors, + i = 0, + color -/** - * Focused Terminal - */ + for (; i < 256; i++) { + color = parseInt(colors[i].substring(1), 16) + out.push([(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]) + } -Terminal.focus = null; + return out + })() -Terminal.prototype.focus = function() { - if (Terminal.focus === this) return; + /** + * Options + */ - if (Terminal.focus) { - Terminal.focus.blur(); + Terminal.defaults = { + colors: Terminal.colors, + convertEol: false, + termName: 'xterm', + geometry: [80, 24], + cursorBlink: true, + visualBell: false, + popOnBell: false, + scrollback: 1000, + screenKeys: false, + debug: false, + useStyle: false, + // programFeatures: false, + // focusKeys: false, } - if (this.sendFocus) this.send('\x1b[I'); - this.showCursor(); + Terminal.options = {} - // try { - // this.element.focus(); - // } catch (e) { - // ; - // } + each(keys(Terminal.defaults), function (key) { + Terminal[key] = Terminal.defaults[key] + Terminal.options[key] = Terminal.defaults[key] + }) - // this.emit('focus'); + /** + * Focused Terminal + */ - Terminal.focus = this; -}; + Terminal.focus = null -Terminal.prototype.blur = function() { - if (Terminal.focus !== this) return; + Terminal.prototype.focus = function () { + if (Terminal.focus === this) return - this.cursorState = 0; - this.refresh(this.y, this.y); - if (this.sendFocus) this.send('\x1b[O'); + if (Terminal.focus) { + Terminal.focus.blur() + } - // try { - // this.element.blur(); - // } catch (e) { - // ; - // } + if (this.sendFocus) this.send('\x1b[I') + this.showCursor() - // this.emit('blur'); + // try { + // this.element.focus(); + // } catch (e) { + // ; + // } - Terminal.focus = null; -}; + // this.emit('focus'); -/** - * Initialize global behavior - */ + Terminal.focus = this + } + + Terminal.prototype.blur = function () { + if (Terminal.focus !== this) return + + this.cursorState = 0 + this.refresh(this.y, this.y) + if (this.sendFocus) this.send('\x1b[O') -Terminal.prototype.initGlobal = function() { - var document = this.document; + // try { + // this.element.blur(); + // } catch (e) { + // ; + // } + + // this.emit('blur'); - Terminal._boundDocs = Terminal._boundDocs || []; - if (~indexOf(Terminal._boundDocs, document)) { - return; + Terminal.focus = null } - Terminal._boundDocs.push(document); - Terminal.bindPaste(document); + /** + * Initialize global behavior + */ + + Terminal.prototype.initGlobal = function () { + var document = this.document + + Terminal._boundDocs = Terminal._boundDocs || [] + if (~indexOf(Terminal._boundDocs, document)) { + return + } + Terminal._boundDocs.push(document) + + Terminal.bindPaste(document) - Terminal.bindKeys(document); + Terminal.bindKeys(document) - Terminal.bindCopy(document); + Terminal.bindCopy(document) + + if (this.isMobile) { + this.fixMobile(document) + } - if (this.isMobile) { - this.fixMobile(document); + if (this.useStyle) { + Terminal.insertStyle(document, this.colors[256], this.colors[257]) + } } - if (this.useStyle) { - Terminal.insertStyle(document, this.colors[256], this.colors[257]); + /** + * Bind to paste event + */ + + Terminal.bindPaste = function (document) { + // This seems to work well for ctrl-V and middle-click, + // even without the contentEditable workaround. + var window = document.defaultView + on(window, 'paste', function (ev) { + var term = Terminal.focus + if (!term) return + if (ev.clipboardData) { + term.send(ev.clipboardData.getData('text/plain')) + } else if (term.context.clipboardData) { + term.send(term.context.clipboardData.getData('Text')) + } + // Not necessary. Do it anyway for good measure. + term.element.contentEditable = 'inherit' + return cancel(ev) + }) + } + + /** + * Global Events for key handling + */ + + Terminal.bindKeys = function (document) { + // We should only need to check `target === body` below, + // but we can check everything for good measure. + on( + document, + 'keydown', + function (ev) { + if (!Terminal.focus) return + var target = ev.target || ev.srcElement + if (!target) return + if ( + target === Terminal.focus.element || + target === Terminal.focus.context || + target === Terminal.focus.document || + target === Terminal.focus.body || + target === Terminal._textarea || + target === Terminal.focus.parent + ) { + return Terminal.focus.keyDown(ev) + } + }, + true + ) + + on( + document, + 'keypress', + function (ev) { + if (!Terminal.focus) return + var target = ev.target || ev.srcElement + if (!target) return + if (ev.ctrlKey && ev.key === 'v') { + // If we got here with Ctrl+V, then we know it's us who enabled it + // to bubble to be handled by browser as Paste, so let this happen. + return + } + if ( + target === Terminal.focus.element || + target === Terminal.focus.context || + target === Terminal.focus.document || + target === Terminal.focus.body || + target === Terminal._textarea || + target === Terminal.focus.parent + ) { + // In case user popped up context menu, widget may be stuck in + // "contentEditable" state (as a workaround for Firefox braindeadness) + // with visual artifacts like browser's cursur. Disable it now. + Terminal.focus.element.contentEditable = 'inherit' + return Terminal.focus.keyPress(ev) + } + }, + true + ) + + // If we click somewhere other than a + // terminal, unfocus the terminal. + on(document, 'mousedown', function (ev) { + if (!Terminal.focus) return + + var el = ev.target || ev.srcElement + if (!el) return + + do { + if (el === Terminal.focus.element) return + } while ((el = el.parentNode)) + + Terminal.focus.blur() + }) + } + + /** + * Copy Selection w/ Ctrl-C (Select Mode) + */ + + Terminal.bindCopy = function (document) { + var window = document.defaultView + + // if (!('onbeforecopy' in document)) { + // // Copies to *only* the clipboard. + // on(window, 'copy', function fn(ev) { + // var term = Terminal.focus; + // if (!term) return; + // if (!term._selected) return; + // var text = term.grabText( + // term._selected.x1, term._selected.x2, + // term._selected.y1, term._selected.y2); + // term.emit('copy', text); + // ev.clipboardData.setData('text/plain', text); + // }); + // return; + // } + + // Copies to primary selection *and* clipboard. + // NOTE: This may work better on capture phase, + // or using the `beforecopy` event. + on(window, 'copy', function (ev) { + var term = Terminal.focus + if (!term) return + if (!term._selected) return + var textarea = term.getCopyTextarea() + var text = term.grabText( + term._selected.x1, + term._selected.x2, + term._selected.y1, + term._selected.y2 + ) + term.emit('copy', text) + textarea.focus() + textarea.textContent = text + textarea.value = text + textarea.setSelectionRange(0, text.length) + setTimeout(function () { + term.element.focus() + term.focus() + }, 1) + }) + } + + /** + * Fix Mobile + */ + + Terminal.prototype.fixMobile = function (document) { + var self = this + + var textarea = document.createElement('textarea') + textarea.style.position = 'absolute' + textarea.style.left = '-32000px' + textarea.style.top = '-32000px' + textarea.style.width = '0px' + textarea.style.height = '0px' + textarea.style.opacity = '0' + textarea.style.backgroundColor = 'transparent' + textarea.style.borderStyle = 'none' + textarea.style.outlineStyle = 'none' + textarea.autocapitalize = 'none' + textarea.autocorrect = 'off' + + document.getElementsByTagName('body')[0].appendChild(textarea) + + Terminal._textarea = textarea + + setTimeout(function () { + textarea.focus() + }, 1000) + + if (this.isAndroid) { + on(textarea, 'change', function () { + var value = textarea.textContent || textarea.value + textarea.value = '' + textarea.textContent = '' + self.send(value + '\r') + }) + } } -}; -/** - * Bind to paste event - */ + /** + * Insert a default style + */ + + Terminal.insertStyle = function (document, bg, fg) { + var style = document.getElementById('term-style') + if (style) return + + var head = document.getElementsByTagName('head')[0] + if (!head) return + + var style = document.createElement('style') + style.id = 'term-style' + + // textContent doesn't work well with IE for