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 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: xtermjs#3260
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
  • Loading branch information
aymanbagabas committed Dec 26, 2022
1 parent 53f8d91 commit 3aac12c
Show file tree
Hide file tree
Showing 13 changed files with 293 additions and 44 deletions.
5 changes: 4 additions & 1 deletion src/browser/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import * as Browser from 'common/Platform';
import { addDisposableDomListener } from 'browser/Lifecycle';
import * as Strings from 'browser/LocalizableStrings';
import { AccessibilityManager } from './AccessibilityManager';
import { ITheme, IMarker, IDisposable, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
import { IMarker, IDisposable, ILinkProvider, IDecorationOptions, IDecoration, IClipboardEvent } from 'xterm';
import { DomRenderer } from 'browser/renderer/dom/DomRenderer';
import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types';
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
Expand Down Expand Up @@ -134,6 +134,8 @@ export class Terminal extends CoreTerminal implements ITerminal {
public readonly onTitleChange = this._onTitleChange.event;
private readonly _onBell = this.register(new EventEmitter<void>());
public readonly onBell = this._onBell.event;
private readonly _onClipboard = this.register(new EventEmitter<IClipboardEvent>());
public readonly onClipboard = this._onClipboard.event;

private _onFocus = this.register(new EventEmitter<void>());
public get onFocus(): IEvent<void> { return this._onFocus.event; }
Expand Down Expand Up @@ -177,6 +179,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(forwardEvent(this._inputHandler.onClipboard, this._onClipboard));
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
13 changes: 7 additions & 6 deletions src/browser/TestUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @license MIT
*/

import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration, IClipboardEvent } from 'xterm';
import { IEvent, EventEmitter } from 'common/EventEmitter';
import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types';
Expand Down Expand Up @@ -47,6 +47,7 @@ export class MockTerminal implements ITerminal {
public onKey!: IEvent<{ key: string, domEvent: KeyboardEvent }>;
public onRender!: IEvent<{ start: number, end: number }>;
public onResize!: IEvent<{ cols: number, rows: number }>;
public onClipboard!: IEvent<IClipboardEvent, void>;
public markers!: IMarker[];
public coreMouseService!: ICoreMouseService;
public coreService!: ICoreService;
Expand Down Expand Up @@ -346,13 +347,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 @@ -366,7 +367,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 @@ -482,7 +483,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
3 changes: 2 additions & 1 deletion src/browser/Types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @license MIT
*/

import { IDecorationOptions, IDecoration, IDisposable, IMarker } from 'xterm';
import { IDecorationOptions, IDecoration, IDisposable, IMarker, IClipboardEvent } from 'xterm';
import { IEvent } from 'common/EventEmitter';
import { ICoreTerminal, CharData, ITerminalOptions, IColor } from 'common/Types';
import { IMouseService, IRenderService } from './services/Services';
Expand Down Expand Up @@ -47,6 +47,7 @@ export interface IPublicTerminal extends IDisposable {
onWriteParsed: IEvent<void>;
onTitleChange: IEvent<string>;
onBell: IEvent<void>;
onClipboard: IEvent<IClipboardEvent>;
blur(): void;
focus(): void;
resize(columns: number, rows: number): void;
Expand Down
3 changes: 2 additions & 1 deletion src/browser/public/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @license MIT
*/

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

public get onBell(): IEvent<void> { return this._core.onBell; }
public get onBinary(): IEvent<string> { return this._core.onBinary; }
public get onClipboard(): IEvent<IClipboardEvent> { return this._core.onClipboard; }
public get onCursorMove(): IEvent<void> { return this._core.onCursorMove; }
public get onData(): IEvent<string> { return this._core.onData; }
public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._core.onKey; }
Expand Down
34 changes: 34 additions & 0 deletions src/common/Base64.ts
Original file line number Diff line number Diff line change
@@ -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, 'binary').toString('base64');
} catch {
return '';
}
};
2 changes: 1 addition & 1 deletion src/common/CoreTerminal.ts
Original file line number Diff line number Diff line change
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
89 changes: 86 additions & 3 deletions src/common/InputHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ 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';
import { ClipboardEventType, ClipboardSelectionType, IClipboardEvent } from 'xterm';

