Skip to content

Commit a0ab52d

Browse files
committed
Add support to ANSI OSC52
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
1 parent d5710af commit a0ab52d

File tree

9 files changed

+194
-13
lines changed

9 files changed

+194
-13
lines changed

src/browser/Terminal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
177177
this.register(this._inputHandler.onRequestReset(() => this.reset()));
178178
this.register(this._inputHandler.onRequestWindowsOptionsReport(type => this._reportWindowsOptions(type)));
179179
this.register(this._inputHandler.onColor((event) => this._handleColorEvent(event)));
180+
this.register(this._inputHandler.onClipboard((event) => this.coreService.triggerDataEvent(event)));
180181
this.register(forwardEvent(this._inputHandler.onCursorMove, this._onCursorMove));
181182
this.register(forwardEvent(this._inputHandler.onTitleChange, this._onTitleChange));
182183
this.register(forwardEvent(this._inputHandler.onA11yChar, this._onA11yCharEmitter));

src/common/CoreTerminal.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*/
2323

2424
import { Disposable, toDisposable } from 'common/Lifecycle';
25-
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
25+
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService, IOscClipboardService } from 'common/services/Services';
2626
import { InstantiationService } from 'common/services/InstantiationService';
2727
import { LogService } from 'common/services/LogService';
2828
import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
@@ -39,6 +39,7 @@ import { IBufferSet } from 'common/buffer/Types';
3939
import { InputHandler } from 'common/InputHandler';
4040
import { WriteBuffer } from 'common/input/WriteBuffer';
4141
import { OscLinkService } from 'common/services/OscLinkService';
42+
import { OscClipboardService } from 'common/services/OscClipboardService';
4243

4344
// Only trigger this warning a single time per session
4445
let hasWriteSyncWarnHappened = false;
@@ -49,6 +50,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
4950
protected readonly _logService: ILogService;
5051
protected readonly _charsetService: ICharsetService;
5152
protected readonly _oscLinkService: IOscLinkService;
53+
protected readonly _oscClipboardService: IOscClipboardService;
5254

5355
public readonly coreMouseService: ICoreMouseService;
5456
public readonly coreService: ICoreService;
@@ -119,17 +121,19 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
119121
this._instantiationService.setService(ICharsetService, this._charsetService);
120122
this._oscLinkService = this._instantiationService.createInstance(OscLinkService);
121123
this._instantiationService.setService(IOscLinkService, this._oscLinkService);
124+
this._oscClipboardService = this._instantiationService.createInstance(OscClipboardService);
125+
this._instantiationService.setService(IOscClipboardService, this._oscClipboardService);
122126

123127
// Register input handler and handle/forward events
124-
this._inputHandler = this.register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService));
128+
this._inputHandler = this.register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this._oscClipboardService, this.coreMouseService, this.unicodeService));
125129
this.register(forwardEvent(this._inputHandler.onLineFeed, this._onLineFeed));
126130
this.register(this._inputHandler);
127131

128132
// Setup listeners
129133
this.register(forwardEvent(this._bufferService.onResize, this._onResize));
130134
this.register(forwardEvent(this.coreService.onData, this._onData));
131135
this.register(forwardEvent(this.coreService.onBinary, this._onBinary));
132-
this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput()));
136+
this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput()));
133137
this.register(this.optionsService.onSpecificOptionChange('windowsMode', e => this._handleWindowsModeOptionChange(e)));
134138
this.register(this._bufferService.onScroll(event => {
135139
this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL });

src/common/InputHandler.test.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { CellData } from 'common/buffer/CellData';
1111
import { Attributes, UnderlineStyle } from 'common/buffer/Constants';
1212
import { AttributeData } from 'common/buffer/AttributeData';
1313
import { Params } from 'common/parser/Params';
14-
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test';
14+
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService, MockOscClipboardService } from 'common/TestUtils.test';
1515
import { IBufferService, ICoreService } from 'common/services/Services';
1616
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
1717
import { clone } from 'common/Clone';
@@ -67,7 +67,7 @@ describe('InputHandler', () => {
6767
bufferService.resize(80, 30);
6868
coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService);
6969

70-
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
70+
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService());
7171
});
7272

