Skip to content

Commit

Permalink
Add support to ANSI OSC52
Browse files Browse the repository at this point in the history
Add support to ANSI OSC52 sequence to manipulate selection and clipboard
data. The sequence specs supports multiple selections but due to the
browser limitations (Clipboard API), this PR only supports manipulating
the clipboard selection.

Reference: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
Fixes: #3260
  • Loading branch information
aymanbagabas committed Oct 19, 2022
1 parent d5710af commit a0ab52d
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 13 deletions.
1 change: 1 addition & 0 deletions src/browser/Terminal.ts
Expand Up @@ -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));
Expand Down
10 changes: 7 additions & 3 deletions src/common/CoreTerminal.ts
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -119,17 +121,19 @@ 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);

// Setup listeners
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 });
Expand Down
48 changes: 41 additions & 7 deletions src/common/InputHandler.test.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -67,7 +67,7 @@ describe('InputHandler', () => {
bufferService.resize(80, 30);
coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService);

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());
});

describe('SL/SR/DECIC/DECDC', () => {
Expand Down Expand Up @@ -236,7 +236,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);
Expand All @@ -261,6 +261,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockOscClipboardService(),
new MockCoreMouseService(),
new MockUnicodeService()
);
Expand Down Expand Up @@ -307,6 +308,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockOscClipboardService(),
new MockCoreMouseService(),
new MockUnicodeService()
);
Expand Down Expand Up @@ -357,6 +359,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockOscClipboardService(),
new MockCoreMouseService(),
new MockUnicodeService()
);
Expand Down Expand Up @@ -394,6 +397,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockOscClipboardService(),
new MockCoreMouseService(),
new MockUnicodeService()
);
Expand Down Expand Up @@ -444,6 +448,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockOscClipboardService(),
new MockCoreMouseService(),
new MockUnicodeService()
);
Expand Down Expand Up @@ -570,6 +575,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockOscClipboardService(),
new MockCoreMouseService(),
new MockUnicodeService()
);
Expand All @@ -593,7 +599,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');
Expand Down Expand Up @@ -790,7 +796,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 () => {
Expand Down Expand Up @@ -1951,6 +1957,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 = Buffer.from('hello world').toString('base64');

it('52: set invalid base64 clipboard string', async () => {
const stack: string[] = [];
inputHandler.onClipboard(ev => stack.push(ev));
await inputHandler.parseP(`\x1b]52;c;${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;${testData}\x07`);
await inputHandler.parseP(`\x1b]52;c;?\x07`);
assert.deepEqual(stack, [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));
Expand All @@ -1963,7 +1997,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 () => {
Expand Down Expand Up @@ -2272,7 +2306,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 () => {
Expand Down
59 changes: 58 additions & 1 deletion src/common/InputHandler.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -159,6 +159,8 @@ export class InputHandler extends Disposable implements IInputHandler {
public readonly onTitleChange = this._onTitleChange.event;
private readonly _onColor = this.register(new EventEmitter<IColorEvent>());
public readonly onColor = this._onColor.event;
private readonly _onClipboard = this.register(new EventEmitter<string>());
public readonly onClipboard = this._onClipboard.event;

private _parseStack: IParseStack = {
paused: false,
Expand All @@ -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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -3027,6 +3031,59 @@ export class InputHandler extends Disposable implements IInputHandler {
return this._setOrReportSpecialColor(data, 2);
}

/**
* OSC 52 ; <selection name> ; <base64 data>|<?> 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<boolean> {
return this._setOrReportClipboard(data);
}

private _setOrReportClipboard(data: string): Promise<boolean> {
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(Buffer.from(data).toString('base64'));
return true;
});
}
const buf = Buffer.from(pd, 'base64');
if (buf.toString('base64') === pd) {
return this._oscClipboardService.putData(buf.toString()).then(() => true);
}
return this._oscClipboardService.putData('').then(() => true);

}
return Promise.resolve(false);
}

/**
* OSC 104 ; <num> ST - restore ANSI color <num>
*
Expand Down
14 changes: 13 additions & 1 deletion src/common/TestUtils.test.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -156,6 +156,18 @@ export class MockOscLinkService implements IOscLinkService {
}
}

export class MockOscClipboardService implements IOscClipboardService {
private _clipboard: string = '';

public putData(data: string): Promise<void> {
this._clipboard = data;
return Promise.resolve();
}
public readData(): Promise<string> {
return Promise.resolve(this._clipboard);
}
}

// defaults to V6 always to keep tests passing
export class MockUnicodeService implements IUnicodeService {
public serviceBrand: any;
Expand Down
25 changes: 25 additions & 0 deletions 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);
});
});
});
35 changes: 35 additions & 0 deletions 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<void> {
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<string> {
return navigator.clipboard.readText();
}
}

0 comments on commit a0ab52d

Please sign in to comment.