/* 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"; 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 name = element.getAttribute("name") if (!name) return; if (name !== "taler-uri") return; 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; try { head.insertBefore(scriptTag, head.children.length ? head.children[0] : null); } catch (e) { logger.info("inserting link handler failed!"); logger.error(e); } } 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; /** * * @param message * @returns */ async function sendMessageToBackground( message: MessageFromExtension, ): Promise { const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` }; if (!chrome.runtime.id) { return Promise.reject(TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {})) } return new Promise((resolve, reject) => { logger.debug("send operation to the wallet background", message, chrome.runtime.id); let timedout = false; const timerId = setTimeout(() => { timedout = true; reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, {})) }, 20 * 1000); //five seconds try { 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; }); } catch (e) { console.log(e) } }); } function start( onTalerMetaTagFound: (listener:(el: HTMLMetaElement)=>void) => void, onHeadReady: (listener:(el: HTMLHeadElement)=>void) => void ) { // do not run everywhere, this is just expected to run on html // sites if (shouldNotInject) return; const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined) const isInjectionEnabled_promise = callBackground("isInjectionEnabled", undefined) onTalerMetaTagFound(async (el)=> { const enabled = await isAutoOpenEnabled_promise; if (!enabled) return; redirectToTalerActionHandler(el) }) onHeadReady(async (el) => { const enabled = await isInjectionEnabled_promise; if (!enabled) return; injectTalerSupportScript(el) }) } /** * Tries to find taler meta tag ASAP and report * @param notify * @returns */ function onTalerMetaTag(notify: (el: HTMLMetaElement) => void) { if (document.head) { const element = document.head.querySelector("meta[name=taler-uri]") if (!element) return; if (!(element instanceof HTMLMetaElement)) return; const name = element.getAttribute("name") if (!name) return; if (name !== "taler-uri") return; const uri = element.getAttribute("content"); if (!uri) return; notify(element) return; } const obs = new MutationObserver(async function (mutations) { try { mutations.forEach((mut) => { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLMetaElement) { const name = added.getAttribute("name") if (!name) return; if (name !== "taler-uri") return; const uri = added.getAttribute("content"); if (!uri) return; notify(added) obs.disconnect() } }); } }); } catch (e) { console.error(e) } }) obs.observe(document, { childList: true, subtree: true, attributes: false, }) } /** * Tries to find HEAD tag ASAP and report * @param notify * @returns */ function onHeaderReady(notify: (el: HTMLHeadElement) => void) { if (document.head) { notify(document.head) return; } const obs = new MutationObserver(async function (mutations) { try { mutations.forEach((mut) => { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLHeadElement) { notify(added) obs.disconnect() } }); } }); } catch (e) { console.error(e) } }) obs.observe(document, { childList: true, subtree: true, attributes: false, }) } start(onTalerMetaTag, onHeaderReady);