/*
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);