Skip to content

Commit 3aac12c

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. This adds a new event listener to the terminal `onClipboard` to allow other external implementations to hook into it. The browser uses a clipboard service that use the Clipboard API to read/write from and to the clipboard. Reference: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands Fixes: #3260 Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
1 parent 53f8d91 commit 3aac12c

File tree

13 files changed

+293
-44
lines changed

13 files changed

+293
-44
lines changed

src/browser/Terminal.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import * as Browser from 'common/Platform';
3333
import { addDisposableDomListener } from 'browser/Lifecycle';
3434
import * as Strings from 'browser/LocalizableStrings';
3535
import { AccessibilityManager } from './AccessibilityManager';
36-
import { ITheme, IMarker, IDisposable, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
36+
import { IMarker, IDisposable, ILinkProvider, IDecorationOptions, IDecoration, IClipboardEvent } from 'xterm';
3737
import { DomRenderer } from 'browser/renderer/dom/DomRenderer';
3838
import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types';
3939
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
@@ -134,6 +134,8 @@ export class Terminal extends CoreTerminal implements ITerminal {
134134
public readonly onTitleChange = this._onTitleChange.event;
135135
private readonly _onBell = this.register(new EventEmitter<void>());
136136
public readonly onBell = this._onBell.event;
137+
private readonly _onClipboard = this.register(new EventEmitter<IClipboardEvent>());
138+
public readonly onClipboard = this._onClipboard.event;
137139

138140
private _onFocus = this.register(new EventEmitter<void>());
139141
public get onFocus(): IEvent<void> { return this._onFocus.event; }
@@ -177,6 +179,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
177179
this.register(this._inputHandler.onRequestReset(() => this.reset()));
178180
this.register(this._inputHandler.onRequestWindowsOptionsReport(type => this._reportWindowsOptions(type)));
179181
this.register(this._inputHandler.onColor((event) => this._handleColorEvent(event)));
182+
this.register(forwardEvent(this._inputHandler.onClipboard, this._onClipboard));
180183
this.register(forwardEvent(this._inputHandler.onCursorMove, this._onCursorMove));
181184
this.register(forwardEvent(this._inputHandler.onTitleChange, this._onTitleChange));
182185
this.register(forwardEvent(this._inputHandler.onA11yChar, this._onA11yCharEmitter));

src/browser/TestUtils.test.ts

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

6-
import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
6+
import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration, IClipboardEvent } from 'xterm';
77
import { IEvent, EventEmitter } from 'common/EventEmitter';
88
import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
99
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types';
@@ -47,6 +47,7 @@ export class MockTerminal implements ITerminal {
4747
public onKey!: IEvent<{ key: string, domEvent: KeyboardEvent }>;
4848
public onRender!: IEvent<{ start: number, end: number }>;
4949
public onResize!: IEvent<{ cols: number, rows: number }>;
50+
public onClipboard!: IEvent<IClipboardEvent, void>;
5051
public markers!: IMarker[];
5152
public coreMouseService!: ICoreMouseService;
5253
public coreService!: ICoreService;
@@ -346,13 +347,13 @@ export class MockCharSizeService implements ICharSizeService {
346347
public serviceBrand: undefined;
347348
public get hasValidSize(): boolean { return this.width > 0 && this.height > 0; }
348349
public onCharSizeChange: IEvent<void> = new EventEmitter<void>().event;
349-
constructor(public width: number, public height: number) {}
350-
public measure(): void {}
350+
constructor(public width: number, public height: number) { }
351+
public measure(): void { }
351352
}
352353

353354
export class MockMouseService implements IMouseService {
354355
public serviceBrand: undefined;
355-
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
356+
public getCoords(event: { clientX: number, clientY: number }, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
356357
throw new Error('Not implemented');
357358
}
358359

@@ -366,7 +367,7 @@ export class MockRenderService implements IRenderService {
366367
public onDimensionsChange: IEvent<IRenderDimensions> = new EventEmitter<IRenderDimensions>().event;
367368
public onRenderedViewportChange: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event;
368369
public onRender: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event;
369-
public onRefreshRequest: IEvent<{ start: number, end: number}, void> = new EventEmitter<{ start: number, end: number }>().event;
370+
public onRefreshRequest: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event;
370371
public dimensions: IRenderDimensions = createRenderDimensions();
371372
public refreshRows(start: number, end: number): void {
372373
throw new Error('Method not implemented.');
@@ -482,7 +483,7 @@ export class MockSelectionService implements ISelectionService {
482483
}
483484
}
484485

485-
export class MockThemeService implements IThemeService{
486+
export class MockThemeService implements IThemeService {
486487
public serviceBrand: undefined;
487488
public onChangeColors = new EventEmitter<ReadonlyColorSet>().event;
488489
public restoreColor(slot?: ColorIndex | undefined): void {

src/browser/Types.d.ts

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

6-
import { IDecorationOptions, IDecoration, IDisposable, IMarker } from 'xterm';
6+
import { IDecorationOptions, IDecoration, IDisposable, IMarker, IClipboardEvent } from 'xterm';
77
import { IEvent } from 'common/EventEmitter';
88
import { ICoreTerminal, CharData, ITerminalOptions, IColor } from 'common/Types';
99
import { IMouseService, IRenderService } from './services/Services';
@@ -47,6 +47,7 @@ export interface IPublicTerminal extends IDisposable {
4747
onWriteParsed: IEvent<void>;
4848
onTitleChange: IEvent<string>;
4949
onBell: IEvent<void>;
50+
onClipboard: IEvent<IClipboardEvent>;
5051
blur(): void;
5152
focus(): void;
5253
resize(columns: number, rows: number): void;

src/browser/public/Terminal.ts

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

6-
import { Terminal as ITerminalApi, IMarker, IDisposable, ILocalizableStrings, ITerminalAddon, IBufferNamespace as IBufferNamespaceApi, IParser, ILinkProvider, IUnicodeHandling, IModes, IDecorationOptions, IDecoration } from 'xterm';
6+
import { Terminal as ITerminalApi, IMarker, IDisposable, ILocalizableStrings, ITerminalAddon, IBufferNamespace as IBufferNamespaceApi, IParser, ILinkProvider, IUnicodeHandling, IModes, IDecorationOptions, IDecoration, IClipboardEvent } from 'xterm';
77
import { IBufferRange, ITerminal } from 'browser/Types';
88
import { Terminal as TerminalCore } from 'browser/Terminal';
99
import * as Strings from 'browser/LocalizableStrings';
@@ -65,6 +65,7 @@ export class Terminal implements ITerminalApi {
6565

6666
public get onBell(): IEvent<void> { return this._core.onBell; }
6767
public get onBinary(): IEvent<string> { return this._core.onBinary; }
68+
public get onClipboard(): IEvent<IClipboardEvent> { return this._core.onClipboard; }
6869
public get onCursorMove(): IEvent<void> { return this._core.onCursorMove; }
6970
public get onData(): IEvent<string> { return this._core.onData; }
7071
public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._core.onKey; }

src/common/Base64.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
/**
7+
* Decode base64 encoded string to UTF-8 string.
8+
* @param data The base64 string to decode.
9+
* @returns The decoded base64 string.
10+
*/
11+
export const decode = (data: string): string => {
12+
try {
13+
return typeof atob !== 'undefined' ?
14+
atob(data) :
15+
Buffer.from(data, 'base64').toString();
16+
} catch {
17+
return '';
18+
}
19+
};
20+
21+
/**
22+
* Encode UTF-8 string to base64 encoded string.
23+
* @param data The string to encode.
24+
* @returns The base64 encoded string.
25+
*/
26+
export const encode = (data: string): string => {
27+
try {
28+
return typeof btoa !== 'undefined' ?
29+
btoa(data) :
30+
Buffer.from(data, 'binary').toString('base64');
31+
} catch {
32+
return '';
33+
}
34+
};

src/common/CoreTerminal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
130130
this.register(forwardEvent(this.coreService.onData, this._onData));
131131
this.register(forwardEvent(this.coreService.onBinary, this._onBinary));
132132
this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom()));
133-
this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput()));
133+
this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput()));
134134
this.register(this.optionsService.onSpecificOptionChange('windowsMode', e => this._handleWindowsModeOptionChange(e)));
135135
this.register(this._bufferService.onScroll(event => {
136136
this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL });

src/common/InputHandler.test.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
1717
import { clone } from 'common/Clone';
1818
import { BufferService } from 'common/services/BufferService';
1919
import { CoreService } from 'common/services/CoreService';
20-
20+
import * as Base64 from 'common/Base64';
21+
import { ClipboardEventType, ClipboardSelectionType, IClipboardEvent } from 'xterm';
2122

2223
function getCursor(bufferService: IBufferService): number[] {
2324
return [
@@ -245,7 +246,7 @@ describe('InputHandler', () => {
245246
assert.equal(coreService.decPrivateModes.bracketedPasteMode, false);
246247
});
247248
});
248-
describe('regression tests', function (): void {
249+
describe('regression tests', function(): void {
249250
function termContent(bufferService: IBufferService, trim: boolean): string[] {
250251
const result = [];
251252
for (let i = 0; i < bufferService.rows; ++i) result.push(bufferService.buffer.lines.get(i)!.translateToString(trim));
@@ -1982,6 +1983,88 @@ describe('InputHandler', () => {
19821983
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: 0, color: [170, 187, 204] }, { type: ColorRequestType.SET, index: 123, color: [0, 17, 34] }]]);
19831984
stack.length = 0;
19841985
});
1986+
describe('52: manipulate selection data', async () => {
1987+
const testDataRaw = 'hello world';
1988+
const testDataB64 = Base64.encode(testDataRaw);
1989+
beforeEach(() => {
1990+
optionsService.options.allowClipboardAccess = true;
1991+
});
1992+
1993+
it('52: set invalid base64 clipboard string', async () => {
1994+
const stack: IClipboardEvent[] = [];
1995+
inputHandler.onClipboard(ev => stack.push(ev));
1996+
await inputHandler.parseP(`\x1b]52;c;${testDataB64}=\x07`);
1997+
await inputHandler.parseP(`\x1b]52;c;?\x07`);
1998+
assert.deepEqual(stack, [
1999+
{
2000+
type: ClipboardEventType.SET,
2001+
selection: ClipboardSelectionType.CLIPBOARD
2002+
},
2003+
{
2004+
2005+
type: ClipboardEventType.QUERY,
2006+
selection: ClipboardSelectionType.CLIPBOARD
2007+
}
2008+
]);
2009+
stack.length = 0;
2010+
});
2011+
it('52: set and query clipboard data', async () => {
2012+
const stack: IClipboardEvent[] = [];
2013+
inputHandler.onClipboard(ev => stack.push(ev));
2014+
await inputHandler.parseP(`\x1b]52;c;${testDataB64}\x07`);
2015+
await inputHandler.parseP(`\x1b]52;c;?\x07`);
2016+
assert.deepEqual(stack, [
2017+
{
2018+
type: ClipboardEventType.SET,
2019+
selection: ClipboardSelectionType.CLIPBOARD,
2020+
payload: testDataRaw
2021+
},
2022+
{
2023+
2024+
type: ClipboardEventType.QUERY,
2025+
selection: ClipboardSelectionType.CLIPBOARD
2026+
}
2027+
]);
2028+
stack.length = 0;
2029+
});
2030+
it('52: clear clipboard data', async () => {
2031+
const stack: IClipboardEvent[] = [];
2032+
inputHandler.onClipboard(ev => stack.push(ev));
2033+
await inputHandler.parseP(`\x1b]52;c;!\x07`);
2034+
await inputHandler.parseP(`\x1b]52;c;?\x07`);
2035+
assert.deepEqual(stack, [
2036+
{
2037+
type: ClipboardEventType.SET,
2038+
selection: ClipboardSelectionType.CLIPBOARD
2039+
},
2040+
{
2041+
2042+
type: ClipboardEventType.QUERY,
2043+
selection: ClipboardSelectionType.CLIPBOARD
2044+
}
2045+
]);
2046+
stack.length = 0;
2047+
});
2048+
it('52: set primary clipboard data', async () => {
2049+
const stack: IClipboardEvent[] = [];
2050+
inputHandler.onClipboard(ev => stack.push(ev));
2051+
await inputHandler.parseP(`\x1b]52;p;${testDataB64}\x07`);
2052+
await inputHandler.parseP(`\x1b]52;p;?\x07`);
2053+
assert.deepEqual(stack, [
2054+
{
2055+
type: ClipboardEventType.SET,
2056+
selection: ClipboardSelectionType.PRIMARY,
2057+
payload: testDataRaw
2058+
},
2059+
{
2060+
2061+
type: ClipboardEventType.QUERY,
2062+
selection: ClipboardSelectionType.PRIMARY
2063+
}
2064+
]);
2065+
stack.length = 0;
2066+
});
2067+
});
19852068
it('104: restore events', async () => {
19862069
const stack: IColorEvent[] = [];
19872070
inputHandler.onColor(ev => stack.push(ev));
@@ -1994,7 +2077,7 @@ describe('InputHandler', () => {
19942077
stack.length = 0;
19952078
// full ANSI table restore
19962079
await inputHandler.parseP('\x1b]104\x07');
1997-
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE}]]);
2080+
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE }]]);
19982081
});
19992082

