/* 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"; import type { MessageFromBackend } from "./platform/api.js"; /** * 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"; // const pageAcceptsTalerSupport = document.head.querySelector( // "meta[name=taler-support]", // ); 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 || // !pageAcceptsTalerSupport || !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; } const walletPage = convertURIToWebExtensionPath(uri) window.location.replace(walletPage) } function injectTalerSupportScript(head: HTMLHeadElement, trusted: boolean) { const meta = head.querySelector("meta[name=taler-support]") if (!meta) return; const content = meta.getAttribute("content"); if (!content) return; const features = content.split(",") const debugEnabled = meta.getAttribute("debug") === "true"; const hijackEnabled = features.indexOf("uri") !== -1 const talerApiEnabled = features.indexOf("api") !== -1 && trusted 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"); } if (talerApiEnabled) { url.searchParams.set("api", "true"); } if (hijackEnabled) { url.searchParams.set("hijack", "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 { isAutoOpenEnabled: { request: void; response: boolean; }; isDomainTrusted: { request: { domain: string; }; 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, { requestMethod: "wallet", requestUrl: message.operation, timeoutMs: 20 * 1000, })) }, 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) } }); } let notificationPort: chrome.runtime.Port | undefined; function listenToWalletBackground(listener: (m: any) => void): () => void { if (notificationPort === undefined) { notificationPort = chrome.runtime.connect({ name: "notifications" }); } notificationPort.onMessage.addListener(listener); function removeListener(): void { if (notificationPort !== undefined) { notificationPort.onMessage.removeListener(listener); } } return removeListener; } const loaderSettings = { isAutoOpenEnabled: false, isDomainTrusted: false, } 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 site // that are aware of taler if (shouldNotInject) return; const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined).then(result => { loaderSettings.isAutoOpenEnabled = result; return result; }) const isDomainTrusted_promise = callBackground("isDomainTrusted", { domain: window.location.origin }).then(result => { loaderSettings.isDomainTrusted = result; return result; }) onTalerMetaTagFound(async (el) => { await isAutoOpenEnabled_promise; if (!loaderSettings.isAutoOpenEnabled) { return; } redirectToTalerActionHandler(el) }) onHeadReady(async (el) => { const trusted = await isDomainTrusted_promise injectTalerSupportScript(el, trusted) }) listenToWalletBackground((e: MessageFromBackend) => { if (e.type === "web-extension" && e.notification.type === "settings-change") { const settings = e.notification.currentValue loaderSettings.isAutoOpenEnabled = settings.autoOpen } }) } function isCorrectMetaElement(el: HTMLMetaElement): boolean { const name = el.getAttribute("name") if (!name) return false; if (name !== "taler-uri") return false; const uri = el.getAttribute("content"); if (!uri) return false; return true } /** * Tries to find taler meta tag ASAP and report * @param notify * @returns */ function notifyWhenTalerUriIsFound(notify: (el: HTMLMetaElement) => void) { if (document.head) { const element = document.head.querySelector("meta[name=taler-uri]") if (!element) return; if (!(element instanceof HTMLMetaElement)) return; if (isCorrectMetaElement(element)) { 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) { if (isCorrectMetaElement(added)) { 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 notifyWhenHeadIsFound(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(notifyWhenTalerUriIsFound, notifyWhenHeadIsFound);