taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 6e8a9a0e6638ed22533affb18e90e7a45aceeaf6
parent 3b08c7334ef506da9ff7a16011a17c78bd56b219
Author: Florian Dold <florian@dold.me>
Date:   Mon, 24 Nov 2025 23:47:39 +0100

webext: overhaul content script injection, support callbacks

Diffstat:
Mpackages/taler-wallet-webextension/src/background.ts | 42+++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-webextension/src/platform/api.ts | 6+++---
Mpackages/taler-wallet-webextension/src/platform/chrome.ts | 10++++++----
Mpackages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts | 401++++++++++++++++++++++++++++++++++---------------------------------------------
Mpackages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts | 27++++++++++++++++++++-------
Mpackages/taler-wallet-webextension/src/wxBackend.ts | 40+++++++++++++++++++++++++++++-----------
6 files changed, 272 insertions(+), 254 deletions(-)

diff --git a/packages/taler-wallet-webextension/src/background.ts b/packages/taler-wallet-webextension/src/background.ts @@ -18,6 +18,7 @@ * Entry point for the background page. * * @author sebasjm + * @author Florian Dold <dold@taler.net> */ /** @@ -40,7 +41,46 @@ if (isFirefox) { setupPlatform(chromeAPI); } -// setGlobalLogLevelFromString("trace") +const injectIntoTabs = async () => { + const urlPatterns = ["https://*/*", "http://*/*"]; + const tabs = await chrome.tabs.query({ url: urlPatterns }); + for (const tab of tabs) { + if ( + !tab.id || + !tab.url || + tab.url.startsWith("chrome://") || + tab.url.startsWith("edge://") + ) { + continue; + } + + console.log("injecting content script into tab", tab.url); + + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["dist/taler-wallet-interaction-loader.js"], + }); + } catch (err) { + // Catch errors for specific restricted pages (like Chrome Web Store) + console.warn(`Failed to inject into tab ${tab.id}:`, err); + } + } +}; + +// Manually run content script in all tabs that are already open. +try { + chrome.runtime.onInstalled.addListener(injectIntoTabs); +} catch (e) { + console.error(e); +} + +// Also run injection directly, in case we're in a service worker. +try { + injectIntoTabs(); +} catch (e) { + console.error(e); +} async function start() { await platform.notifyWhenAppIsReady(); diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts @@ -21,11 +21,11 @@ import { WalletRunConfig, } from "@gnu-taler/taler-util"; import { WalletOperations } from "@gnu-taler/taler-wallet-core"; -import { +import { BackgroundOperations } from "../wxApi.js"; +import type { ExtensionOperations, MessageFromExtension, -} from "../taler-wallet-interaction-loader.js"; -import { BackgroundOperations } from "../wxApi.js"; +} from "../wxBackend.js"; export interface Permissions { /** diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -23,6 +23,7 @@ import { stringifyTalerUri, } from "@gnu-taler/taler-util"; import { WalletOperations } from "@gnu-taler/taler-wallet-core"; +import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; import { BackgroundOperations } from "../wxApi.js"; import { BackgroundPlatformAPI, @@ -35,7 +36,6 @@ import { Settings, defaultSettings, } from "./api.js"; -import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { isFirefox, @@ -242,7 +242,9 @@ function openWalletURIFromPopup(uri: TalerUri): void { logger.warn(`taler://dev-experiment URIs are not allowed in headers`); return; case TalerUriAction.WithdrawalTransferResult: - logger.warn(`taler://withdrawal-transfer-result URIs are not allowed in headers`); + logger.warn( + `taler://withdrawal-transfer-result URIs are not allowed in headers`, + ); return; default: { const error: never = uri; @@ -605,8 +607,8 @@ async function registerIconChangeOnTalerContent(): Promise<void> { return; } - //this browser doesn't have declarativeContent - //we need host_permission and we will check the content for changing the icon + // this browser doesn't have declarativeContent + // we need host_permission and we will check the content for changing the icon chrome.tabs.onUpdated.addListener( async (tabId, info: chrome.tabs.TabChangeInfo) => { if (tabId < 0) return; diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022 Taler Systems S.A. + (C) 2022-2025 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 @@ -14,24 +14,24 @@ 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"; -// FIXME: mem leak problems -// import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; - - /** - * This will modify all the pages that the user load when navigating with Web Extension enabled + * This content script injects Taler support into pages that request it. * - * 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. + * Since the content script runs in an isolated context, we inject + * Taler support by adding a script tag to the DOM. + */ + +/** + * Imports. + * Since this script runs as a content script, we want to import + * as little as possible. */ +import type { MessageFromBackend } from "./platform/api.js"; +import type { + ExtensionOperations, + MessageFromExtension, + MessageResponse, +} from "./wxBackend.js"; // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#content_script_environment @@ -47,17 +47,28 @@ import type { MessageFromBackend } from "./platform/api.js"; // 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]", -// ); +interface TalerSupportStatus { + debugEnabled: boolean; + talerApiEnabled: boolean; + hijackEnabled: boolean; + callbackEnabled: boolean; +} + +/** Current set of support flags, if requested by the page. */ +let talerSupportFlags: TalerSupportStatus | undefined; +/** Currently injected script tag. */ +let interactionSupportElement: HTMLScriptElement | undefined = undefined; + +/** Index of the next message we send to the webext backend. */ +let nextMessageIndex = 0; + +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), +}; function validateTalerUri(uri: string): boolean { return ( @@ -68,34 +79,12 @@ function validateTalerUri(uri: string): boolean { function convertURIToWebExtensionPath(uri: string) { const url = new URL( chrome.runtime.getURL( - // FIXME: mem leak problems - // `static/wallet.html#/taler-uri/${encodeCrockForURI(uri)}`, `static/wallet.html#/taler-uri-simple/${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; @@ -112,65 +101,76 @@ function redirectToTalerActionHandler(element: HTMLMetaElement) { 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; - +function loadScript(args: { unload?: boolean } = {}): void { + if (interactionSupportElement) { + interactionSupportElement.remove(); + interactionSupportElement = undefined; + } + if (!talerSupportFlags) { + return; + } 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"); - } + const setParamBool = (key: string, value: boolean | undefined) => { + if (value) { + url.searchParams.set(key, "true"); + } + }; + setParamBool("callback", talerSupportFlags.callbackEnabled); + setParamBool("debug", talerSupportFlags.debugEnabled); + setParamBool("api", talerSupportFlags.talerApiEnabled); + setParamBool("hijack", talerSupportFlags.hijackEnabled); + setParamBool("unload", args.unload); scriptTag.src = url.href; - + const head = document.head; try { - head.insertBefore( + interactionSupportElement = head.insertBefore( scriptTag, head.children.length ? head.children[0] : null, ); } catch (e) { logger.info("inserting link handler failed!"); logger.error(e); + return undefined; } } -export interface ExtensionOperations { - isAutoOpenEnabled: { - request: void; - response: boolean; - }; - isDomainTrusted: { - request: { - domain: string; - }; - response: boolean; - }; +function maybeHandleMetaTalerUri() { + const metaTalerUri = document.head.querySelector("meta[name=taler-uri]"); + if (metaTalerUri && metaTalerUri instanceof HTMLMetaElement) { + const uri = metaTalerUri.getAttribute("content"); + if (!uri) { + return; + } + redirectToTalerActionHandler(metaTalerUri); + } } -export type MessageFromExtension<Op extends keyof ExtensionOperations> = { - channel: "extension"; - operation: Op; - payload: ExtensionOperations[Op]["request"]; -}; - -export type MessageResponse = CoreApiResponse; +function maybeHandleMetaTalerSupport() { + if (talerSupportFlags) { + // Already loaded. + return; + } + const meta = document.head.querySelector("meta[name=taler-support]"); + if (!meta || !(meta instanceof HTMLMetaElement)) { + return; + } + const content = meta.getAttribute("content"); + if (!content) { + return; + } + const features = content.split(","); + talerSupportFlags = { + debugEnabled: meta.getAttribute("debug") === "true", + hijackEnabled: features.indexOf("uri") >= 0, + callbackEnabled: features.indexOf("callback") >= 0, + talerApiEnabled: features.indexOf("api") >= 0, + }; + loadScript(); +} async function callBackground<Op extends keyof ExtensionOperations>( operation: Op, @@ -189,21 +189,13 @@ async function callBackground<Op extends keyof ExtensionOperations>( 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: `ld:${nextMessageIndex++ % 1000}` }; if (!chrome.runtime.id) { - return Promise.reject( - TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {}), - ); + return Promise.reject(new Error("wallet-core not available")); } return new Promise<any>((resolve, reject) => { logger.debug( @@ -214,18 +206,12 @@ async function sendMessageToBackground<Op extends keyof ExtensionOperations>( 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 + reject(new Error(`wallet-core timeout ${message.operation}`)); + }, 20 * 1000); try { chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { if (timedout) { - return false; //already rejected + return false; // already rejected } clearTimeout(timerId); if (chrome.runtime.lastError) { @@ -242,154 +228,113 @@ async function sendMessageToBackground<Op extends keyof ExtensionOperations>( }); } -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, -) { +async function start() { + 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"; + + // safe check, if one of this is true then taler handler is not useful + // or not expected + const shouldNotInject = + !documentDocTypeIsHTML || !suffixIsNotXMLorPDF || !rootElementIsHTML; // 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", + const injectSettings = await callBackground( + "getInjectionSettings", 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; + await waitHeadReady(); + + const checkMeta = () => { + if (injectSettings.autoOpen) { + maybeHandleMetaTalerUri(); } - redirectToTalerActionHandler(el); - }); + if (injectSettings.injectTalerSupport) { + maybeHandleMetaTalerSupport(); + } + }; - onHeadReady(async (el) => { - const trusted = await isDomainTrusted_promise; - injectTalerSupportScript(el, trusted); - }); + checkMeta(); + + const observerCallback = function ( + mutationsList: MutationRecord[], + observer: MutationObserver, + ) { + for (const mutation of mutationsList) { + if (mutation.type === "childList") { + mutation.addedNodes.forEach((node) => { + if (node instanceof Element && node.tagName === "META") { + checkMeta(); + } + if (talerSupportFlags) { + observer.disconnect(); + return; + } + }); + } + } + }; + const observer = new MutationObserver(observerCallback); + observer.observe(document.head, { childList: true, subtree: false }); - listenToWalletBackground((e: MessageFromBackend) => { + // Listen for notifications from the webext + const notificationPort = chrome.runtime.connect({ name: "notifications" }); + notificationPort.onMessage.addListener((e: MessageFromBackend) => { if ( e.type === "web-extension" && e.notification.type === "settings-change" ) { const settings = e.notification.currentValue; - loaderSettings.isAutoOpenEnabled = settings.autoOpen; + injectSettings.autoOpen = settings.autoOpen; + checkMeta(); } }); -} - -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, + notificationPort.onDisconnect.addListener(() => { + loadScript({ + unload: true, + }); }); } /** - * Tries to find HEAD tag ASAP and report - * @param notify - * @returns + * Tries to find HEAD tag ASAP and report. */ -function notifyWhenHeadIsFound(notify: (el: HTMLHeadElement) => void) { +async function waitHeadReady(): Promise<void> { if (document.head) { - notify(document.head); return; } - const obs = new MutationObserver(async function (mutations) { - try { + return new Promise((resolve, reject) => { + const obs = new MutationObserver(async function (mutations) { mutations.forEach((mut) => { - if (mut.type === "childList") { - mut.addedNodes.forEach((added) => { - if (added instanceof HTMLHeadElement) { - notify(added); - obs.disconnect(); - } - }); + if (mut.type !== "childList") { + return; } + mut.addedNodes.forEach((added) => { + if (added instanceof HTMLHeadElement) { + resolve(); + obs.disconnect(); + } + }); }); - } catch (e) { - console.error(e); - } - }); + }); - obs.observe(document, { - childList: true, - subtree: true, - attributes: false, + obs.observe(document, { + childList: true, + subtree: true, + attributes: false, + }); }); } -start(notifyWhenTalerUriIsFound, notifyWhenHeadIsFound); +if (!("contentScriptDidRun" in window)) { + (window as any).contentScriptDidRun = true; + start(); +} diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts @@ -14,8 +14,6 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; - /** * WARNING * @@ -24,7 +22,7 @@ import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; */ (() => { const logger = { - debug: (..._msg: unknown[]) => { }, + debug: (..._msg: unknown[]) => {}, info: (...msg: unknown[]) => console.log(`${new Date().toISOString()} TALER`, ...msg), error: (...msg: unknown[]) => @@ -78,9 +76,9 @@ import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; return undefined; } const host = `${config.protocol}//${config.hostname}`; - // FIXME: mem leak problems - // const path = `static/wallet.html#/taler-uri/${encodeCrockForURI(uri)}`; - const path = `static/wallet.html#/taler-uri-simple/${encodeURIComponent(uri)}`; + const path = `static/wallet.html#/taler-uri-simple/${encodeURIComponent( + uri, + )}`; return `${host}/${path}`; } @@ -170,6 +168,15 @@ import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; const debugEnabled = searchParams.get("debug") === "true"; const apiEnabled = searchParams.get("api") === "true"; const hijackEnabled = searchParams.get("hijack") === "true"; + const callbackEnabled = searchParams.get("callback") === "true"; + const unloadEnabled = searchParams.get("unload") === "true"; + + if (unloadEnabled) { + if (callbackEnabled && "talercb" in window) { + (window as any).talercb({ present: false }); + } + return; + } const info: Info = Object.freeze({ extensionId, @@ -187,7 +194,7 @@ import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; }; if (apiEnabled) { - // @ts-expect-error we now that `taler` doesn't exist. + // @ts-expect-error we now that `taler` doesn't exist. // we are creating the property window.taler = taler; } @@ -195,6 +202,12 @@ import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; if (hijackEnabled) { taler.__internal.registerProtocolHandler(); } + + if (callbackEnabled) { + if ("talercb" in window) { + (window as any).talercb({ present: true }); + } + } } // utils functions diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts @@ -25,6 +25,7 @@ */ import { AbsoluteTime, + CoreApiResponse, LogLevel, Logger, NotificationType, @@ -56,9 +57,8 @@ import { } from "@gnu-taler/taler-wallet-core"; import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser"; import { BridgeIDBFactory } from "../../idb-bridge/src/bridge-idb.js"; -import { MessageFromFrontend, MessageResponse } from "./platform/api.js"; +import { MessageFromFrontend } from "./platform/api.js"; import { platform } from "./platform/background.js"; -import { ExtensionOperations } from "./taler-wallet-interaction-loader.js"; import { BackgroundOperations } from "./wxApi.js"; /** @@ -75,6 +75,24 @@ const walletInit: OpenedPromise<void> = openPromise<void>(); const logger = new Logger("wxBackend.ts"); +export interface ExtensionOperations { + getInjectionSettings: { + request: void; + response: { + autoOpen: boolean; + injectTalerSupport: boolean; + }; + }; +} + +export type MessageFromExtension<Op extends keyof ExtensionOperations> = { + channel: "extension"; + operation: Op; + payload: ExtensionOperations[Op]["request"]; +}; + +export type MessageResponse = CoreApiResponse; + type BackendHandlerType = { [Op in keyof BackgroundOperations]: ( req: BackgroundOperations[Op]["request"], @@ -277,18 +295,18 @@ async function runGarbageCollector(): Promise<void> { } const extensionHandlers: ExtensionHandlerType = { - isAutoOpenEnabled, - isDomainTrusted, + getInjectionSettings, }; -async function isAutoOpenEnabled(): Promise<boolean> { +async function getInjectionSettings(): Promise<{ + autoOpen: boolean; + injectTalerSupport: boolean; +}> { const settings = await platform.getSettingsFromStorage(); - return settings.autoOpen === true; -} - -async function isDomainTrusted(): Promise<boolean> { - const settings = await platform.getSettingsFromStorage(); - return settings.injectTalerSupport === true; + return { + autoOpen: settings.autoOpen, + injectTalerSupport: settings.injectTalerSupport, + }; } const backendHandlers: BackendHandlerType = {