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}`)]));