20002083
it('10: FG set & query events', async () => {

src/common/InputHandler.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { OscHandler } from 'common/parser/OscParser';
2121
import { DcsHandler } from 'common/parser/DcsParser';
2222
import { IBuffer } from 'common/buffer/Types';
2323
import { parseColor } from 'common/input/XParseColor';
24+
import * as Base64 from 'common/Base64';
25+
import { ClipboardEventType, ClipboardSelectionType, IClipboardEvent } from 'xterm';
2426

2527
/**
2628
* Map collect to glevel. Used in `selectCharset`.
@@ -158,6 +160,8 @@ export class InputHandler extends Disposable implements IInputHandler {
158160
public readonly onTitleChange = this._onTitleChange.event;
159161
private readonly _onColor = this.register(new EventEmitter<IColorEvent>());
160162
public readonly onColor = this._onColor.event;
163+
private readonly _onClipboard = this.register(new EventEmitter<IClipboardEvent>());
164+
public readonly onClipboard = this._onClipboard.event;
161165

162166
private _parseStack: IParseStack = {
163167
paused: false,
@@ -319,6 +323,7 @@ export class InputHandler extends Disposable implements IInputHandler {
319323
// 50 - Set Font to Pt.
320324
// 51 - reserved for Emacs shell.
321325
// 52 - Manipulate Selection Data.
326+
this._parser.registerOscHandler(52, new OscHandler(data => this.setOrReportClipboard(data)));
322327
// 104 ; c - Reset Color Number c.
323328
this._parser.registerOscHandler(104, new OscHandler(data => this.restoreIndexedColor(data)));
324329
// 105 ; c - Reset Special Color Number c.
@@ -3037,6 +3042,67 @@ export class InputHandler extends Disposable implements IInputHandler {
30373042
return this._setOrReportSpecialColor(data, 2);
30383043
}
30393044

3045+
/**
3046+
* OSC 52 ; <selection name> ; <base64 data>|<?> BEL - set or query selection and clipboard data
3047+
*
3048+
* Test case:
3049+
*
3050+
* ```sh
3051+
* printf "\e]52;c;%s\a" "$(echo -n "Hello, World" | base64)"
3052+
* ```
3053+
*
3054+
* @vt: #Y OSC 52 "Manipulate Selection Data" "OSC 52 ; Pc ; Pd BEL" "Set or query selection and clipboard data."
3055+
* Pc is the selection name. Can be one of:
3056+
* - `c` - clipboard
3057+
* - `p` - primary
3058+
* - `q` - secondary
3059+
* - `s` - select
3060+
* - `0-7` - cut-buffers 0-7
3061+
* Only the `c` selection (clipboard) is supported by xterm.js. The browser
3062+
* Clipboard API only supports the clipboard selection.
3063+
*
3064+
* Pd is the base64 encoded data.
3065+
* If Pd is `?`, the terminal returns the current clipboard contents.
3066+
* If Pd is neither base64 encoded nor `?`, then the clipboard is cleared.
3067+
*/
3068+
public setOrReportClipboard(data: string): boolean {
3069+
return this._setOrReportClipboard(data);
3070+
}
3071+
3072+
private _setOrReportClipboard(data: string): boolean {
3073+
if (!this._optionsService.options.allowClipboardAccess) {
3074+
return true;
3075+
}
3076+
const args = data.split(';');
3077+
if (args.length < 2) {
3078+
return true;
3079+
}
3080+
const pc = args[0];
3081+
const pd = args[1];
3082+
switch (pc) {
3083+
case ClipboardSelectionType.PRIMARY:
3084+
case ClipboardSelectionType.CLIPBOARD:
3085+
this._logService.debug(`Clipboard: selection: ${pc} data: ${pd}`);
3086+
const ev: IClipboardEvent = {
3087+
selection: pc,
3088+
type: ClipboardEventType.SET
3089+
};
3090+
if (pd === '?') {
3091+
ev.type = ClipboardEventType.QUERY;
3092+
} else {
3093+
// Verify that the data is base64 encoded before writing it to the
3094+
// clipboard.
3095+
const str = Base64.decode(pd);
3096+
if (Base64.encode(str) === pd) {
3097+
ev.payload = str;
3098+
}
3099+
}
3100+
this._onClipboard.fire(ev);
3101+
break;
3102+
}
3103+
return true;
3104+
}
3105+
30403106
/**
30413107
* OSC 104 ; <num> ST - restore ANSI color <num>
30423108
*

0 commit comments

Comments
 (0)