December 4, 2015

Making a Chrome Extension that Reads & Writes to the Clipboard

Say you want to make a web app that syncs the user’s clipboard amongst two or more machines with minimal user interaction. Even with document.execCommand() this is difficult or impossible to do in an asynchronous manner because of browser security restrictions.

document.execCommand('copy') - which writes the window’s current text selection to the user’s clipboard - only works inside a user click event. You can programmatically select arbitrary text in an invisible input element, but you still need the user’s implicit permission to copy that selection to their clipboard. If you always want to involve the user in the copy process, you’re good to go. If not, you’ll need to resort to a browser extension.

document.execCommand('paste') - which reads the user’s clipboard and puts it into the focused input element on the page - won’t work at all in Chrome unless it’s run from an extension.

So Let’s Make a Chrome Extension

For the sake of brevity:

There are three main parts to this extension:

Here’s a Github Gist of the complete files.

Web App Page

Our example web app shows the current content of your clipboard when the page loads and updates every time the page becomes visible, for instance after switching to a different app or tab to copy some text and switching back to this test page.

The following shows where the clipboard content should appear in index.html:

<h4>Current Clipboard Text:</h4>

<p id="output"></p>

The following shows where we register for events to read the clipboard changes in main.js:

document.addEventListener('DOMContentLoaded', function() {
    readClipboard();

    // Re-read the clipboard every time the page becomes visible.
    document.addEventListener('visibilitychange', function() {
        if (!document.hidden) {
            readClipboard();
        }
    });
});

The readClipboard function below sends an asynchronous message to the extension. The extension reads the system clipboard and executes the callback we provide, giving us the clipboard content. The extensionId is assigned by Chrome the first time you load an extension on the chrome://extensions/ page. Yours will be different.

var extensionId = 'YOUR EXTENSION ID HERE';

function readClipboard() {
    chrome.runtime.sendMessage(
        extensionId,
        { method: 'getClipboard' },
        function(response) {
            document.getElementById('clipboard-content').textContent = response;
        }
    );
}

index.html also contains a button you can click to write some arbitrary text - the content of the #para paragraph in this case - to the system clipboard.

<button id="copyButton">Set System Clipboard To The Following:</button>
<p id="para">Ut scelerisque posuere sem, non aliquam ipsum sodales et. Fusce luctus, mauris ut volutpat varius, leo dui posuere ligula, vitae rhoncus leo ipsum eu neque.</p>

main.js contains a click handler for the copy button that sends a message to the extension to request it to write some text to the system clipboard. chrome.runtime.sendMessage() is inherently asynchronous - which proves we’re no longer limited to running inside a user event.[1]

document.getElementById('copyButton').addEventListener('click', function() {
    var text = document.getElementById('para').textContent;

    chrome.runtime.sendMessage(
        extensionId,
        { method: 'setClipboard', value: text },
        function(response) {
            console.log('extension setClipboard response', response);
        }
    );
});

After clicking the copy button, you can then paste somewhere else to verify it worked.

Manifest

The manifest lists permissions the extension will require from the user, what domains the extension can make requests to, and what domains’ pages are allowed to send messages to the extension.

There are three sections to note:

The background section declares the name of the background page inside the extension bundle. Chrome creates an invisible, sandboxed DOM for this page automatically.

"background": {
  "persistent": false,
  "page": "background.html"
},

The permissions section lists the permissions the extension requires from the user. This extension requires permission to read and write to the user’s system clipboard. The clipboardRead permission enables the extension’s background script to use document.execCommand('paste'); clipboardWrite enables document.execCommand('copy').

"permissions": [
  "clipboardRead",
  "clipboardWrite"
],

The externally_connectable section declares the domains of webpages that are allowed to send messages directly to the extension using the Chrome messaging API - chrome.runtime.sendMessage, etc. For this example, I’ve hard-coded it to a localhost domain in order to show the simplest possible solution. In a real web app that’s not running on your local machine, you’d need to specify specific domains, and you’d certainly want to use only an https:// scheme because of the sensitive nature of clipboard data.[2]

"externally_connectable": {
  "matches": [
    "http://localhost/*"
  ]
}

Background Page

The background page consists of a simple background.html document and a background.js script.

The HTML page consists of a textarea we’ll use to make document.execCommand() work, and a script tag to load background.js.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <script src="background.js"></script>
</head>
<body>
    <textarea id="ta"></textarea>
</body>
</html>

In background.js we register a handler for Chrome runtime messages sent from the external webpage. The Chrome message API accepts simple request objects. Our request objects consist of a method property that specifies which operation to perform and an optional value property if the method accepts a parameter. sendResponse is the callback function we provided in main.js:

chrome.runtime.onMessageExternal.addListener(function(request, sender, sendResponse) {
    switch (request.method) {
        case 'getClipboard':
            sendResponse(getClipboard());
            break;
        case 'setClipboard':
            sendResponse(setClipboard(request.value));
            break;
        default:
            console.error('Unknown method "%s"', request.method);
            break;
    }
});

The getClipboard() function invokes document.execCommand('paste'), but in order for it to work, a textarea or input[type=text] element must be selected so there’s somewhere for the pasted text to go, even though this background document is invisible. We then immediately read the text back out of the textarea and return it to the webpage:

function getClipboard() {
    var result = null;
    var textarea = document.getElementById('ta');
    textarea.value = '';
    textarea.select();

    if (document.execCommand('paste')) {
        result = textarea.value;
    } else {
        console.error('failed to get clipboard content');
    }

    textarea.value = '';
    return result;
}

The setClipboard() function follows a similar pattern. It sets the textarea value to the given text provided by the webpage, then selects the textarea content, and runs document.execCommand('copy') to copy the textarea content to the system clipboard.

function setClipboard(value) {
    var result = false;
    var textarea = document.getElementById('ta');
    textarea.value = value;
    textarea.select();

    if (document.execCommand('copy')) {
        result = true;
    } else {
        console.error('failed to get clipboard content');
    }

    textarea.value = '';
    return result;
}

Conclusion

This is an oversimplified example just to show what’s possible with the smallest amount of code. But these same basic techniques can be extended to do a lot more if desired.

References


  1. But if you’re skeptical, wrap the chrome.runtime.sendMessage() call in a setTimeout().  ↩

  2. Note that externally_connectable does not allow wildcard URLs like https://*/* the way some other parts of the manifest do. If you’re unable to specify the hostnames of the webpages up front, you may need to take a more indirect approach and communicate via a content script.  ↩