Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add automatic clipboard support #1347

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ contributors:
# to add yourself.
- jalf <[email protected]>
- NTT corp.
- Juanjo Díaz (@juanjoDiaz)
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ that list and you think you should be, feel free to send a PR to fix that.
* Original Logo : Michael Sersen
* tight encoding : Michael Tinglof (Mercuri.ca)
* RealVNC RSA AES authentication : USTC Vlab Team
* Clipboard support: Juanjo Díaz

* Included libraries:
* base64 : Martijn Pieters (Digital Creations 2), Samuel Sieb (sieb.net)
Expand Down
78 changes: 78 additions & 0 deletions core/clipboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* noVNC: HTML5 VNC client
* Copyright (c) 2021 Juanjo Díaz
* Licensed under MPL 2.0 or any later version (see LICENSE.txt)
*/

export default class Clipboard {
constructor(target) {
this._target = target;

this._eventHandlers = {
'copy': this._handleCopy.bind(this),
'focus': this._handleFocus.bind(this)
};

// ===== EVENT HANDLERS =====

this.onpaste = () => {};
}

// ===== PRIVATE METHODS =====

async _handleCopy(e) {
try {
if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: "clipboard-write", allowWithoutGesture: false });
if (permission.state === 'denied') return;
}
} catch (err) {
// Some browsers might error due to lack of support, e.g. Firefox.
}

if (navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(e.clipboardData.getData('text/plain'));
} catch (e) {
/* Do nothing */
}
}
}

async _handleFocus() {
try {
if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: "clipboard-read", allowWithoutGesture: false });
if (permission.state === 'denied') return;
}
} catch (err) {
// Some browsers might error due to lack of support, e.g. Firefox.
}

if (navigator.clipboard.readText) {
try {
const data = await navigator.clipboard.readText();
this.onpaste(data);
} catch (e) {
/* Do nothing */
return;
}
}
}

// ===== PUBLIC METHODS =====

grab() {
if (!Clipboard.isSupported) return;
this._target.addEventListener('copy', this._eventHandlers.copy);
this._target.addEventListener('focus', this._eventHandlers.focus);
}

ungrab() {
if (!Clipboard.isSupported) return;
this._target.removeEventListener('copy', this._eventHandlers.copy);
this._target.removeEventListener('focus', this._eventHandlers.focus);
}
}

