/* 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 { classifyTalerUri, Logger, TalerUriType } from "@gnu-taler/taler-util"; import { WalletOperations } from "@gnu-taler/taler-wallet-core"; import { BackgroundOperations } from "../wxApi.js"; import { BackgroundPlatformAPI, CrossBrowserPermissionsApi, ForegroundPlatformAPI, MessageFromBackend, MessageFromFrontend, MessageResponse, Permissions, } from "./api.js"; const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { isFirefox, findTalerUriInActiveTab, findTalerUriInClipboard, getPermissionsApi, getWalletWebExVersion, listenToWalletBackground, notifyWhenAppIsReady, openWalletPage, openWalletPageFromPopup, openWalletURIFromPopup, redirectTabToWalletPage, registerAllIncomingConnections, registerOnInstalled, listenToAllChannels: listenToAllChannels as any, registerReloadOnNewVersion, registerTalerHeaderListener, sendMessageToAllChannels, sendMessageToBackground, useServiceWorkerAsBackgroundProcess, containsTalerHeaderListener, keepAlive, }; export default api; const logger = new Logger("chrome.ts"); function keepAlive(callback: any): void { if (extensionIsManifestV3()) { chrome.alarms.create("wallet-worker", { periodInMinutes: 1 }); chrome.alarms.onAlarm.addListener((a) => { logger.trace(`kee p alive alarm: ${a.name}`); // callback() }); // } else { } callback(); } function isFirefox(): boolean { return false; } const hostPermissions = { permissions: ["webRequest"], origins: ["http://*/*", "https://*/*"], }; export function containsClipboardPermissions(): Promise { return new Promise((res, rej) => { res(false); // chrome.permissions.contains({ permissions: ["clipboardRead"] }, (resp) => { // const le = chrome.runtime.lastError?.message; // if (le) { // rej(le); // } // res(resp); // }); }); } export function containsHostPermissions(): Promise { return new Promise((res, rej) => { chrome.permissions.contains(hostPermissions, (resp) => { const le = chrome.runtime.lastError?.message; if (le) { rej(le); } res(resp); }); }); } export async function requestClipboardPermissions(): Promise { return new Promise((res, rej) => { res(false); // chrome.permissions.request({ permissions: ["clipboardRead"] }, (resp) => { // const le = chrome.runtime.lastError?.message; // if (le) { // rej(le); // } // res(resp); // }); }); } export async function requestHostPermissions(): Promise { return new Promise((res, rej) => { chrome.permissions.request(hostPermissions, (resp) => { const le = chrome.runtime.lastError?.message; if (le) { rej(le); } res(resp); }); }); } type HeaderListenerFunc = ( details: chrome.webRequest.WebResponseHeadersDetails, ) => void; let currentHeaderListener: HeaderListenerFunc | undefined = undefined; type TabListenerFunc = (tabId: number, info: chrome.tabs.TabChangeInfo) => void; let currentTabListener: TabListenerFunc | undefined = undefined; export function containsTalerHeaderListener(): boolean { return ( currentHeaderListener !== undefined || currentTabListener !== undefined ); } export async function removeHostPermissions(): Promise { //if there is a handler already, remove it if ( currentHeaderListener && chrome?.webRequest?.onHeadersReceived?.hasListener(currentHeaderListener) ) { chrome.webRequest.onHeadersReceived.removeListener(currentHeaderListener); } if ( currentTabListener && chrome?.tabs?.onUpdated?.hasListener(currentTabListener) ) { chrome.tabs.onUpdated.removeListener(currentTabListener); } currentHeaderListener = undefined; currentTabListener = undefined; //notify the browser about this change, this operation is expensive if ("webRequest" in chrome) { chrome.webRequest.handlerBehaviorChanged(() => { if (chrome.runtime.lastError) { logger.error(JSON.stringify(chrome.runtime.lastError)); } }); } if (extensionIsManifestV3()) { // Trying to remove host permissions with manifest >= v3 throws an error return true; } return new Promise((res, rej) => { chrome.permissions.remove(hostPermissions, (resp) => { const le = chrome.runtime.lastError?.message; if (le) { rej(le); } res(resp); }); }); } export function removeClipboardPermissions(): Promise { return new Promise((res, rej) => { res(true); // chrome.permissions.remove({ permissions: ["clipboardRead"] }, (resp) => { // const le = chrome.runtime.lastError?.message; // if (le) { // rej(le); // } // res(resp); // }); }); } function addPermissionsListener( callback: (p: Permissions, lastError?: string) => void, ): void { chrome.permissions.onAdded.addListener((perm: Permissions) => { const lastError = chrome.runtime.lastError?.message; callback(perm, lastError); }); } function getPermissionsApi(): CrossBrowserPermissionsApi { return { addPermissionsListener, containsHostPermissions, requestHostPermissions, removeHostPermissions, requestClipboardPermissions, removeClipboardPermissions, containsClipboardPermissions, }; } /** * * @param callback function to be called */ function notifyWhenAppIsReady(callback: () => void): void { if (extensionIsManifestV3()) { callback(); } else { window.addEventListener("load", callback); } } function openWalletURIFromPopup(maybeTalerUri: string): void { const talerUri = maybeTalerUri.startsWith("ext+") ? maybeTalerUri.substring(4) : maybeTalerUri; const uriType = classifyTalerUri(talerUri); let url: string | undefined = undefined; switch (uriType) { case TalerUriType.TalerWithdraw: url = chrome.runtime.getURL( `static/wallet.html#/cta/withdraw?talerWithdrawUri=${talerUri}`, ); break; case TalerUriType.TalerRecovery: url = chrome.runtime.getURL( `static/wallet.html#/cta/recovery?talerRecoveryUri=${talerUri}`, ); break; case TalerUriType.TalerPay: url = chrome.runtime.getURL( `static/wallet.html#/cta/pay?talerPayUri=${talerUri}`, ); break; case TalerUriType.TalerTip: url = chrome.runtime.getURL( `static/wallet.html#/cta/tip?talerTipUri=${talerUri}`, ); break; case TalerUriType.TalerRefund: url = chrome.runtime.getURL( `static/wallet.html#/cta/refund?talerRefundUri=${talerUri}`, ); break; case TalerUriType.TalerPayPull: url = chrome.runtime.getURL( `static/wallet.html#/cta/invoice/pay?talerPayPullUri=${talerUri}`, ); break; case TalerUriType.TalerPayPush: url = chrome.runtime.getURL( `static/wallet.html#/cta/transfer/pickup?talerPayPushUri=${talerUri}`, ); break; case TalerUriType.Unknown: logger.warn( `Response with HTTP 402 the Taler header but could not classify ${talerUri}`, ); return; case TalerUriType.TalerDevExperiment: logger.warn(`taler://dev-experiment URIs are not allowed in headers`); return; default: { const error: never = uriType; logger.warn( `Response with HTTP 402 the Taler header "${error}", but header value is not a taler:// URI.`, ); return; } } chrome.tabs.create({ active: true, url }, () => { window.close(); }); } function openWalletPage(page: string): void { const url = chrome.runtime.getURL(`/static/wallet.html#${page}`); chrome.tabs.create({ active: true, url }); } function openWalletPageFromPopup(page: string): void { const url = chrome.runtime.getURL(`/static/wallet.html#${page}`); chrome.tabs.create({ active: true, url }, () => { window.close(); }); } let nextMessageIndex = 0; /** * To be used by the foreground * @param message * @returns */ async function sendMessageToBackground< Op extends WalletOperations | BackgroundOperations, >(message: MessageFromFrontend): Promise { const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` }; return new Promise((resolve, reject) => { logger.trace("send operation to the wallet background", message); let timedout = false; setTimeout(() => { timedout = true; reject("timedout"); }, 2000); chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { if (timedout) return false; if (chrome.runtime.lastError) { reject(chrome.runtime.lastError.message); } else { resolve(backgroundResponse); } // return true to keep the channel open return true; }); }); } /** * To be used by the foreground */ 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 allPorts: chrome.runtime.Port[] = []; function sendMessageToAllChannels(message: MessageFromBackend): void { for (const notif of allPorts) { // const message: MessageFromBackend = { type: msg.type }; try { notif.postMessage(message); } catch (e) { logger.error("error posting a message", e); } } } function registerAllIncomingConnections(): void { chrome.runtime.onConnect.addListener((port) => { try { allPorts.push(port); port.onDisconnect.addListener((discoPort) => { try { const idx = allPorts.indexOf(discoPort); if (idx >= 0) { allPorts.splice(idx, 1); } } catch (e) { logger.error("error trying to remove connection", e); } }); } catch (e) { logger.error("error trying to save incoming connection", e); } }); } function listenToAllChannels( notifyNewMessage: ( message: MessageFromFrontend & { id: string }, ) => Promise, ): void { chrome.runtime.onMessage.addListener((message, sender, reply) => { notifyNewMessage(message) .then((apiResponse) => { try { reply(apiResponse); } catch (e) { logger.error( "sending response to frontend failed", message, apiResponse, e, ); } }) .catch((e) => { logger.error("notify to background failed", e); }); // keep the connection open return true; }); } function registerReloadOnNewVersion(): void { // Explicitly unload the extension page as soon as an update is available, // so the update gets installed as soon as possible. chrome.runtime.onUpdateAvailable.addListener((details) => { logger.info("update available:", details); chrome.runtime.reload(); }); } function redirectTabToWalletPage(tabId: number, page: string): void { const url = chrome.runtime.getURL(`/static/wallet.html#${page}`); logger.trace("redirecting tabId: ", tabId, " to: ", url); chrome.tabs.update(tabId, { url }); } interface WalletVersion { version_name?: string | undefined; version: string; } function getWalletWebExVersion(): WalletVersion { const manifestData = chrome.runtime.getManifest(); return manifestData; } function registerTalerHeaderListener( callback: (tabId: number, url: string) => void, ): void { logger.trace("setting up header listener"); function headerListener( details: chrome.webRequest.WebResponseHeadersDetails, ): void { if (chrome.runtime.lastError) { logger.error(JSON.stringify(chrome.runtime.lastError)); return; } if ( details.statusCode === 402 || details.statusCode === 202 || details.statusCode === 200 ) { const values = (details.responseHeaders || []) .filter((h) => h.name.toLowerCase() === "taler") .map((h) => h.value) .filter((value): value is string => !!value); if (values.length > 0) { logger.info( `Found a Taler URI in a response header for the request ${details.url} from tab ${details.tabId}`, ); callback(details.tabId, values[0]); } } return; } async function tabListener( tabId: number, info: chrome.tabs.TabChangeInfo, ): Promise { if (tabId < 0) return; const tabLocationHasBeenUpdated = info.status === "complete"; const tabTitleHasBeenUpdated = info.title !== undefined; if (tabLocationHasBeenUpdated || tabTitleHasBeenUpdated) { const uri = await findTalerUriInTab(tabId); if (!uri) return; logger.info(`Found a Taler URI in the tab ${tabId}`); callback(tabId, uri); } } const prevHeaderListener = currentHeaderListener; const prevTabListener = currentTabListener; getPermissionsApi() .containsHostPermissions() .then((result) => { //if there is a handler already, remove it if ( prevHeaderListener && chrome?.webRequest?.onHeadersReceived?.hasListener(prevHeaderListener) ) { chrome.webRequest.onHeadersReceived.removeListener(prevHeaderListener); } if ( prevTabListener && chrome?.tabs?.onUpdated?.hasListener(prevTabListener) ) { chrome.tabs.onUpdated.removeListener(prevTabListener); } //if the result was positive, add the headerListener if (result) { const headersEvent: | chrome.webRequest.WebResponseHeadersEvent | undefined = chrome?.webRequest?.onHeadersReceived; if (headersEvent) { headersEvent.addListener(headerListener, { urls: [""] }, [ "responseHeaders", ]); currentHeaderListener = headerListener; } const tabsEvent: chrome.tabs.TabUpdatedEvent | undefined = chrome?.tabs?.onUpdated; if (tabsEvent) { tabsEvent.addListener(tabListener); currentTabListener = tabListener; } } //notify the browser about this change, this operation is expensive chrome?.webRequest?.handlerBehaviorChanged(() => { if (chrome.runtime.lastError) { logger.error(JSON.stringify(chrome.runtime.lastError)); } }); }); } const alertIcons = { "16": "/static/img/taler-alert-16.png", "19": "/static/img/taler-alert-19.png", "32": "/static/img/taler-alert-32.png", "38": "/static/img/taler-alert-38.png", "48": "/static/img/taler-alert-48.png", "64": "/static/img/taler-alert-64.png", "128": "/static/img/taler-alert-128.png", "256": "/static/img/taler-alert-256.png", "512": "/static/img/taler-alert-512.png", }; const normalIcons = { "16": "/static/img/taler-logo-16.png", "19": "/static/img/taler-logo-19.png", "32": "/static/img/taler-logo-32.png", "38": "/static/img/taler-logo-38.png", "48": "/static/img/taler-logo-48.png", "64": "/static/img/taler-logo-64.png", "128": "/static/img/taler-logo-128.png", "256": "/static/img/taler-logo-256.png", "512": "/static/img/taler-logo-512.png", }; function setNormalIcon(): void { if (extensionIsManifestV3()) { chrome.action.setIcon({ path: normalIcons }); } else { chrome.browserAction.setIcon({ path: normalIcons }); } } function setAlertedIcon(): void { if (extensionIsManifestV3()) { chrome.action.setIcon({ path: alertIcons }); } else { chrome.browserAction.setIcon({ path: alertIcons }); } } interface OffscreenCanvasRenderingContext2D extends CanvasState, CanvasTransform, CanvasCompositing, CanvasImageSmoothing, CanvasFillStrokeStyles, CanvasShadowStyles, CanvasFilters, CanvasRect, CanvasDrawPath, CanvasUserInterface, CanvasText, CanvasDrawImage, CanvasImageData, CanvasPathDrawingStyles, CanvasTextDrawingStyles, CanvasPath { readonly canvas: OffscreenCanvas; } declare const OffscreenCanvasRenderingContext2D: { prototype: OffscreenCanvasRenderingContext2D; new (): OffscreenCanvasRenderingContext2D; }; interface OffscreenCanvas extends EventTarget { width: number; height: number; getContext( contextId: "2d", contextAttributes?: CanvasRenderingContext2DSettings, ): OffscreenCanvasRenderingContext2D | null; } declare const OffscreenCanvas: { prototype: OffscreenCanvas; new (width: number, height: number): OffscreenCanvas; }; function createCanvas(size: number): OffscreenCanvas { if (extensionIsManifestV3()) { return new OffscreenCanvas(size, size); } else { const c = document.createElement("canvas"); c.height = size; c.width = size; return c; } } async function createImage(size: number, file: string): Promise { const r = await fetch(file); const b = await r.blob(); const image = await createImageBitmap(b); const canvas = createCanvas(size); const canvasContext = canvas.getContext("2d")!; canvasContext.clearRect(0, 0, canvas.width, canvas.height); canvasContext.drawImage(image, 0, 0, canvas.width, canvas.height); const imageData = canvasContext.getImageData( 0, 0, canvas.width, canvas.height, ); return imageData; } async function registerIconChangeOnTalerContent(): Promise { const imgs = await Promise.all( Object.entries(alertIcons).map(([key, value]) => createImage(parseInt(key, 10), value), ), ); const imageData = imgs.reduce( (prev, cur) => ({ ...prev, [cur.width]: cur }), {} as { [size: string]: ImageData }, ); if (chrome.declarativeContent) { // using declarative content does not need host permission // and is faster const secureTalerUrlLookup = { conditions: [ new chrome.declarativeContent.PageStateMatcher({ css: ["a[href^='taler://'"], }), ], actions: [new chrome.declarativeContent.SetIcon({ imageData })], }; const inSecureTalerUrlLookup = { conditions: [ new chrome.declarativeContent.PageStateMatcher({ css: ["a[href^='taler+http://'"], }), ], actions: [new chrome.declarativeContent.SetIcon({ imageData })], }; chrome.declarativeContent.onPageChanged.removeRules(undefined, function () { chrome.declarativeContent.onPageChanged.addRules([ secureTalerUrlLookup, inSecureTalerUrlLookup, ]); }); return; } //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; if (info.status !== "complete") return; const uri = await findTalerUriInTab(tabId); if (uri) { setAlertedIcon(); } else { setNormalIcon(); } }, ); chrome.tabs.onActivated.addListener( async ({ tabId }: chrome.tabs.TabActiveInfo) => { if (tabId < 0) return; const uri = await findTalerUriInTab(tabId); if (uri) { setAlertedIcon(); } else { setNormalIcon(); } }, ); } function registerOnInstalled(callback: () => void): void { // This needs to be outside of main, as Firefox won't fire the event if // the listener isn't created synchronously on loading the backend. chrome.runtime.onInstalled.addListener(async (details) => { logger.info(`onInstalled with reason: "${details.reason}"`); if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { callback(); } registerIconChangeOnTalerContent(); }); } function extensionIsManifestV3(): boolean { return chrome.runtime.getManifest().manifest_version === 3; } function useServiceWorkerAsBackgroundProcess(): boolean { return extensionIsManifestV3(); } function searchForTalerLinks(): string | undefined { let found; found = document.querySelector("a[href^='taler://'"); if (found) return found.toString(); found = document.querySelector("a[href^='taler+http://'"); if (found) return found.toString(); return undefined; } async function getCurrentTab(): Promise { const queryOptions = { active: true, currentWindow: true }; return new Promise((resolve, reject) => { chrome.tabs.query(queryOptions, (tabs) => { if (chrome.runtime.lastError) { reject(chrome.runtime.lastError); return; } resolve(tabs[0]); }); }); } async function findTalerUriInTab(tabId: number): Promise { if (extensionIsManifestV3()) { // manifest v3 try { const res = await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, func: searchForTalerLinks, args: [], }); return res[0].result; } catch (e) { return; } } else { return new Promise((resolve, reject) => { //manifest v2 chrome.tabs.executeScript( tabId, { code: ` (() => { let x = document.querySelector("a[href^='taler://'") || document.querySelector("a[href^='taler+http://'"); return x ? x.href.toString() : null; })(); `, allFrames: false, }, (result) => { if (chrome.runtime.lastError) { logger.error(JSON.stringify(chrome.runtime.lastError)); resolve(undefined); return; } resolve(result[0]); }, ); }); } } async function timeout(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function findTalerUriInClipboard(): Promise { //FIXME: add clipboard feature // try { // //It looks like clipboard promise does not return, so we need a timeout // const textInClipboard = await Promise.any([ // timeout(100), // window.navigator.clipboard.readText(), // ]); // if (!textInClipboard) return; // return textInClipboard.startsWith("taler://") || // textInClipboard.startsWith("taler+http://") // ? textInClipboard // : undefined; // } catch (e) { // logger.error("could not read clipboard", e); // return undefined; // } return undefined; } async function findTalerUriInActiveTab(): Promise { const tab = await getCurrentTab(); if (!tab || tab.id === undefined) return; return findTalerUriInTab(tab.id); }