diff --git a/.eslintrc.json b/.eslintrc.json index 0ccafafb..a6f2d2ed 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,6 +18,8 @@ "addons/xterm-addon-attach/test/tsconfig.json", "addons/xterm-addon-canvas/src/tsconfig.json", "addons/xterm-addon-canvas/test/tsconfig.json", + "addons/xterm-addon-clipboard/src/tsconfig.json", + "addons/xterm-addon-clipboard/test/tsconfig.json", "addons/xterm-addon-fit/src/tsconfig.json", "addons/xterm-addon-fit/test/tsconfig.json", "addons/xterm-addon-image/src/tsconfig.json", diff --git a/README.md b/README.md index 0abcd32c..39cca88a 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ terminal.loadAddon(new WebLinksAddon()); The xterm.js team maintains the following addons, but anyone can build them: - [`xterm-addon-attach`](https://github.com/xtermjs/xterm.js/tree/master/addons/xterm-addon-attach): Attaches to a server running a process via a websocket +- [`xterm-addon-clipboard`](https://github.com/xtermjs/xterm.js/tree/master/addons/xterm-addon-clipboard): Access the browser's clipboard - [`xterm-addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/xterm-addon-fit): Fits the terminal to the containing element - [`xterm-addon-search`](https://github.com/xtermjs/xterm.js/tree/master/addons/xterm-addon-search): Adds search functionality - [`xterm-addon-web-links`](https://github.com/xtermjs/xterm.js/tree/master/addons/xterm-addon-web-links): Adds web link detection and interaction diff --git a/addons/xterm-addon-clipboard/.gitignore b/addons/xterm-addon-clipboard/.gitignore new file mode 100644 index 00000000..a9f4ed54 --- /dev/null +++ b/addons/xterm-addon-clipboard/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules \ No newline at end of file diff --git a/addons/xterm-addon-clipboard/.npmignore b/addons/xterm-addon-clipboard/.npmignore new file mode 100644 index 00000000..b203232a --- /dev/null +++ b/addons/xterm-addon-clipboard/.npmignore @@ -0,0 +1,29 @@ +# Blacklist - exclude everything except npm defaults such as LICENSE, etc +* +!*/ + +# Whitelist - lib/ +!lib/**/*.d.ts + +!lib/**/*.js +!lib/**/*.js.map + +!lib/**/*.css + +# Whitelist - src/ +!src/**/*.ts +!src/**/*.d.ts + +!src/**/*.js +!src/**/*.js.map + +!src/**/*.css + +# Blacklist - src/ test files +src/**/*.test.ts +src/**/*.test.d.ts +src/**/*.test.js +src/**/*.test.js.map + +# Whitelist - typings/ +!typings/*.d.ts diff --git a/addons/xterm-addon-clipboard/LICENSE b/addons/xterm-addon-clipboard/LICENSE new file mode 100644 index 00000000..b6c38b15 --- /dev/null +++ b/addons/xterm-addon-clipboard/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/addons/xterm-addon-clipboard/README.md b/addons/xterm-addon-clipboard/README.md new file mode 100644 index 00000000..4515d191 --- /dev/null +++ b/addons/xterm-addon-clipboard/README.md @@ -0,0 +1,52 @@ +## xterm-addon-clipboard + +An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables accessing the system clipboard. This addon requires xterm.js v4+. + +### Install + +```bash +npm install --save xterm-addon-clipboard +``` + +### Usage + +```ts +import { Terminal } from 'xterm'; +import { ClipboardAddon } from 'xterm-addon-clipboard'; + +const terminal = new Terminal(); +const clipboardAddon = new ClipboardAddon(); +terminal.loadAddon(clipboardAddon); +``` + +To use a custom clipboard provider + +```ts +import { Terminal, IClipboardProvider, ClipboardSelection } from 'xterm'; +import { ClipboardAddon } from 'xterm-addon-clipboard'; + +function b64Encode(data: string): string { + // Base64 encode impl +} + +function b64Decode(data: string): string { + // Base64 decode impl +} + +class MyCustomClipboardProvider implements IClipboardProvider { + private _data: string + public readText(selection: ClipboardSelection): Promise { + return Promise.resolve(b64Encode(this._data)); + } + public writeText(selection: ClipboardSelection, data: string): Promise { + this._data = b64Decode(data); + return Promise.resolve(); + } +} + +const terminal = new Terminal(); +const clipboardAddon = new ClipboardAddon(new MyCustomClipboardProvider()); +terminal.loadAddon(clipboardAddon); +``` + +See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/xterm-addon-clipboard/typings/xterm-addon-clipboard.d.ts) for more advanced usage. diff --git a/addons/xterm-addon-clipboard/package.json b/addons/xterm-addon-clipboard/package.json new file mode 100644 index 00000000..2929d90f --- /dev/null +++ b/addons/xterm-addon-clipboard/package.json @@ -0,0 +1,29 @@ +{ + "name": "xterm-addon-clipboard", + "version": "0.1.0", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/xterm-addon-clipboard.js", + "types": "typings/xterm-addon-clipboard.d.ts", + "repository": "https://github.com/xtermjs/xterm.js", + "license": "MIT", + "keywords": [ + "terminal", + "xterm", + "xterm.js" + ], + "scripts": { + "build": "../../node_modules/.bin/tsc -p .", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "prepublishOnly": "npm run package" + }, + "peerDependencies": { + "xterm": "^5.3.0" + }, + "dependencies": { + "js-base64": "^3.7.5" + } +} diff --git a/addons/xterm-addon-clipboard/src/ClipboardAddon.ts b/addons/xterm-addon-clipboard/src/ClipboardAddon.ts new file mode 100644 index 00000000..5a89751f --- /dev/null +++ b/addons/xterm-addon-clipboard/src/ClipboardAddon.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ClipboardProvider } from './ClipboardProvider'; +import { IClipboardProvider, ITerminalAddon, Terminal } from 'xterm'; + +export class ClipboardAddon implements ITerminalAddon { + private _terminal: Terminal | undefined; + constructor(private _provider: IClipboardProvider = new ClipboardProvider()) {} + + public activate(terminal: Terminal): void { + this._terminal = terminal; + terminal.registerClipboardProvider(this._provider); + } + + public dispose(): void { + this._terminal?.deregisterClipboardProvider(); + } +} diff --git a/addons/xterm-addon-clipboard/src/ClipboardProvider.ts b/addons/xterm-addon-clipboard/src/ClipboardProvider.ts new file mode 100644 index 00000000..a28ef8d5 --- /dev/null +++ b/addons/xterm-addon-clipboard/src/ClipboardProvider.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Base64 } from 'js-base64'; +import { ClipboardSelection, IClipboardProvider } from 'xterm'; + +export class ClipboardProvider implements IClipboardProvider { + constructor( + /** + * The maximum amount of data that can be copied to the clipboard. + * Zero means no limit. + */ + public limit = 1000000 // 1MB + ){} + public readText(selection: ClipboardSelection): Promise { + if (selection !== 'c') { + return Promise.resolve(''); + } + return navigator.clipboard.readText().then((text) => + Base64.encode(text)); + } + public writeText(selection: ClipboardSelection, data: string): Promise { + if (selection !== 'c' || (this.limit > 0 && data.length > this.limit)) { + return Promise.resolve(); + } + const text = Base64.decode(data); + // clear the clipboard if the data is not valid base64 + if (!Base64.isValid(data) || Base64.encode(text) !== data) { + return navigator.clipboard.writeText(''); + } + return navigator.clipboard.writeText(text); + } +} diff --git a/addons/xterm-addon-clipboard/src/tsconfig.json b/addons/xterm-addon-clipboard/src/tsconfig.json new file mode 100644 index 00000000..7f87b445 --- /dev/null +++ b/addons/xterm-addon-clipboard/src/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2017", + "sourceMap": true, + "outDir": "../out", + "rootDir": ".", + "strict": true, + "noUnusedLocals": true, + "preserveWatchOutput": true, + "types": [ + "../../../node_modules/@types/mocha" + ], + "baseUrl": ".", + "paths": { + "browser/*": [ + "../../../src/browser/*" + ], + "common/*": [ + "../../../src/common/*" + ] + } + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { + "path": "../../../src/browser" + }, + { + "path": "../../../src/common" + } + ] +} diff --git a/addons/xterm-addon-clipboard/test/ClipboardAddon.api.ts b/addons/xterm-addon-clipboard/test/ClipboardAddon.api.ts new file mode 100644 index 00000000..d6eea6ab --- /dev/null +++ b/addons/xterm-addon-clipboard/test/ClipboardAddon.api.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { openTerminal, launchBrowser, writeSync, getBrowserType } from '../../../out-test/api/TestUtils'; +import { Browser, BrowserContext, Page } from '@playwright/test'; + +const APP = 'http://127.0.0.1:3001/test'; + +let browser: Browser; +let context: BrowserContext; +let page: Page; +const width = 800; +const height = 600; + +describe('ClipboardAddon', () => { + before(async function (): Promise { + browser = await launchBrowser({ + // Enable clipboard access in firefox, mainly for readText + firefoxUserPrefs: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'dom.events.testing.asyncClipboard': true, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'dom.events.asyncClipboard.readText': true + } + }); + context = await browser.newContext(); + if (getBrowserType().name() !== 'webkit') { + // Enable clipboard access in chromium without user gesture + context.grantPermissions(['clipboard-read', 'clipboard-write']); + } + page = await context.newPage(); + await page.setViewportSize({ width, height }); + await page.goto(APP); + await openTerminal(page, { allowClipboardAccess: true }); + await page.evaluate(` + window.clipboardAddon = new ClipboardAddon(); + window.term.loadAddon(window.clipboardAddon); + `); + }); + + after(() => { + browser.close(); + }); + + beforeEach(async () => { + await page.evaluate(`window.term.reset()`); + }); + + const testDataEncoded = 'aGVsbG8gd29ybGQ='; + const testDataDecoded = 'hello world'; + + describe('write data', async function (): Promise { + it('simple string', async () => { + await writeSync(page, `\x1b]52;c;${testDataEncoded}\x07`); + assert.deepEqual(await page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); + }); + it('invalid base64 string', async () => { + await writeSync(page, `\x1b]52;c;${testDataEncoded}invalid\x07`); + assert.deepEqual(await page.evaluate(() => window.navigator.clipboard.readText()), ''); + }); + it('empty string', async () => { + await writeSync(page, `\x1b]52;c;\x07`); + assert.deepEqual(await page.evaluate(() => window.navigator.clipboard.readText()), ''); + }); + }); + + describe('read data', async function (): Promise { + it('simple string', async () => { + await page.evaluate(` + window.data = []; + window.term.onData(e => data.push(e)); + `); + await page.evaluate(() => window.navigator.clipboard.writeText('hello world')); + await writeSync(page, `\x1b]52;c;?\x07`); + assert.deepEqual(await page.evaluate(`window.data`), [testDataEncoded]); + }); + it('clear clipboard', async () => { + await writeSync(page, `\x1b]52;c;!\x07`); + await writeSync(page, `\x1b]52;c;?\x07`); + assert.deepEqual(await page.evaluate(() => window.navigator.clipboard.readText()), ''); + }); + }); +}); diff --git a/addons/xterm-addon-clipboard/test/tsconfig.json b/addons/xterm-addon-clipboard/test/tsconfig.json new file mode 100644 index 00000000..1e5ab21e --- /dev/null +++ b/addons/xterm-addon-clipboard/test/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "lib": [ + "es2015" + ], + "rootDir": ".", + "outDir": "../out-test", + "sourceMap": true, + "removeComments": true, + "strict": true, + "types": [ + "../../../node_modules/@types/mocha", + "../../../node_modules/@types/node", + ] + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ] +} \ No newline at end of file diff --git a/addons/xterm-addon-clipboard/tsconfig.json b/addons/xterm-addon-clipboard/tsconfig.json new file mode 100644 index 00000000..2d820dd1 --- /dev/null +++ b/addons/xterm-addon-clipboard/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "include": [], + "references": [ + { "path": "./src" }, + { "path": "./test" } + ] +} diff --git a/addons/xterm-addon-clipboard/typings/xterm-addon-clipboard.d.ts b/addons/xterm-addon-clipboard/typings/xterm-addon-clipboard.d.ts new file mode 100644 index 00000000..71d4f20d --- /dev/null +++ b/addons/xterm-addon-clipboard/typings/xterm-addon-clipboard.d.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ITerminalAddon, IClipboardProvider, ClipboardSelection } from 'xterm'; + +declare module 'xterm-addon-clipboard' { + export class ClipboardProvider implements IClipboardProvider{ + public readText(selection: ClipboardSelection): Promise; + public writeText(selection: ClipboardSelection, data: string): Promise; + } + + /** + * An xterm.js addon that enables accessing the system clipboard from + * xterm.js. + */ + export class ClipboardAddon implements ITerminalAddon { + /** + * Creates a new clipboard addon. + */ + constructor(_provider: IClipboardProvider); + + /** + * Activates the addon + * @param terminal The terminal the addon is being loaded in. + */ + public activate(terminal: Terminal): void; + + /** + * Disposes the addon. + */ + public dispose(): void + } +} diff --git a/addons/xterm-addon-clipboard/webpack.config.js b/addons/xterm-addon-clipboard/webpack.config.js new file mode 100644 index 00000000..0def5bbc --- /dev/null +++ b/addons/xterm-addon-clipboard/webpack.config.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'ClipboardAddon'; +const mainFile = 'xterm-addon-clipboard.js'; + +module.exports = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd' + }, + mode: 'production' +}; diff --git a/addons/xterm-addon-clipboard/yarn.lock b/addons/xterm-addon-clipboard/yarn.lock new file mode 100644 index 00000000..de7e5b43 --- /dev/null +++ b/addons/xterm-addon-clipboard/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +js-base64@^3.7.5: + version "3.7.5" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca" + integrity sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA== diff --git a/bin/publish.js b/bin/publish.js index e9c7c3e8..909877f5 100644 --- a/bin/publish.js +++ b/bin/publish.js @@ -29,6 +29,7 @@ if (changedFiles.some(e => e.search(/^addons\//) === -1)) { const addonPackageDirs = [ path.resolve(__dirname, '../addons/xterm-addon-attach'), path.resolve(__dirname, '../addons/xterm-addon-canvas'), + path.resolve(__dirname, '../addons/xterm-addon-clipboard'), path.resolve(__dirname, '../addons/xterm-addon-fit'), // path.resolve(__dirname, '../addons/xterm-addon-image'), path.resolve(__dirname, '../addons/xterm-addon-ligatures'), diff --git a/demo/client.ts b/demo/client.ts index a1145ab9..78685b87 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -12,6 +12,7 @@ import { Terminal } from '../out/browser/public/Terminal'; import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; import { CanvasAddon } from '../addons/xterm-addon-canvas/out/CanvasAddon'; +import { ClipboardAddon } from '../addons/xterm-addon-clipboard/out/ClipboardAddon'; import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; @@ -32,6 +33,7 @@ if ('WebAssembly' in window) { // Use webpacked version (yarn package) // import { Terminal } from '../lib/xterm'; // import { AttachAddon } from 'xterm-addon-attach'; +// import { ClipboardAddon } from 'xterm-addon-clipboard'; // import { FitAddon } from 'xterm-addon-fit'; // import { ImageAddon } from 'xterm-addon-image'; // import { SearchAddon, ISearchOptions } from 'xterm-addon-search'; @@ -51,6 +53,7 @@ export interface IWindowWithTerminal extends Window { Terminal?: typeof TerminalType; // eslint-disable-line @typescript-eslint/naming-convention AttachAddon?: typeof AttachAddon; // eslint-disable-line @typescript-eslint/naming-convention CanvasAddon?: typeof CanvasAddon; // eslint-disable-line @typescript-eslint/naming-convention + ClipboardAddon?: typeof ClipboardAddon; // eslint-disable-line @typescript-eslint/naming-convention FitAddon?: typeof FitAddon; // eslint-disable-line @typescript-eslint/naming-convention ImageAddon?: typeof ImageAddonType; // eslint-disable-line @typescript-eslint/naming-convention SearchAddon?: typeof SearchAddon; // eslint-disable-line @typescript-eslint/naming-convention @@ -70,7 +73,7 @@ let socket; let pid; let autoResize: boolean = true; -type AddonType = 'attach' | 'canvas' | 'fit' | 'image' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webLinks' | 'webgl' | 'ligatures'; +type AddonType = 'attach' | 'canvas' | 'clipboard' | 'fit' | 'image' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webLinks' | 'webgl' | 'ligatures'; interface IDemoAddon { name: T; @@ -78,35 +81,38 @@ interface IDemoAddon { ctor: ( T extends 'attach' ? typeof AttachAddon : T extends 'canvas' ? typeof CanvasAddon : - T extends 'fit' ? typeof FitAddon : - T extends 'image' ? typeof ImageAddonType : - T extends 'search' ? typeof SearchAddon : - T extends 'serialize' ? typeof SerializeAddon : - T extends 'webLinks' ? typeof WebLinksAddon : - T extends 'unicode11' ? typeof Unicode11Addon : - T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon : - T extends 'ligatures' ? typeof LigaturesAddon : + T extends 'clipboard' ? typeof ClipboardAddon : + T extends 'fit' ? typeof FitAddon : + T extends 'image' ? typeof ImageAddonType : + T extends 'search' ? typeof SearchAddon : + T extends 'serialize' ? typeof SerializeAddon : + T extends 'webLinks' ? typeof WebLinksAddon : + T extends 'unicode11' ? typeof Unicode11Addon : + T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon : + T extends 'ligatures' ? typeof LigaturesAddon : typeof WebglAddon ); instance?: ( T extends 'attach' ? AttachAddon : T extends 'canvas' ? CanvasAddon : - T extends 'fit' ? FitAddon : - T extends 'image' ? ImageAddonType : - T extends 'search' ? SearchAddon : - T extends 'serialize' ? SerializeAddon : - T extends 'webLinks' ? WebLinksAddon : - T extends 'webgl' ? WebglAddon : - T extends 'unicode11' ? typeof Unicode11Addon : - T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon : - T extends 'ligatures' ? typeof LigaturesAddon : - never + T extends 'clipboard' ? ClipboardAddon : + T extends 'fit' ? FitAddon : + T extends 'image' ? ImageAddonType : + T extends 'search' ? SearchAddon : + T extends 'serialize' ? SerializeAddon : + T extends 'webLinks' ? WebLinksAddon : + T extends 'webgl' ? WebglAddon : + T extends 'unicode11' ? typeof Unicode11Addon : + T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon : + T extends 'ligatures' ? typeof LigaturesAddon : + never ); } const addons: { [T in AddonType]: IDemoAddon } = { attach: { name: 'attach', ctor: AttachAddon, canChange: false }, canvas: { name: 'canvas', ctor: CanvasAddon, canChange: true }, + clipboard: { name: 'clipboard', ctor: ClipboardAddon, canChange: true }, fit: { name: 'fit', ctor: FitAddon, canChange: false }, image: { name: 'image', ctor: ImageAddon, canChange: true }, search: { name: 'search', ctor: SearchAddon, canChange: true }, @@ -179,6 +185,7 @@ const disposeRecreateButtonHandler: () => void = () => { socket = null; addons.attach.instance = undefined; addons.canvas.instance = undefined; + addons.clipboard.instance = undefined; addons.fit.instance = undefined; addons.image.instance = undefined; addons.search.instance = undefined; @@ -228,6 +235,7 @@ if (document.location.pathname === '/test') { window.Terminal = Terminal; window.AttachAddon = AttachAddon; window.CanvasAddon = CanvasAddon; + window.ClipboardAddon = ClipboardAddon; window.FitAddon = FitAddon; window.ImageAddon = ImageAddon; window.SearchAddon = SearchAddon; @@ -287,6 +295,7 @@ function createTerminal(): void { addons.fit.instance = new FitAddon(); addons.image.instance = new ImageAddon(); addons.unicodeGraphemes.instance = new UnicodeGraphemesAddon(); + addons.clipboard.instance = new ClipboardAddon(); try { // try to start with webgl renderer (might throw on older safari/webkit) addons.webgl.instance = new WebglAddon(); } catch (e) { @@ -299,6 +308,7 @@ function createTerminal(): void { typedTerm.loadAddon(addons.serialize.instance); typedTerm.loadAddon(addons.unicodeGraphemes.instance); typedTerm.loadAddon(addons.webLinks.instance); + typedTerm.loadAddon(addons.clipboard.instance); window.term = term; // Expose `term` to window for debugging purposes term.onResize((size: { cols: number, rows: number }) => { diff --git a/demo/tsconfig.json b/demo/tsconfig.json index f728f20e..d4c2abd4 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -7,6 +7,7 @@ "baseUrl": ".", "paths": { "xterm-addon-attach": ["../addons/xterm-addon-attach"], + "xterm-addon-clipboard": ["../addons/xterm-addon-clipboard"], "xterm-addon-fit": ["../addons/xterm-addon-fit"], "xterm-addon-image": ["../addons/xterm-addon-image"], "xterm-addon-search": ["../addons/xterm-addon-search"], diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 18af3e4e..edbdda85 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -46,7 +46,7 @@ import { CoreTerminal } from 'common/CoreTerminal'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { MutableDisposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; -import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, ScrollSource, SpecialColorIndex } from 'common/Types'; +import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IClipboardEvent, IColorEvent, ITerminalOptions, KeyboardResultType, ScrollSource, SpecialColorIndex } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { C0, C1_ESCAPED } from 'common/data/EscapeSequences'; @@ -54,7 +54,7 @@ import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { toRgbString } from 'common/input/XParseColor'; import { DecorationService } from 'common/services/DecorationService'; import { IDecorationService } from 'common/services/Services'; -import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker } from 'xterm'; +import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker, IClipboardProvider } from 'xterm'; import { WindowsOptionsReportType } from '../common/InputHandler'; import { AccessibilityManager } from './AccessibilityManager'; @@ -119,6 +119,7 @@ export class Terminal extends CoreTerminal implements ITerminal { public viewport: IViewport | undefined; private _compositionHelper: ICompositionHelper | undefined; private _accessibilityManager: MutableDisposable = this.register(new MutableDisposable()); + private _clipboardProvider: IClipboardProvider | undefined; private readonly _onCursorMove = this.register(new EventEmitter()); public readonly onCursorMove = this._onCursorMove.event; @@ -163,6 +164,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._handleClipboardEvent(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)); @@ -881,6 +883,14 @@ export class Terminal extends CoreTerminal implements ITerminal { return this.linkifier2.registerLinkProvider(linkProvider); } + public registerClipboardProvider(provider: IClipboardProvider): void { + this._clipboardProvider = provider; + } + + public deregisterClipboardProvider(): void { + this._clipboardProvider = undefined; + } + public registerCharacterJoiner(handler: CharacterJoinerHandler): number { if (!this._characterJoinerService) { throw new Error('Terminal must be opened first'); @@ -1281,6 +1291,18 @@ export class Terminal extends CoreTerminal implements ITerminal { } } + private _handleClipboardEvent(ev: IClipboardEvent): void { + if (!this._clipboardProvider) { + return; + } + if (ev.data === '?') { + this._clipboardProvider.readText(ev.selection).then(data => + this.coreService.triggerDataEvent(data)); + return; + } + this._clipboardProvider.writeText(ev.selection, ev.data); + } + // TODO: Remove cancel function and cancelEvents option public cancel(ev: Event, force?: boolean): boolean | undefined { if (!this.options.cancelEvents && !force) { diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 7b464a33..5a96f0af 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm'; +import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration, IClipboardProvider } 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'; @@ -104,6 +104,12 @@ export class MockTerminal implements ITerminal { public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { throw new Error('Method not implemented.'); } + public registerClipboardProvider(provider: IClipboardProvider): void { + throw new Error('Method not implemented.'); + } + public deregisterClipboardProvider(): void { + throw new Error('Method not implemented.'); + } public hasSelection(): boolean { throw new Error('Method not implemented.'); } diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index 2c75d7b8..c7495b21 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -13,7 +13,7 @@ import { AddonManager } from 'common/public/AddonManager'; import { BufferNamespaceApi } from 'common/public/BufferNamespaceApi'; import { ParserApi } from 'common/public/ParserApi'; import { UnicodeApi } from 'common/public/UnicodeApi'; -import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling } from 'xterm'; +import { IBufferNamespace as IBufferNamespaceApi, IClipboardProvider, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling } from 'xterm'; /** * The set of options that only have an effect when set in the Terminal constructor. @@ -168,6 +168,12 @@ export class Terminal extends Disposable implements ITerminalApi { this._verifyPositiveIntegers(decorationOptions.x ?? 0, decorationOptions.width ?? 0, decorationOptions.height ?? 0); return this._core.registerDecoration(decorationOptions); } + public registerClipboardProvider(provider: IClipboardProvider): void { + this._core.registerClipboardProvider(provider); + } + public deregisterClipboardProvider(): void { + this._core.deregisterClipboardProvider(); + } public hasSelection(): boolean { return this._core.hasSelection(); } diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 8f9a988f..0ae912a5 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { InputHandler } from 'common/InputHandler'; -import { IBufferLine, IAttributeData, IColorEvent, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types'; +import { IBufferLine, IAttributeData, IColorEvent, ColorIndex, ColorRequestType, SpecialColorIndex, IClipboardEvent, ClipboardEventType } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { Attributes, BgFlags, UnderlineStyle } from 'common/buffer/Constants'; @@ -17,7 +17,7 @@ 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 { ClipboardSelection } from 'xterm'; function getCursor(bufferService: IBufferService): number[] { return [ @@ -1982,6 +1982,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 = 'aGVsbG8gd29ybGQ='; + optionsService.options.allowClipboardAccess = true; + const stack: IClipboardEvent[] = []; + inputHandler.onClipboard(ev => stack.push(ev)); + await inputHandler.parseP(`\x1b]52;c;\x07`); + await inputHandler.parseP(`\x1b]52;c;${testDataRaw}\x07`); + await inputHandler.parseP(`\x1b]52;c;${testDataB64}\x07`); + await inputHandler.parseP(`\x1b]52;c;${testDataB64}invalid\x07`); + await inputHandler.parseP(`\x1b]52;c;!\x07`); + await inputHandler.parseP(`\x1b]52;c;?\x07`); + await inputHandler.parseP(`\x1b]52;p;\x07`); + await inputHandler.parseP(`\x1b]52;p;${testDataRaw}\x07`); + await inputHandler.parseP(`\x1b]52;p;${testDataB64}\x07`); + await inputHandler.parseP(`\x1b]52;p;${testDataB64}invalid\x07`); + await inputHandler.parseP(`\x1b]52;p;!\x07`); + await inputHandler.parseP(`\x1b]52;p;?\x07`); + assert.deepEqual(stack, [ + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.SYSTEM, + data: '' + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.SYSTEM, + data: testDataRaw + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.SYSTEM, + data: testDataB64 + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.SYSTEM, + data: testDataB64+'invalid' + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.SYSTEM, + data: '!' + }, + { + type: ClipboardEventType.REPORT, + selection: ClipboardSelection.SYSTEM, + data: '?' + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.PRIMARY, + data: '' + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.PRIMARY, + data: testDataRaw + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.PRIMARY, + data: testDataB64 + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.PRIMARY, + data: testDataB64+'invalid' + }, + { + type: ClipboardEventType.SET, + selection: ClipboardSelection.PRIMARY, + data: '!' + }, + { + type: ClipboardEventType.REPORT, + selection: ClipboardSelection.PRIMARY, + data: '?' + } + ]); + stack.length = 0; + }); it('104: restore events', async () => { const stack: IColorEvent[] = []; inputHandler.onColor(ev => stack.push(ev)); @@ -1994,7 +2076,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 () => { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index b5c91bfb..cb46bccf 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -4,7 +4,7 @@ * @license MIT */ -import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types'; +import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, SpecialColorIndex, IClipboardEvent, ClipboardEventType } from 'common/Types'; import { C0, C1 } from 'common/data/EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets'; import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; @@ -22,6 +22,7 @@ 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 { ClipboardSelection } from 'xterm'; /** * Map collect to glevel. Used in `selectCharset`. @@ -159,6 +160,8 @@ export class InputHandler extends Disposable implements IInputHandler { public readonly onTitleChange = this._onTitleChange.event; private readonly _onColor = this.register(new EventEmitter()); public readonly onColor = this._onColor.event; + private readonly _onClipboard = this.register(new EventEmitter()); + public readonly onClipboard = this._onClipboard.event; private _parseStack: IParseStack = { paused: false, @@ -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. @@ -3081,6 +3085,60 @@ export class InputHandler extends Disposable implements IInputHandler { return this._setOrReportSpecialColor(data, 2); } + 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]; + if (pd.length === 0) { + return true; + } + switch (pc) { + case ClipboardSelection.SYSTEM: + case ClipboardSelection.PRIMARY: + this._onClipboard.fire({ + type: pd === '?' ? ClipboardEventType.REPORT : ClipboardEventType.SET, + selection: pc, + data: pd + }); + break; + } + return true; + } + + /** + * OSC 52 ; ; | ST - 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); + } + /** * OSC 104 ; ST - restore ANSI color * diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index fc8fdf4e..9146bb67 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -9,7 +9,7 @@ import { Attributes, UnderlineStyle } from 'common/buffer/Constants'; // eslint- import { IBufferSet } from 'common/buffer/Types'; import { IParams } from 'common/parser/Types'; import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services'; -import { IFunctionIdentifier, ITerminalOptions as IPublicTerminalOptions } from 'xterm'; +import { ClipboardSelection as ClipboardSelection, IFunctionIdentifier, ITerminalOptions as IPublicTerminalOptions } from 'xterm'; export interface ICoreTerminal { coreMouseService: ICoreMouseService; @@ -445,6 +445,16 @@ export interface IColorRestoreRequest { } export type IColorEvent = (IColorReportRequest | IColorSetRequest | IColorRestoreRequest)[]; +export const enum ClipboardEventType { + REPORT = 0, + SET = 1 +} + +export interface IClipboardEvent { + type: ClipboardEventType; + selection: ClipboardSelection; + data: string; +} /** * Calls the parser and handles actions generated by the parser. @@ -515,6 +525,7 @@ export interface IInputHandler { /** OSC 10 */ setOrReportFgColor(data: string): boolean; /** OSC 11 */ setOrReportBgColor(data: string): boolean; /** OSC 12 */ setOrReportCursorColor(data: string): boolean; + /** OSC 52 */ setOrReportClipboard(data: string): boolean; /** OSC 104 */ restoreIndexedColor(data: string): boolean; /** OSC 110 */ restoreFgColor(data: string): boolean; /** OSC 111 */ restoreBgColor(data: string): boolean; diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 3c572445..5907d2da 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -52,7 +52,8 @@ export const DEFAULT_OPTIONS: Readonly> = { convertEol: false, termName: 'xterm', cancelEvents: false, - overviewRulerWidth: 0 + overviewRulerWidth: 0, + allowClipboardAccess: false }; const FONT_WEIGHT_OPTIONS: Extract[] = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900']; @@ -160,7 +161,7 @@ export class OptionsService extends Disposable implements IOptionsService { break; case 'cursorWidth': value = Math.floor(value); - // Fall through for bounds check + // Fall through for bounds check case 'lineHeight': case 'tabStopWidth': if (value < 1) { diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 52c2a79f..b68189af 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -206,6 +206,7 @@ export type FontWeight = 'normal' | 'bold' | '100' | '200' | '300' | '400' | '50 export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off'; export interface ITerminalOptions { + allowClipboardAccess?: boolean; allowProposedApi?: boolean; allowTransparency?: boolean; altClickMovesCursor?: boolean; diff --git a/test/api/TestUtils.ts b/test/api/TestUtils.ts index 288a3c07..a6bf51fc 100644 --- a/test/api/TestUtils.ts +++ b/test/api/TestUtils.ts @@ -74,9 +74,10 @@ export function getBrowserType(): playwright.BrowserType { +export function launchBrowser(opts?: playwright.LaunchOptions): Promise { const browserType = getBrowserType(); - const options: Record = { + const options: playwright.LaunchOptions = { + ...opts, headless: process.argv.includes('--headless') }; diff --git a/test/playwright/TestUtils.ts b/test/playwright/TestUtils.ts index 0a51757f..6b5547d7 100644 --- a/test/playwright/TestUtils.ts +++ b/test/playwright/TestUtils.ts @@ -77,6 +77,8 @@ type TerminalProxyCustomOverrides = 'buffer' | ( 'attachCustomKeyEventHandler' | 'registerLinkProvider' | 'registerCharacterJoiner' | + 'registerClipboardProvider' | + 'deregisterClipboardProvider' | 'deregisterCharacterJoiner' | 'loadAddon' ); diff --git a/tsconfig.all.json b/tsconfig.all.json index 5d8af629..d09638e4 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -9,6 +9,7 @@ { "path": "./test/playwright" }, { "path": "./addons/xterm-addon-attach" }, { "path": "./addons/xterm-addon-canvas" }, + { "path": "./addons/xterm-addon-clipboard" }, { "path": "./addons/xterm-addon-fit" }, { "path": "./addons/xterm-addon-image" }, { "path": "./addons/xterm-addon-ligatures" }, diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 3f603b9e..10b8939e 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -24,6 +24,12 @@ declare module 'xterm' { * An object containing options for the terminal. */ export interface ITerminalOptions { + /** + * Whether to allow clipboard access. When false, any access to the + * clipboard is ignored. The default is false. + */ + allowClipboardAccess?: boolean; + /** * Whether to allow the use of proposed API. When false, any usage of APIs * marked as experimental/proposed will throw an error. The default is @@ -1061,6 +1067,19 @@ declare module 'xterm' { */ registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; + /** + * Registers a clipboard provider, allowing custom handling of clipboard + * selection events. This is used primarily to enable accessing the + * clipboard to read/write clipboard data. + * @param provider The provider to register. + */ + registerClipboardProvider(provider: IClipboardProvider): void; + + /** + * Deregisters the active clipboard provider. + */ + deregisterClipboardProvider(): void; + /** * Gets whether the terminal has an active selection. */ @@ -1842,4 +1861,29 @@ declare module 'xterm' { */ readonly wraparoundMode: boolean; } + + export interface IClipboardProvider { + /** + * Gets the clipboard content. + * @param selection The clipboard selection to read. + * @returns A promise that resolves with the base64 encoded data. + */ + readText(selection: ClipboardSelection): Promise; + + /** + * Sets the clipboard content. + * @param selection The clipboard selection to set. + * @param data The base64 encoded data to set. If the data is invalid base64, the clipboard is + * cleared. + */ + writeText(selection: ClipboardSelection, data: string): Promise; + } + + /** + * Clipboard selection type. + */ + export const enum ClipboardSelection { + SYSTEM = 'c', + PRIMARY = 'p', + } }