Commit 9c16dbae authored by Kai Uwe Broulik's avatar Kai Uwe Broulik 🍇
Browse files

Implement Web Share API through Purpose

This implements Web Share API Level 1 [1] through Purpose and also adds a generic "Share..." context menu entry.
It can be tested on [2].

[1] https://w3c.github.io/web-share/
[2] https://w3c.github.io/web-share/demos/share.html

Differential Revision: https://phabricator.kde.org/D23151
parent abb87524
......@@ -30,6 +30,7 @@ find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS
Notifications
Runner
Activities
Purpose
FileMetaData
)
......
......@@ -80,6 +80,15 @@
"message": "Make sure the “Browser Tabs” module is enabled in <a id=\"$1\" href=\"$2\">Plasma Search settings</a>."
},
"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"
......@@ -125,6 +134,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_unknown": {
"description": "An unknown error occurred, usually used when an error message by the system is not provided",
"message": "Unknown Error"
......
......@@ -33,6 +33,9 @@ DEFAULT_EXTENSION_SETTINGS = {
tabsrunner: {
enabled: true
},
purpose: {
enabled: true
},
breezeScrollBars: {
// this breaks pages in interesting ways, disable by default
enabled: false
......
......@@ -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) {
const 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,14 @@ storage.get(DEFAULT_EXTENSION_SETTINGS, function (items) {
loadMediaSessionsShim();
}
}
if (items.purpose.enabled) {
sendMessage("settings", "getSubsystemStatus").then((status) => {
if (status && status.purpose) {
loadPurpose();
}
});
}
});
// BREEZE SCROLL BARS
......@@ -129,16 +145,9 @@ html::-webkit-scrollbar-corner {
// ------------------------------------------------------------------------
//
// 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);
});
// 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
var mediaSessionsClassName = "f" + mediaSessionsTransferDivId.replace(/-/g, "");
const mediaSessionsClassName = "f" + generateGuid().replace(/-/g, "");
var activePlayer;
// When a player has no duration yet, we'll wait for it becoming known
......@@ -834,3 +843,123 @@ function loadMediaSessionsShim() {
}
}
}
// PURPOSE / WEB SHARE API
// ------------------------------------------------------------------------
//
const purposeTransferClassName = "p" + generateGuid().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("pbiPurposeMessage", (e) => {
const data = e.detail || {};
const action = data.action;
const payload = data.payload;
if (action !== "share") {
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;
const event = new CustomEvent("pbiPurposeMessage", {
detail: {
action: "share",
payload: data
}
});
window.dispatchEvent(event);
});
};
}
}
`);
}
/*
Copyright (C) 2019 Kai Uwe Broulik <kde@privat.broulik.de>
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 <http://www.gnu.org/licenses/>.
*/
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);
});
......@@ -28,6 +28,7 @@
"extension-mpris.js",
"extension-downloads.js",
"extension-tabsrunner.js",
"extension-purpose.js",
"extension.js"
],
......
......@@ -72,6 +72,12 @@
</label>
<p data-i18n="options_plugin_tabsrunner_description, open-krunner-settings, #" data-i18n-html="true">I18N</p>
</li>
<li data-requires-extension="purpose">
<label>
<input type="checkbox" data-extension="purpose" data-settings-key="enabled"> <span data-i18n="options_plugin_purpose_title">I18N</span>
</label>
<p data-i18n="options_plugin_purpose_description">I18N</p>
</li>
<li data-not-show-in="firefox">
<label>
<input type="checkbox" data-extension="breezeScrollBars" data-settings-key="enabled"> <span data-i18n="options_plugin_breezeScrollBars_title">I18N</span>
......
......@@ -10,6 +10,7 @@ set(HOST_SOURCES main.cpp
downloadplugin.cpp
downloadjob.cpp
tabsrunnerplugin.cpp
purposeplugin.cpp
)
qt5_add_dbus_adaptor(HOST_SOURCES ../dbus/org.kde.plasma.browser_integration.TabsRunner.xml tabsrunnerplugin.h TabsRunnerPlugin)
......@@ -27,6 +28,7 @@ target_link_libraries(
KF5::Crash
KF5::I18n
KF5::KIOCore
KF5::PurposeWidgets
KF5::FileMetaData
)
......
......@@ -36,6 +36,7 @@
#include "downloadplugin.h"
#include "tabsrunnerplugin.h"
#include "mprisplugin.h"
#include "purposeplugin.h"
void msgHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
......@@ -81,6 +82,7 @@ int main(int argc, char *argv[])
PluginManager::self().addPlugin(new DownloadPlugin(&a));
PluginManager::self().addPlugin(new TabsRunnerPlugin(&a));
PluginManager::self().addPlugin(new MPrisPlugin(&a));
PluginManager::self().addPlugin(new PurposePlugin(&a));
// TODO make this prettier, also prevent unloading them at any cost
PluginManager::self().loadPlugin(&Settings::self());
......
/*
Copyright (C) 2019 by Kai Uwe Broulik <kde@privat.broulik.de>
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.
*/
#include "purposeplugin.h"
#include <QClipboard>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonObject>
#include <KIO/MimetypeJob>
#include <Purpose/AlternativesModel>
#include <PurposeWidgets/Menu>
PurposePlugin::PurposePlugin(QObject *parent)
: AbstractBrowserPlugin(QStringLiteral("purpose"), 1, parent)
{
}
PurposePlugin::~PurposePlugin()
{
onUnload();
}
bool PurposePlugin::onUnload()
{
m_menu.reset();
return true;
}
QJsonObject PurposePlugin::handleData(int serial, const QString &event, const QJsonObject &data)
{
if (event == QLatin1String("share")) {
if (m_pendingReplySerial != -1 || (m_menu && m_menu->isVisible())) {
return {
{QStringLiteral("success"), false},
{QStringLiteral("errorCode"), QStringLiteral("BUSY")}
};
}
// store request serial for asynchronous reply
m_pendingReplySerial = serial;
const QJsonObject shareData = data.value(QStringLiteral("data")).toObject();
const QString title = shareData.value(QStringLiteral("title")).toString();
const QString text = shareData.value(QStringLiteral("text")).toString();
const QString urlString = shareData.value(QStringLiteral("url")).toString();
if (!m_menu) {
m_menu.reset(new Purpose::Menu());
m_menu->model()->setPluginType(QStringLiteral("Export"));
connect(m_menu.data(), &QMenu::aboutToHide, this, [this] {
if (!m_menu->activeAction()) {
sendPendingReply(false, {
{QStringLiteral("errorCode"), QStringLiteral("CANCELED")}
});
}
});
connect(m_menu.data(), &Purpose::Menu::finished, this, [this](const QJsonObject &output, int errorCode, const QString &errorMessage) {
if (errorCode) {
debug() << "Error:" << errorCode << errorMessage;
sendPendingReply(false, {
{QStringLiteral("errorCode"), errorCode},
{QStringLiteral("errorMessage"), errorMessage}
});
return;
}
const QString url = output.value(QStringLiteral("url")).toString();
if (!url.isEmpty()) {
// Do this here rather than on the extension side to avoid having to request an additional permission after updating
QGuiApplication::clipboard()->setText(url);
}
debug() << "Finished:" << output;
sendPendingReply(true, {
{QStringLiteral("response"), output}
});
});
}
QJsonObject shareJson;
if (!title.isEmpty()) {
shareJson.insert(QStringLiteral("title"), title);
}
QJsonArray urls;
if (!urlString.isEmpty()) {
urls.append(urlString);
}
// Sends even text as URL...
if (!text.isEmpty()) {
urls.append(text);
}
if (!urls.isEmpty()) {
shareJson.insert(QStringLiteral("urls"), urls);
}
if (!text.isEmpty()) {
showShareMenu(shareJson, QStringLiteral("text/plain"));
return {};
}
if (!urls.isEmpty()) {
auto *mimeJob = KIO::mimetype(QUrl(urlString), KIO::HideProgressInfo);
connect(mimeJob, &KJob::finished, this, [this, mimeJob, shareJson] {
showShareMenu(shareJson, mimeJob->mimetype());
});
return {};
}
// navigator.share({title: "foo"}) is valid but makes no sense
// and we also cannot share via Purpose without "urls"
return {
{QStringLiteral("success"), false},
{QStringLiteral("errorCode"), QStringLiteral("INVALID_ARGUMENT")}
};
}
return {};
}
void PurposePlugin::sendPendingReply(bool success, const QJsonObject &data)
{
QJsonObject reply = data;
reply.insert(QStringLiteral("success"), success);
sendReply(m_pendingReplySerial, reply);
m_pendingReplySerial = -1;
}
void PurposePlugin::showShareMenu(const QJsonObject &data, const QString &mimeType)
{
QJsonObject shareData = data;
if (!mimeType.isEmpty() && mimeType != QLatin1String("application/octet-stream")) {
shareData.insert(QStringLiteral("mimeType"), mimeType);
} else {
shareData.insert(QStringLiteral("mimeType"), QStringLiteral("*/*"));
}
debug() << "Share mime type" << mimeType << "with data" << data;
m_menu->model()->setInputData(shareData);
m_menu->reload();
m_menu->popup(QCursor::pos());
}
/*
Copyright (C) 2019 by Kai Uwe Broulik <kde@privat.broulik.de>
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.
*/
#pragma once
#include "abstractbrowserplugin.h"
#include <QPointer>
#include <QScopedPointer>
#include <QString>
#include <QUrl>
namespace Purpose {
class Menu;
}
class PurposePlugin : public AbstractBrowserPlugin
{
Q_OBJECT
public:
explicit PurposePlugin(QObject *parent);
~PurposePlugin() override;
bool onUnload() override;