7373
describe('SL/SR/DECIC/DECDC', () => {
@@ -236,7 +236,7 @@ describe('InputHandler', () => {
236236
describe('setMode', () => {
237237
it('should toggle bracketedPasteMode', () => {
238238
const coreService = new MockCoreService();
239-
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
239+
const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService());
240240
// Set bracketed paste mode
241241
inputHandler.setModePrivate(Params.fromArray([2004]));
242242
assert.equal(coreService.decPrivateModes.bracketedPasteMode, true);
@@ -261,6 +261,7 @@ describe('InputHandler', () => {
261261
new MockLogService(),
262262
new MockOptionsService(),
263263
new MockOscLinkService(),
264+
new MockOscClipboardService(),
264265
new MockCoreMouseService(),
265266
new MockUnicodeService()
266267
);
@@ -307,6 +308,7 @@ describe('InputHandler', () => {
307308
new MockLogService(),
308309
new MockOptionsService(),
309310
new MockOscLinkService(),
311+
new MockOscClipboardService(),
310312
new MockCoreMouseService(),
311313
new MockUnicodeService()
312314
);
@@ -357,6 +359,7 @@ describe('InputHandler', () => {
357359
new MockLogService(),
358360
new MockOptionsService(),
359361
new MockOscLinkService(),
362+
new MockOscClipboardService(),
360363
new MockCoreMouseService(),
361364
new MockUnicodeService()
362365
);
@@ -394,6 +397,7 @@ describe('InputHandler', () => {
394397
new MockLogService(),
395398
new MockOptionsService(),
396399
new MockOscLinkService(),
400+
new MockOscClipboardService(),
397401
new MockCoreMouseService(),
398402
new MockUnicodeService()
399403
);
@@ -444,6 +448,7 @@ describe('InputHandler', () => {
444448
new MockLogService(),
445449
new MockOptionsService(),
446450
new MockOscLinkService(),
451+
new MockOscClipboardService(),
447452
new MockCoreMouseService(),
448453
new MockUnicodeService()
449454
);
@@ -570,6 +575,7 @@ describe('InputHandler', () => {
570575
new MockLogService(),
571576
new MockOptionsService(),
572577
new MockOscLinkService(),
578+
new MockOscClipboardService(),
573579
new MockCoreMouseService(),
574580
new MockUnicodeService()
575581
);
@@ -593,7 +599,7 @@ describe('InputHandler', () => {
593599

594600
beforeEach(() => {
595601
bufferService = new MockBufferService(80, 30);
596-
handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
602+
handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService());
597603
});
598604
it('should handle DECSET/DECRST 47 (alt screen buffer)', async () => {
599605
await handler.parseP('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST');
@@ -790,7 +796,7 @@ describe('InputHandler', () => {
790796
describe('colon notation', () => {
791797
let inputHandler2: TestInputHandler;
792798
beforeEach(() => {
793-
inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
799+
inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService());
794800
});
795801
describe('should equal to semicolon', () => {
796802
it('CSI 38:2::50:100:150 m', async () => {
@@ -1951,6 +1957,34 @@ describe('InputHandler', () => {
19511957
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: 0, color: [170, 187, 204] }, { type: ColorRequestType.SET, index: 123, color: [0, 17, 34] }]]);
19521958
stack.length = 0;
19531959
});
1960+
describe('52: manipulate selection data', async () => {
1961+
const testData = Buffer.from('hello world').toString('base64');
1962+
1963+
it('52: set invalid base64 clipboard string', async () => {
1964+
const stack: string[] = [];
1965+
inputHandler.onClipboard(ev => stack.push(ev));
1966+
await inputHandler.parseP(`\x1b]52;c;${testData}=\x07`);
1967+
await inputHandler.parseP(`\x1b]52;c;?\x07`);
1968+
assert.deepEqual(stack, ['']);
1969+
stack.length = 0;
1970+
});
1971+
it('52: set and query clipboard data', async () => {
1972+
const stack: string[] = [];
1973+
inputHandler.onClipboard(ev => stack.push(ev));
1974+
await inputHandler.parseP(`\x1b]52;c;${testData}\x07`);
1975+
await inputHandler.parseP(`\x1b]52;c;?\x07`);
1976+
assert.deepEqual(stack, [testData]);
1977+
stack.length = 0;
1978+
});
1979+
it('52: clear clipboard data', async () => {
1980+
const stack: string[] = [];
1981+
inputHandler.onClipboard(ev => stack.push(ev));
1982+
await inputHandler.parseP(`\x1b]52;c;!\x07`);
1983+
await inputHandler.parseP(`\x1b]52;c;?\x07`);
1984+
assert.deepEqual(stack, ['']);
1985+
stack.length = 0;
1986+
});
1987+
});
19541988
it('104: restore events', async () => {
19551989
const stack: IColorEvent[] = [];
19561990
inputHandler.onColor(ev => stack.push(ev));
@@ -1963,7 +1997,7 @@ describe('InputHandler', () => {
19631997
stack.length = 0;
19641998
// full ANSI table restore
19651999
await inputHandler.parseP('\x1b]104\x07');
1966-
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE}]]);
2000+
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE }]]);
19672001
});
19682002

19692003
it('10: FG set & query events', async () => {
@@ -2272,7 +2306,7 @@ describe('InputHandler - async handlers', () => {
22722306
coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService);
22732307
coreService.onData(data => { console.log(data); });
22742308

2275-
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
2309+
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockOscClipboardService(), new MockCoreMouseService(), new MockUnicodeService());
22762310
});
22772311

22782312
it('async CUP with CPR check', async () => {

src/common/InputHandler.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } fr
1616
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
1717
import { CellData } from 'common/buffer/CellData';
1818
import { AttributeData } from 'common/buffer/AttributeData';
19-
import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
19+
import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService, IOscClipboardService } from 'common/services/Services';
2020
import { OscHandler } from 'common/parser/OscParser';
2121
import { DcsHandler } from 'common/parser/DcsParser';
2222
import { IBuffer } from 'common/buffer/Types';
@@ -159,6 +159,8 @@ export class InputHandler extends Disposable implements IInputHandler {
159159
public readonly onTitleChange = this._onTitleChange.event;
160160
private readonly _onColor = this.register(new EventEmitter<IColorEvent>());
161161
public readonly onColor = this._onColor.event;
162+
private readonly _onClipboard = this.register(new EventEmitter<string>());
163+
public readonly onClipboard = this._onClipboard.event;
162164

163165
private _parseStack: IParseStack = {
164166
paused: false,
@@ -175,6 +177,7 @@ export class InputHandler extends Disposable implements IInputHandler {
175177
private readonly _logService: ILogService,
176178
private readonly _optionsService: IOptionsService,
177179
private readonly _oscLinkService: IOscLinkService,
180+
private readonly _oscClipboardService: IOscClipboardService,
178181
private readonly _coreMouseService: ICoreMouseService,
179182
private readonly _unicodeService: IUnicodeService,
180183
private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
@@ -320,6 +323,7 @@ export class InputHandler extends Disposable implements IInputHandler {
320323
// 50 - Set Font to Pt.
321324
// 51 - reserved for Emacs shell.
322325
// 52 - Manipulate Selection Data.
326+
this._parser.registerOscHandler(52, new OscHandler(data => this.setOrReportClipboard(data)));
323327
// 104 ; c - Reset Color Number c.
324328
this._parser.registerOscHandler(104, new OscHandler(data => this.restoreIndexedColor(data)));
325329
// 105 ; c - Reset Special Color Number c.
@@ -3027,6 +3031,59 @@ export class InputHandler extends Disposable implements IInputHandler {
30273031
return this._setOrReportSpecialColor(data, 2);
30283032
}
30293033

3034+
/**
3035+
* OSC 52 ; <selection name> ; <base64 data>|<?> BEL - set or query selection and clipboard data
3036+
*
3037+
* Test case:
3038+
*
3039+
* ```sh
3040+
* printf "\e]52;c;%s\a" "$(echo -n "Hello, World" | base64)"
3041+
* ```
3042+
*
3043+
* @vt: #Y OSC 52 "Manipulate Selection Data" "OSC 52 ; Pc ; Pd BEL" "Set or query selection and clipboard data."
3044+
* Pc is the selection name. Can be one of:
3045+
* - `c` - clipboard
3046+
* - `p` - primary
3047+
* - `q` - secondary
3048+
* - `s` - select
3049+
* - `0-7` - cut-buffers 0-7
3050+
* Only the `c` selection (clipboard) is supported by xterm.js. The browser
3051+
* Clipboard API only supports the clipboard selection.
3052+
*
3053+
* Pd is the base64 encoded data.
3054+
* If Pd is `?`, the terminal returns the current clipboard contents.
3055+
* If Pd is neither base64 encoded nor `?`, then the clipboard is cleared.
3056+
*/
3057+
public setOrReportClipboard(data: string): Promise<boolean> {
3058+
return this._setOrReportClipboard(data);
3059+
}
3060+
3061+
private _setOrReportClipboard(data: string): Promise<boolean> {
3062+
const args = data.split(';');
3063+
if (args.length < 2) {
3064+
return Promise.resolve(false);
3065+
}
3066+
switch (args[0]) {
3067+
case 'c':
3068+
const pd = args[1];
3069+
if (pd === '?') {
3070+
// Reply with the current clipboard contents encoded in base64
3071+
return this._oscClipboardService.readData()
3072+
.then(data => {
3073+
this._onClipboard.fire(Buffer.from(data).toString('base64'));
3074+
return true;
3075+
});
3076+
}
3077+
const buf = Buffer.from(pd, 'base64');
3078+
if (buf.toString('base64') === pd) {
3079+
return this._oscClipboardService.putData(buf.toString()).then(() => true);
3080+
}
3081+
return this._oscClipboardService.putData('').then(() => true);
3082+
3083+
}
3084+
return Promise.resolve(false);
3085+
}
3086+
30303087
/**
30313088
* OSC 104 ; <num> ST - restore ANSI color <num>
30323089
*

src/common/TestUtils.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @license MIT
44
*/
55

6-
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService } from 'common/services/Services';
6+
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, IOscClipboardService } from 'common/services/Services';
77
import { IEvent, EventEmitter } from 'common/EventEmitter';
88
import { clone } from 'common/Clone';
99
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
@@ -156,6 +156,18 @@ export class MockOscLinkService implements IOscLinkService {
156156
}
157157
}
158158

159+
export class MockOscClipboardService implements IOscClipboardService {
160+
private _clipboard: string = '';
161+
162+
public putData(data: string): Promise<void> {
163+
this._clipboard = data;
164+
return Promise.resolve();
165+
}
166+
public readData(): Promise<string> {
167+
return Promise.resolve(this._clipboard);
168+
}
169+
}
170+
159171
// defaults to V6 always to keep tests passing
160172
export class MockUnicodeService implements IUnicodeService {
161173
public serviceBrand: any;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { assert } from 'chai';
7+
import { OscClipboardService } from 'common/services/OscClipboardService';
8+
import { IOscClipboardService } from 'common/services/Services';
9+
10+
describe('OscClipboardService', () => {
11+
it('constructor', () => {
12+
const testData = 'Hello world!';
13+
let oscClipboardService: IOscClipboardService;
14+
beforeEach(() => {
15+
oscClipboardService = new OscClipboardService();
16+
});
17+
it('should be able to write data to the clipboard', async () => {
18+
assert.ok(await oscClipboardService.putData(testData));
19+
});
20+
it('should be able to read data from the clipboard', async () => {
21+
const data = await oscClipboardService.readData();
22+
assert.equal(data, testData);
23+
});
24+
});
25+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { IOscClipboardService } from 'common/services/Services';
7+
8+
/**
9+
* A service that handles OSC 52 clipboard data.
10+
*
11+
* This service is used to handle OSC 52 selection and clipboard data
12+
* manipulation. It uses the Clipboard API to write and read to and from the
13+
* clipboard. The OSC52 protocol supports writing and reading to and from
14+
* different selections. However, the browser Clipboard API only supports
15+
* reading and writing to the clipboard selection.
16+
*/
17+
export class OscClipboardService implements IOscClipboardService {
18+
/**
19+
* Writes data to the clipboard.
20+
*
21+
* This is an async operation since we're using the Clipboard API.
22+
*/
23+
public putData(data: string): Promise<void> {
24+
return navigator.clipboard.writeText(data);
25+
}
26+
27+
/**
28+
* Reads data from the clipboard.
29+
*
30+
* This is an async operation since we're using the Clipboard API.
31+
*/
32+
public readData(): Promise<string> {
33+
return navigator.clipboard.readText();
34+
}
35+
}

0 commit comments

Comments
 (0)