diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 3c88b94816..50a1b3d140 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -177,6 +177,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this.register(this._inputHandler.onRequestReset(() => this.reset())); this.register(this._inputHandler.onRequestWindowsOptionsReport(type => this._reportWindowsOptions(type))); this.register(this._inputHandler.onColor((event) => this._handleColorEvent(event))); + this.register(this._inputHandler.onClipboard((event) => this.coreService.triggerDataEvent(event))); this.register(forwardEvent(this._inputHandler.onCursorMove, this._onCursorMove)); this.register(forwardEvent(this._inputHandler.onTitleChange, this._onTitleChange)); this.register(forwardEvent(this._inputHandler.onA11yChar, this._onA11yCharEmitter)); diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index c518255430..570753928e 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -22,7 +22,7 @@ */ import { Disposable, toDisposable } from 'common/Lifecycle'; -import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services'; +import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService, IOscClipboardService } from 'common/services/Services'; import { InstantiationService } from 'common/services/InstantiationService'; import { LogService } from 'common/services/LogService'; import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService'; @@ -39,6 +39,7 @@ import { IBufferSet } from 'common/buffer/Types'; import { InputHandler } from 'common/InputHandler'; import { WriteBuffer } from 'common/input/WriteBuffer'; import { OscLinkService } from 'common/services/OscLinkService'; +import { OscClipboardService } from 'common/services/OscClipboardService'; // Only trigger this warning a single time per session let hasWriteSyncWarnHappened = false; @@ -49,6 +50,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { protected readonly _logService: ILogService; protected readonly _charsetService: ICharsetService; protected readonly _oscLinkService: IOscLinkService; + protected readonly _oscClipboardService: IOscClipboardService; public readonly coreMouseService: ICoreMouseService; public readonly coreService: ICoreService; @@ -119,9 +121,11 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this._instantiationService.setService(ICharsetService, this._charsetService); this._oscLinkService = this._instantiationService.createInstance(OscLinkService); this._instantiationService.setService(IOscLinkService, this._oscLinkService); + this._oscClipboardService = this._instantiationService.createInstance(OscClipboardService); + this._instantiationService.setService(IOscClipboardService, this._oscClipboardService); // Register input handler and handle/forward events - this._inputHandler = this.register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService)); + this._inputHandler = this.register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this._oscClipboardService, this.coreMouseService, this.unicodeService)); this.register(forwardEvent(this._inputHandler.onLineFeed, this._onLineFeed)); this.register(this._inputHandler); @@ -129,7 +133,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(forwardEvent(this._bufferService.onResize, this._onResize)); this.register(forwardEvent(this.coreService.onData, this._onData)); this.register(forwardEvent(this.coreService.onBinary, this._onBinary)); - this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput())); + this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput())); this.register(this.optionsService.onSpecificOptionChange('windowsMode', e => this._handleWindowsModeOptionChange(e))); this.register(this._bufferService.onScroll(event => { this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL }); diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index d9127e7b20..8766702045 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -11,7 +11,7 @@ import { CellData } from 'common/buffer/CellData'; import { Attributes, UnderlineStyle } from 'common/buffer/Constants'; import { AttributeData } from 'common/buffer/AttributeData'; import { Params } from 'common/parser/Params'; -import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test'; +import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService, MockOscClipboardService } from 'common/TestUtils.test'; import { IBufferService, ICoreService } from 'common/services/Services'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { clone } from 'common/Clone'; @@ -59,6 +59,7 @@ describe('InputHandler', () => { let bufferService: IBufferService; let coreService: ICoreService; let optionsService: MockOptionsService; + let clipboardService: MockOscClipboardService; let inputHandler: TestInputHandler; beforeEach(() => { @@ -66,8 +67,9 @@ describe('InputHandler', () => { bufferService = new BufferService(optionsService); bufferService.resize(80, 30); coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService); + clipboardService = new MockOscClipboardService(); - inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); + inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), clipboardService, new MockCoreMouseService(), new MockUnicodeService()); }); describe('SL/SR/DECIC/DECDC', () => { @@ -236,7 +238,7 @@ describe('InputHandler', () => { describe('setMode', () => { it('should toggle bracketedPasteMode', () => { const coreService = new MockCoreService(); - const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); + const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService()); // Set bracketed paste mode inputHandler.setModePrivate(Params.fromArray([2004])); assert.equal(coreService.decPrivateModes.bracketedPasteMode, true); @@ -261,6 +263,7 @@ describe('InputHandler', () => { new MockLogService(), new MockOptionsService(), new MockOscLinkService(), + new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -307,6 +310,7 @@ describe('InputHandler', () => { new MockLogService(), new MockOptionsService(), new MockOscLinkService(), + new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -357,6 +361,7 @@ describe('InputHandler', () => { new MockLogService(), new MockOptionsService(), new MockOscLinkService(), + new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -394,6 +399,7 @@ describe('InputHandler', () => { new MockLogService(), new MockOptionsService(), new MockOscLinkService(), + new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -444,6 +450,7 @@ describe('InputHandler', () => { new MockLogService(), new MockOptionsService(), new MockOscLinkService(), + new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -570,6 +577,7 @@ describe('InputHandler', () => { new MockLogService(), new MockOptionsService(), new MockOscLinkService(), + new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -593,7 +601,7 @@ describe('InputHandler', () => { beforeEach(() => { bufferService = new MockBufferService(80, 30); - handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); + handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService()); }); it('should handle DECSET/DECRST 47 (alt screen buffer)', async () => { await handler.parseP('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST'); @@ -790,7 +798,7 @@ describe('InputHandler', () => { describe('colon notation', () => { let inputHandler2: TestInputHandler; beforeEach(() => { - inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); + inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService()); }); describe('should equal to semicolon', () => { it('CSI 38:2::50:100:150 m', async () => { @@ -1951,6 +1959,34 @@ describe('InputHandler', () => { assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: 0, color: [170, 187, 204] }, { type: ColorRequestType.SET, index: 123, color: [0, 17, 34] }]]); stack.length = 0; }); + describe('52: manipulate selection data', async () => { + const testData = 'hello world'; + + it('52: set invalid base64 clipboard string', async () => { + const stack: string[] = []; + inputHandler.onClipboard(ev => stack.push(ev)); + await inputHandler.parseP(`\x1b]52;c;${btoa(testData)}=\x07`); + await inputHandler.parseP(`\x1b]52;c;?\x07`); + assert.deepEqual(stack, ['']); + stack.length = 0; + }); + it('52: set and query clipboard data', async () => { + const stack: string[] = []; + inputHandler.onClipboard(ev => stack.push(ev)); + await inputHandler.parseP(`\x1b]52;c;${btoa(testData)}\x07`); + await inputHandler.parseP(`\x1b]52;c;?\x07`); + assert.deepEqual(stack, [btoa(testData)]); + stack.length = 0; + }); + it('52: clear clipboard data', async () => { + const stack: string[] = []; + inputHandler.onClipboard(ev => stack.push(ev)); + await inputHandler.parseP(`\x1b]52;c;!\x07`); + await inputHandler.parseP(`\x1b]52;c;?\x07`); + assert.deepEqual(stack, ['']); + stack.length = 0; + }); + }); it('104: restore events', async () => { const stack: IColorEvent[] = []; inputHandler.onColor(ev => stack.push(ev)); @@ -1963,7 +1999,7 @@ describe('InputHandler', () => { stack.length = 0; // full ANSI table restore await inputHandler.parseP('\x1b]104\x07'); - assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE}]]); + assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE }]]); }); it('10: FG set & query events', async () => { @@ -2272,7 +2308,7 @@ describe('InputHandler - async handlers', () => { coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService); coreService.onData(data => { console.log(data); }); - inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); + inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService()); }); it('async CUP with CPR check', async () => { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 95ec00c1e5..3d6baf571e 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -16,7 +16,7 @@ import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } fr import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; import { AttributeData } from 'common/buffer/AttributeData'; -import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services'; +import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService, IOscClipboardService } from 'common/services/Services'; import { OscHandler } from 'common/parser/OscParser'; import { DcsHandler } from 'common/parser/DcsParser'; import { IBuffer } from 'common/buffer/Types'; @@ -159,6 +159,8 @@ export class InputHandler extends Disposable implements IInputHandler { public readonly onTitleChange = this._onTitleChange.event; private readonly _onColor = this.register(new EventEmitter()); public readonly onColor = this._onColor.event; + private readonly _onClipboard = this.register(new EventEmitter()); + public readonly onClipboard = this._onClipboard.event; private _parseStack: IParseStack = { paused: false, @@ -175,6 +177,7 @@ export class InputHandler extends Disposable implements IInputHandler { private readonly _logService: ILogService, private readonly _optionsService: IOptionsService, private readonly _oscLinkService: IOscLinkService, + private readonly _oscClipboardService: IOscClipboardService, private readonly _coreMouseService: ICoreMouseService, private readonly _unicodeService: IUnicodeService, private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser() @@ -320,6 +323,7 @@ export class InputHandler extends Disposable implements IInputHandler { // 50 - Set Font to Pt. // 51 - reserved for Emacs shell. // 52 - Manipulate Selection Data. + this._parser.registerOscHandler(52, new OscHandler(data => this.setOrReportClipboard(data))); // 104 ; c - Reset Color Number c. this._parser.registerOscHandler(104, new OscHandler(data => this.restoreIndexedColor(data))); // 105 ; c - Reset Special Color Number c. @@ -3027,6 +3031,59 @@ export class InputHandler extends Disposable implements IInputHandler { return this._setOrReportSpecialColor(data, 2); } + /** + * OSC 52 ; ; | BEL - set or query selection and clipboard data + * + * Test case: + * + * ```sh + * printf "\e]52;c;%s\a" "$(echo -n "Hello, World" | base64)" + * ``` + * + * @vt: #Y OSC 52 "Manipulate Selection Data" "OSC 52 ; Pc ; Pd BEL" "Set or query selection and clipboard data." + * Pc is the selection name. Can be one of: + * - `c` - clipboard + * - `p` - primary + * - `q` - secondary + * - `s` - select + * - `0-7` - cut-buffers 0-7 + * Only the `c` selection (clipboard) is supported by xterm.js. The browser + * Clipboard API only supports the clipboard selection. + * + * Pd is the base64 encoded data. + * If Pd is `?`, the terminal returns the current clipboard contents. + * If Pd is neither base64 encoded nor `?`, then the clipboard is cleared. + */ + public setOrReportClipboard(data: string): Promise { + return this._setOrReportClipboard(data); + } + + private _setOrReportClipboard(data: string): Promise { + const args = data.split(';'); + if (args.length < 2) { + return Promise.resolve(false); + } + switch (args[0]) { + case 'c': + const pd = args[1]; + if (pd === '?') { + // Reply with the current clipboard contents encoded in base64 + return this._oscClipboardService.readData() + .then(data => { + this._onClipboard.fire(btoa(data)); + return true; + }); + } + try { + const decoded = atob(pd); + return this._oscClipboardService.putData(decoded).then(() => true); + } catch { + return this._oscClipboardService.putData('').then(() => true); + } + } + return Promise.resolve(false); + } + /** * OSC 104 ; ST - restore ANSI color * diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 3aa0f69455..a22e6346a2 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService } from 'common/services/Services'; +import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, IOscClipboardService } from 'common/services/Services'; import { IEvent, EventEmitter } from 'common/EventEmitter'; import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; @@ -156,6 +156,18 @@ export class MockOscLinkService implements IOscLinkService { } } +export class MockOscClipboardService implements IOscClipboardService { + private _clipboard: string = ''; + + public putData(data: string): Promise { + this._clipboard = data; + return Promise.resolve(); + } + public readData(): Promise { + return Promise.resolve(this._clipboard); + } +} + // defaults to V6 always to keep tests passing export class MockUnicodeService implements IUnicodeService { public serviceBrand: any; diff --git a/src/common/services/OscClipboardService.test.ts b/src/common/services/OscClipboardService.test.ts new file mode 100644 index 0000000000..71b517e602 --- /dev/null +++ b/src/common/services/OscClipboardService.test.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2020 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { OscClipboardService } from 'common/services/OscClipboardService'; +import { IOscClipboardService } from 'common/services/Services'; + +describe('OscClipboardService', () => { + it('constructor', () => { + const testData = 'Hello world!'; + let oscClipboardService: IOscClipboardService; + beforeEach(() => { + oscClipboardService = new OscClipboardService(); + }); + it('should be able to write data to the clipboard', async () => { + assert.ok(await oscClipboardService.putData(testData)); + }); + it('should be able to read data from the clipboard', async () => { + const data = await oscClipboardService.readData(); + assert.equal(data, testData); + }); + }); +}); diff --git a/src/common/services/OscClipboardService.ts b/src/common/services/OscClipboardService.ts new file mode 100644 index 0000000000..8d46d3b359 --- /dev/null +++ b/src/common/services/OscClipboardService.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IOscClipboardService } from 'common/services/Services'; + +/** + * A service that handles OSC 52 clipboard data. + * + * This service is used to handle OSC 52 selection and clipboard data + * manipulation. It uses the Clipboard API to write and read to and from the + * clipboard. The OSC52 protocol supports writing and reading to and from + * different selections. However, the browser Clipboard API only supports + * reading and writing to the clipboard selection. + */ +export class OscClipboardService implements IOscClipboardService { + /** + * Writes data to the clipboard. + * + * This is an async operation since we're using the Clipboard API. + */ + public putData(data: string): Promise { + return navigator.clipboard.writeText(data); + } + + /** + * Reads data from the clipboard. + * + * This is an async operation since we're using the Clipboard API. + */ + public readData(): Promise { + return navigator.clipboard.readText(); + } +} diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index cc3880630a..514cdbd3f4 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -133,15 +133,15 @@ export interface IBrandedService { type GetLeadingNonServiceArgs = Args extends [...IBrandedService[]] ? [] - : Args extends [infer A1, ...IBrandedService[]] ? [A1] - : Args extends [infer A1, infer A2, ...IBrandedService[]] ? [A1, A2] - : Args extends [infer A1, infer A2, infer A3, ...IBrandedService[]] ? [A1, A2, A3] - : Args extends [infer A1, infer A2, infer A3, infer A4, ...IBrandedService[]] ? [A1, A2, A3, A4] - : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, ...IBrandedService[]] ? [A1, A2, A3, A4, A5] - : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6, ...IBrandedService[]] ? [A1, A2, A3, A4, A5, A6] - : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6, infer A7, ...IBrandedService[]] ? [A1, A2, A3, A4, A5, A6, A7] - : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6, infer A7, infer A8, ...IBrandedService[]] ? [A1, A2, A3, A4, A5, A6, A7, A8] - : never; + : Args extends [infer A1, ...IBrandedService[]] ? [A1] + : Args extends [infer A1, infer A2, ...IBrandedService[]] ? [A1, A2] + : Args extends [infer A1, infer A2, infer A3, ...IBrandedService[]] ? [A1, A2, A3] + : Args extends [infer A1, infer A2, infer A3, infer A4, ...IBrandedService[]] ? [A1, A2, A3, A4] + : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, ...IBrandedService[]] ? [A1, A2, A3, A4, A5] + : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6, ...IBrandedService[]] ? [A1, A2, A3, A4, A5, A6] + : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6, infer A7, ...IBrandedService[]] ? [A1, A2, A3, A4, A5, A6, A7] + : Args extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6, infer A7, infer A8, ...IBrandedService[]] ? [A1, A2, A3, A4, A5, A6, A7, A8] + : never; export const IInstantiationService = createDecorator('InstantiationService'); export interface IInstantiationService { @@ -300,6 +300,18 @@ export interface IOscLinkService { getLinkData(linkId: number): IOscLinkData | undefined; } +export const IOscClipboardService = createDecorator('OscClipboardService'); +export interface IOscClipboardService { + /** + * Writes data to the clipboard. + */ + putData(data: string): Promise; + /** + * Reads data from the clipboard. + */ + readData(): Promise; +} + export const IUnicodeService = createDecorator('UnicodeService'); export interface IUnicodeService { serviceBrand: undefined;