diff --git a/lib/components/term.tsx b/lib/components/term.tsx index 45c1464c97cc..b02a70455f69 100644 --- a/lib/components/term.tsx +++ b/lib/components/term.tsx @@ -18,6 +18,7 @@ import {WebglAddon} from 'xterm-addon-webgl'; import type {TermProps} from '../../typings/hyper'; import terms from '../terms'; +import {handleOsc52Clipboard} from '../utils/osc52'; import processClipboard from '../utils/paste'; import {decorate} from '../utils/plugins'; @@ -170,6 +171,13 @@ export default class Term extends React.PureComponent< this.termOptions = getTermOptions(props); this.term = props.term || new Terminal(this.termOptions); + this.disposableListeners.push( + this.term.parser.registerOscHandler(52, (data) => + handleOsc52Clipboard(data, (text) => { + clipboard.writeText(text); + }) + ) + ); this.defaultBellSound = new Audio( // Source: https://freesound.org/people/altemark/sounds/45759/ // This sound is released under the Creative Commons Attribution 3.0 Unported diff --git a/lib/utils/osc52.ts b/lib/utils/osc52.ts new file mode 100644 index 000000000000..e83068d453b5 --- /dev/null +++ b/lib/utils/osc52.ts @@ -0,0 +1,44 @@ +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +/** + * Decode an OSC 52 system clipboard write. + * + * Hyper intentionally handles writes only. Clipboard reads would expose the + * host clipboard to processes running in the terminal. + */ +export function decodeOsc52Clipboard(data: string): string | undefined { + const separator = data.indexOf(';'); + if (separator < 0 || data.slice(0, separator) !== 'c') { + return undefined; + } + + const encoded = data.slice(separator + 1); + if (encoded === '?') { + return undefined; + } + + if (encoded === '!') { + return ''; + } + + if (!BASE64_PATTERN.test(encoded)) { + return undefined; + } + + const decoded = Buffer.from(encoded, 'base64'); + if (decoded.toString('base64') !== encoded) { + return undefined; + } + + return decoded.toString('utf8'); +} + +export function handleOsc52Clipboard(data: string, writeClipboard: (text: string) => void): boolean { + const text = decodeOsc52Clipboard(data); + if (text === undefined) { + return false; + } + + writeClipboard(text); + return true; +} diff --git a/test/unit/osc52.test.ts b/test/unit/osc52.test.ts new file mode 100644 index 000000000000..50decac4ad73 --- /dev/null +++ b/test/unit/osc52.test.ts @@ -0,0 +1,36 @@ +import test from 'ava'; + +import {decodeOsc52Clipboard, handleOsc52Clipboard} from '../../lib/utils/osc52'; + +test('decodes OSC 52 system clipboard writes', (t) => { + const encoded = Buffer.from('Olá, Hyper!').toString('base64'); + + t.is(decodeOsc52Clipboard(`c;${encoded}`), 'Olá, Hyper!'); +}); + +test('ignores OSC 52 clipboard reads', (t) => { + t.is(decodeOsc52Clipboard('c;?'), undefined); +}); + +test('ignores non-system clipboard selections', (t) => { + const encoded = Buffer.from('primary').toString('base64'); + + t.is(decodeOsc52Clipboard(`p;${encoded}`), undefined); +}); + +test('rejects malformed OSC 52 payloads', (t) => { + t.is(decodeOsc52Clipboard('c;not-base64'), undefined); + t.is(decodeOsc52Clipboard('c;'), ''); +}); + +test('writes decoded OSC 52 data through the host clipboard', (t) => { + let copied = ''; + const encoded = Buffer.from('from a pane').toString('base64'); + + t.true( + handleOsc52Clipboard(`c;${encoded}`, (text) => { + copied = text; + }) + ); + t.is(copied, 'from a pane'); +});