diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,7 @@ Notifications Runner Activities + Purpose ) add_definitions(-DQT_NO_NARROWING_CONVERSIONS_IN_CONNECT) diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json --- a/extension/_locales/en/messages.json +++ b/extension/_locales/en/messages.json @@ -67,6 +67,15 @@ "message": "Make sure the “Browser Tabs” module is enabled in Plasma Search settings." }, + "options_plugin_purpose_title": { + "description": "Title for Purpose / Web Share plugin", + "message": "Content Sharing" + }, + "options_plugin_purpose_description": { + "description": "Description for Purpose / Web Share plugin", + "message": "Adds a \"Share...\" context menu entry and allows websites to open a dialog for sharing contents using the Web Share API." + }, + "options_plugin_breezeScrollBars_title": { "description": "Title for Breeze style scroll bars plugin", "message": "Use Breeze-style scroll bars" @@ -112,6 +121,27 @@ "message": "Open on '$1'" }, + "purpose_share": { + "description": "Context menu, share link or page via Purpose framework", + "message": "Share..." + }, + "purpose_share_finished_title": { + "description": "Title of share finished notification", + "message": "Content Shared" + }, + "purpose_share_finished_text": { + "description": "Text of the share finished notification", + "message": "The shared content link ($1) has been copied to the clipboard." + }, + "purpose_share_failed_title": { + "description": "Title of share failed notification", + "message": "Sharing Failed" + }, + "purpose_share_failed_text": { + "description": "Text of share failed notification", + "message": "Could not share this content: $1" + }, + "general_error_title": { "description": "Title message for most error notifications", "message": "Plasma Browser Integration Error" diff --git a/extension/constants.js b/extension/constants.js --- a/extension/constants.js +++ b/extension/constants.js @@ -32,6 +32,9 @@ tabsrunner: { enabled: true }, + purpose: { + enabled: true + }, breezeScrollBars: { // this breaks pages in interesting ways, disable by default enabled: false diff --git a/extension/content-script.js b/extension/content-script.js --- a/extension/content-script.js +++ b/extension/content-script.js @@ -18,6 +18,14 @@ var callbacks = {}; +// from https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript +function generateGuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); +} + function addCallback(subsystem, action, callback) { if (!callbacks[subsystem]) { @@ -62,6 +70,9 @@ loadMediaSessionsShim(); } } + if (items.purpose.enabled) { + loadPurpose(); + } }); // BREEZE SCROLL BARS @@ -129,12 +140,7 @@ // ------------------------------------------------------------------------ // -// we give our transfer div a "random id" for privacy -// from https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript -var mediaSessionsTransferDivId ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); - return v.toString(16); -}); +var mediaSessionsTransferDivId = generateGuid(); // also give the function a "random" name as we have to have it in global scope to be able // to invoke callbacks from outside, UUID might start with a number, so prepend something @@ -808,3 +814,119 @@ } } + +// PURPOSE / WEB SHARE API +// ------------------------------------------------------------------------ +// +var purposeTransferDivId = generateGuid(); +var purposeTransferClassName = "p" + purposeTransferDivId.replace(/-/g, ""); + +var purposeLoaded = false; +function loadPurpose() { + if (purposeLoaded) { + return; + } + + purposeLoaded = true; + + // navigator.share must only be defined in secure (https) context + if (!window.isSecureContext) { + return; + } + + window.addEventListener("message", (e) => { + let data = e.data || {}; + let payload = data.payload; + if (data.subsystem !== "purpose" || data.action !== "share" || typeof payload !== "object") { + return; + } + + sendMessage("purpose", "share", payload).then((response) => { + executeScript(` + function() { + ${purposeTransferClassName}.pendingResolve(); + } + `); + }, (err) => { + // Deliberately not giving any more details about why it got rejected + executeScript(` + function() { + ${purposeTransferClassName}.pendingReject(new DOMException("Share request aborted", "AbortError")); + } + `); + }).finally(() => { + executeScript(` + function() { + ${purposeTransferClassName}.reset(); + } + `); + });; + }); + + executeScript(` + function() { + ${purposeTransferClassName} = function() {}; + let transfer = ${purposeTransferClassName}; + transfer.reset = () => { + transfer.pendingResolve = null; + transfer.pendingReject = null; + }; + transfer.reset(); + + if (!navigator.canShare) { + navigator.canShare = (data) => { + if (!data) { + return false; + } + + if (data.title === undefined && data.text === undefined && data.url === undefined) { + return false; + } + + if (data.url) { + // check if URL is valid + try { + new URL(data.url, document.location.href); + } catch (e) { + return false; + } + } + + return true; + } + } + + if (!navigator.share) { + navigator.share = (data) => { + return new Promise((resolve, reject) => { + if (!navigator.canShare(data)) { + return reject(new TypeError()); + } + + if (data.url) { + // validity already checked in canShare, hence no catch + data.url = new URL(data.url, document.location.href).toString(); + } + + if (!window.event || !window.event.isTrusted) { + return reject(new DOMException("navigator.share can only be called in response to user interaction", "NotAllowedError")); + } + + if (transfer.pendingResolve || transfer.pendingReject) { + return reject(new DOMException("A share is already in progress", "AbortError")); + } + + transfer.pendingResolve = resolve; + transfer.pendingReject = reject; + + window.postMessage({ + subsystem: "purpose", + action: "share", + payload: data + }); + }); + }; + } + } + `); +} diff --git a/extension/extension-purpose.js b/extension/extension-purpose.js new file mode 100644 --- /dev/null +++ b/extension/extension-purpose.js @@ -0,0 +1,105 @@ +/* + Copyright (C) 2019 Kai Uwe Broulik + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 3 of + the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +let purposeShareMenuId = "purpose_share"; + +function purposeShare(data) { + return new Promise((resolve, reject) => { + sendPortMessageWithReply("purpose", "share", {data}).then((reply) => { + if (!reply.success) { + if (!["BUSY", "CANCELED", "INVALID_ARGUMENT"].includes(reply.errorCode) + && reply.errorCode !== 1 /*ERR_USER_CANCELED*/) { + chrome.notifications.create(null, { + type: "basic", + title: chrome.i18n.getMessage("purpose_share_failed_title"), + message: chrome.i18n.getMessage("purpose_share_failed_text", + reply.errorMessage || chrome.i18n.getMessage("general_error_unknown")), + iconUrl: "icons/document-share-128.png" // add an "error" overlay? + }); + } + + reject(); + return; + } + + let url = reply.response.url; + if (url) { + chrome.notifications.create(null, { + type: "basic", + title: chrome.i18n.getMessage("purpose_share_finished_title"), + message: chrome.i18n.getMessage("purpose_share_finished_text", url), + iconUrl: "icons/document-share-128.png", // add an "ok tick" overlay? + }); + } + + resolve(); + }); + }); +} + +chrome.contextMenus.onClicked.addListener((info) => { + if (info.menuItemId !== purposeShareMenuId) { + return; + } + + let url = info.linkUrl || info.srcUrl || info.pageUrl; + let selection = info.selectionText; + if (!url && !selection) { + return; + } + + let shareData = {}; + if (selection) { + shareData.text = selection; + } else if (url) { + shareData.url = url; + } + + // We probably shared the current page, add its title to shareData + new Promise((resolve, reject) => { + if (!info.linkUrl && !info.srcUrl && info.pageUrl) { + chrome.tabs.query({ + // more correct would probably be currentWindow + activeTab + url: info.pageUrl + }, (tabs) => { + if (tabs[0]) { + return resolve(tabs[0].title); + } + resolve(""); + }); + return; + } + + resolve(""); + }).then((title) => { + if (title) { + shareData.title = title; + } + + purposeShare(shareData); + }); +}); + +chrome.contextMenus.create({ + id: purposeShareMenuId, + contexts: ["link", "page", "image", "audio", "video", "selection"], + title: chrome.i18n.getMessage("purpose_share") +}); + +addRuntimeCallback("purpose", "share", (message, sender, action) => { + return purposeShare(message); +}); diff --git a/extension/manifest.json b/extension/manifest.json --- a/extension/manifest.json +++ b/extension/manifest.json @@ -28,6 +28,7 @@ "extension-mpris.js", "extension-downloads.js", "extension-tabsrunner.js", + "extension-purpose.js", "extension.js" ], diff --git a/extension/options.html b/extension/options.html --- a/extension/options.html +++ b/extension/options.html @@ -52,6 +52,12 @@

I18N

+
  • + +

    I18N

    +