function getCursor(bufferService: IBufferService): number[] {
return [
Expand Down Expand Up @@ -245,7 +246,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 +1983,88 @@ 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 testDataRaw = 'hello world';
const testDataB64 = Base64.encode(testDataRaw);
beforeEach(() => {
optionsService.options.allowClipboardAccess = true;
});

it('52: set invalid base64 clipboard string', async () => {
const stack: IClipboardEvent[] = [];
inputHandler.onClipboard(ev => stack.push(ev));
await inputHandler.parseP(`\x1b]52;c;${testDataB64}=\x07`);
await inputHandler.parseP(`\x1b]52;c;?\x07`);
assert.deepEqual(stack, [
{
type: ClipboardEventType.SET,
selection: ClipboardSelectionType.CLIPBOARD
},
{

type: ClipboardEventType.QUERY,
selection: ClipboardSelectionType.CLIPBOARD
}
]);
stack.length = 0;
});
it('52: set and query clipboard data', async () => {
const stack: IClipboardEvent[] = [];
inputHandler.onClipboard(ev => stack.push(ev));
await inputHandler.parseP(`\x1b]52;c;${testDataB64}\x07`);
await inputHandler.parseP(`\x1b]52;c;?\x07`);
assert.deepEqual(stack, [
{
type: ClipboardEventType.SET,
selection: ClipboardSelectionType.CLIPBOARD,
payload: testDataRaw
},
{

type: ClipboardEventType.QUERY,
selection: ClipboardSelectionType.CLIPBOARD
}
]);
stack.length = 0;
});
it('52: clear clipboard data', async () => {
const stack: IClipboardEvent[] = [];
inputHandler.onClipboard(ev => stack.push(ev));
await inputHandler.parseP(`\x1b]52;c;!\x07`);
await inputHandler.parseP(`\x1b]52;c;?\x07`);
assert.deepEqual(stack, [
{
type: ClipboardEventType.SET,
selection: ClipboardSelectionType.CLIPBOARD
},
{

type: ClipboardEventType.QUERY,
selection: ClipboardSelectionType.CLIPBOARD
}
]);
stack.length = 0;
});
it('52: set primary clipboard data', async () => {
const stack: IClipboardEvent[] = [];
inputHandler.onClipboard(ev => stack.push(ev));
await inputHandler.parseP(`\x1b]52;p;${testDataB64}\x07`);
await inputHandler.parseP(`\x1b]52;p;?\x07`);
assert.deepEqual(stack, [
{
type: ClipboardEventType.SET,
selection: ClipboardSelectionType.PRIMARY,
payload: testDataRaw
},
{

type: ClipboardEventType.QUERY,
selection: ClipboardSelectionType.PRIMARY
}
]);
stack.length = 0;
});
});
it('104: restore events', async () => {
const stack: IColorEvent[] = [];
inputHandler.onColor(ev => stack.push(ev));
Expand All @@ -1994,7 +2077,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
66 changes: 66 additions & 0 deletions src/common/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { OscHandler } from 'common/parser/OscParser';
import { DcsHandler } from 'common/parser/DcsParser';
import { IBuffer } from 'common/buffer/Types';
import { parseColor } from 'common/input/XParseColor';
import * as Base64 from 'common/Base64';
import { ClipboardEventType, ClipboardSelectionType, IClipboardEvent } from 'xterm';

/**
* Map collect to glevel. Used in `selectCharset`.
Expand Down Expand Up @@ -158,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<IClipboardEvent>());
public readonly onClipboard = this._onClipboard.event;

private _parseStack: IParseStack = {
paused: false,
Expand Down Expand Up @@ -319,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 @@ -3037,6 +3042,67 @@ 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): boolean {
return this._setOrReportClipboard(data);
}

private _setOrReportClipboard(data: string): boolean {
if (!this._optionsService.options.allowClipboardAccess) {
return true;
}
const args = data.split(';');
if (args.length < 2) {
return true;
}
const pc = args[0];
const pd = args[1];
switch (pc) {
case ClipboardSelectionType.PRIMARY:
case ClipboardSelectionType.CLIPBOARD:
this._logService.debug(`Clipboard: selection: ${pc} data: ${pd}`);
const ev: IClipboardEvent = {
selection: pc,
type: ClipboardEventType.SET
};
if (pd === '?') {
ev.type = ClipboardEventType.QUERY;
} else {
// Verify that the data is base64 encoded before writing it to the
// clipboard.
const str = Base64.decode(pd);
if (Base64.encode(str) === pd) {
ev.payload = str;
}
}
this._onClipboard.fire(ev);
break;
}
return true;
}

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

0 comments on commit 3aac12c

Please sign in to comment.