summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts')
-rw-r--r--packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts372
1 files changed, 372 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts
new file mode 100644
index 000000000..3b7cbcbb7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts
@@ -0,0 +1,372 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+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<Op extends keyof ExtensionOperations> = {
+ channel: "extension";
+ operation: Op;
+ payload: ExtensionOperations[Op]["request"];
+};
+
+export type MessageResponse = CoreApiResponse;
+
+async function callBackground<Op extends keyof ExtensionOperations>(
+ operation: Op,
+ payload: ExtensionOperations[Op]["request"],
+): Promise<ExtensionOperations[Op]["response"]> {
+ const message: MessageFromExtension<Op> = {
+ 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<Op extends keyof ExtensionOperations>(
+ message: MessageFromExtension<Op>,
+): Promise<MessageResponse> {
+ const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` };
+
+ if (!chrome.runtime.id) {
+ return Promise.reject(TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {}))
+ }
+ return new Promise<any>((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);