From 3f275889dc09537c83bf10576aa9561ddd00fdef Mon Sep 17 00:00:00 2001 From: Yucheng Chuang Date: Wed, 16 Nov 2022 22:41:53 +0900 Subject: [PATCH] Inject an iframe to perform copy on Chrome + Keyboard Shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤯 --- chrome/manifest.json | 12 ++- src/iframe-copy.html | 11 +++ src/iframe-copy.js | 26 ++++++ src/lib/clipboard-access.js | 159 ++++++++++++++++++++++++++---------- 4 files changed, 163 insertions(+), 45 deletions(-) create mode 100644 src/iframe-copy.html create mode 100644 src/iframe-copy.js diff --git a/chrome/manifest.json b/chrome/manifest.json index 67c5fb0..3305034 100644 --- a/chrome/manifest.json +++ b/chrome/manifest.json @@ -54,5 +54,15 @@ }, "options_ui": { "page": "./dist/ui/options.html" - } + }, + "web_accessible_resources": [ + { + "resources": [ + "dist/iframe-copy.html" + ], + "matches": [ + "" + ] + } + ] } diff --git a/src/iframe-copy.html b/src/iframe-copy.html new file mode 100644 index 0000000..4d8670b --- /dev/null +++ b/src/iframe-copy.html @@ -0,0 +1,11 @@ + + + + + Copy as Markdown (Content Script) + + + + + + diff --git a/src/iframe-copy.js b/src/iframe-copy.js new file mode 100644 index 0000000..1497cbc --- /dev/null +++ b/src/iframe-copy.js @@ -0,0 +1,26 @@ +window.addEventListener('message', (event) => { + switch (event.data.cmd) { + case 'copy': { + const { text } = event.data; + if (text === '' || !text) { + event.source.postMessage({ topic: 'iframe-copy-response', ok: false, reason: 'no text' }, event.origin); + } + + const textBox = document.getElementById('copy'); + textBox.innerHTML = text; + textBox.select(); + const result = document.execCommand('Copy'); + if (result) { + event.source.postMessage({ topic: 'iframe-copy-response', ok: true }, event.origin); + } else { + event.source.postMessage({ topic: 'iframe-copy-response', ok: false, reason: 'execCommand returned false' }, event.origin); + } + textBox.innerHTML = ''; + break; + } + + default: { + event.source.postMessage({ topic: 'iframe-copy-response', ok: false, reason: `unknown command ${event.data.cmd}` }, event.origin); + } + } +}); diff --git a/src/lib/clipboard-access.js b/src/lib/clipboard-access.js index 94e2d73..b90df93 100644 --- a/src/lib/clipboard-access.js +++ b/src/lib/clipboard-access.js @@ -1,3 +1,6 @@ +/* eslint-disable max-len */ +/* eslint-disable max-classes-per-file */ + /** * Before modifying anything here, read the following articles first: * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard @@ -13,68 +16,136 @@ * NOTE: the whole function must be passed to content script as a function literal. * i.e. please do not extract any code to separate functions. * @param text - * @returns {Promise<{ok: bool, errorMessage?: string, method: 'navigator_api'|'textarea'}>} + * @returns {Promise<{ok: boolean, errorMessage?: string, method: 'navigator_api'|'textarea'}>} */ async function copy(text) { + class KnownFailureError extends Error {} + const useClipboardAPI = async (t) => { + /** @type {PermissionStatus} */ + let ret; try { - await navigator.clipboard.writeText(t); - return Promise.resolve({ ok: true, method: 'navigator_api' }); - } catch (error) { - return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'navigator_api' }); + // XXX: In Chrome, clipboard-write permission is required in order to use + // navigator.clipboard.writeText() in Content Script. + // + // There are some inconsistent behaviors when navigator.clipboard is called + // via onCommand (Keyboard Shortcut) vs via onMenu (Context Menu). + // The keyboard shortcut _may_ trigger permission prompt while the context menu one almost + // don't. + // + // Here we behave conservatively -- if permission query don't return 'granted' then + // don't even bother to try calling navigator.clipboard.writeText(). + // + // See https://web.dev/async-clipboard/#security-and-permissions + // See https://bugs.chromium.org/p/chromium/issues/detail?id=1382608#c4 + ret = await navigator.permissions.query({ + name: 'clipboard-write', allowWithoutGesture: true, + }); + } catch (e) { + if (e instanceof TypeError) { + // ... And also `clipboard-write` is not a permission that can be queried in Firefox, + // but since navigator.clipboard.writeText() always work on Firefox as long as it is declared + // in manifest.json, we don't even bother handling it. + await navigator.clipboard.writeText(t); + return true; + } + + throw e; } - }; - try { - // XXX: In Chrome, clipboard-write permission is required in order to use - // navigator.clipboard.writeText() in Content Script. - // - // There are some inconsistent behaviors when navigator.clipboard is called - // via onCommand (Keyboard Shortcut) vs via onMenu (Context Menu). - // The keyboard shortcut _may_ trigger permission prompt while the context menu one almost - // don't. - // - // Here we behave conservatively -- if permission query don't return 'granted' then - // don't even bother to try calling navigator.clipboard.writeText(). - // - // See https://web.dev/async-clipboard/#security-and-permissions - // See https://bugs.chromium.org/p/chromium/issues/detail?id=1382608#c4 - const ret = await navigator.permissions.query({ - name: 'clipboard-write', allowWithoutGesture: true, - }); // state will be 'granted', 'denied' or 'prompt': - if (ret.state === 'granted') { - return useClipboardAPI(text); + if (ret && ret.state === 'granted') { + await navigator.clipboard.writeText(t); + return true; } console.debug(`clipboard-write permission state: ${ret.state}`); - // ... continue with textarea approach - } catch (e) { - if (e instanceof TypeError) { - // ... And also `clipboard-write` is not a permission that can be queried in Firefox, - // but since navigator.clipboard.writeText() always work on Firefox as long as it is declared - // in manifest.json, we don't even bother handling it. - return useClipboardAPI(text); - } - return Promise.resolve({ ok: false, error: `${e.name} ${e.message}`, method: 'navigator_api' }); - } + throw new KnownFailureError('no permission to call navigator.clipboard API'); + }; - return new Promise((resolve) => { + const useOnPageTextarea = async (t) => { + /** @type {HTMLTextAreaElement} */ + const textBox = document.createElement('textarea'); + document.body.appendChild(textBox); try { - /** @type {HTMLTextAreaElement} */ - const textBox = document.createElement('textarea'); - document.body.append(textBox); - textBox.innerHTML = text; + textBox.innerHTML = t; textBox.select(); const result = document.execCommand('Copy'); - document.body.removeChild(textBox); if (result) { - resolve({ ok: true, method: 'textarea' }); + return Promise.resolve(true); } - resolve({ ok: false, error: 'document.execCommand returned false', method: 'textarea' }); + return Promise.reject(new KnownFailureError('execCommand returned false')); } catch (e) { - resolve({ ok: false, error: `${e.name} ${e.message}`, method: 'textarea' }); + return Promise.reject(e); + } finally { + if (document.body.contains(textBox)) { + document.body.removeChild(textBox); + } } + }; + + const useIframeTextarea = async (t) => new Promise((resolve, reject) => { + const iframe = document.createElement('iframe'); + iframe.src = chrome.runtime.getURL('dist/iframe-copy.html'); + iframe.width = '10'; + iframe.height = '10'; + iframe.style.position = 'absolute'; + iframe.style.left = '-100px'; + document.body.appendChild(iframe); + window.addEventListener('message', (event) => { + switch (event.data.topic) { + case 'iframe-copy-response': { + if (document.body.contains(iframe)) { + document.body.removeChild(iframe); + } + if (event.data.ok) { + resolve(true); + } else { + reject(new KnownFailureError(event.data.reason)); + } + break; + } + default: { + reject(new Error(`unknown topic ${event.data.topic}`)); + } + } + }); + + setTimeout(() => { + iframe.contentWindow.postMessage({ cmd: 'copy', text: t }, '*'); + }, 100); }); + + try { + await useClipboardAPI(text); + return Promise.resolve({ ok: true, method: 'navigator_api' }); + } catch (error) { + if (error instanceof KnownFailureError) { + console.debug(error); + // try next method + } else { + return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'navigator_api' }); + } + } + + try { + await useOnPageTextarea(text); + return Promise.resolve({ ok: true, method: 'textarea' }); + } catch (error) { + if (error instanceof KnownFailureError) { + console.debug(error); + // try next method + } else { + return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'textarea' }); + } + } + + try { + await useIframeTextarea(text); + return Promise.resolve({ ok: true, method: 'iframe' }); + } catch (error) { + console.debug(error); + return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'iframe' }); + } } /**