Clipboard.isSupported = (navigator && navigator.clipboard) ? true : false;
36 changes: 28 additions & 8 deletions core/rfb.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { clientToElement } from './util/element.js';
import { setCapture } from './util/events.js';
import EventTargetMixin from './util/eventtarget.js';
import Display from "./display.js";
import Clipboard from "./clipboard.js";
import Inflator from "./inflator.js";
import Deflator from "./deflator.js";
import Keyboard from "./input/keyboard.js";
Expand Down Expand Up @@ -158,6 +159,7 @@ export default class RFB extends EventTargetMixin {
this._sock = null; // Websock object
this._display = null; // Display object
this._flushing = false; // Display flushing state
this._clipboard = null; // Clipboard object
this._keyboard = null; // Keyboard input handler object
this._gestures = null; // Gesture input handler object
this._resizeObserver = null; // Resize observer object
Expand Down Expand Up @@ -259,6 +261,9 @@ export default class RFB extends EventTargetMixin {
}
this._display.onflush = this._onFlush.bind(this);

this._clipboard = new Clipboard(this._canvas);
this._clipboard.onpaste = this.clipboardPasteFrom.bind(this);

this._keyboard = new Keyboard(this._canvas);
this._keyboard.onkeyevent = this._handleKeyEvent.bind(this);

Expand Down Expand Up @@ -310,8 +315,10 @@ export default class RFB extends EventTargetMixin {
this._rfbConnectionState === "connected") {
if (viewOnly) {
this._keyboard.ungrab();
this._clipboard.ungrab();
} else {
this._keyboard.grab();
this._clipboard.grab();
}
}
}
Expand Down Expand Up @@ -2062,7 +2069,10 @@ export default class RFB extends EventTargetMixin {
this._setDesktopName(name);
this._resize(width, height);

if (!this._viewOnly) { this._keyboard.grab(); }
if (!this._viewOnly) {
this._keyboard.grab();
this._clipboard.grab();
}

this._fbDepth = 24;

Expand Down Expand Up @@ -2170,6 +2180,21 @@ export default class RFB extends EventTargetMixin {
return this._fail("Unexpected SetColorMapEntries message");
}

_triggerClipboardEvent(text) {
this.dispatchEvent(new CustomEvent("clipboard", { detail: { text: text } }));

if (Clipboard.isSupported) {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
const clipboardEvent = new ClipboardEvent('copy', { clipboardData });
// Force initialization since the constructor is broken in Firefox
if (!clipboardEvent.clipboardData.items.length) {
clipboardEvent.clipboardData.items.add(text, "text/plain");
}
this._canvas.dispatchEvent(clipboardEvent);
}
}

_handleServerCutText() {
Log.Debug("ServerCutText");

Expand All @@ -2189,10 +2214,7 @@ export default class RFB extends EventTargetMixin {
return true;
}

this.dispatchEvent(new CustomEvent(
"clipboard",
{ detail: { text: text } }));

this._triggerClipboardEvent(text);
} else {
//Extended msg.
length = Math.abs(length);
Expand Down Expand Up @@ -2327,9 +2349,7 @@ export default class RFB extends EventTargetMixin {

textData = textData.replace("\r\n", "\n");

this.dispatchEvent(new CustomEvent(
"clipboard",
{ detail: { text: textData } }));
this._triggerClipboardEvent(textData);
}
} else {
return this._fail("Unexpected action in extended clipboard message: " + actions);
Expand Down
24 changes: 23 additions & 1 deletion docs/API-internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ keysym values.
* __Display__ (core/display.js): Efficient 2D rendering abstraction
layered on the HTML5 canvas element.

* __Clipboard__ (core/clipboard.js): Clipboard event handler.

* __Websock__ (core/websock.js): Websock client from websockify
with transparent binary data support.
[Websock API](https://github.com/novnc/websockify-js/wiki/websock.js) wiki page.


## 1.2 Callbacks

For the Mouse, Keyboard and Display objects the callback functions are
For the Mouse, Keyboard, Display and Clipboard objects the callback functions are
assigned to configuration attributes, just as for the RFB object. The
WebSock module has a method named 'on' that takes two parameters: the
callback event name, and the callback function.
Expand Down Expand Up @@ -87,3 +89,23 @@ None
| name | parameters | description
| ------- | ---------- | ------------
| onflush | () | A display flush has been requested and we are now ready to resume FBU processing


## 2.4 Clipboard Module

### 2.4.1 Configuration Attributes

None

### 2.4.2 Methods

| name | parameters | description
| ------------------ | ----------------- | ------------
| grab | () | Begin capturing clipboard events
| ungrab | () | Stop capturing clipboard events

### 2.3.3 Callbacks

| name | parameters | description
| ------- | ---------- | ------------
| onpaste | (text) | Called with the text content of the clipboard when the user paste something
62 changes: 62 additions & 0 deletions tests/test.clipboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const expect = chai.expect;

import Clipboard from '../core/clipboard.js';

describe('Automatic Clipboard Sync', function () {
"use strict";

if (Clipboard.isSupported) {
beforeEach(function () {
if (navigator.clipboard.writeText) {
sinon.spy(navigator.clipboard, 'writeText');
}
if (navigator.clipboard.readText) {
sinon.spy(navigator.clipboard, 'readText');
}
});

afterEach(function () {
if (navigator.clipboard.writeText) {
navigator.clipboard.writeText.restore();
}
if (navigator.clipboard.readText) {
navigator.clipboard.readText.restore();
}
});
}

it('incoming clipboard data from the server is copied to the local clipboard', async function () {
const text = 'Random string for testing';
const clipboard = new Clipboard();
if (Clipboard.isSupported) {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
const clipboardEvent = new ClipboardEvent('paste', { clipboardData });
// Force initialization since the constructor is broken in Firefox
if (!clipboardEvent.clipboardData.items.length) {
clipboardEvent.clipboardData.items.add(text, "text/plain");
}
await clipboard._handleCopy(clipboardEvent);
if (navigator.clipboard.writeText) {
expect(navigator.clipboard.writeText).to.have.been.calledWith(text);
}
}
});

it('should copy local pasted data to the server clipboard', async function () {
const text = 'Another random string for testing';
const clipboard = new Clipboard();

clipboard.onpaste = pastedText => expect(pastedText).to.equal(text);
if (Clipboard.isSupported) {
if (navigator.clipboard.readText) {
navigator.clipboard.readText.restore();
sinon.stub(navigator.clipboard, "readText").returns(text);
}
await clipboard._handleFocus();
if (navigator.clipboard.readText) {
expect(navigator.clipboard.readText).to.have.been.called;
}
}
});
});