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
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
  • Loading branch information
aymanbagabas committed Dec 19, 2022
1 parent 84d5363 commit fa2637e
Show file tree
Hide file tree
Showing 14 changed files with 290 additions and 24 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();
}
}
34 changes: 34 additions & 0 deletions src/common/Base64.ts
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/

/**
* Decode base64 encoded string to UTF-8 string.
* @param data The base64 string to decode.
* @returns The decoded base64 string.
*/
export const decode = (data: string): string => {
try {
return typeof atob !== 'undefined' ?
atob(data) :
Buffer.from(data, 'base64').toString();
} catch {
return '';
}
};

/**
* Encode UTF-8 string to base64 encoded string.
* @param data The string to encode.
* @returns The base64 encoded string.
*/
export const encode = (data: string): string => {
try {
return typeof btoa !== 'undefined' ?
btoa(data) :
Buffer.from(data).toString('base64');
} catch {
return '';
}
};
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 @@ -130,7 +130,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
this.register(forwardEvent(this.coreService.onData, this._onData));
this.register(forwardEvent(this.coreService.onBinary, this._onBinary));
this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom()));
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
42 changes: 38 additions & 4 deletions src/common/InputHandler.test.ts
Expand Up @@ -11,13 +11,13 @@ import { CellData } from 'common/buffer/CellData';
import { Attributes, BgFlags, UnderlineStyle } from 'common/buffer/Constants';
import { AttributeData, ExtendedAttrs } 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';
import { BufferService } from 'common/services/BufferService';
import { CoreService } from 'common/services/CoreService';

import * as Base64 from 'common/Base64';

function getCursor(bufferService: IBufferService): number[] {
return [
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 @@ -245,7 +248,7 @@ describe('InputHandler', () => {
assert.equal(coreService.decPrivateModes.bracketedPasteMode, false);
});
});
describe('regression tests', function (): void {
describe('regression tests', function(): void {
function termContent(bufferService: IBufferService, trim: boolean): string[] {
const result = [];
for (let i = 0; i < bufferService.rows; ++i) result.push(bufferService.buffer.lines.get(i)!.translateToString(trim));
Expand Down Expand Up @@ -1982,6 +1985,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 = Base64.encode('hello world');
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 @@ -1994,7 +2028,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

0 comments on commit fa2637e

Please sign in to comment.