/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* This file is part of TALER (C) 2017 INRIA 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. 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 TALER; see the file COPYING. If not, see */ import { classifyTalerUri, CoreApiResponse, Logger, TalerUriType } from "@gnu-taler/taler-util"; import { CrossBrowserPermissionsApi, MessageFromBackend, Permissions, PlatformAPI } from "./api.js"; const api: PlatformAPI = { isFirefox, findTalerUriInActiveTab, getPermissionsApi, getWalletVersion, listenToWalletBackground, notifyWhenAppIsReady, openWalletPage, openWalletPageFromPopup, openWalletURIFromPopup, redirectTabToWalletPage, registerAllIncomingConnections, registerOnInstalled, listenToAllChannels, registerReloadOnNewVersion, registerTalerHeaderListener, sendMessageToAllChannels, sendMessageToWalletBackground, 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 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 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; export function containsTalerHeaderListener(): boolean { return currentHeaderListener !== undefined; } export async function removeHostPermissions(): Promise { //if there is a handler already, remove it if ( "webRequest" in chrome && "onHeadersReceived" in chrome.webRequest && currentHeaderListener && chrome.webRequest.onHeadersReceived.hasListener(currentHeaderListener) ) { chrome.webRequest.onHeadersReceived.removeListener(currentHeaderListener); } currentHeaderListener = undefined; //notify the browser about this change, this operation is expensive if ("webRequest" in chrome) { chrome.webRequest.handlerBehaviorChanged(() => { if (chrome.runtime.lastError) { console.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) }) }) } function addPermissionsListener(callback: (p: Permissions, lastError?: string) => void): void { console.log("addPermissionListener is not supported for Firefox"); chrome.permissions.onAdded.addListener((perm: Permissions) => { const lastError = chrome.runtime.lastError?.message; callback(perm, lastError) }) } function getPermissionsApi(): CrossBrowserPermissionsApi { return { addPermissionsListener, containsHostPermissions, requestHostPermissions, removeHostPermissions } } /** * * @param callback function to be called */ function notifyWhenAppIsReady(callback: () => void): void { if (extensionIsManifestV3()) { callback() } else { window.addEventListener("load", callback); } } function openWalletURIFromPopup(talerUri: string): void { 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.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; default: console.warn( "Response with HTTP 402 has Taler header, 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(); }, ); } async function sendMessageToWalletBackground(operation: string, payload: any): Promise { return new Promise((resolve, reject) => { chrome.runtime.sendMessage({ operation, payload, id: "(none)" }, (resp) => { if (chrome.runtime.lastError) { reject(chrome.runtime.lastError.message) } resolve(resp) // return true to keep the channel open return true; }) }) } 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) { console.error(e); } } } function registerAllIncomingConnections(): void { chrome.runtime.onConnect.addListener((port) => { allPorts.push(port); port.onDisconnect.addListener((discoPort) => { const idx = allPorts.indexOf(discoPort); if (idx >= 0) { allPorts.splice(idx, 1); } }); }); } function listenToAllChannels(cb: (message: any, sender: any, callback: (r: CoreApiResponse) => void) => void): void { chrome.runtime.onMessage.addListener((m, s, c) => { cb(m, s, c) // 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) => { console.log("update available:", details); chrome.runtime.reload(); }); } function redirectTabToWalletPage( tabId: number, page: string, ): void { const url = chrome.runtime.getURL(`/static/wallet.html#${page}`); console.log("redirecting tabId: ", tabId, " to: ", url); chrome.tabs.update(tabId, { url }); } interface WalletVersion { version_name?: string | undefined; version: string; } function getWalletVersion(): WalletVersion { const manifestData = chrome.runtime.getManifest(); return manifestData; } function registerTalerHeaderListener(callback: (tabId: number, url: string) => void): void { console.log("setting up header listener"); function headerListener( details: chrome.webRequest.WebResponseHeadersDetails, ): void { if (chrome.runtime.lastError) { console.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) { callback(details.tabId, values[0]) } } return; } const prevHeaderListener = currentHeaderListener; 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 the result was positive, add the headerListener if (result) { const listener: chrome.webRequest.WebResponseHeadersEvent | undefined = chrome?.webRequest?.onHeadersReceived; if (listener) { listener.addListener( headerListener, { urls: [""] }, ["responseHeaders"], ); currentHeaderListener = headerListener; } } //notify the browser about this change, this operation is expensive chrome?.webRequest?.handlerBehaviorChanged(() => { if (chrome.runtime.lastError) { console.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; logger.info("tab updated", tabId, info); if (info.status !== "complete") return; const uri = await findTalerUriInTab(tabId); console.log("urio", uri) if (uri) { setAlertedIcon() } else { setNormalIcon() } }); chrome.tabs.onActivated.addListener(async ({ tabId }: chrome.tabs.TabActiveInfo) => { if (tabId < 0) return; logger.info("tab activated", tabId); const uri = await findTalerUriInTab(tabId); console.log("urio", uri) 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) { console.error(JSON.stringify(chrome.runtime.lastError)); resolve(undefined); return; } resolve(result[0]); }, ); }); } } async function findTalerUriInActiveTab(): Promise { const tab = await getCurrentTab(); if (!tab || tab.id === undefined) return; return findTalerUriInTab(tab.id) }