/* This file is part of GNU Taler (C) 2022 Taler Systems S.A. GNU Taler 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, or (at your option) any later version. GNU Taler 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 GNU Taler; see the file COPYING. If not, see */ import { CoreApiResponse, TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; /** * This will modify all the pages that the user load when navigating with Web Extension enabled * * Can't do useful integration since it run in ISOLATED (or equivalent) mode. * * If taler support is expected, it will inject a script which will complete the integration. */ // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#content_script_environment // ISOLATED mode in chromium browsers // https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world // X-Ray vision in Firefox // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts#xray_vision_in_firefox // *** IMPORTANT *** // Content script lifecycle during navigation // In Firefox: Content scripts remain injected in a web page after the user has navigated away, // however, window object properties are destroyed. // In Chrome: Content scripts are destroyed when the user navigates away from a web page. const documentDocTypeIsHTML = window.document.doctype && window.document.doctype.name === "html"; const suffixIsNotXMLorPDF = !window.location.pathname.endsWith(".xml") && !window.location.pathname.endsWith(".pdf"); const rootElementIsHTML = document.documentElement.nodeName && document.documentElement.nodeName.toLowerCase() === "html"; /** * Listen to any HTML Element and react to it. * - will inject taler-support-lib * - will redirect to call to action */ function listenToHeaderMutation() { new MutationObserver(async function (mutations) { const autoOpen = await callBackground("isAutoOpenEnabled", undefined) mutations.forEach((mut) => { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLHeadElement) { injectTalerSupportScript(added) } else if (added instanceof HTMLMetaElement) { const name = added.getAttribute("name") if (!name) return; if (autoOpen && name === "taler-uri") { redirectToTalerActionHandler(added) } } }); } }); }).observe(document, { childList: true, subtree: true, attributes: false, }) } function validateTalerUri(uri: string): boolean { return ( !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://")) ); } function convertURIToWebExtensionPath(uri: string) { const url = new URL( chrome.runtime.getURL(`static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`), ); return url.href; } // safe check, if one of this is true then taler handler is not useful // or not expected const shouldNotInject = !documentDocTypeIsHTML || !suffixIsNotXMLorPDF || !rootElementIsHTML; const logger = { debug: (...msg: any[]) => { }, info: (...msg: any[]) => console.log(`${new Date().toISOString()} TALER`, ...msg), error: (...msg: any[]) => console.error(`${new Date().toISOString()} TALER`, ...msg), }; // logger.debug = logger.info /** */ function redirectToTalerActionHandler(element: HTMLMetaElement) { const uri = element.getAttribute("content"); if (!uri) return; if (!validateTalerUri(uri)) { logger.error(`taler:// URI is invalid: ${uri}`); return; } location.href = convertURIToWebExtensionPath(uri) } function injectTalerSupportScript(head: HTMLHeadElement) { const meta = head.querySelector("meta[name=taler-support]") const debugEnabled = meta?.getAttribute("debug") === "true"; const scriptTag = document.createElement("script"); scriptTag.setAttribute("async", "false"); const url = new URL( chrome.runtime.getURL("/dist/taler-wallet-interaction-support.js"), ); url.searchParams.set("id", chrome.runtime.id); if (debugEnabled) { url.searchParams.set("debug", "true"); } scriptTag.src = url.href; callBackground("isInjectionEnabled", undefined).then(shouldInject => { if (!shouldInject) return; try { head.insertBefore(scriptTag, head.children.length ? head.children[0] : null); } catch (e) { logger.info("inserting link handler failed!"); logger.error(e); } }); } /** * Create a bridge connection between the page and the extension. * * Useful for API calls and replies. Not yet supported. */ function createBridgeWithExtension() { const port = chrome.runtime.connect(); window.addEventListener( "message", (event) => { logger.debug("message received", event); if (event.source !== window) { return; } if (event.origin !== window.origin) { return; } if (event.data.type && event.data.type === "FROM_PAGE") { logger.debug("Content script received: " + event.data.text); port.postMessage(event.data.text); } }, false, ); } export interface ExtensionOperations { isInjectionEnabled: { request: void; response: boolean; }; isAutoOpenEnabled: { request: void; response: boolean; }; } export type MessageFromExtension = { channel: "extension"; operation: Op; payload: ExtensionOperations[Op]["request"]; }; export type MessageResponse = CoreApiResponse; async function callBackground( operation: Op, payload: ExtensionOperations[Op]["request"], ): Promise { const message: MessageFromExtension = { channel: "extension", operation, payload, }; const response = await sendMessageToBackground(message); if (response.type === "error") { throw new Error(`Background operation "${operation}" failed`); } return response.result as any; } let nextMessageIndex = 0; async function sendMessageToBackground( message: MessageFromExtension, ): Promise { const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` }; return new Promise((resolve, reject) => { // logger.trace("send operation to the wallet background", message); let timedout = false; const timerId = setTimeout(() => { timedout = true; reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, {})) }, 20 * 1000); //five seconds chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { if (timedout) { return false; //already rejected } clearTimeout(timerId); if (chrome.runtime.lastError) { reject(chrome.runtime.lastError.message); } else { resolve(backgroundResponse); } // return true to keep the channel open return true; }); }); } function start() { if (shouldNotInject) return; listenToHeaderMutation(); createBridgeWithExtension(); logger.debug("bridged created"); } start();