summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/wxBackend.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-webextension/src/wxBackend.ts')
-rw-r--r--packages/taler-wallet-webextension/src/wxBackend.ts566
1 files changed, 566 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts
new file mode 100644
index 000000000..3adc9a82d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wxBackend.ts
@@ -0,0 +1,566 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Messaging for the WebExtensions wallet. Should contain
+ * parts that are specific for WebExtensions, but as little business
+ * logic as possible.
+ */
+
+/**
+ * Imports.
+ */
+import { isFirefox, getPermissionsApi } from "./compat";
+import * as wxApi from "./wxApi";
+import MessageSender = chrome.runtime.MessageSender;
+import { extendedPermissions } from "./permissions";
+
+import { Wallet, promiseUtil, db, walletTypes, taleruri, queryLib } from "taler-wallet-core";
+import { BrowserHttpLib } from "./browserHttpLib";
+import { BrowserCryptoWorkerFactory } from "./browserCryptoWorkerFactory";
+
+const NeedsWallet = Symbol("NeedsWallet");
+
+/**
+ * Currently active wallet instance. Might be unloaded and
+ * re-instantiated when the database is reset.
+ */
+let currentWallet: Wallet | undefined;
+
+let currentDatabase: IDBDatabase | undefined;
+
+/**
+ * Last version if an outdated DB, if applicable.
+ */
+let outdatedDbVersion: number | undefined;
+
+const walletInit: promiseUtil.OpenedPromise<void> = promiseUtil.openPromise<void>();
+
+const notificationPorts: chrome.runtime.Port[] = [];
+
+async function handleMessage(
+ sender: MessageSender,
+ type: string,
+ detail: any,
+): Promise<any> {
+ function needsWallet(): Wallet {
+ if (!currentWallet) {
+ throw NeedsWallet;
+ }
+ return currentWallet;
+ }
+ switch (type) {
+ case "balances": {
+ return needsWallet().getBalances();
+ }
+ case "dump-db": {
+ const db = needsWallet().db;
+ return db.exportDatabase();
+ }
+ case "import-db": {
+ const db = needsWallet().db;
+ return db.importDatabase(detail.dump);
+ }
+ case "ping": {
+ return Promise.resolve();
+ }
+ case "reset-db": {
+ db.deleteTalerDatabase(indexedDB);
+ setBadgeText({ text: "" });
+ console.log("reset done");
+ if (!currentWallet) {
+ reinitWallet();
+ }
+ return Promise.resolve({});
+ }
+ case "confirm-pay": {
+ if (typeof detail.proposalId !== "string") {
+ throw Error("proposalId must be string");
+ }
+ return needsWallet().confirmPay(detail.proposalId, detail.sessionId);
+ }
+ case "exchange-info": {
+ if (!detail.baseUrl) {
+ return Promise.resolve({ error: "bad url" });
+ }
+ return needsWallet().updateExchangeFromUrl(detail.baseUrl);
+ }
+ case "get-exchanges": {
+ return needsWallet().getExchangeRecords();
+ }
+ case "get-currencies": {
+ return needsWallet().getCurrencies();
+ }
+ case "update-currency": {
+ return needsWallet().updateCurrency(detail.currencyRecord);
+ }
+ case "get-reserves": {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangeBaseUrl missing"));
+ }
+ return needsWallet().getReserves(detail.exchangeBaseUrl);
+ }
+ case "get-coins": {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangBaseUrl missing"));
+ }
+ return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl);
+ }
+ case "get-denoms": {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangBaseUrl missing"));
+ }
+ return needsWallet().getDenoms(detail.exchangeBaseUrl);
+ }
+ case "refresh-coin": {
+ if (typeof detail.coinPub !== "string") {
+ return Promise.reject(Error("coinPub missing"));
+ }
+ return needsWallet().refresh(detail.coinPub);
+ }
+ case "get-sender-wire-infos": {
+ return needsWallet().getSenderWireInfos();
+ }
+ case "return-coins": {
+ const d = {
+ amount: detail.amount,
+ exchange: detail.exchange,
+ senderWire: detail.senderWire,
+ };
+ return needsWallet().returnCoins(d);
+ }
+ case "check-upgrade": {
+ let dbResetRequired = false;
+ if (!currentWallet) {
+ dbResetRequired = true;
+ }
+ const resp: wxApi.UpgradeResponse = {
+ currentDbVersion: db.WALLET_DB_MINOR_VERSION.toString(),
+ dbResetRequired,
+ oldDbVersion: (outdatedDbVersion || "unknown").toString(),
+ };
+ return resp;
+ }
+ case "get-purchase-details": {
+ const proposalId = detail.proposalId;
+ if (!proposalId) {
+ throw Error("proposalId missing");
+ }
+ if (typeof proposalId !== "string") {
+ throw Error("proposalId must be a string");
+ }
+ return needsWallet().getPurchaseDetails(proposalId);
+ }
+ case "accept-refund":
+ return needsWallet().applyRefund(detail.refundUrl);
+ case "get-tip-status": {
+ return needsWallet().getTipStatus(detail.talerTipUri);
+ }
+ case "accept-tip": {
+ return needsWallet().acceptTip(detail.talerTipUri);
+ }
+ case "abort-failed-payment": {
+ if (!detail.contractTermsHash) {
+ throw Error("contracTermsHash not given");
+ }
+ return needsWallet().abortFailedPayment(detail.contractTermsHash);
+ }
+ case "benchmark-crypto": {
+ if (!detail.repetitions) {
+ throw Error("repetitions not given");
+ }
+ return needsWallet().benchmarkCrypto(detail.repetitions);
+ }
+ case "accept-withdrawal": {
+ return needsWallet().acceptWithdrawal(
+ detail.talerWithdrawUri,
+ detail.selectedExchange,
+ );
+ }
+ case "get-diagnostics": {
+ const manifestData = chrome.runtime.getManifest();
+ const errors: string[] = [];
+ let firefoxIdbProblem = false;
+ let dbOutdated = false;
+ try {
+ await walletInit.promise;
+ } catch (e) {
+ errors.push("Error during wallet initialization: " + e);
+ if (
+ currentDatabase === undefined &&
+ outdatedDbVersion === undefined &&
+ isFirefox()
+ ) {
+ firefoxIdbProblem = true;
+ }
+ }
+ if (!currentWallet) {
+ errors.push("Could not create wallet backend.");
+ }
+ if (!currentDatabase) {
+ errors.push("Could not open database");
+ }
+ if (outdatedDbVersion !== undefined) {
+ errors.push(`Outdated DB version: ${outdatedDbVersion}`);
+ dbOutdated = true;
+ }
+ const diagnostics: walletTypes.WalletDiagnostics = {
+ walletManifestDisplayVersion:
+ manifestData.version_name || "(undefined)",
+ walletManifestVersion: manifestData.version,
+ errors,
+ firefoxIdbProblem,
+ dbOutdated,
+ };
+ return diagnostics;
+ }
+ case "prepare-pay":
+ return needsWallet().preparePayForUri(detail.talerPayUri);
+ case "set-extended-permissions": {
+ const newVal = detail.value;
+ console.log("new extended permissions value", newVal);
+ if (newVal) {
+ setupHeaderListener();
+ return { newValue: true };
+ } else {
+ await new Promise((resolve, reject) => {
+ getPermissionsApi().remove(extendedPermissions, (rem) => {
+ console.log("permissions removed:", rem);
+ resolve();
+ });
+ });
+ return { newVal: false };
+ }
+ }
+ case "get-extended-permissions": {
+ const res = await new Promise((resolve, reject) => {
+ getPermissionsApi().contains(extendedPermissions, (result: boolean) => {
+ resolve(result);
+ });
+ });
+ return { newValue: res };
+ }
+ default:
+ console.error(`Request type ${type} unknown`);
+ console.error(`Request detail was ${detail}`);
+ return {
+ error: {
+ message: `request type ${type} unknown`,
+ requestType: type,
+ },
+ };
+ }
+}
+
+async function dispatch(
+ req: any,
+ sender: any,
+ sendResponse: any,
+): Promise<void> {
+ try {
+ const p = handleMessage(sender, req.type, req.detail);
+ const r = await p;
+ try {
+ sendResponse(r);
+ } catch (e) {
+ // might fail if tab disconnected
+ }
+ } catch (e) {
+ console.log(`exception during wallet handler for '${req.type}'`);
+ console.log("request", req);
+ console.error(e);
+ let stack;
+ try {
+ stack = e.stack.toString();
+ } catch (e) {
+ // might fail
+ }
+ try {
+ sendResponse({
+ error: {
+ message: e.message,
+ stack,
+ },
+ });
+ } catch (e) {
+ console.log(e);
+ // might fail if tab disconnected
+ }
+ }
+}
+
+function getTab(tabId: number): Promise<chrome.tabs.Tab> {
+ return new Promise((resolve, reject) => {
+ chrome.tabs.get(tabId, (tab: chrome.tabs.Tab) => resolve(tab));
+ });
+}
+
+function setBadgeText(options: chrome.browserAction.BadgeTextDetails): void {
+ // not supported by all browsers ...
+ if (chrome && chrome.browserAction && chrome.browserAction.setBadgeText) {
+ chrome.browserAction.setBadgeText(options);
+ } else {
+ console.warn("can't set badge text, not supported", options);
+ }
+}
+
+function waitMs(timeoutMs: number): Promise<void> {
+ return new Promise((resolve, reject) => {
+ const bgPage = chrome.extension.getBackgroundPage();
+ if (!bgPage) {
+ reject("fatal: no background page");
+ return;
+ }
+ bgPage.setTimeout(() => resolve(), timeoutMs);
+ });
+}
+
+function makeSyncWalletRedirect(
+ url: string,
+ tabId: number,
+ oldUrl: string,
+ params?: { [name: string]: string | undefined },
+): Record<string, unknown> {
+ const innerUrl = new URL(chrome.extension.getURL("/" + url));
+ if (params) {
+ for (const key in params) {
+ const p = params[key];
+ if (p) {
+ innerUrl.searchParams.set(key, p);
+ }
+ }
+ }
+ if (isFirefox()) {
+ // Some platforms don't support the sync redirect (yet), so fall back to
+ // async redirect after a timeout.
+ const doit = async (): Promise<void> => {
+ await waitMs(150);
+ const tab = await getTab(tabId);
+ if (tab.url === oldUrl) {
+ chrome.tabs.update(tabId, { url: innerUrl.href });
+ }
+ };
+ doit();
+ }
+ console.log("redirecting to", innerUrl.href);
+ chrome.tabs.update(tabId, { url: innerUrl.href });
+ return { redirectUrl: innerUrl.href };
+}
+
+async function reinitWallet(): Promise<void> {
+ if (currentWallet) {
+ currentWallet.stop();
+ currentWallet = undefined;
+ }
+ currentDatabase = undefined;
+ setBadgeText({ text: "" });
+ try {
+ currentDatabase = await db.openTalerDatabase(indexedDB, reinitWallet);
+ } catch (e) {
+ console.error("could not open database", e);
+ walletInit.reject(e);
+ return;
+ }
+ const http = new BrowserHttpLib();
+ console.log("setting wallet");
+ const wallet = new Wallet(
+ new queryLib.Database(currentDatabase),
+ http,
+ new BrowserCryptoWorkerFactory(),
+ );
+ wallet.addNotificationListener((x) => {
+ for (const x of notificationPorts) {
+ try {
+ x.postMessage({ type: "notification" });
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ });
+ wallet.runRetryLoop().catch((e) => {
+ console.log("error during wallet retry loop", e);
+ });
+ // Useful for debugging in the background page.
+ (window as any).talerWallet = wallet;
+ currentWallet = wallet;
+ walletInit.resolve();
+}
+
+try {
+ // 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((details) => {
+ console.log("onInstalled with reason", details.reason);
+ if (details.reason === "install") {
+ const url = chrome.extension.getURL("/welcome.html");
+ chrome.tabs.create({ active: true, url: url });
+ }
+ });
+} catch (e) {
+ console.error(e);
+}
+
+function headerListener(
+ details: chrome.webRequest.WebResponseHeadersDetails,
+): chrome.webRequest.BlockingResponse | undefined {
+ console.log("header listener");
+ if (chrome.runtime.lastError) {
+ console.error(chrome.runtime.lastError);
+ return;
+ }
+ const wallet = currentWallet;
+ if (!wallet) {
+ console.warn("wallet not available while handling header");
+ return;
+ }
+ console.log("in header listener");
+ if (details.statusCode === 402 || details.statusCode === 202) {
+ console.log(`got 402/202 from ${details.url}`);
+ for (const header of details.responseHeaders || []) {
+ if (header.name.toLowerCase() === "taler") {
+ const talerUri = header.value || "";
+ const uriType = taleruri.classifyTalerUri(talerUri);
+ switch (uriType) {
+ case taleruri.TalerUriType.TalerWithdraw:
+ return makeSyncWalletRedirect(
+ "withdraw.html",
+ details.tabId,
+ details.url,
+ {
+ talerWithdrawUri: talerUri,
+ },
+ );
+ case taleruri.TalerUriType.TalerPay:
+ return makeSyncWalletRedirect(
+ "pay.html",
+ details.tabId,
+ details.url,
+ {
+ talerPayUri: talerUri,
+ },
+ );
+ case taleruri.TalerUriType.TalerTip:
+ return makeSyncWalletRedirect(
+ "tip.html",
+ details.tabId,
+ details.url,
+ {
+ talerTipUri: talerUri,
+ },
+ );
+ case taleruri.TalerUriType.TalerRefund:
+ return makeSyncWalletRedirect(
+ "refund.html",
+ details.tabId,
+ details.url,
+ {
+ talerRefundUri: talerUri,
+ },
+ );
+ case taleruri.TalerUriType.TalerNotifyReserve:
+ Promise.resolve().then(() => {
+ const w = currentWallet;
+ if (!w) {
+ return;
+ }
+ w.handleNotifyReserve();
+ });
+ break;
+ default:
+ console.warn(
+ "Response with HTTP 402 has Taler header, but header value is not a taler:// URI.",
+ );
+ break;
+ }
+ }
+ }
+ }
+ return;
+}
+
+function setupHeaderListener(): void {
+ console.log("setting up header listener");
+ // Handlers for catching HTTP requests
+ getPermissionsApi().contains(extendedPermissions, (result: boolean) => {
+ if (
+ chrome.webRequest.onHeadersReceived &&
+ chrome.webRequest.onHeadersReceived.hasListener(headerListener)
+ ) {
+ chrome.webRequest.onHeadersReceived.removeListener(headerListener);
+ }
+ if (result) {
+ console.log("actually adding listener");
+ chrome.webRequest.onHeadersReceived.addListener(
+ headerListener,
+ { urls: ["<all_urls>"] },
+ ["responseHeaders", "blocking"],
+ );
+ }
+ chrome.webRequest.handlerBehaviorChanged(() => {
+ if (chrome.runtime.lastError) {
+ console.error(chrome.runtime.lastError);
+ }
+ });
+ });
+}
+
+/**
+ * Main function to run for the WebExtension backend.
+ *
+ * Sets up all event handlers and other machinery.
+ */
+export async function wxMain(): Promise<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();
+ });
+ reinitWallet();
+
+ // Handlers for messages coming directly from the content
+ // script on the page
+ chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
+ dispatch(req, sender, sendResponse);
+ return true;
+ });
+
+ chrome.runtime.onConnect.addListener((port) => {
+ notificationPorts.push(port);
+ port.onDisconnect.addListener((discoPort) => {
+ const idx = notificationPorts.indexOf(discoPort);
+ if (idx >= 0) {
+ notificationPorts.splice(idx, 1);
+ }
+ });
+ });
+
+ try {
+ setupHeaderListener();
+ } catch (e) {
+ console.log(e);
+ }
+
+ // On platforms that support it, also listen to external
+ // modification of permissions.
+ getPermissionsApi().addPermissionsListener((perm) => {
+ if (chrome.runtime.lastError) {
+ console.error(chrome.runtime.lastError);
+ return;
+ }
+ setupHeaderListener();
+ });
+}