From 8c5a0a4e940cd922dcaef4dd9fd85009798309d1 Mon Sep 17 00:00:00 2001 From: Mike Frysinger Date: Fri, 21 Apr 2017 11:38:27 -0400 Subject: [PATCH] hterm: implement OSC 1337 file display/transfer This is the escape sequence used by iTerm2 for displaying files inline and downloading them. Currently we only support displaying of images. There is no container type in HTML for autodetecting formats, and at this point I'd rather not implement a custom mime type sniffer. Since things are transferred inline and embedded directly in the DOM, this does not handle large transfers well (think 5+ MB). Not clear whether there's much we could ever do about this, especially since the base64 encoding is so inefficient. One possible avenue of research is the HTML FS, but those too usually have quota limits. Based on the current parser framework, the escape sequence doesn't trigger until it's been fully received. That means we can't display a progress bar for showing overall transfers. We might revise that based on user feedback, or we just discourage people from doing large transfers here and develop a more efficient solution (e.g. zmodem or sixel). Two other limitations worth noting: - If the terminal is resized, the number of rows the image occupies is not updated accordingly. When increasing the character size, this can make the padded rows take up more empty space than the image. - The image is shown only when the last row is visible. This comes up when browsing the history from old->newer -- the image completely disappears. Picking the last row rather than the first row behaves better when old content automatically scrolls off (newer->older) which I suspect is the more common usage scenario. Change-Id: Ib1acc8addcf0b94a180b22712039b05d20c4dfc7 Reviewed-on: https://chromium-review.googlesource.com/484519 Tested-by: Mike Frysinger Reviewed-by: Brandon Gilmore --- hterm/doc/ControlSequences.md | 54 +++++++- hterm/etc/hterm-notify.sh | 38 ++++-- hterm/etc/hterm-show-file.sh | 120 ++++++++++++++++++ hterm/js/hterm_preference_manager.js | 9 ++ hterm/js/hterm_terminal.js | 181 +++++++++++++++++++++++++++ hterm/js/hterm_terminal_tests.js | 158 +++++++++++++++++++++++ hterm/js/hterm_vt.js | 79 ++++++++++++ hterm/js/hterm_vt_tests.js | 124 ++++++++++++++++++ nassh/_locales/en/messages.json | 48 +++++++ nassh/doc/FAQ.md | 11 ++ nassh/js/nassh_command_instance.js | 2 +- 11 files changed, 809 insertions(+), 15 deletions(-) create mode 100755 hterm/etc/hterm-show-file.sh diff --git a/hterm/doc/ControlSequences.md b/hterm/doc/ControlSequences.md index b68fda9b6..c1af5aeb0 100644 --- a/hterm/doc/ControlSequences.md +++ b/hterm/doc/ControlSequences.md @@ -388,7 +388,57 @@ For example: | 118 | Reset Tektronix cursor color | Ignored | ESC ] 118 ; \a | | 119 | Reset highlight foreground color | Ignored | ESC ] 119 ; \a | | 777 | rxvt-unicode (urxvt) modules | Only "notify" supported | ESC ] 777 ; notify ; [title] ; [body] \a | -| 1337 | iTerm2 sequences | Ignored | | +|[1337]| iTerm2 sequences | Only "File" supported | ESC ] 1337 ; File = [args] : [base64 data] \a | + +### OSC+1337: iTerm2 sequences {#OSC-1337} + +The [iTerm2](https://www.iterm2.com/) terminal for macOS provides a lot of +proprietary options via the OSC 1337 command. Many of them duplicate other +standard sequences, so most of them aren't supported. + +We support media display and file transfers. This is specified via the `File=` +keyword. None of the options below are required as a reasonable default will +be selected automatically. + +***note +There is a [helper script](../etc/hterm-show-file.sh) you can use to handle +the protocol for you. +*** + +***note +*Warning:* You should avoid transferring larger files as Chrome performance +will suffer. If it's under 2 MB, it probably will be fine, but YMMV. +*** + +The overall form looks like ESC+] 1337 ; File=name=[base64];inline=1 : +[base64 data] BEL. + +* `name`: The base64 encoded name of the file or other human readable text. +* `size`: How many bytes in the base64 data (for transfer progress). +* `width`: The display width specification (see below). Defaults to `auto`. +* `height`: The display height specification (see below). Defaults to `auto`. +* `preserveAspectRatio`: If `0`, scale/stretch the display to fit the space. + If `1` (the default), fill the display as much as possible without stretching. +* `inline`: If `0` (the default), download the file instead of displaying it. + If `1`, display the file in the terminal. +* `align`: Set the display alignment with `left` (the default), `right`, or + `center`. + +For the base64 encoded fields, make sure to omit whitespace (e.g. newlines) if +using a tool like `base64`. + +For the `width` & `height` fields, a number of forms are accepted. Note that +the terminal will probably restrict the maximum size automatically to the active +terminal dimensions. e.g. If the terminal is 1000 pixels wide, specifying a +width greater than that will automatically be limited to 1000 pixels. + +* `N`: How many cells (e.g. rows or columns) to fill. +* `Npx`: How many pixels to fill. +* `N%`: A percentage of the overall terminal screen. +* `auto`: Use the file's dimension. + +For inline display, currently only images in formats Chrome itself understands +are supported. ## Control Sequence Introducer (CSI) {#CSI} @@ -770,6 +820,8 @@ color selection. [SGR]: #SGR [SM]: #SM +[1337]: #OSC-1337 + [ECMA-35]: http://www.ecma-international.org/publications/standards/Ecma-035.htm [ECMA-43]: http://www.ecma-international.org/publications/standards/Ecma-043.htm [ECMA-48]: http://www.ecma-international.org/publications/standards/Ecma-048.htm diff --git a/hterm/etc/hterm-notify.sh b/hterm/etc/hterm-notify.sh index a93204544..4644afc5e 100755 --- a/hterm/etc/hterm-notify.sh +++ b/hterm/etc/hterm-notify.sh @@ -10,37 +10,49 @@ die() { exit 1 } -# Send a notification running under tmux. -# Usage: [title] [body] -notify_tmux() { - local title="${1-}" body="${2-}" - printf '\033Ptmux;\033\033]777;notify;%s;%s\a\033\\' "${title}" "${body}" +# Send a DCS sequence through tmux. +# Usage: +tmux_dcs() { + printf '\033Ptmux;\033%s\033\\' "$1" } -# Send a notification. -# Usage: [title] [body] -notify() { - local title="${1-}" body="${2-}" +# Send a DCS sequence through screen. +# Usage: +screen_dcs() { + printf '\033P\033%s\033\\' "$1" +} + +# Send an escape sequence to hterm. +# Usage: +print_seq() { + local seq="$1" case ${TERM-} in screen*) # Since tmux defaults to setting TERM=screen (ugh), we need to detect # it here specially. if [ -n "${TMUX-}" ]; then - notify_tmux "${title}" "${body}" + tmux_dcs "${seq}" else - printf '\033P\033\033]777;notify;%s;%s\a\033\\' "${title}" "${body}" + screen_dcs "${seq}" fi ;; tmux*) - notify_tmux "${title}" "${body}" + tmux_dcs "${seq}" ;; *) - printf '\033]777;notify;%s;%s\a' "${title}" "${body}" + echo "${seq}" ;; esac } +# Send a notification. +# Usage: [title] [body] +notify() { + local title="${1-}" body="${2-}" + print_seq "$(printf '\033]777;notify;%s;%s\a' "${title}" "${body}")" +} + # Write tool usage and exit. # Usage: [error message] usage() { diff --git a/hterm/etc/hterm-show-file.sh b/hterm/etc/hterm-show-file.sh new file mode 100755 index 000000000..46d943c0e --- /dev/null +++ b/hterm/etc/hterm-show-file.sh @@ -0,0 +1,120 @@ +#!/bin/sh +# Copyright 2017 The Chromium OS Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# Write an error message and exit. +# Usage: +die() { + echo "ERROR: $*" + exit 1 +} + +# Send a DCS sequence through tmux. +# Usage: +tmux_dcs() { + printf '\033Ptmux;\033%s\033\\' "$1" +} + +# Send a DCS sequence through screen. +# Usage: +screen_dcs() { + printf '\033P\033%s\033\\' "$1" +} + +# Send an escape sequence to hterm. +# Usage: +print_seq() { + local seq="$1" + + case ${TERM-} in + screen*) + # Since tmux defaults to setting TERM=screen (ugh), we need to detect + # it here specially. + if [ -n "${TMUX-}" ]; then + tmux_dcs "${seq}" + else + screen_dcs "${seq}" + fi + ;; + tmux*) + tmux_dcs "${seq}" + ;; + *) + echo "${seq}" + ;; + esac +} + +# Base64 encode stdin. +b64enc() { + base64 | tr -d '\n' +} + +# Get the image height/width via imagemagick if possible. +# Usage: +dimensions() { + identify -format 'width=%wpx;height=%hpx;' "$1" 2>/dev/null +} + +# Send the 1337 OSC sequence to display the file. +# Usage: +show() { + local name="$1" + local opts="inline=1;$2" + + print_seq "$(printf '\033]1337;File=name=%s;%s%s:%s\a' \ + "$(echo "$(basename "${name}")" | b64enc)" \ + "$(dimensions "${name}")" \ + "${opts}" \ + "$(b64enc <"${name}")")" +} + +# Write tool usage and exit. +# Usage: [error message] +usage() { + if [ $# -gt 0 ]; then + exec 1>&2 + fi + cat < [options] + +Send a file to hterm. It can be shown inline or downloaded. +This can also be used for small file transfers. +EOF + + if [ $# -gt 0 ]; then + echo + die "$@" + else + exit 0 + fi +} + +main() { + set -e + + while [ $# -gt 0 ]; do + case $1 in + -h|--help) + usage + ;; + -*) + usage "Unknown option: $1" + ;; + *) + break + ;; + esac + done + + if [ $# -eq 0 ]; then + die "Missing file to send" + fi + if [ $# -gt 2 ]; then + usage "Too many arguments" + fi + + show "$@" +} +main "$@" diff --git a/hterm/js/hterm_preference_manager.js b/hterm/js/hterm_preference_manager.js index 4edd4c19d..8e3f75280 100644 --- a/hterm/js/hterm_preference_manager.js +++ b/hterm/js/hterm_preference_manager.js @@ -52,6 +52,7 @@ hterm.PreferenceManager.categories.CopyPaste = 'CopyPaste'; hterm.PreferenceManager.categories.Sounds = 'Sounds'; hterm.PreferenceManager.categories.Scrolling = 'Scrolling'; hterm.PreferenceManager.categories.Encoding = 'Encoding'; +hterm.PreferenceManager.categories.Extensions = 'Extensions'; hterm.PreferenceManager.categories.Miscellaneous = 'Miscellaneous'; /** @@ -70,6 +71,8 @@ hterm.PreferenceManager.categoryDefinitions = [ text: 'Scrolling'}, { id: hterm.PreferenceManager.categories.Sounds, text: 'Sounds'}, + { id: hterm.PreferenceManager.categories.Extensions, + text: 'Extensions'}, { id: hterm.PreferenceManager.categories.Miscellaneous, text: 'Misc.'} ]; @@ -496,6 +499,12 @@ hterm.PreferenceManager.defaultPreferences = { 'user-css-text': [hterm.PreferenceManager.categories.Appearance, '', 'multiline-string', 'Custom CSS text for styling the terminal.'], + + 'allow-images-inline': + [hterm.PreferenceManager.categories.Extensions, null, 'tristate', + 'Whether to allow the remote side to display images in the terminal.\n' + + '\n' + + 'By default, we prompt until a choice is made.'], }; hterm.PreferenceManager.prototype = diff --git a/hterm/js/hterm_terminal.js b/hterm/js/hterm_terminal.js index 718096721..921a4435f 100644 --- a/hterm/js/hterm_terminal.js +++ b/hterm/js/hterm_terminal.js @@ -150,6 +150,9 @@ hterm.Terminal = function(opt_profileId) { this.realizeSize_(80, 24); this.setDefaultTabStops(); + // Whether we allow images to be shown. + this.allowImagesInline = null; + this.reportFocus = false; this.setProfile(opt_profileId || 'default', @@ -558,6 +561,10 @@ hterm.Terminal.prototype.setProfile = function(profileId, opt_callback) { terminal.primaryScreen_.wordBreakMatchMiddle = v; terminal.alternateScreen_.wordBreakMatchMiddle = v; }, + + 'allow-images-inline': function(v) { + terminal.allowImagesInline = v; + }, }); this.prefs_.readStorage(function() { @@ -2929,6 +2936,180 @@ hterm.Terminal.prototype.copyStringToClipboard = function(str) { copySource.parentNode.removeChild(copySource); }; +/** + * Display an image. + * + * @param {Object} options The image to display. + * @param {string=} options.name A human readable string for the image. + * @param {string|number=} options.size The size (in bytes). + * @param {boolean=} options.preserveAspectRatio Whether to preserve aspect. + * @param {boolean=} options.inline Whether to display the image inline. + * @param {string|number=} options.width The width of the image. + * @param {string|number=} options.height The height of the image. + * @param {string=} options.align Direction to align the image. + * @param {string} options.uri The source URI for the image. + */ +hterm.Terminal.prototype.displayImage = function(options) { + // Make sure we're actually given a resource to display. + if (options.uri === undefined) + return; + + // Set up the defaults to simplify code below. + if (!options.name) + options.name = ''; + + // Has the user approved image display yet? + if (this.allowImagesInline !== true) { + this.newLine(); + const row = this.getRowNode(this.scrollbackRows_.length + + this.getCursorRow() - 1); + + if (this.allowImagesInline === false) { + row.textContent = hterm.msg('POPUP_INLINE_IMAGE_DISABLED', [], + 'Inline Images Disabled'); + return; + } + + // Show a prompt. + let button; + const span = this.document_.createElement('span'); + span.innerText = hterm.msg('POPUP_INLINE_IMAGE', [], 'Inline Images'); + span.style.fontWeight = 'bold'; + span.style.borderWidth = '1px'; + span.style.borderStyle = 'dashed'; + button = this.document_.createElement('span'); + button.innerText = hterm.msg('BUTTON_BLOCK', [], 'block'); + button.style.marginLeft = '1em'; + button.style.borderWidth = '1px'; + button.style.borderStyle = 'solid'; + button.addEventListener('click', () => { + this.prefs_.set('allow-images-inline', false); + }); + span.appendChild(button); + button = this.document_.createElement('span'); + button.innerText = hterm.msg('BUTTON_ALLOW_SESSION', [], + 'allow this session'); + button.style.marginLeft = '1em'; + button.style.borderWidth = '1px'; + button.style.borderStyle = 'solid'; + button.addEventListener('click', () => { + this.allowImagesInline = true; + }); + span.appendChild(button); + button = this.document_.createElement('span'); + button.innerText = hterm.msg('BUTTON_ALLOW_ALWAYS', [], 'always allow'); + button.style.marginLeft = '1em'; + button.style.borderWidth = '1px'; + button.style.borderStyle = 'solid'; + button.addEventListener('click', () => { + this.prefs_.set('allow-images-inline', true); + }); + span.appendChild(button); + + row.appendChild(span); + return; + } + + // See if we should show this object directly, or download it. + if (options.inline) { + const io = this.io.push(); + io.showOverlay(hterm.msg('LOADING_RESOURCE_START', [options.name], + 'Loading $1 ...'), null); + + // While we're loading the image, eat all the user's input. + io.onVTKeystroke = io.sendString = () => {}; + + // Initialize this new image. + const img = this.document_.createElement('img'); + img.src = options.uri; + img.title = img.alt = options.name; + + // Attach the image to the page to let it load/render. It won't stay here. + // This is needed so it's visible and the DOM can calculate the height. If + // the image is hidden or not in the DOM, the height is always 0. + this.document_.body.appendChild(img); + + // Wait for the image to finish loading before we try moving it to the + // right place in the terminal. + img.onload = () => { + // Now that we have the image dimensions, figure out how to show it. + img.style.objectFit = options.preserveAspectRatio ? 'scale-down' : 'fill'; + img.style.maxWidth = `${this.document_.body.clientWidth}px`; + img.style.maxHeight = `${this.document_.body.clientHeight}px`; + + // Parse a width/height specification. + const parseDim = (dim, maxDim, cssVar) => { + if (!dim || dim == 'auto') + return ''; + + const ary = dim.match(/^([0-9]+)(px|%)?$/); + if (ary) { + if (ary[2] == '%') + return maxDim * parseInt(ary[1]) / 100 + 'px'; + else if (ary[2] == 'px') + return dim; + else + return `calc(${dim} * var(${cssVar}))`; + } + + return ''; + }; + img.style.width = + parseDim(options.width, this.document_.body.clientWidth, + '--hterm-charsize-width'); + img.style.height = + parseDim(options.height, this.document_.body.clientHeight, + '--hterm-charsize-height'); + + // Figure out how many rows the image occupies, then add that many. + // XXX: This count will be inaccurate if the font size changes on us. + const padRows = Math.ceil(img.clientHeight / + this.scrollPort_.characterSize.height); + for (let i = 0; i < padRows; ++i) + this.newLine(); + + // Update the max height in case the user shrinks the character size. + img.style.maxHeight = `calc(${padRows} * var(--hterm-charsize-height))`; + + // Move the image to the last row. This way when we scroll up, it doesn't + // disappear when the first row gets clipped. It will disappear when we + // scroll down and the last row is clipped ... + this.document_.body.removeChild(img); + // Create a wrapper node so we can do an absolute in a relative position. + // This helps with rounding errors between JS & CSS counts. + const div = this.document_.createElement('div'); + div.style.position = 'relative'; + div.style.textAlign = options.align; + img.style.position = 'absolute'; + img.style.bottom = 'calc(0px - var(--hterm-charsize-height))'; + div.appendChild(img); + const row = this.getRowNode(this.scrollbackRows_.length + + this.getCursorRow() - 1); + row.appendChild(div); + + io.hideOverlay(); + io.pop(); + }; + + // If we got a malformed image, give up. + img.onerror = (e) => { + this.document_.body.removeChild(img); + io.showOverlay(hterm.msg('LOADING_RESOURCE_FAILED', [options.name], + 'Loading $1 failed ...')); + io.pop(); + }; + } else { + // We can't use chrome.downloads.download as that requires "downloads" + // permissions, and that works only in extensions, not apps. + const a = this.document_.createElement('a'); + a.href = options.uri; + a.download = options.name; + this.document_.body.appendChild(a); + a.click(); + a.remove(); + } +}; + /** * Returns the selected text, or null if no text is selected. * diff --git a/hterm/js/hterm_terminal_tests.js b/hterm/js/hterm_terminal_tests.js index ea1572b11..bb22f0a74 100644 --- a/hterm/js/hterm_terminal_tests.js +++ b/hterm/js/hterm_terminal_tests.js @@ -319,3 +319,161 @@ hterm.Terminal.Tests.addTest('per-screen-cursor-state', function(result, cx) { result.pass(); }); + +/** + * Check image display handling when disabled. + */ +hterm.Terminal.Tests.addTest('display-img-disabled', function(result, cx) { + this.terminal.allowImagesInline = false; + + this.terminal.displayImage({uri: ''}); + const text = this.terminal.getRowsText(0, 1); + result.assertEQ('Inline Images Disabled', text); + + result.pass(); +}); + +/** + * Check image display handling when not yet decided. + */ +hterm.Terminal.Tests.addTest('display-img-prompt', function(result, cx) { + this.terminal.allowImagesInline = null; + + // Search for the block & allow buttons. + this.terminal.displayImage({uri: ''}); + const text = this.terminal.getRowsText(0, 1); + result.assert(text.includes('block')); + result.assert(text.includes('allow')); + + result.pass(); +}); + +/** + * Check simple image display handling. + */ +hterm.Terminal.Tests.addTest('display-img-normal', function(result, cx) { + this.terminal.allowImagesInline = true; + + // This is a 16px x 8px gif. + const data = 'R0lGODdhCAAQAIAAAP///wAAACwAAAAACAAQAAACFkSAhpfMC1uMT1mabHWZy6t1U/htQAEAOw=='; + + // Display an image that only takes up one row. + this.terminal.displayImage({ + height: '2px', + inline: true, + align: 'center', + uri: `data:application/octet-stream;base64,${data}`, + }); + + // Hopefully 100msecs is enough time for Chrome to load the image. + setTimeout(() => { + result.assertEQ(1, this.terminal.getCursorRow()); + const row = this.terminal.getRowNode(0); + const container = row.childNodes[1]; + const img = container.childNodes[0]; + + result.assertEQ('center', container.style.textAlign); + result.assertEQ(2, img.clientHeight); + + result.pass(); + }, 100); + + result.requestTime(200); +}); + +/** + * Check handling of image dimensions. + */ +hterm.Terminal.Tests.addTest('display-img-dimensions', function(result, cx) { + this.terminal.allowImagesInline = true; + + // This is a 16px x 8px gif. + const data = 'R0lGODdhCAAQAIAAAP///wAAACwAAAAACAAQAAACFkSAhpfMC1uMT1mabHWZy6t1U/htQAEAOw=='; + + // Display an image that only takes up one row. + this.terminal.displayImage({ + height: '4', + width: '75%', + inline: true, + uri: `data:application/octet-stream;base64,${data}`, + }); + + // Hopefully 100msecs is enough time for Chrome to load the image. + setTimeout(() => { + result.assertEQ(4, this.terminal.getCursorRow()); + const row = this.terminal.getRowNode(3); + const container = row.childNodes[1]; + const img = container.childNodes[0]; + + // The image should be 4 rows tall. + result.assert(img.clientHeight == + this.terminal.scrollPort_.characterSize.height * 4); + + // Do a range check for the percentage width. + const bodyWidth = this.terminal.document_.body.clientWidth; + result.assert(img.clientWidth > bodyWidth * 0.70); + result.assert(img.clientWidth < bodyWidth * 0.80); + + result.pass(); + }, 100); + + result.requestTime(200); +}); + +/** + * Check handling of max image dimensions. + */ +hterm.Terminal.Tests.addTest('display-img-max-dimensions', function(result, cx) { + this.terminal.allowImagesInline = true; + + // This is a 16px x 8px gif. + const data = 'R0lGODdhCAAQAIAAAP///wAAACwAAAAACAAQAAACFkSAhpfMC1uMT1mabHWZy6t1U/htQAEAOw=='; + + // Display an image that only takes up one row. + this.terminal.displayImage({ + height: '4000px', + width: '1000', + inline: true, + uri: `data:application/octet-stream;base64,${data}`, + }); + + // Hopefully 100msecs is enough time for Chrome to load the image. + setTimeout(() => { + const rowNum = this.terminal.screen_.getHeight() - 1; + result.assertEQ(rowNum, this.terminal.getCursorRow()); + const row = this.terminal.getRowNode(rowNum); + const container = row.childNodes[1]; + const img = container.childNodes[0]; + + // The image should take up the whole screen, but not more. + const body = this.terminal.document_.body; + result.assertEQ(img.clientHeight, body.clientHeight); + result.assertEQ(img.clientWidth, body.clientWidth); + + result.pass(); + }, 100); + + result.requestTime(200); +}); + +/** + * Check loading of invalid images doesn't wedge the terminal. + */ +hterm.Terminal.Tests.addTest('display-img-invalid', function(result, cx) { + this.terminal.allowImagesInline = true; + + // The data is invalid image content. + this.terminal.displayImage({ + inline: true, + uri: 'data:application/octet-stream;base64,asdf', + }); + + // Hopefully 100msecs is enough time for Chrome to load the image. + setTimeout(() => { + // The cursor should not have advanced. + result.assertEQ(0, this.terminal.getCursorRow()); + result.pass(); + }, 100); + + result.requestTime(200); +}); diff --git a/hterm/js/hterm_vt.js b/hterm/js/hterm_vt.js index ffec1a2ba..65f91b009 100644 --- a/hterm/js/hterm_vt.js +++ b/hterm/js/hterm_vt.js @@ -1896,6 +1896,85 @@ hterm.VT.OSC['52'] = function(parseState) { this.terminal.copyStringToClipboard(this.decode(data)); }; +/** + * iTerm2 extended sequences. + * + * We only support image display atm. + */ +hterm.VT.OSC['1337'] = function(parseState) { + // Args come in as a set of key value pairs followed by data. + // File=name=;size=123;inline=1: + let args = parseState.args[0].match(/^File=([^:]*):([\s\S]*)$/m); + if (!args) { + if (this.warnUnimplemented) + console.log(`iTerm2 1337: unsupported sequence: ${args[1]}`); + return; + } + + const options = { + name: '', + size: 0, + preserveAspectRatio: true, + inline: false, + width: 'auto', + height: 'auto', + align: 'left', + uri: 'data:application/octet-stream;base64,' + args[2].replace(/[\n\r]+/gm, ''), + }; + // Walk the "key=value;" sets. + args[1].split(';').forEach((ele) => { + const kv = ele.match(/^([^=]+)=(.*)$/m); + if (!kv) + return; + + // Sanitize values nicely. + switch (kv[1]) { + case 'name': + try { + options.name = window.atob(kv[2]); + } catch (e) {} + break; + case 'size': + try { + options.size = parseInt(kv[2]); + } catch (e) {} + break; + case 'width': + options.width = kv[2]; + break; + case 'height': + options.height = kv[2]; + break; + case 'preserveAspectRatio': + options.preserveAspectRatio = !(kv[2] == '0'); + break; + case 'inline': + options.inline = !(kv[2] == '0'); + break; + // hterm-specific keys. + case 'align': + options.align = kv[2]; + break; + default: + // Ignore unknown keys. Don't want remote stuffing our JS env. + break; + } + }); + + // This is a bit of a hack. If the buffer has data following the image, we + // need to delay processing of it until after we've finished with the image. + // Otherwise while we wait for the the image to load asynchronously, the new + // text data will intermingle with the image. + if (options.inline) { + const io = this.terminal.io; + const queued = parseState.peekRemainingBuf(); + parseState.advance(queued.length); + this.terminal.displayImage(options); + io.writeUTF8(queued); + } else + this.terminal.displayImage(options); +}; + /** * URxvt perl modules. * diff --git a/hterm/js/hterm_vt_tests.js b/hterm/js/hterm_vt_tests.js index dccc62dd5..3da172ca9 100644 --- a/hterm/js/hterm_vt_tests.js +++ b/hterm/js/hterm_vt_tests.js @@ -1981,6 +1981,130 @@ hterm.VT.Tests.addTest('OSC-777-notify', function(result, cx) { result.pass(); }); +/** + * Test iTerm2 1337 non-file transfers. + */ +hterm.VT.Tests.addTest('OSC-1337-ignore', function(result, cx) { + this.terminal.displayImage = + () => result.fail('Unknown should not trigger file display'); + + this.terminal.interpret('\x1b]1337;CursorShape=1\x07'); + + result.pass(); +}); + +/** + * Test iTerm2 1337 file transfer defaults. + */ +hterm.VT.Tests.addTest('OSC-1337-file-defaults', function(result, cx) { + this.terminal.displayImage = (options) => { + result.assertEQ('', options.name); + result.assertEQ(0, options.size); + result.assertEQ(true, options.preserveAspectRatio); + result.assertEQ(false, options.inline); + result.assertEQ('auto', options.width); + result.assertEQ('auto', options.height); + result.assertEQ('left', options.align); + result.assertEQ('data:application/octet-stream;base64,Cg==', + options.uri); + result.pass(); + }; + + this.terminal.interpret('\x1b]1337;File=:Cg==\x07'); +}); + +/** + * Test iTerm2 1337 invalid values. + */ +hterm.VT.Tests.addTest('OSC-1337-file-invalid', function(result, cx) { + this.terminal.displayImage = (options) => { + result.assertEQ('', options.name); + result.assertEQ(1, options.size); + result.assertEQ(undefined, options.unk); + result.pass(); + }; + + this.terminal.interpret( + '\x1b]1337;File=' + + // Ignore unknown keys. + 'unk=key;' + + // The name is supposed to be base64 encoded. + 'name=[oo]ps;' + + // Include a valid field to make sure we parsed it all + 'size=1;;;:Cg==\x07'); +}); + +/** + * Test iTerm2 1337 valid options. + */ +hterm.VT.Tests.addTest('OSC-1337-file-valid', function(result, cx) { + // Check "false" values. + this.terminal.displayImage = (options) => { + result.assertEQ(false, options.preserveAspectRatio); + result.assertEQ(false, options.inline); + }; + this.terminal.interpret( + '\x1b]1337;File=preserveAspectRatio=0;inline=0:Cg==\x07'); + + // Check "true" values. + this.terminal.displayImage = (options) => { + result.assertEQ(true, options.preserveAspectRatio); + result.assertEQ(true, options.inline); + }; + this.terminal.interpret( + '\x1b]1337;File=preserveAspectRatio=1;inline=1:Cg==\x07'); + + // Check the rest. + this.terminal.displayImage = (options) => { + result.assertEQ('yes', options.name); + result.assertEQ(1234, options.size); + result.assertEQ('12px', options.width); + result.assertEQ('50%', options.height); + result.assertEQ('center', options.align); + + result.pass(); + }; + this.terminal.interpret( + '\x1b]1337;File=' + + 'name=eWVz;' + + 'size=1234;' + + 'width=12px;' + + 'height=50%;' + + 'align=center;' + + ':Cg==\x07'); +}); + +/** + * Test handling of extra data after an iTerm2 1337 file sequence. + */ +hterm.VT.Tests.addTest('OSC-1337-file-queue', function(result, cx) { + let text; + + // For non-inline files, things will be processed right away. + this.terminal.displayImage = () => {}; + this.terminal.interpret('\x1b]1337;File=:Cg==\x07OK'); + text = this.terminal.getRowsText(0, 1); + result.assertEQ('OK', text); + + // For inline files, things should be delayed. + // The io/timeout logic is supposed to mock the normal behavior. + this.terminal.displayImage = function() { + const io = this.io.push(); + setTimeout(() => { + io.pop(); + text = this.getRowsText(0, 1); + result.assertEQ('OK', text); + result.pass(); + }, 0); + }; + this.terminal.clearHome(); + this.terminal.interpret('\x1b]1337;File=inline=1:Cg==\x07OK'); + text = this.terminal.getRowsText(0, 1); + result.assertEQ('', text); + + result.requestTime(200); +}); + /** * Test the cursor shape changes using DECSCUSR. */ diff --git a/nassh/_locales/en/messages.json b/nassh/_locales/en/messages.json index f1d8e0ae9..4baf7603d 100644 --- a/nassh/_locales/en/messages.json +++ b/nassh/_locales/en/messages.json @@ -215,6 +215,50 @@ } } }, + "HTERM_BUTTON_ALLOW_ALWAYS": { + "description": "Button label for always allowing the requested feature.", + "message": "always allow" + }, + "HTERM_BUTTON_ALLOW_SESSION": { + "description": "Button label for allowing the requested feature for this session only.", + "message": "allow this session" + }, + "HTERM_BUTTON_BLOCK": { + "description": "Button label for blocking the requested feature.", + "message": "block" + }, + "HTERM_LOADING_RESOURCE_FAILED": { + "description": "Message shown to the user when loading a resource (image, audio file, video, etc...) failed for any reason.", + "message": "Loading $RESOURCE$ failed ...", + "placeholders": { + "resource": { + "content": "$1", + "example": "some-image.gif" + } + } + }, + "HTERM_LOADING_RESOURCE_START": { + "description": "Message shown to the user when loading a resource (image, audio file, video, etc...).", + "message": "Loading $RESOURCE$ ...", + "placeholders": { + "resource": { + "content": "$1", + "example": "some-image.gif" + } + } + }, + "HTERM_POPUP_INLINE_IMAGE": { + "description": "Message shown when asking users how they want to display inline images.", + "message": "Inline Images" + }, + "HTERM_POPUP_INLINE_IMAGE_DISABLED": { + "description": "Message shown to the user when trying to display an image but support is disabled.", + "message": "Inline Images Disabled" + }, + "HTERM_PREF_ALLOW_IMAGES_INLINE": { + "description": "Help text shown for the hterm 'allow-images-inline' preference.", + "message": "Whether to allow the remote side to display images in the terminal.\n\nBy default, we prompt until a choice is made." + }, "HTERM_PREF_ALT_BACKSPACE_IS_META_BACKSPACE": { "description": "Help text shown for the hterm 'alt-backspace-is-meta-backspace' preference.", "message": "If set, undoes the Chrome OS Alt-Backspace->DEL remap, so that alt-backspace indeed is alt-backspace." @@ -671,6 +715,10 @@ "description": "Helpful tip shown to user advertising features. Do not translate 'Yubikeys' as it's a product name.", "message": "Use Yubikeys and other smart cards for ssh auth: https://goo.gl/3ZEU1w" }, + "TIP_13": { + "description": "Helpful tip shown to user advertising features.", + "message": "Display images inline: https://goo.gl/MnSysj" + }, "TIP_2": { "description": "Helpful tip shown to user advertising features. Do not translate 'hterm-notify' as it's the name of a program.", "message": "Display notifications in the browser using hterm-notify: https://goo.gl/ZNxGdF" diff --git a/nassh/doc/FAQ.md b/nassh/doc/FAQ.md index 88b0f603e..6a9794a53 100644 --- a/nassh/doc/FAQ.md +++ b/nassh/doc/FAQ.md @@ -1011,7 +1011,18 @@ different app, visit the chrome://settings/handlers page. $ hterm-notify.sh "Some Title" "Lots of text here." +### How do I view images? + + We support the iTerm2's OSC 1337 file transfer sequence. The protocol is a + little bit complicated, so there's a [hterm-show-file.sh] helper script for + you. iTerm2's "imgcat" script should also work. + + For more details on the options available, see the + [specification](../../hterm/doc/ControlSequences.md#OSC-1337). + + [hterm-notify.sh]: ../../hterm/etc/hterm-notify.sh +[hterm-show-file.sh]: ../../hterm/etc/hterm-show-file.sh [osc52.el]: ../../hterm/etc/osc52.el [osc52.sh]: ../../hterm/etc/osc52.sh [osc52.vim]: ../../hterm/etc/osc52.vim diff --git a/nassh/js/nassh_command_instance.js b/nassh/js/nassh_command_instance.js index b85a095a9..7d299ac31 100644 --- a/nassh/js/nassh_command_instance.js +++ b/nassh/js/nassh_command_instance.js @@ -163,7 +163,7 @@ nassh.CommandInstance.prototype.run = function() { } // Display a random tip every time they launch to advertise features. - var num = lib.f.randomInt(1, 12); + let num = lib.f.randomInt(1, 13); this.io.println(''); this.io.println(nassh.msg('WELCOME_TIP_OF_DAY', [num, nassh.msg(`TIP_${num}`)]));