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.

This adds a new `registerClipboardService` method to allow headless
xtermjs to register their own clipboard service.

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
  • Loading branch information
aymanbagabas committed Oct 26, 2022
1 parent cd0dd52 commit 7cb2104
Show file tree
Hide file tree
Showing 13 changed files with 242 additions and 22 deletions.
11 changes: 9 additions & 2 deletions src/browser/Terminal.ts
Expand Up @@ -35,7 +35,7 @@ import * as Strings from 'browser/LocalizableStrings';
import { AccessibilityManager } from './AccessibilityManager';
import { ITheme, IMarker, IDisposable, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
import { DomRenderer } from 'browser/renderer/dom/DomRenderer';
import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types';
import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType, ClipboardType } from 'common/Types';
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
Expand All @@ -53,10 +53,11 @@ import { toRgbString } from 'common/input/XParseColor';
import { BufferDecorationRenderer } from 'browser/decorations/BufferDecorationRenderer';
import { OverviewRulerRenderer } from 'browser/decorations/OverviewRulerRenderer';
import { DecorationService } from 'common/services/DecorationService';
import { IDecorationService } from 'common/services/Services';
import { IClipboardService, IDecorationService } from 'common/services/Services';
import { OscLinkProvider } from 'browser/OscLinkProvider';
import { toDisposable } from 'common/Lifecycle';
import { ThemeService } from 'browser/services/ThemeService';
import { ClipboardService } from 'browser/services/ClipboardService';

// Let it work inside Node.js for automated testing purposes.
const document: Document = (typeof window !== 'undefined') ? window.document : null as any;
Expand Down Expand Up @@ -89,6 +90,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
private _themeService: IThemeService | undefined;
private _characterJoinerService: ICharacterJoinerService | undefined;
private _selectionService: ISelectionService | undefined;
private _clipboardService: IClipboardService | undefined;

/**
* Records whether the keydown event has already been handled and triggered a data event, if so
Expand Down Expand Up @@ -169,6 +171,10 @@ export class Terminal extends CoreTerminal implements ITerminal {
this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(OscLinkProvider));
this._decorationService = this._instantiationService.createInstance(DecorationService);
this._instantiationService.setService(IDecorationService, this._decorationService);
this._clipboardService = this._instantiationService.createInstance(ClipboardService);
this._instantiationService.setService(IClipboardService, this._clipboardService);

this._inputHandler.registerClipboardService(this._clipboardService);

// Setup InputHandler listeners
this.register(this._inputHandler.onRequestBell(() => this._onBell.fire()));
Expand All @@ -177,6 +183,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
12 changes: 6 additions & 6 deletions src/browser/TestUtils.test.ts
Expand Up @@ -13,7 +13,7 @@ import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, I
import { Buffer } from 'common/buffer/Buffer';
import * as Browser from 'common/Platform';
import { Terminal } from 'browser/Terminal';
import { IUnicodeService, IOptionsService, ICoreService, ICoreMouseService } from 'common/services/Services';
import { IUnicodeService, IOptionsService, ICoreService, ICoreMouseService, IClipboardService } from 'common/services/Services';
import { IFunctionIdentifier, IParams } from 'common/parser/Types';
import { AttributeData } from 'common/buffer/AttributeData';
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
Expand Down Expand Up @@ -352,13 +352,13 @@ export class MockCharSizeService implements ICharSizeService {
public serviceBrand: undefined;
public get hasValidSize(): boolean { return this.width > 0 && this.height > 0; }
public onCharSizeChange: IEvent<void> = new EventEmitter<void>().event;
constructor(public width: number, public height: number) {}
public measure(): void {}
constructor(public width: number, public height: number) { }
public measure(): void { }
}

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

Expand All @@ -372,7 +372,7 @@ export class MockRenderService implements IRenderService {
public onDimensionsChange: IEvent<IRenderDimensions> = new EventEmitter<IRenderDimensions>().event;
public onRenderedViewportChange: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event;
public onRender: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event;
public onRefreshRequest: IEvent<{ start: number, end: number}, void> = new EventEmitter<{ start: number, end: number }>().event;
public onRefreshRequest: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event;
public dimensions: IRenderDimensions = createRenderDimensions();
public refreshRows(start: number, end: number): void {
throw new Error('Method not implemented.');
Expand Down Expand Up @@ -488,7 +488,7 @@ export class MockSelectionService implements ISelectionService {
}
}

export class MockThemeService implements IThemeService{
export class MockThemeService implements IThemeService {
public serviceBrand: undefined;
public onChangeColors = new EventEmitter<ReadonlyColorSet>().event;
public restoreColor(slot?: ColorIndex | undefined): void {
Expand Down
1 change: 1 addition & 0 deletions src/browser/Types.d.ts
Expand Up @@ -9,6 +9,7 @@ import { ICoreTerminal, CharData, ITerminalOptions, IColor } from 'common/Types'
import { IMouseService, IRenderService } from './services/Services';
import { IBuffer } from 'common/buffer/Types';
import { IFunctionIdentifier, IParams } from 'common/parser/Types';
import { IClipboardService as IClipboardProvider, IClipboardService } from 'common/services/Services';

export interface ITerminal extends IPublicTerminal, ICoreTerminal {
element: HTMLElement | undefined;
Expand Down
30 changes: 30 additions & 0 deletions src/browser/services/ClipboardService.test.ts
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/

import { assert } from 'chai';
import { ClipboardService } from 'browser/services/ClipboardService';
import { IClipboardService } from 'common/services/Services';
import { ClipboardType } from 'common/Types';

describe('OscClipboardService', () => {
it('constructor', () => {
const testData = 'Hello world!';
let clipboardService: IClipboardService;
beforeEach(() => {
clipboardService = new ClipboardService();
});
it('should be able to write data to the clipboard', async () => {
assert.ok(await clipboardService.putData(ClipboardType.CLIPBOARD, testData));
});
it('should be able to read data from the clipboard', async () => {
const data = await clipboardService.readData(ClipboardType.CLIPBOARD);
assert.equal(data, testData);
});
it('should be able to clear data from the clipboard', async () => {
await clipboardService.clearData(ClipboardType.CLIPBOARD);
assert.equal(await clipboardService.readData(ClipboardType.CLIPBOARD), '');
});
});
});
21 changes: 21 additions & 0 deletions src/browser/services/ClipboardService.ts
@@ -0,0 +1,21 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/

import { IClipboardService } from 'common/services/Services';
import { ClipboardType } from 'common/Types';

export class ClipboardService implements IClipboardService {
public serviceBrand: any;

public clearData(clipboard: ClipboardType): Promise<void> {
return this.putData(clipboard, '');
}
public putData(clipboard: ClipboardType, data: string): Promise<void> {
return navigator.clipboard.writeText(data);
}
public readData(clipboard: ClipboardType): Promise<string> {
return navigator.clipboard.readText();
}
}
4 changes: 2 additions & 2 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, IClipboardService } 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 Down Expand Up @@ -129,7 +129,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 });
Expand Down
38 changes: 36 additions & 2 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 @@ -60,14 +60,17 @@ describe('InputHandler', () => {
let coreService: ICoreService;
let optionsService: MockOptionsService;
let inputHandler: TestInputHandler;
let clipboardService: MockOscClipboardService;

beforeEach(() => {
optionsService = new MockOptionsService();
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.registerClipboardService(clipboardService);
});

describe('SL/SR/DECIC/DECDC', () => {
Expand Down Expand Up @@ -1951,6 +1954,37 @@ 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');
beforeEach(() => {
optionsService.options.allowClipboardAccess = true;
});

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
71 changes: 69 additions & 2 deletions src/common/InputHandler.ts
Expand Up @@ -4,7 +4,7 @@
* @license MIT
*/

