/* 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 { Logger, TalerErrorCode, TalerUriAction, TalerError, parseTalerUri, TalerUri, stringifyTalerUri, } 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, Settings, defaultSettings, } from "./api.js"; const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { isFirefox, getSettingsFromStorage, findTalerUriInActiveTab, findTalerUriInClipboard, getPermissionsApi, getWalletWebExVersion, listenToWalletBackground, notifyWhenAppIsReady, openWalletPage, openWalletPageFromPopup, openWalletURIFromPopup, redirectTabToWalletPage, registerAllIncomingConnections, registerOnInstalled, listenToAllChannels: listenToAllChannels as any, registerReloadOnNewVersion, sendMessageToAllChannels, sendMessageToBackground, useServiceWorkerAsBackgroundProcess, keepAlive, listenNetworkConnectionState, }; export default api; const logger = new Logger("chrome.ts"); async function getSettingsFromStorage(): Promise { const data = await chrome.storage.local.get("wallet-settings"); if (!data) return defaultSettings; const settings = data["wallet-settings"]; if (!settings) return defaultSettings; try { const parsed = JSON.parse(settings); return parsed; } catch (e) { return defaultSettings; } } 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 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(): Promise { return new Promise((resolve, reject) => { if (extensionIsManifestV3()) { resolve(); } else { window.addEventListener("load", () => { resolve(); }); } }); } function openWalletURIFromPopup(uri: TalerUri): void { const talerUri = stringifyTalerUri(uri); //FIXME: this should redirect to just one place // the target pathname should handle what happens if the endpoint is not there // like "trying to open from popup but this uri is not handled" encodeURIComponent; let url: string | undefined = undefined; switch (uri.type) { case TalerUriAction.Withdraw: url = chrome.runtime.getURL( `static/wallet.html#/cta/withdraw?talerUri=${encodeURIComponent( talerUri, )}`, ); break; case TalerUriAction.Restore: url = chrome.runtime.getURL( `static/wallet.html#/cta/recovery?talerUri=${encodeURIComponent( talerUri, )}`, ); break; case TalerUriAction.Pay: url = chrome.runtime.getURL( `static/wallet.html#/cta/pay?talerUri=${encodeURIComponent(talerUri)}`, ); break; case TalerUriAction.Tip: url = chrome.runtime.getURL( `static/wallet.html#/cta/tip?talerUri=${encodeURIComponent(talerUri)}`, ); break; case TalerUriAction.Refund: url = chrome.runtime.getURL( `static/wallet.html#/cta/refund?talerUri=${encodeURIComponent( talerUri, )}`, ); break; case TalerUriAction.PayPull: url = chrome.runtime.getURL( `static/wallet.html#/cta/invoice/pay?talerUri=${encodeURIComponent( talerUri, )}`, ); break; case TalerUriAction.PayPush: url = chrome.runtime.getURL( `static/wallet.html#/cta/transfer/pickup?talerUri=${encodeURIComponent( talerUri, )}`, ); break; case TalerUriAction.PayTemplate: url = chrome.runtime.getURL( `static/wallet.html#/cta/pay/template?talerUri=${encodeURIComponent( talerUri, )}`, ); break; case TalerUriAction.DevExperiment: logger.warn(`taler://dev-experiment URIs are not allowed in headers`); return; case TalerUriAction.Exchange: logger.warn(`taler://exchange not yet supported`); return; case TalerUriAction.Auditor: logger.warn(`taler://auditor not yet supported`); return; default: { const error: never = uri; logger.warn( `Response with HTTP 402 the Taler header "${error}", but header value is not a taler:// URI.`, ); return; } } chrome.tabs.update({ 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; const timerId = setTimeout(() => { timedout = true; throw TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, {}); }, 5 * 1000); //five seconds 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; }); }); } /** * 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; } 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(); } await 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); } function listenNetworkConnectionState( notify: (state: "on" | "off") => void, ): () => void { function notifyOffline() { notify("off"); } function notifyOnline() { notify("on"); } window.addEventListener("offline", notifyOffline); window.addEventListener("online", notifyOnline); return () => { window.removeEventListener("offline", notifyOffline); window.removeEventListener("online", notifyOnline); }; }