import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType } from 'common/Types';
import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, ClipboardType } from 'common/Types';
import { C0, C1 } from 'common/data/EscapeSequences';
import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets';
import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
Expand All @@ -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, IClipboardService } 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 @@ -123,6 +123,7 @@ export class InputHandler extends Disposable implements IInputHandler {
private _iconName = '';
private _currentLinkId?: number;
private _dirtyRowTracker: IDirtyRowTracker;
private _clipboardService: IClipboardService | undefined;
protected _windowTitleStack: string[] = [];
protected _iconNameStack: string[] = [];

Expand Down Expand Up @@ -159,6 +160,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 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 @@ -637,6 +641,10 @@ export class InputHandler extends Disposable implements IInputHandler {
this._dirtyRowTracker.markDirty(this._activeBuffer.y);
}

public registerClipboardService(clipboardService: IClipboardService): void {
this._clipboardService = clipboardService;
}

/**
* Forward registerCsiHandler from parser.
*/
Expand Down Expand Up @@ -3027,6 +3035,65 @@ 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> {
if (!this._clipboardService || !this._optionsService.options.allowClipboardAccess) {
return Promise.resolve(false);
}
const args = data.split(';');
if (args.length < 2) {
return Promise.resolve(false);
}
const pc = args[0];
const pd = args[1];
switch (pc) {
case ClipboardType.CLIPBOARD:
case ClipboardType.PRIMARY:
if (pd === '?') {
// Reply with the current clipboard contents encoded in base64
return this._clipboardService.readData(pc)
.then(data => {
this._onClipboard.fire(Buffer.from(data).toString('base64'));
return true;
});
}
// Verify that the data is base64 encoded before writing it to the clipboard
// otherwise clear the clipboard
const buf = Buffer.from(pd, 'base64');
if (buf.toString('base64') === pd) {
return this._clipboardService.putData(pc, buf.toString()).then(() => true);
}
return this._clipboardService.clearData(pc).then(() => true);
}
return Promise.resolve(false);
}

/**
* OSC 104 ; <num> ST - restore ANSI color <num>
*
Expand Down

0 comments on commit 7cb2104

Please sign in to comment.