summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-08-03 13:00:48 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-08-03 13:01:05 +0530
commitffd2a62c3f7df94365980302fef3bc3376b48182 (patch)
tree270af6f16b4cc7f5da2afdba55c8bc9dbea5eca5 /packages/taler-wallet-webextension/src
parentaa481e42675fb7c4dcbbeec0ba1c61e1953b9596 (diff)
downloadwallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.gz
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.bz2
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.zip
modularize repo, use pnpm, improve typechecking
Diffstat (limited to 'packages/taler-wallet-webextension/src')
-rw-r--r--packages/taler-wallet-webextension/src/background.ts30
-rw-r--r--packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js44
-rw-r--r--packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map1
-rw-r--r--packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts43
-rw-r--r--packages/taler-wallet-webextension/src/browserHttpLib.ts129
-rw-r--r--packages/taler-wallet-webextension/src/browserWorkerEntry.ts74
-rw-r--r--packages/taler-wallet-webextension/src/chromeBadge.ts288
-rw-r--r--packages/taler-wallet-webextension/src/compat.js61
-rw-r--r--packages/taler-wallet-webextension/src/compat.ts85
-rw-r--r--packages/taler-wallet-webextension/src/i18n-test.tsx68
-rw-r--r--packages/taler-wallet-webextension/src/i18n.tsx206
-rw-r--r--packages/taler-wallet-webextension/src/pageEntryPoint.ts72
-rw-r--r--packages/taler-wallet-webextension/src/pages/pay.tsx180
-rw-r--r--packages/taler-wallet-webextension/src/pages/payback.tsx30
-rw-r--r--packages/taler-wallet-webextension/src/pages/popup.tsx502
-rw-r--r--packages/taler-wallet-webextension/src/pages/refund.tsx89
-rw-r--r--packages/taler-wallet-webextension/src/pages/reset-required.tsx93
-rw-r--r--packages/taler-wallet-webextension/src/pages/return-coins.tsx30
-rw-r--r--packages/taler-wallet-webextension/src/pages/tip.tsx103
-rw-r--r--packages/taler-wallet-webextension/src/pages/welcome.tsx190
-rw-r--r--packages/taler-wallet-webextension/src/pages/withdraw.tsx229
-rw-r--r--packages/taler-wallet-webextension/src/permissions.ts20
-rw-r--r--packages/taler-wallet-webextension/src/renderHtml.tsx341
-rw-r--r--packages/taler-wallet-webextension/src/wxApi.ts239
-rw-r--r--packages/taler-wallet-webextension/src/wxBackend.ts566
25 files changed, 3713 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/background.ts b/packages/taler-wallet-webextension/src/background.ts
new file mode 100644
index 000000000..dbc540df4
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/background.ts
@@ -0,0 +1,30 @@
+/*
+ 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/>
+ */
+
+/**
+ * Entry point for the background page.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { wxMain } from "./wxBackend";
+
+window.addEventListener("load", () => {
+ wxMain();
+});
diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js
new file mode 100644
index 000000000..e9492a2fb
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js
@@ -0,0 +1,44 @@
+"use strict";
+/*
+ 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/>
+ */
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.BrowserCryptoWorkerFactory = void 0;
+/**
+ * API to access the Taler crypto worker thread.
+ * @author Florian Dold
+ */
+class BrowserCryptoWorkerFactory {
+ startWorker() {
+ const workerCtor = Worker;
+ const workerPath = "/browserWorkerEntry.js";
+ return new workerCtor(workerPath);
+ }
+ getConcurrency() {
+ let concurrency = 2;
+ try {
+ // only works in the browser
+ // tslint:disable-next-line:no-string-literal
+ concurrency = navigator["hardwareConcurrency"];
+ concurrency = Math.max(1, Math.ceil(concurrency / 2));
+ }
+ catch (e) {
+ concurrency = 2;
+ }
+ return concurrency;
+ }
+}
+exports.BrowserCryptoWorkerFactory = BrowserCryptoWorkerFactory;
+//# sourceMappingURL=browserCryptoWorkerFactory.js.map \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map
new file mode 100644
index 000000000..db56d4451
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"browserCryptoWorkerFactory.js","sourceRoot":"","sources":["browserCryptoWorkerFactory.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;GAcG;;;AAEH;;;GAGG;AAEH,MAAa,0BAA0B;IACrC,WAAW;QACT,MAAM,UAAU,GAAG,MAAM,CAAC;QAC1B,MAAM,UAAU,GAAG,wBAAwB,CAAC;QAC5C,OAAO,IAAI,UAAU,CAAC,UAAU,CAAiB,CAAC;IACpD,CAAC;IAED,cAAc;QACZ,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,IAAI;YACF,4BAA4B;YAC5B,6CAA6C;YAC7C,WAAW,GAAI,SAAiB,CAAC,qBAAqB,CAAC,CAAC;YACxD,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC,CAAC;SACvD;QAAC,OAAO,CAAC,EAAE;YACV,WAAW,GAAG,CAAC,CAAC;SACjB;QACD,OAAO,WAAW,CAAC;IACrB,CAAC;CACF;AAnBD,gEAmBC"} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts
new file mode 100644
index 000000000..b91f49f17
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts
@@ -0,0 +1,43 @@
+/*
+ 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/>
+ */
+
+/**
+ * API to access the Taler crypto worker thread.
+ * @author Florian Dold
+ */
+
+import type { CryptoWorker, CryptoWorkerFactory } from "taler-wallet-core";
+
+export class BrowserCryptoWorkerFactory implements CryptoWorkerFactory {
+ startWorker(): CryptoWorker {
+ const workerCtor = Worker;
+ const workerPath = "/browserWorkerEntry.js";
+ return new workerCtor(workerPath) as CryptoWorker;
+ }
+
+ getConcurrency(): number {
+ let concurrency = 2;
+ try {
+ // only works in the browser
+ // tslint:disable-next-line:no-string-literal
+ concurrency = (navigator as any)["hardwareConcurrency"];
+ concurrency = Math.max(1, Math.ceil(concurrency / 2));
+ } catch (e) {
+ concurrency = 2;
+ }
+ return concurrency;
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/browserHttpLib.ts b/packages/taler-wallet-webextension/src/browserHttpLib.ts
new file mode 100644
index 000000000..2782e4a14
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/browserHttpLib.ts
@@ -0,0 +1,129 @@
+
+import { httpLib, OperationFailedError, Logger } from "taler-wallet-core";
+import { TalerErrorCode } from "taler-wallet-core/lib/TalerErrorCode";
+
+const logger = new Logger("browserHttpLib");
+
+/**
+ * An implementation of the [[HttpRequestLibrary]] using the
+ * browser's XMLHttpRequest.
+ */
+export class BrowserHttpLib implements httpLib.HttpRequestLibrary {
+ private req(
+ method: string,
+ url: string,
+ requestBody?: any,
+ options?: httpLib.HttpRequestOptions,
+ ): Promise<httpLib.HttpResponse> {
+ return new Promise<httpLib.HttpResponse>((resolve, reject) => {
+ const myRequest = new XMLHttpRequest();
+ myRequest.open(method, url);
+ if (options?.headers) {
+ for (const headerName in options.headers) {
+ myRequest.setRequestHeader(headerName, options.headers[headerName]);
+ }
+ }
+ myRequest.setRequestHeader;
+ if (requestBody) {
+ myRequest.send(requestBody);
+ } else {
+ myRequest.send();
+ }
+
+ myRequest.onerror = (e) => {
+ logger.error("http request error");
+ reject(
+ OperationFailedError.fromCode(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ "Could not make request",
+ {
+ requestUrl: url,
+ },
+ ),
+ );
+ };
+
+ myRequest.addEventListener("readystatechange", (e) => {
+ if (myRequest.readyState === XMLHttpRequest.DONE) {
+ if (myRequest.status === 0) {
+ const exc = OperationFailedError.fromCode(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ "HTTP request failed (status 0, maybe URI scheme was wrong?)",
+ {
+ requestUrl: url,
+ },
+ );
+ reject(exc);
+ return;
+ }
+ const makeJson = async (): Promise<any> => {
+ let responseJson;
+ try {
+ responseJson = JSON.parse(myRequest.responseText);
+ } catch (e) {
+ throw OperationFailedError.fromCode(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ "Invalid JSON from HTTP response",
+ {
+ requestUrl: url,
+ httpStatusCode: myRequest.status,
+ },
+ );
+ }
+ if (responseJson === null || typeof responseJson !== "object") {
+ throw OperationFailedError.fromCode(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ "Invalid JSON from HTTP response",
+ {
+ requestUrl: url,
+ httpStatusCode: myRequest.status,
+ },
+ );
+ }
+ return responseJson;
+ };
+
+ const headers = myRequest.getAllResponseHeaders();
+ const arr = headers.trim().split(/[\r\n]+/);
+
+ // Create a map of header names to values
+ const headerMap: httpLib.Headers = new httpLib.Headers();
+ arr.forEach(function (line) {
+ const parts = line.split(": ");
+ const headerName = parts.shift();
+ if (!headerName) {
+ logger.warn("skipping invalid header");
+ return;
+ }
+ const value = parts.join(": ");
+ headerMap.set(headerName, value);
+ });
+ const resp: httpLib.HttpResponse = {
+ requestUrl: url,
+ status: myRequest.status,
+ headers: headerMap,
+ json: makeJson,
+ text: async () => myRequest.responseText,
+ };
+ resolve(resp);
+ }
+ });
+ });
+ }
+
+ get(url: string, opt?: httpLib.HttpRequestOptions): Promise<httpLib.HttpResponse> {
+ return this.req("get", url, undefined, opt);
+ }
+
+ postJson(
+ url: string,
+ body: unknown,
+ opt?: httpLib.HttpRequestOptions,
+ ): Promise<httpLib.HttpResponse> {
+ return this.req("post", url, JSON.stringify(body), opt);
+ }
+
+ stop(): void {
+ // Nothing to do
+ }
+} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/browserWorkerEntry.ts b/packages/taler-wallet-webextension/src/browserWorkerEntry.ts
new file mode 100644
index 000000000..77c38fda9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/browserWorkerEntry.ts
@@ -0,0 +1,74 @@
+/*
+ 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/>
+*/
+
+/**
+ * Web worker for crypto operations.
+ */
+
+/**
+ * Imports.
+ */
+
+import { CryptoImplementation } from "taler-wallet-core";
+
+const worker: Worker = (self as any) as Worker;
+
+async function handleRequest(
+ operation: string,
+ id: number,
+ args: string[],
+): Promise<void> {
+ const impl = new CryptoImplementation();
+
+ if (!(operation in impl)) {
+ console.error(`crypto operation '${operation}' not found`);
+ return;
+ }
+
+ try {
+ const result = (impl as any)[operation](...args);
+ worker.postMessage({ result, id });
+ } catch (e) {
+ console.log("error during operation", e);
+ return;
+ }
+}
+
+worker.onmessage = (msg: MessageEvent) => {
+ const args = msg.data.args;
+ if (!Array.isArray(args)) {
+ console.error("args must be array");
+ return;
+ }
+ const id = msg.data.id;
+ if (typeof id !== "number") {
+ console.error("RPC id must be number");
+ return;
+ }
+ const operation = msg.data.operation;
+ if (typeof operation !== "string") {
+ console.error("RPC operation must be string");
+ return;
+ }
+
+ if (CryptoImplementation.enableTracing) {
+ console.log("onmessage with", operation);
+ }
+
+ handleRequest(operation, id, args).catch((e) => {
+ console.error("error in browsere worker", e);
+ });
+};
diff --git a/packages/taler-wallet-webextension/src/chromeBadge.ts b/packages/taler-wallet-webextension/src/chromeBadge.ts
new file mode 100644
index 000000000..7bc5d368d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/chromeBadge.ts
@@ -0,0 +1,288 @@
+/*
+ This file is part of TALER
+ (C) 2016 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 <http://www.gnu.org/licenses/>
+ */
+
+import { isFirefox } from "./compat";
+
+/**
+ * Polyfill for requestAnimationFrame, which
+ * doesn't work from a background page.
+ */
+function rAF(cb: (ts: number) => void): void {
+ window.setTimeout(() => {
+ cb(performance.now());
+ }, 100 /* 100 ms delay between frames */);
+}
+
+/**
+ * Badge for Chrome that renders a Taler logo with a rotating ring if some
+ * background activity is happening.
+ */
+export class ChromeBadge {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ /**
+ * True if animation running. The animation
+ * might still be running even if we're not busy anymore,
+ * just to transition to the "normal" state in a animated way.
+ */
+ private animationRunning = false;
+
+ /**
+ * Is the wallet still busy? Note that we do not stop the
+ * animation immediately when the wallet goes idle, but
+ * instead slowly close the gap.
+ */
+ private isBusy = false;
+
+ /**
+ * Current rotation angle, ranges from 0 to rotationAngleMax.
+ */
+ private rotationAngle = 0;
+
+ /**
+ * While animating, how wide is the current gap in the circle?
+ * Ranges from 0 to openMax.
+ */
+ private gapWidth = 0;
+
+ /**
+ * Should we show the notification dot?
+ */
+ private hasNotification = false;
+
+ /**
+ * Maximum value for our rotationAngle, corresponds to 2 Pi.
+ */
+ static rotationAngleMax = 1000;
+
+ /**
+ * How fast do we rotate? Given in rotation angle (relative to rotationAngleMax) per millisecond.
+ */
+ static rotationSpeed = 0.5;
+
+ /**
+ * How fast to we open? Given in rotation angle (relative to rotationAngleMax) per millisecond.
+ */
+ static openSpeed = 0.15;
+
+ /**
+ * How fast to we close? Given as a multiplication factor per frame update.
+ */
+ static closeSpeed = 0.7;
+
+ /**
+ * How far do we open? Given relative to rotationAngleMax.
+ */
+ static openMax = 100;
+
+ constructor(window?: Window) {
+ // Allow injecting another window for testing
+ const bg = window || chrome.extension.getBackgroundPage();
+ if (!bg) {
+ throw Error("no window available");
+ }
+ this.canvas = bg.document.createElement("canvas");
+ // Note: changing the width here means changing the font
+ // size in draw() as well!
+ this.canvas.width = 32;
+ this.canvas.height = 32;
+ const ctx = this.canvas.getContext("2d");
+ if (!ctx) {
+ throw Error("unable to get canvas context");
+ }
+ this.ctx = ctx;
+ this.draw();
+ }
+
+ /**
+ * Draw the badge based on the current state.
+ */
+ private draw(): void {
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
+
+ this.ctx.beginPath();
+ this.ctx.arc(0, 0, this.canvas.width / 2 - 2, 0, 2 * Math.PI);
+ this.ctx.fillStyle = "white";
+ this.ctx.fill();
+
+ // move into the center, off by 2 for aligning the "T" with the bottom
+ // of the circle.
+ this.ctx.translate(0, 2);
+
+ // pick sans-serif font; note: 14px is based on the 32px width above!
+ this.ctx.font = "bold 24px sans-serif";
+ // draw the "T" perfectly centered (x and y) to the current position
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+ this.ctx.fillStyle = "black";
+ this.ctx.fillText("T", 0, 0);
+ // now move really into the center
+ this.ctx.translate(0, -2);
+ // start drawing the (possibly open) circle
+ this.ctx.beginPath();
+ this.ctx.lineWidth = 2.5;
+ if (this.animationRunning) {
+ /* Draw circle around the "T" with an opening of this.gapWidth */
+ const aMax = ChromeBadge.rotationAngleMax;
+ const startAngle = (this.rotationAngle / aMax) * Math.PI * 2;
+ const stopAngle =
+ ((this.rotationAngle + aMax - this.gapWidth) / aMax) * Math.PI * 2;
+ this.ctx.arc(
+ 0,
+ 0,
+ this.canvas.width / 2 - 2,
+ /* radius */ startAngle,
+ stopAngle,
+ false,
+ );
+ } else {
+ /* Draw full circle */
+ this.ctx.arc(
+ 0,
+ 0,
+ this.canvas.width / 2 - 2 /* radius */,
+ 0,
+ Math.PI * 2,
+ false,
+ );
+ }
+ this.ctx.stroke();
+ // go back to the origin
+ this.ctx.translate(-this.canvas.width / 2, -this.canvas.height / 2);
+
+ if (this.hasNotification) {
+ // We draw a circle with a soft border in the
+ // lower right corner.
+ const r = 8;
+ const cw = this.canvas.width;
+ const ch = this.canvas.height;
+ this.ctx.beginPath();
+ this.ctx.arc(cw - r, ch - r, r, 0, 2 * Math.PI, false);
+ const gradient = this.ctx.createRadialGradient(
+ cw - r,
+ ch - r,
+ r,
+ cw - r,
+ ch - r,
+ 5,
+ );
+ gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
+ gradient.addColorStop(1, "blue");
+ this.ctx.fillStyle = gradient;
+ this.ctx.fill();
+ }
+
+ // Allow running outside the extension for testing
+ // tslint:disable-next-line:no-string-literal
+ if (window["chrome"] && window.chrome["browserAction"]) {
+ try {
+ const imageData = this.ctx.getImageData(
+ 0,
+ 0,
+ this.canvas.width,
+ this.canvas.height,
+ );
+ chrome.browserAction.setIcon({ imageData });
+ } catch (e) {
+ // Might fail if browser has over-eager canvas fingerprinting countermeasures.
+ // There's nothing we can do then ...
+ }
+ }
+ }
+
+ private animate(): void {
+ if (this.animationRunning) {
+ return;
+ }
+ if (isFirefox()) {
+ // Firefox does not support badge animations properly
+ return;
+ }
+ this.animationRunning = true;
+ let start: number | undefined;
+ const step = (timestamp: number): void => {
+ if (!this.animationRunning) {
+ return;
+ }
+ if (!start) {
+ start = timestamp;
+ }
+ if (!this.isBusy && 0 === this.gapWidth) {
+ // stop if we're close enough to origin
+ this.rotationAngle = 0;
+ } else {
+ this.rotationAngle =
+ (this.rotationAngle +
+ (timestamp - start) * ChromeBadge.rotationSpeed) %
+ ChromeBadge.rotationAngleMax;
+ }
+ if (this.isBusy) {
+ if (this.gapWidth < ChromeBadge.openMax) {
+ this.gapWidth += ChromeBadge.openSpeed * (timestamp - start);
+ }
+ if (this.gapWidth > ChromeBadge.openMax) {
+ this.gapWidth = ChromeBadge.openMax;
+ }
+ } else {
+ if (this.gapWidth > 0) {
+ this.gapWidth--;
+ this.gapWidth *= ChromeBadge.closeSpeed;
+ }
+ }
+
+ if (this.isBusy || this.gapWidth > 0) {
+ start = timestamp;
+ rAF(step);
+ } else {
+ this.animationRunning = false;
+ }
+ this.draw();
+ };
+ rAF(step);
+ }
+
+ /**
+ * Draw the badge such that it shows the
+ * user that something happened (balance changed).
+ */
+ showNotification(): void {
+ this.hasNotification = true;
+ this.draw();
+ }
+
+ /**
+ * Draw the badge without the notification mark.
+ */
+ clearNotification(): void {
+ this.hasNotification = false;
+ this.draw();
+ }
+
+ startBusy(): void {
+ if (this.isBusy) {
+ return;
+ }
+ this.isBusy = true;
+ this.animate();
+ }
+
+ stopBusy(): void {
+ this.isBusy = false;
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/compat.js b/packages/taler-wallet-webextension/src/compat.js
new file mode 100644
index 000000000..fdfcbd4b9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/compat.js
@@ -0,0 +1,61 @@
+"use strict";
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.getPermissionsApi = exports.isNode = exports.isFirefox = void 0;
+/**
+ * Compatibility helpers needed for browsers that don't implement
+ * WebExtension APIs consistently.
+ */
+function isFirefox() {
+ const rt = chrome.runtime;
+ if (typeof rt.getBrowserInfo === "function") {
+ return true;
+ }
+ return false;
+}
+exports.isFirefox = isFirefox;
+/**
+ * Check if we are running under nodejs.
+ */
+function isNode() {
+ return typeof process !== "undefined" && process.release.name === "node";
+}
+exports.isNode = isNode;
+function getPermissionsApi() {
+ const myBrowser = globalThis.browser;
+ if (typeof myBrowser === "object" &&
+ typeof myBrowser.permissions === "object") {
+ return {
+ addPermissionsListener: () => {
+ // Not supported yet.
+ },
+ contains: myBrowser.permissions.contains,
+ request: myBrowser.permissions.request,
+ remove: myBrowser.permissions.remove,
+ };
+ }
+ else {
+ return {
+ addPermissionsListener: chrome.permissions.onAdded.addListener.bind(chrome.permissions.onAdded),
+ contains: chrome.permissions.contains,
+ request: chrome.permissions.request,
+ remove: chrome.permissions.remove,
+ };
+ }
+}
+exports.getPermissionsApi = getPermissionsApi;
+//# sourceMappingURL=compat.js.map \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/compat.ts b/packages/taler-wallet-webextension/src/compat.ts
new file mode 100644
index 000000000..4635abd80
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/compat.ts
@@ -0,0 +1,85 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Compatibility helpers needed for browsers that don't implement
+ * WebExtension APIs consistently.
+ */
+
+export function isFirefox(): boolean {
+ const rt = chrome.runtime as any;
+ if (typeof rt.getBrowserInfo === "function") {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Check if we are running under nodejs.
+ */
+export function isNode(): boolean {
+ return typeof process !== "undefined" && process.release.name === "node";
+}
+
+/**
+ * Compatibility API that works on multiple browsers.
+ */
+export interface CrossBrowserPermissionsApi {
+ contains(
+ permissions: chrome.permissions.Permissions,
+ callback: (result: boolean) => void,
+ ): void;
+
+ addPermissionsListener(
+ callback: (permissions: chrome.permissions.Permissions) => void,
+ ): void;
+
+ request(
+ permissions: chrome.permissions.Permissions,
+ callback?: (granted: boolean) => void,
+ ): void;
+
+ remove(
+ permissions: chrome.permissions.Permissions,
+ callback?: (removed: boolean) => void,
+ ): void;
+}
+
+export function getPermissionsApi(): CrossBrowserPermissionsApi {
+ const myBrowser = (globalThis as any).browser;
+ if (
+ typeof myBrowser === "object" &&
+ typeof myBrowser.permissions === "object"
+ ) {
+ return {
+ addPermissionsListener: () => {
+ // Not supported yet.
+ },
+ contains: myBrowser.permissions.contains,
+ request: myBrowser.permissions.request,
+ remove: myBrowser.permissions.remove,
+ };
+ } else {
+ return {
+ addPermissionsListener: chrome.permissions.onAdded.addListener.bind(
+ chrome.permissions.onAdded,
+ ),
+ contains: chrome.permissions.contains,
+ request: chrome.permissions.request,
+ remove: chrome.permissions.remove,
+ };
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/i18n-test.tsx b/packages/taler-wallet-webextension/src/i18n-test.tsx
new file mode 100644
index 000000000..e17a455ce
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n-test.tsx
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+import test from "ava";
+import { internalSetStrings, str, Translate, strings } from "./i18n";
+import React from "react";
+import { render } from "enzyme";
+import { configure } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+
+configure({ adapter: new Adapter() });
+
+const testStrings = {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ str1: ["foo1"],
+ str2: [""],
+ "str3 %1$s / %2$s": ["foo3 %2$s ; %1$s"],
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "",
+ },
+ },
+ },
+};
+
+test("str translation", (t) => {
+ // Alias, so we nly use the function for lookups, not for string extranction.
+ const strAlias = str;
+ const TranslateAlias = Translate;
+ internalSetStrings(testStrings);
+ t.is(strAlias`str1`, "foo1");
+ t.is(strAlias`str2`, "str2");
+ const a = "a";
+ const b = "b";
+ t.is(strAlias`str3 ${a} / ${b}`, "foo3 b ; a");
+ const r = render(<TranslateAlias>str1</TranslateAlias>);
+ t.is(r.text(), "foo1");
+
+ const r2 = render(
+ <TranslateAlias>
+ str3 <span>{a}</span> / <span>{b}</span>
+ </TranslateAlias>,
+ );
+ t.is(r2.text(), "foo3 b ; a");
+
+ t.pass();
+});
+
+test("existing str translation", (t) => {
+ internalSetStrings(strings);
+ t.pass();
+});
diff --git a/packages/taler-wallet-webextension/src/i18n.tsx b/packages/taler-wallet-webextension/src/i18n.tsx
new file mode 100644
index 000000000..afbb0e278
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n.tsx
@@ -0,0 +1,206 @@
+/*
+ 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/>
+ */
+
+/**
+ * Translation helpers for React components and template literals.
+ */
+
+/**
+ * Imports
+ */
+import React from "react";
+
+import { i18n as i18nCore } from "taler-wallet-core";
+/**
+ * Convert template strings to a msgid
+ */
+function toI18nString(stringSeq: ReadonlyArray<string>): string {
+ let s = "";
+ for (let i = 0; i < stringSeq.length; i++) {
+ s += stringSeq[i];
+ if (i < stringSeq.length - 1) {
+ s += `%${i + 1}$s`;
+ }
+ }
+ return s;
+}
+
+
+export const str = i18nCore.str;
+export const internalSetStrings = i18nCore.internalSetStrings;
+export const strings = i18nCore.strings;
+
+
+interface TranslateSwitchProps {
+ target: number;
+}
+
+function stringifyChildren(children: any): string {
+ let n = 1;
+ const ss = React.Children.map(children, (c) => {
+ if (typeof c === "string") {
+ return c;
+ }
+ return `%${n++}$s`;
+ });
+ const s = ss.join("").replace(/ +/g, " ").trim();
+ console.log("translation lookup", JSON.stringify(s));
+ return s;
+}
+
+interface TranslateProps {
+ /**
+ * Component that the translated element should be wrapped in.
+ * Defaults to "div".
+ */
+ wrap?: any;
+
+ /**
+ * Props to give to the wrapped component.
+ */
+ wrapProps?: any;
+}
+
+function getTranslatedChildren(
+ translation: string,
+ children: React.ReactNode,
+): React.ReactNode[] {
+ const tr = translation.split(/%(\d+)\$s/);
+ const childArray = React.Children.toArray(children);
+ // Merge consecutive string children.
+ const placeholderChildren = [];
+ for (let i = 0; i < childArray.length; i++) {
+ const x = childArray[i];
+ if (x === undefined) {
+ continue;
+ } else if (typeof x === "string") {
+ continue;
+ } else {
+ placeholderChildren.push(x);
+ }
+ }
+ const result = [];
+ for (let i = 0; i < tr.length; i++) {
+ if (i % 2 == 0) {
+ // Text
+ result.push(tr[i]);
+ } else {
+ const childIdx = Number.parseInt(tr[i]) - 1;
+ result.push(placeholderChildren[childIdx]);
+ }
+ }
+ return result;
+}
+
+/**
+ * Translate text node children of this component.
+ * If a child component might produce a text node, it must be wrapped
+ * in a another non-text element.
+ *
+ * Example:
+ * ```
+ * <Translate>
+ * Hello. Your score is <span><PlayerScore player={player} /></span>
+ * </Translate>
+ * ```
+ */
+export class Translate extends React.Component<TranslateProps, {}> {
+ render(): JSX.Element {
+ const s = stringifyChildren(this.props.children);
+ const translation: string = i18nCore.jed.ngettext(s, s, 1);
+ const result = getTranslatedChildren(translation, this.props.children);
+ if (!this.props.wrap) {
+ return <div>{result}</div>;
+ }
+ return React.createElement(this.props.wrap, this.props.wrapProps, result);
+ }
+}
+
+/**
+ * Switch translation based on singular or plural based on the target prop.
+ * Should only contain TranslateSingular and TransplatePlural as children.
+ *
+ * Example:
+ * ```
+ * <TranslateSwitch target={n}>
+ * <TranslateSingular>I have {n} apple.</TranslateSingular>
+ * <TranslatePlural>I have {n} apples.</TranslatePlural>
+ * </TranslateSwitch>
+ * ```
+ */
+export class TranslateSwitch extends React.Component<
+ TranslateSwitchProps,
+ void
+> {
+ render(): JSX.Element {
+ let singular: React.ReactElement<TranslationPluralProps> | undefined;
+ let plural: React.ReactElement<TranslationPluralProps> | undefined;
+ const children = this.props.children;
+ if (children) {
+ React.Children.forEach(children, (child: any) => {
+ if (child.type === TranslatePlural) {
+ plural = child;
+ }
+ if (child.type === TranslateSingular) {
+ singular = child;
+ }
+ });
+ }
+ if (!singular || !plural) {
+ console.error("translation not found");
+ return React.createElement("span", {}, ["translation not found"]);
+ }
+ singular.props.target = this.props.target;
+ plural.props.target = this.props.target;
+ // We're looking up the translation based on the
+ // singular, even if we must use the plural form.
+ return singular;
+ }
+}
+
+interface TranslationPluralProps {
+ target: number;
+}
+
+/**
+ * See [[TranslateSwitch]].
+ */
+export class TranslatePlural extends React.Component<
+ TranslationPluralProps,
+ void
+> {
+ render(): JSX.Element {
+ const s = stringifyChildren(this.props.children);
+ const translation = i18nCore.jed.ngettext(s, s, 1);
+ const result = getTranslatedChildren(translation, this.props.children);
+ return <div>{result}</div>;
+ }
+}
+
+/**
+ * See [[TranslateSwitch]].
+ */
+export class TranslateSingular extends React.Component<
+ TranslationPluralProps,
+ void
+> {
+ render(): JSX.Element {
+ const s = stringifyChildren(this.props.children);
+ const translation = i18nCore.jed.ngettext(s, s, this.props.target);
+ const result = getTranslatedChildren(translation, this.props.children);
+ return <div>{result}</div>;
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/pageEntryPoint.ts b/packages/taler-wallet-webextension/src/pageEntryPoint.ts
new file mode 100644
index 000000000..9fd1d36f1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pageEntryPoint.ts
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Main entry point for extension pages.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+import ReactDOM from "react-dom";
+import { createPopup } from "./pages/popup";
+import { createWithdrawPage } from "./pages/withdraw";
+import { createWelcomePage } from "./pages/welcome";
+import { createPayPage } from "./pages/pay";
+import { createRefundPage } from "./pages/refund";
+
+function main(): void {
+ try {
+ let mainElement;
+ const m = location.pathname.match(/([^/]+)$/);
+ if (!m) {
+ throw Error("can't parse page URL");
+ }
+ const page = m[1];
+ switch (page) {
+ case "popup.html":
+ mainElement = createPopup();
+ break;
+ case "withdraw.html":
+ mainElement = createWithdrawPage();
+ break;
+ case "welcome.html":
+ mainElement = createWelcomePage();
+ break;
+ case "pay.html":
+ mainElement = createPayPage();
+ break;
+ case "refund.html":
+ mainElement = createRefundPage();
+ break;
+ default:
+ throw Error(`page '${page}' not implemented`);
+ }
+ const container = document.getElementById("container");
+ if (!container) {
+ throw Error("container not found, can't mount page contents");
+ }
+ ReactDOM.render(mainElement, container);
+ } catch (e) {
+ console.error("got error", e);
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/taler-wallet-webextension/src/pages/pay.tsx b/packages/taler-wallet-webextension/src/pages/pay.tsx
new file mode 100644
index 000000000..2abd423bd
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/pay.tsx
@@ -0,0 +1,180 @@
+/*
+ This file is part of TALER
+ (C) 2015 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/>
+ */
+
+/**
+ * Page shown to the user to confirm entering
+ * a contract.
+ */
+
+/**
+ * Imports.
+ */
+import * as i18n from "../i18n";
+
+
+import { renderAmount, ProgressButton } from "../renderHtml";
+import * as wxApi from "../wxApi";
+
+import React, { useState, useEffect } from "react";
+
+import { Amounts, AmountJson, walletTypes, talerTypes } from "taler-wallet-core";
+
+function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element {
+ const [payStatus, setPayStatus] = useState<walletTypes.PreparePayResult | undefined>();
+ const [payErrMsg, setPayErrMsg] = useState<string | undefined>("");
+ const [numTries, setNumTries] = useState(0);
+ const [loading, setLoading] = useState(false);
+ let amountEffective: AmountJson | undefined = undefined;
+
+ useEffect(() => {
+ const doFetch = async (): Promise<void> => {
+ const p = await wxApi.preparePay(talerPayUri);
+ setPayStatus(p);
+ };
+ doFetch();
+ }, [numTries, talerPayUri]);
+
+ if (!payStatus) {
+ return <span>Loading payment information ...</span>;
+ }
+
+ let insufficientBalance = false;
+ if (payStatus.status == "insufficient-balance") {
+ insufficientBalance = true;
+ }
+
+ if (payStatus.status === "payment-possible") {
+ amountEffective = Amounts.parseOrThrow(payStatus.amountEffective);
+ }
+
+ if (payStatus.status === walletTypes.PreparePayResultType.AlreadyConfirmed && numTries === 0) {
+ return (
+ <span>
+ You have already paid for this article. Click{" "}
+ <a href={payStatus.nextUrl}>here</a> to view it again.
+ </span>
+ );
+ }
+
+ let contractTerms: talerTypes.ContractTerms;
+
+ try {
+ contractTerms = talerTypes.codecForContractTerms().decode(payStatus.contractTerms);
+ } catch (e) {
+ // This should never happen, as the wallet is supposed to check the contract terms
+ // before storing them.
+ console.error(e);
+ console.log("raw contract terms were", payStatus.contractTerms);
+ return <span>Invalid contract terms.</span>;
+ }
+
+ if (!contractTerms) {
+ return (
+ <span>
+ Error: did not get contract terms from merchant or wallet backend.
+ </span>
+ );
+ }
+
+ let merchantName: React.ReactElement;
+ if (contractTerms.merchant && contractTerms.merchant.name) {
+ merchantName = <strong>{contractTerms.merchant.name}</strong>;
+ } else {
+ merchantName = <strong>(pub: {contractTerms.merchant_pub})</strong>;
+ }
+
+ const amount = (
+ <strong>{renderAmount(Amounts.parseOrThrow(contractTerms.amount))}</strong>
+ );
+
+ const doPayment = async (): Promise<void> => {
+ if (payStatus.status !== "payment-possible") {
+ throw Error(`invalid state: ${payStatus.status}`);
+ }
+ const proposalId = payStatus.proposalId;
+ setNumTries(numTries + 1);
+ try {
+ setLoading(true);
+ const res = await wxApi.confirmPay(proposalId, undefined);
+ document.location.href = res.nextUrl;
+ } catch (e) {
+ console.error(e);
+ setPayErrMsg(e.message);
+ }
+ };
+
+ return (
+ <div>
+ <p>
+ <i18n.Translate wrap="p">
+ The merchant <span>{merchantName}</span> offers you to purchase:
+ </i18n.Translate>
+ <div style={{ textAlign: "center" }}>
+ <strong>{contractTerms.summary}</strong>
+ </div>
+ {amountEffective ? (
+ <i18n.Translate wrap="p">
+ The total price is <span>{amount} </span>
+ (plus <span>{renderAmount(amountEffective)}</span> fees).
+ </i18n.Translate>
+ ) : (
+ <i18n.Translate wrap="p">
+ The total price is <span>{amount}</span>.
+ </i18n.Translate>
+ )}
+ </p>
+
+ {insufficientBalance ? (
+ <div>
+ <p style={{ color: "red", fontWeight: "bold" }}>
+ Unable to pay: Your balance is insufficient.
+ </p>
+ </div>
+ ) : null}
+
+ {payErrMsg ? (
+ <div>
+ <p>Payment failed: {payErrMsg}</p>
+ <button
+ className="pure-button button-success"
+ onClick={() => doPayment()}
+ >
+ {i18n.str`Retry`}
+ </button>
+ </div>
+ ) : (
+ <div>
+ <ProgressButton
+ loading={loading}
+ disabled={insufficientBalance}
+ onClick={() => doPayment()}
+ >
+ {i18n.str`Confirm payment`}
+ </ProgressButton>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function createPayPage(): JSX.Element {
+ const url = new URL(document.location.href);
+ const talerPayUri = url.searchParams.get("talerPayUri");
+ if (!talerPayUri) {
+ throw Error("invalid parameter");
+ }
+ return <TalerPayDialog talerPayUri={talerPayUri} />;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/payback.tsx b/packages/taler-wallet-webextension/src/pages/payback.tsx
new file mode 100644
index 000000000..5d42f5f47
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/payback.tsx
@@ -0,0 +1,30 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * View and edit auditors.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import * as React from "react";
+
+export function makePaybackPage(): JSX.Element {
+ return <div>not implemented</div>;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/popup.tsx b/packages/taler-wallet-webextension/src/pages/popup.tsx
new file mode 100644
index 000000000..72c9f4bcb
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/popup.tsx
@@ -0,0 +1,502 @@
+/*
+ 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/>
+ */
+
+/**
+ * Popup shown to the user when they click
+ * the Taler browser action button.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import * as i18n from "../i18n";
+
+import {
+ AmountJson,
+ Amounts,
+ time,
+ taleruri,
+ walletTypes,
+} from "taler-wallet-core";
+
+
+import { abbrev, renderAmount, PageLink } from "../renderHtml";
+import * as wxApi from "../wxApi";
+
+import React, { Fragment, useState, useEffect } from "react";
+
+import moment from "moment";
+import { PermissionsCheckbox } from "./welcome";
+
+// FIXME: move to newer react functions
+/* eslint-disable react/no-deprecated */
+
+class Router extends React.Component<any, any> {
+ static setRoute(s: string): void {
+ window.location.hash = s;
+ }
+
+ static getRoute(): string {
+ // Omit the '#' at the beginning
+ return window.location.hash.substring(1);
+ }
+
+ static onRoute(f: any): () => void {
+ Router.routeHandlers.push(f);
+ return () => {
+ const i = Router.routeHandlers.indexOf(f);
+ this.routeHandlers = this.routeHandlers.splice(i, 1);
+ };
+ }
+
+ private static routeHandlers: any[] = [];
+
+ componentWillMount(): void {
+ console.log("router mounted");
+ window.onhashchange = () => {
+ this.setState({});
+ for (const f of Router.routeHandlers) {
+ f();
+ }
+ };
+ }
+
+ render(): JSX.Element {
+ const route = window.location.hash.substring(1);
+ console.log("rendering route", route);
+ let defaultChild: React.ReactChild | null = null;
+ let foundChild: React.ReactChild | null = null;
+ React.Children.forEach(this.props.children, (child) => {
+ const childProps: any = (child as any).props;
+ if (!childProps) {
+ return;
+ }
+ if (childProps.default) {
+ defaultChild = child as React.ReactChild;
+ }
+ if (childProps.route === route) {
+ foundChild = child as React.ReactChild;
+ }
+ });
+ const c: React.ReactChild | null = foundChild || defaultChild;
+ if (!c) {
+ throw Error("unknown route");
+ }
+ Router.setRoute((c as any).props.route);
+ return <div>{c}</div>;
+ }
+}
+
+interface TabProps {
+ target: string;
+ children?: React.ReactNode;
+}
+
+function Tab(props: TabProps): JSX.Element {
+ let cssClass = "";
+ if (props.target === Router.getRoute()) {
+ cssClass = "active";
+ }
+ const onClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
+ Router.setRoute(props.target);
+ e.preventDefault();
+ };
+ return (
+ <a onClick={onClick} href={props.target} className={cssClass}>
+ {props.children}
+ </a>
+ );
+}
+
+class WalletNavBar extends React.Component<any, any> {
+ private cancelSubscription: any;
+
+ componentWillMount(): void {
+ this.cancelSubscription = Router.onRoute(() => {
+ this.setState({});
+ });
+ }
+
+ componentWillUnmount(): void {
+ if (this.cancelSubscription) {
+ this.cancelSubscription();
+ }
+ }
+
+ render(): JSX.Element {
+ console.log("rendering nav bar");
+ return (
+ <div className="nav" id="header">
+ <Tab target="/balance">{i18n.str`Balance`}</Tab>
+ <Tab target="/history">{i18n.str`History`}</Tab>
+ <Tab target="/settings">{i18n.str`Settings`}</Tab>
+ <Tab target="/debug">{i18n.str`Debug`}</Tab>
+ </div>
+ );
+ }
+}
+
+/**
+ * Render an amount as a large number with a small currency symbol.
+ */
+function bigAmount(amount: AmountJson): JSX.Element {
+ const v = amount.value + amount.fraction / Amounts.fractionalBase;
+ return (
+ <span>
+ <span style={{ fontSize: "5em", display: "block" }}>{v}</span>{" "}
+ <span>{amount.currency}</span>
+ </span>
+ );
+}
+
+function EmptyBalanceView(): JSX.Element {
+ return (
+ <i18n.Translate wrap="p">
+ You have no balance to show. Need some{" "}
+ <PageLink pageName="welcome.html">help</PageLink> getting started?
+ </i18n.Translate>
+ );
+}
+
+class WalletBalanceView extends React.Component<any, any> {
+ private balance?: walletTypes.BalancesResponse;
+ private gotError = false;
+ private canceler: (() => void) | undefined = undefined;
+ private unmount = false;
+ private updateBalanceRunning = false;
+
+ componentWillMount(): void {
+ this.canceler = wxApi.onUpdateNotification(() => this.updateBalance());
+ this.updateBalance();
+ }
+
+ componentWillUnmount(): void {
+ console.log("component WalletBalanceView will unmount");
+ if (this.canceler) {
+ this.canceler();
+ }
+ this.unmount = true;
+ }
+
+ async updateBalance(): Promise<void> {
+ if (this.updateBalanceRunning) {
+ return;
+ }
+ this.updateBalanceRunning = true;
+ let balance: walletTypes.BalancesResponse;
+ try {
+ balance = await wxApi.getBalance();
+ } catch (e) {
+ if (this.unmount) {
+ return;
+ }
+ this.gotError = true;
+ console.error("could not retrieve balances", e);
+ this.setState({});
+ return;
+ } finally {
+ this.updateBalanceRunning = false;
+ }
+ if (this.unmount) {
+ return;
+ }
+ this.gotError = false;
+ console.log("got balance", balance);
+ this.balance = balance;
+ this.setState({});
+ }
+
+ formatPending(entry: walletTypes.Balance): JSX.Element {
+ let incoming: JSX.Element | undefined;
+ let payment: JSX.Element | undefined;
+
+ const available = Amounts.parseOrThrow(entry.available);
+ const pendingIncoming = Amounts.parseOrThrow(entry.pendingIncoming);
+ const pendingOutgoing = Amounts.parseOrThrow(entry.pendingOutgoing);
+
+ console.log(
+ "available: ",
+ entry.pendingIncoming ? renderAmount(entry.available) : null,
+ );
+ console.log(
+ "incoming: ",
+ entry.pendingIncoming ? renderAmount(entry.pendingIncoming) : null,
+ );
+
+ if (!Amounts.isZero(pendingIncoming)) {
+ incoming = (
+ <i18n.Translate wrap="span">
+ <span style={{ color: "darkgreen" }}>
+ {"+"}
+ {renderAmount(entry.pendingIncoming)}
+ </span>{" "}
+ incoming
+ </i18n.Translate>
+ );
+ }
+
+ const l = [incoming, payment].filter((x) => x !== undefined);
+ if (l.length === 0) {
+ return <span />;
+ }
+
+ if (l.length === 1) {
+ return <span>({l})</span>;
+ }
+ return (
+ <span>
+ ({l[0]}, {l[1]})
+ </span>
+ );
+ }
+
+ render(): JSX.Element {
+ const wallet = this.balance;
+ if (this.gotError) {
+ return (
+ <div className="balance">
+ <p>{i18n.str`Error: could not retrieve balance information.`}</p>
+ <p>
+ Click <PageLink pageName="welcome.html">here</PageLink> for help and
+ diagnostics.
+ </p>
+ </div>
+ );
+ }
+ if (!wallet) {
+ return <span></span>;
+ }
+ console.log(wallet);
+ const listing = wallet.balances.map((entry) => {
+ const av = Amounts.parseOrThrow(entry.available);
+ return (
+ <p key={av.currency}>
+ {bigAmount(av)} {this.formatPending(entry)}
+ </p>
+ );
+ });
+ return listing.length > 0 ? (
+ <div className="balance">{listing}</div>
+ ) : (
+ <EmptyBalanceView />
+ );
+ }
+}
+
+function Icon({ l }: { l: string }): JSX.Element {
+ return <div className={"icon"}>{l}</div>;
+}
+
+function formatAndCapitalize(text: string): string {
+ text = text.replace("-", " ");
+ text = text.replace(/^./, text[0].toUpperCase());
+ return text;
+}
+
+const HistoryComponent = (props: any): JSX.Element => {
+ return <span>TBD</span>;
+};
+
+class WalletSettings extends React.Component<any, any> {
+ render(): JSX.Element {
+ return (
+ <div>
+ <h2>Permissions</h2>
+ <PermissionsCheckbox />
+ </div>
+ );
+ }
+}
+
+function reload(): void {
+ try {
+ chrome.runtime.reload();
+ window.close();
+ } catch (e) {
+ // Functionality missing in firefox, ignore!
+ }
+}
+
+function confirmReset(): void {
+ if (
+ confirm(
+ "Do you want to IRREVOCABLY DESTROY everything inside your" +
+ " wallet and LOSE ALL YOUR COINS?",
+ )
+ ) {
+ wxApi.resetDb();
+ window.close();
+ }
+}
+
+function WalletDebug(props: any): JSX.Element {
+ return (
+ <div>
+ <p>Debug tools:</p>
+ <button onClick={openExtensionPage("/popup.html")}>wallet tab</button>
+ <button onClick={openExtensionPage("/benchmark.html")}>benchmark</button>
+ <button onClick={openExtensionPage("/show-db.html")}>show db</button>
+ <button onClick={openExtensionPage("/tree.html")}>show tree</button>
+ <br />
+ <button onClick={confirmReset}>reset</button>
+ <button onClick={reload}>reload chrome extension</button>
+ </div>
+ );
+}
+
+function openExtensionPage(page: string) {
+ return () => {
+ chrome.tabs.create({
+ url: chrome.extension.getURL(page),
+ });
+ };
+}
+
+function openTab(page: string) {
+ return (evt: React.SyntheticEvent<any>) => {
+ evt.preventDefault();
+ chrome.tabs.create({
+ url: page,
+ });
+ };
+}
+
+function makeExtensionUrlWithParams(
+ url: string,
+ params?: { [name: string]: string | undefined },
+): string {
+ 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);
+ }
+ }
+ }
+ return innerUrl.href;
+}
+
+function actionForTalerUri(talerUri: string): string | undefined {
+ const uriType = taleruri.classifyTalerUri(talerUri);
+ switch (uriType) {
+ case taleruri.TalerUriType.TalerWithdraw:
+ return makeExtensionUrlWithParams("withdraw.html", {
+ talerWithdrawUri: talerUri,
+ });
+ case taleruri.TalerUriType.TalerPay:
+ return makeExtensionUrlWithParams("pay.html", {
+ talerPayUri: talerUri,
+ });
+ case taleruri.TalerUriType.TalerTip:
+ return makeExtensionUrlWithParams("tip.html", {
+ talerTipUri: talerUri,
+ });
+ case taleruri.TalerUriType.TalerRefund:
+ return makeExtensionUrlWithParams("refund.html", {
+ talerRefundUri: talerUri,
+ });
+ case taleruri.TalerUriType.TalerNotifyReserve:
+ // FIXME: implement
+ break;
+ default:
+ console.warn(
+ "Response with HTTP 402 has Taler header, but header value is not a taler:// URI.",
+ );
+ break;
+ }
+ return undefined;
+}
+
+async function findTalerUriInActiveTab(): Promise<string | undefined> {
+ return new Promise((resolve, reject) => {
+ chrome.tabs.executeScript(
+ {
+ code: `
+ (() => {
+ let x = document.querySelector("a[href^='taler://'");
+ return x ? x.href.toString() : null;
+ })();
+ `,
+ allFrames: false,
+ },
+ (result) => {
+ if (chrome.runtime.lastError) {
+ console.error(chrome.runtime.lastError);
+ resolve(undefined);
+ return;
+ }
+ console.log("got result", result);
+ resolve(result[0]);
+ },
+ );
+ });
+}
+
+function WalletPopup(): JSX.Element {
+ const [talerActionUrl, setTalerActionUrl] = useState<string | undefined>(
+ undefined,
+ );
+ const [dismissed, setDismissed] = useState(false);
+ useEffect(() => {
+ async function check(): Promise<void> {
+ const talerUri = await findTalerUriInActiveTab();
+ if (talerUri) {
+ const actionUrl = actionForTalerUri(talerUri);
+ setTalerActionUrl(actionUrl);
+ }
+ }
+ check();
+ });
+ if (talerActionUrl && !dismissed) {
+ return (
+ <div style={{ padding: "1em" }}>
+ <h1>Taler Action</h1>
+ <p>This page has a Taler action. </p>
+ <p>
+ <button
+ onClick={() => {
+ window.open(talerActionUrl, "_blank");
+ }}
+ >
+ Open
+ </button>
+ </p>
+ <p>
+ <button onClick={() => setDismissed(true)}>Dismiss</button>
+ </p>
+ </div>
+ );
+ }
+ return (
+ <div>
+ <WalletNavBar />
+ <div style={{ margin: "1em" }}>
+ <Router>
+ <WalletBalanceView route="/balance" default />
+ <WalletSettings route="/settings" />
+ <WalletDebug route="/debug" />
+ </Router>
+ </div>
+ </div>
+ );
+}
+
+export function createPopup(): JSX.Element {
+ return <WalletPopup />;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/refund.tsx b/packages/taler-wallet-webextension/src/pages/refund.tsx
new file mode 100644
index 000000000..7326dfc88
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/refund.tsx
@@ -0,0 +1,89 @@
+/*
+ This file is part of TALER
+ (C) 2015-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/>
+ */
+
+/**
+ * Page that shows refund status for purchases.
+ *
+ * @author Florian Dold
+ */
+
+import React, { useEffect, useState } from "react";
+
+import * as wxApi from "../wxApi";
+import { AmountView } from "../renderHtml";
+import { walletTypes } from "taler-wallet-core";
+
+function RefundStatusView(props: { talerRefundUri: string }): JSX.Element {
+ const [applied, setApplied] = useState(false);
+ const [purchaseDetails, setPurchaseDetails] = useState<
+ walletTypes.PurchaseDetails | undefined
+ >(undefined);
+ const [errMsg, setErrMsg] = useState<string | undefined>(undefined);
+
+ useEffect(() => {
+ const doFetch = async (): Promise<void> => {
+ try {
+ const result = await wxApi.applyRefund(props.talerRefundUri);
+ setApplied(true);
+ const r = await wxApi.getPurchaseDetails(result.proposalId);
+ setPurchaseDetails(r);
+ } catch (e) {
+ console.error(e);
+ setErrMsg(e.message);
+ console.log("err message", e.message);
+ }
+ };
+ doFetch();
+ }, [props.talerRefundUri]);
+
+ console.log("rendering");
+
+ if (errMsg) {
+ return <span>Error: {errMsg}</span>;
+ }
+
+ if (!applied || !purchaseDetails) {
+ return <span>Updating refund status</span>;
+ }
+
+ return (
+ <>
+ <h2>Refund Status</h2>
+ <p>
+ The product <em>{purchaseDetails.contractTerms.summary}</em> has
+ received a total refund of{" "}
+ <AmountView amount={purchaseDetails.totalRefundAmount} />.
+ </p>
+ <p>Note that additional fees from the exchange may apply.</p>
+ </>
+ );
+}
+
+export function createRefundPage(): JSX.Element {
+ const url = new URL(document.location.href);
+
+ const container = document.getElementById("container");
+ if (!container) {
+ throw Error("fatal: can't mount component, container missing");
+ }
+
+ const talerRefundUri = url.searchParams.get("talerRefundUri");
+ if (!talerRefundUri) {
+ throw Error("taler refund URI requred");
+ }
+
+ return <RefundStatusView talerRefundUri={talerRefundUri} />;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/reset-required.tsx b/packages/taler-wallet-webextension/src/pages/reset-required.tsx
new file mode 100644
index 000000000..0ef5fe8b7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/reset-required.tsx
@@ -0,0 +1,93 @@
+/*
+ This file is part of TALER
+ (C) 2017 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/>
+ */
+
+/**
+ * Page to inform the user when a database reset is required.
+ *
+ * @author Florian Dold
+ */
+
+import * as React from "react";
+
+import * as wxApi from "../wxApi";
+
+interface State {
+ /**
+ * Did the user check the confirmation check box?
+ */
+ checked: boolean;
+
+ /**
+ * Do we actually need to reset the db?
+ */
+ resetRequired: boolean;
+}
+
+class ResetNotification extends React.Component<any, State> {
+ constructor(props: any) {
+ super(props);
+ this.state = { checked: false, resetRequired: true };
+ setInterval(() => this.update(), 500);
+ }
+ async update(): Promise<void> {
+ const res = await wxApi.checkUpgrade();
+ this.setState({ resetRequired: res.dbResetRequired });
+ }
+ render(): JSX.Element {
+ if (this.state.resetRequired) {
+ return (
+ <div>
+ <h1>Manual Reset Reqired</h1>
+ <p>
+ The wallet&apos;s database in your browser is incompatible with the{" "}
+ currently installed wallet. Please reset manually.
+ </p>
+ <p>
+ Once the database format has stabilized, we will provide automatic
+ upgrades.
+ </p>
+ <input
+ id="check"
+ type="checkbox"
+ checked={this.state.checked}
+ onChange={(e) => this.setState({ checked: e.target.checked })}
+ />{" "}
+ <label htmlFor="check">
+ I understand that I will lose all my data
+ </label>
+ <br />
+ <button
+ className="pure-button"
+ disabled={!this.state.checked}
+ onClick={() => wxApi.resetDb()}
+ >
+ Reset
+ </button>
+ </div>
+ );
+ }
+ return (
+ <div>
+ <h1>Everything is fine!</h1>A reset is not required anymore, you can
+ close this page.
+ </div>
+ );
+ }
+}
+
+export function createResetRequiredPage(): JSX.Element {
+ return <ResetNotification />;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/return-coins.tsx b/packages/taler-wallet-webextension/src/pages/return-coins.tsx
new file mode 100644
index 000000000..e8cf8c9dd
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/return-coins.tsx
@@ -0,0 +1,30 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Return coins to own bank account.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import * as React from "react";
+
+export function createReturnCoinsPage(): JSX.Element {
+ return <span>Not implemented yet.</span>;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/tip.tsx b/packages/taler-wallet-webextension/src/pages/tip.tsx
new file mode 100644
index 000000000..6cf4e1875
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/tip.tsx
@@ -0,0 +1,103 @@
+/*
+ This file is part of TALER
+ (C) 2017 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/>
+ */
+
+/**
+ * Page shown to the user to confirm creation
+ * of a reserve, usually requested by the bank.
+ *
+ * @author Florian Dold
+ */
+
+import * as React from "react";
+
+import { acceptTip, getTipStatus } from "../wxApi";
+
+import { renderAmount, ProgressButton } from "../renderHtml";
+
+import { useState, useEffect } from "react";
+import { walletTypes } from "taler-wallet-core";
+
+function TipDisplay(props: { talerTipUri: string }): JSX.Element {
+ const [tipStatus, setTipStatus] = useState<walletTypes.TipStatus | undefined>(undefined);
+ const [discarded, setDiscarded] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [finished, setFinished] = useState(false);
+
+ useEffect(() => {
+ const doFetch = async (): Promise<void> => {
+ const ts = await getTipStatus(props.talerTipUri);
+ setTipStatus(ts);
+ };
+ doFetch();
+ }, [props.talerTipUri]);
+
+ if (discarded) {
+ return <span>You&apos;ve discarded the tip.</span>;
+ }
+
+ if (finished) {
+ return <span>Tip has been accepted!</span>;
+ }
+
+ if (!tipStatus) {
+ return <span>Loading ...</span>;
+ }
+
+ const discard = (): void => {
+ setDiscarded(true);
+ };
+
+ const accept = async (): Promise<void> => {
+ setLoading(true);
+ await acceptTip(tipStatus.tipId);
+ setFinished(true);
+ };
+
+ return (
+ <div>
+ <h2>Tip Received!</h2>
+ <p>
+ You received a tip of <strong>{renderAmount(tipStatus.amount)}</strong>{" "}
+ from <span> </span>
+ <strong>{tipStatus.merchantOrigin}</strong>.
+ </p>
+ <p>
+ The tip is handled by the exchange{" "}
+ <strong>{tipStatus.exchangeUrl}</strong>. This exchange will charge fees
+ of <strong>{renderAmount(tipStatus.totalFees)}</strong> for this
+ operation.
+ </p>
+ <form className="pure-form">
+ <ProgressButton loading={loading} onClick={() => accept()}>
+ Accept Tip
+ </ProgressButton>{" "}
+ <button className="pure-button" type="button" onClick={() => discard()}>
+ Discard tip
+ </button>
+ </form>
+ </div>
+ );
+}
+
+export function createTipPage(): JSX.Element {
+ const url = new URL(document.location.href);
+ const talerTipUri = url.searchParams.get("talerTipUri");
+ if (typeof talerTipUri !== "string") {
+ throw Error("talerTipUri must be a string");
+ }
+
+ return <TipDisplay talerTipUri={talerTipUri} />;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/welcome.tsx b/packages/taler-wallet-webextension/src/pages/welcome.tsx
new file mode 100644
index 000000000..ff5de572c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/welcome.tsx
@@ -0,0 +1,190 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems SA
+
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Welcome page, shown on first installs.
+ *
+ * @author Florian Dold
+ */
+
+import React, { useState, useEffect } from "react";
+import { getDiagnostics } from "../wxApi";
+import { PageLink } from "../renderHtml";
+import * as wxApi from "../wxApi";
+import { getPermissionsApi } from "../compat";
+import { extendedPermissions } from "../permissions";
+import { walletTypes } from "taler-wallet-core";
+
+function Diagnostics(): JSX.Element | null {
+ const [timedOut, setTimedOut] = useState(false);
+ const [diagnostics, setDiagnostics] = useState<walletTypes.WalletDiagnostics | undefined>(
+ undefined,
+ );
+
+ useEffect(() => {
+ let gotDiagnostics = false;
+ setTimeout(() => {
+ if (!gotDiagnostics) {
+ console.error("timed out");
+ setTimedOut(true);
+ }
+ }, 1000);
+ const doFetch = async (): Promise<void> => {
+ const d = await getDiagnostics();
+ console.log("got diagnostics", d);
+ gotDiagnostics = true;
+ setDiagnostics(d);
+ };
+ console.log("fetching diagnostics");
+ doFetch();
+ }, []);
+
+ if (timedOut) {
+ return <p>Diagnostics timed out. Could not talk to the wallet backend.</p>;
+ }
+
+ if (diagnostics) {
+ if (diagnostics.errors.length === 0) {
+ return null;
+ } else {
+ return (
+ <div
+ style={{
+ borderLeft: "0.5em solid red",
+ paddingLeft: "1em",
+ paddingTop: "0.2em",
+ paddingBottom: "0.2em",
+ }}
+ >
+ <p>Problems detected:</p>
+ <ol>
+ {diagnostics.errors.map((errMsg) => (
+ <li key={errMsg}>{errMsg}</li>
+ ))}
+ </ol>
+ {diagnostics.firefoxIdbProblem ? (
+ <p>
+ Please check in your <code>about:config</code> settings that you
+ have IndexedDB enabled (check the preference name{" "}
+ <code>dom.indexedDB.enabled</code>).
+ </p>
+ ) : null}
+ {diagnostics.dbOutdated ? (
+ <p>
+ Your wallet database is outdated. Currently automatic migration is
+ not supported. Please go{" "}
+ <PageLink pageName="reset-required.html">here</PageLink> to reset
+ the wallet database.
+ </p>
+ ) : null}
+ </div>
+ );
+ }
+ }
+
+ return <p>Running diagnostics ...</p>;
+}
+
+export function PermissionsCheckbox(): JSX.Element {
+ const [extendedPermissionsEnabled, setExtendedPermissionsEnabled] = useState(
+ false,
+ );
+ async function handleExtendedPerm(requestedVal: boolean): Promise<void> {
+ let nextVal: boolean | undefined;
+ if (requestedVal) {
+ const granted = await new Promise<boolean>((resolve, reject) => {
+ // We set permissions here, since apparently FF wants this to be done
+ // as the result of an input event ...
+ getPermissionsApi().request(extendedPermissions, (granted: boolean) => {
+ if (chrome.runtime.lastError) {
+ console.error("error requesting permissions");
+ console.error(chrome.runtime.lastError);
+ reject(chrome.runtime.lastError);
+ return;
+ }
+ console.log("permissions granted:", granted);
+ resolve(granted);
+ });
+ });
+ const res = await wxApi.setExtendedPermissions(granted);
+ console.log(res);
+ nextVal = res.newValue;
+ } else {
+ const res = await wxApi.setExtendedPermissions(false);
+ console.log(res);
+ nextVal = res.newValue;
+ }
+ console.log("new permissions applied:", nextVal);
+ setExtendedPermissionsEnabled(nextVal ?? false);
+ }
+ useEffect(() => {
+ async function getExtendedPermValue(): Promise<void> {
+ const res = await wxApi.getExtendedPermissions();
+ setExtendedPermissionsEnabled(res.newValue);
+ }
+ getExtendedPermValue();
+ });
+ return (
+ <div>
+ <input
+ checked={extendedPermissionsEnabled}
+ onChange={(x) => handleExtendedPerm(x.target.checked)}
+ type="checkbox"
+ id="checkbox-perm"
+ style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }}
+ />
+ <label
+ htmlFor="checkbox-perm"
+ style={{ marginLeft: "0.5em", fontWeight: "bold" }}
+ >
+ Automatically open wallet based on page content
+ </label>
+ <span
+ style={{
+ color: "#383838",
+ fontSize: "smaller",
+ display: "block",
+ marginLeft: "2em",
+ }}
+ >
+ (Enabling this option below will make using the wallet faster, but
+ requires more permissions from your browser.)
+ </span>
+ </div>
+ );
+}
+
+function Welcome(): JSX.Element {
+ return (
+ <>
+ <p>Thank you for installing the wallet.</p>
+ <Diagnostics />
+ <h2>Permissions</h2>
+ <PermissionsCheckbox />
+ <h2>Next Steps</h2>
+ <a href="https://demo.taler.net/" style={{ display: "block" }}>
+ Try the demo »
+ </a>
+ <a href="https://demo.taler.net/" style={{ display: "block" }}>
+ Learn how to top up your wallet balance »
+ </a>
+ </>
+ );
+}
+
+export function createWelcomePage(): JSX.Element {
+ return <Welcome />;
+}
diff --git a/packages/taler-wallet-webextension/src/pages/withdraw.tsx b/packages/taler-wallet-webextension/src/pages/withdraw.tsx
new file mode 100644
index 000000000..4a92704b3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pages/withdraw.tsx
@@ -0,0 +1,229 @@
+/*
+ This file is part of TALER
+ (C) 2015-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/>
+ */
+
+/**
+ * Page shown to the user to confirm creation
+ * of a reserve, usually requested by the bank.
+ *
+ * @author Florian Dold
+ */
+
+import * as i18n from "../i18n";
+
+import { WithdrawDetailView, renderAmount } from "../renderHtml";
+
+import React, { useState, useEffect } from "react";
+import {
+ acceptWithdrawal,
+ onUpdateNotification,
+} from "../wxApi";
+
+function WithdrawalDialog(props: { talerWithdrawUri: string }): JSX.Element {
+ const [details, setDetails] = useState<
+ any | undefined
+ >();
+ const [selectedExchange, setSelectedExchange] = useState<
+ string | undefined
+ >();
+ const talerWithdrawUri = props.talerWithdrawUri;
+ const [cancelled, setCancelled] = useState(false);
+ const [selecting, setSelecting] = useState(false);
+ const [customUrl, setCustomUrl] = useState<string>("");
+ const [errMsg, setErrMsg] = useState<string | undefined>("");
+ const [updateCounter, setUpdateCounter] = useState(1);
+
+ useEffect(() => {
+ return onUpdateNotification(() => {
+ setUpdateCounter(updateCounter + 1);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ const fetchData = async (): Promise<void> => {
+ // FIXME: re-implement with new API
+ // console.log("getting from", talerWithdrawUri);
+ // let d: WithdrawalDetailsResponse | undefined = undefined;
+ // try {
+ // d = await getWithdrawDetails(talerWithdrawUri, selectedExchange);
+ // } catch (e) {
+ // console.error(
+ // `error getting withdraw details for uri ${talerWithdrawUri}, exchange ${selectedExchange}`,
+ // e,
+ // );
+ // setErrMsg(e.message);
+ // return;
+ // }
+ // console.log("got withdrawDetails", d);
+ // if (!selectedExchange && d.bankWithdrawDetails.suggestedExchange) {
+ // console.log("setting selected exchange");
+ // setSelectedExchange(d.bankWithdrawDetails.suggestedExchange);
+ // }
+ // setDetails(d);
+ };
+ fetchData();
+ }, [selectedExchange, errMsg, selecting, talerWithdrawUri, updateCounter]);
+
+ if (errMsg) {
+ return (
+ <div>
+ <i18n.Translate wrap="p">
+ Could not get details for withdraw operation:
+ </i18n.Translate>
+ <p style={{ color: "red" }}>{errMsg}</p>
+ <p>
+ <span
+ role="button"
+ tabIndex={0}
+ style={{ textDecoration: "underline", cursor: "pointer" }}
+ onClick={() => {
+ setSelecting(true);
+ setErrMsg(undefined);
+ setSelectedExchange(undefined);
+ setDetails(undefined);
+ }}
+ >
+ {i18n.str`Chose different exchange provider`}
+ </span>
+ </p>
+ </div>
+ );
+ }
+
+ if (!details) {
+ return <span>Loading...</span>;
+ }
+
+ if (cancelled) {
+ return <span>Withdraw operation has been cancelled.</span>;
+ }
+
+ if (selecting) {
+ const bankSuggestion =
+ details && details.bankWithdrawDetails.suggestedExchange;
+ return (
+ <div>
+ {i18n.str`Please select an exchange. You can review the details before after your selection.`}
+ {bankSuggestion && (
+ <div>
+ <h2>Bank Suggestion</h2>
+ <button
+ className="pure-button button-success"
+ onClick={() => {
+ setDetails(undefined);
+ setSelectedExchange(bankSuggestion);
+ setSelecting(false);
+ }}
+ >
+ <i18n.Translate wrap="span">
+ Select <strong>{bankSuggestion}</strong>
+ </i18n.Translate>
+ </button>
+ </div>
+ )}
+ <h2>Custom Selection</h2>
+ <p>
+ <input
+ type="text"
+ onChange={(e) => setCustomUrl(e.target.value)}
+ value={customUrl}
+ />
+ </p>
+ <button
+ className="pure-button button-success"
+ onClick={() => {
+ setDetails(undefined);
+ setSelectedExchange(customUrl);
+ setSelecting(false);
+ }}
+ >
+ <i18n.Translate wrap="span">Select custom exchange</i18n.Translate>
+ </button>
+ </div>
+ );
+ }
+
+ const accept = async (): Promise<void> => {
+ if (!selectedExchange) {
+ throw Error("can't accept, no exchange selected");
+ }
+ console.log("accepting exchange", selectedExchange);
+ const res = await acceptWithdrawal(talerWithdrawUri, selectedExchange);
+ console.log("accept withdrawal response", res);
+ if (res.confirmTransferUrl) {
+ document.location.href = res.confirmTransferUrl;
+ }
+ };
+
+ return (
+ <div>
+ <h1>Digital Cash Withdrawal</h1>
+ <i18n.Translate wrap="p">
+ You are about to withdraw{" "}
+ <strong>{renderAmount(details.bankWithdrawDetails.amount)}</strong> from
+ your bank account into your wallet.
+ </i18n.Translate>
+ {selectedExchange ? (
+ <p>
+ The exchange <strong>{selectedExchange}</strong> will be used as the
+ Taler payment service provider.
+ </p>
+ ) : null}
+
+ <div>
+ <button
+ className="pure-button button-success"
+ disabled={!selectedExchange}
+ onClick={() => accept()}
+ >
+ {i18n.str`Accept fees and withdraw`}
+ </button>
+ <p>
+ <span
+ role="button"
+ tabIndex={0}
+ style={{ textDecoration: "underline", cursor: "pointer" }}
+ onClick={() => setSelecting(true)}
+ >
+ {i18n.str`Chose different exchange provider`}
+ </span>
+ <br />
+ <span
+ role="button"
+ tabIndex={0}
+ style={{ textDecoration: "underline", cursor: "pointer" }}
+ onClick={() => setCancelled(true)}
+ >
+ {i18n.str`Cancel withdraw operation`}
+ </span>
+ </p>
+
+ {details.exchangeWithdrawDetails ? (
+ <WithdrawDetailView rci={details.exchangeWithdrawDetails} />
+ ) : null}
+ </div>
+ </div>
+ );
+}
+
+export function createWithdrawPage(): JSX.Element {
+ const url = new URL(document.location.href);
+ const talerWithdrawUri = url.searchParams.get("talerWithdrawUri");
+ if (!talerWithdrawUri) {
+ throw Error("withdraw URI required");
+ }
+ return <WithdrawalDialog talerWithdrawUri={talerWithdrawUri} />;
+}
diff --git a/packages/taler-wallet-webextension/src/permissions.ts b/packages/taler-wallet-webextension/src/permissions.ts
new file mode 100644
index 000000000..bcd357fd6
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/permissions.ts
@@ -0,0 +1,20 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 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 <http://www.gnu.org/licenses/>
+ */
+
+export const extendedPermissions = {
+ permissions: ["webRequest", "webRequestBlocking"],
+ origins: ["http://*/*", "https://*/*"],
+};
diff --git a/packages/taler-wallet-webextension/src/renderHtml.tsx b/packages/taler-wallet-webextension/src/renderHtml.tsx
new file mode 100644
index 000000000..89f6c12e8
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/renderHtml.tsx
@@ -0,0 +1,341 @@
+/*
+ This file is part of TALER
+ (C) 2016 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers functions to render Taler-related data structures to HTML.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { AmountJson, Amounts, time, walletTypes } from "taler-wallet-core";
+import * as i18n from "./i18n";
+import React from "react";
+
+/**
+ * Render amount as HTML, which non-breaking space between
+ * decimal value and currency.
+ */
+export function renderAmount(amount: AmountJson | string): JSX.Element {
+ let a;
+ if (typeof amount === "string") {
+ a = Amounts.parse(amount);
+ } else {
+ a = amount;
+ }
+ if (!a) {
+ return <span>(invalid amount)</span>;
+ }
+ const x = a.value + a.fraction / Amounts.fractionalBase;
+ return (
+ <span>
+ {x}&nbsp;{a.currency}
+ </span>
+ );
+}
+
+export const AmountView = ({
+ amount,
+}: {
+ amount: AmountJson | string;
+}): JSX.Element => renderAmount(amount);
+
+/**
+ * Abbreviate a string to a given length, and show the full
+ * string on hover as a tooltip.
+ */
+export function abbrev(s: string, n = 5): JSX.Element {
+ let sAbbrev = s;
+ if (s.length > n) {
+ sAbbrev = s.slice(0, n) + "..";
+ }
+ return (
+ <span className="abbrev" title={s}>
+ {sAbbrev}
+ </span>
+ );
+}
+
+interface CollapsibleState {
+ collapsed: boolean;
+}
+
+interface CollapsibleProps {
+ initiallyCollapsed: boolean;
+ title: string;
+}
+
+/**
+ * Component that shows/hides its children when clicking
+ * a heading.
+ */
+export class Collapsible extends React.Component<
+ CollapsibleProps,
+ CollapsibleState
+> {
+ constructor(props: CollapsibleProps) {
+ super(props);
+ this.state = { collapsed: props.initiallyCollapsed };
+ }
+ render(): JSX.Element {
+ const doOpen = (e: any): void => {
+ this.setState({ collapsed: false });
+ e.preventDefault();
+ };
+ const doClose = (e: any): void => {
+ this.setState({ collapsed: true });
+ e.preventDefault();
+ };
+ if (this.state.collapsed) {
+ return (
+ <h2>
+ <a className="opener opener-collapsed" href="#" onClick={doOpen}>
+ {" "}
+ {this.props.title}
+ </a>
+ </h2>
+ );
+ }
+ return (
+ <div>
+ <h2>
+ <a className="opener opener-open" href="#" onClick={doClose}>
+ {" "}
+ {this.props.title}
+ </a>
+ </h2>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+function WireFee(props: {
+ s: string;
+ rci: walletTypes.ExchangeWithdrawDetails;
+}): JSX.Element {
+ return (
+ <>
+ <thead>
+ <tr>
+ <th colSpan={3}>Wire Method {props.s}</th>
+ </tr>
+ <tr>
+ <th>Applies Until</th>
+ <th>Wire Fee</th>
+ <th>Closing Fee</th>
+ </tr>
+ </thead>
+ <tbody>
+ {props.rci.wireFees.feesForType[props.s].map((f) => (
+ <tr key={f.sig}>
+ <td>{time.stringifyTimestamp(f.endStamp)}</td>
+ <td>{renderAmount(f.wireFee)}</td>
+ <td>{renderAmount(f.closingFee)}</td>
+ </tr>
+ ))}
+ </tbody>
+ </>
+ );
+}
+
+function AuditorDetailsView(props: {
+ rci: walletTypes.ExchangeWithdrawDetails | null;
+}): JSX.Element {
+ const rci = props.rci;
+ console.log("rci", rci);
+ if (!rci) {
+ return (
+ <p>
+ Details will be displayed when a valid exchange provider URL is entered.
+ </p>
+ );
+ }
+ if ((rci.exchangeInfo.details?.auditors ?? []).length === 0) {
+ return <p>The exchange is not audited by any auditors.</p>;
+ }
+ return (
+ <div>
+ {(rci.exchangeInfo.details?.auditors ?? []).map((a) => (
+ <div key={a.auditor_pub}>
+ <h3>Auditor {a.auditor_url}</h3>
+ <p>
+ Public key: <ExpanderText text={a.auditor_pub} />
+ </p>
+ <p>
+ Trusted:{" "}
+ {rci.trustedAuditorPubs.indexOf(a.auditor_pub) >= 0 ? "yes" : "no"}
+ </p>
+ <p>
+ Audits {a.denomination_keys.length} of {rci.numOfferedDenoms}{" "}
+ denominations
+ </p>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function FeeDetailsView(props: {
+ rci: walletTypes.ExchangeWithdrawDetails | null;
+}): JSX.Element {
+ const rci = props.rci;
+ if (!rci) {
+ return (
+ <p>
+ Details will be displayed when a valid exchange provider URL is entered.
+ </p>
+ );
+ }
+
+ const denoms = rci.selectedDenoms;
+ const withdrawFee = renderAmount(rci.withdrawFee);
+ const overhead = renderAmount(rci.overhead);
+
+ return (
+ <div>
+ <h3>Overview</h3>
+ <p>
+ Public key:{" "}
+ <ExpanderText
+ text={rci.exchangeInfo.details?.masterPublicKey ?? "??"}
+ />
+ </p>
+ <p>
+ {i18n.str`Withdrawal fees:`} {withdrawFee}
+ </p>
+ <p>
+ {i18n.str`Rounding loss:`} {overhead}
+ </p>
+ <p>{i18n.str`Earliest expiration (for deposit): ${time.stringifyTimestamp(
+ rci.earliestDepositExpiration,
+ )}`}</p>
+ <h3>Coin Fees</h3>
+ <div style={{ overflow: "auto" }}>
+ <table className="pure-table">
+ <thead>
+ <tr>
+ <th>{i18n.str`# Coins`}</th>
+ <th>{i18n.str`Value`}</th>
+ <th>{i18n.str`Withdraw Fee`}</th>
+ <th>{i18n.str`Refresh Fee`}</th>
+ <th>{i18n.str`Deposit Fee`}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {denoms.selectedDenoms.map((ds) => {
+ return (
+ <tr key={ds.denom.denomPub}>
+ <td>{ds.count + "x"}</td>
+ <td>{renderAmount(ds.denom.value)}</td>
+ <td>{renderAmount(ds.denom.feeWithdraw)}</td>
+ <td>{renderAmount(ds.denom.feeRefresh)}</td>
+ <td>{renderAmount(ds.denom.feeDeposit)}</td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ <h3>Wire Fees</h3>
+ <div style={{ overflow: "auto" }}>
+ <table className="pure-table">
+ {Object.keys(rci.wireFees.feesForType).map((s) => (
+ <WireFee key={s} s={s} rci={rci} />
+ ))}
+ </table>
+ </div>
+ </div>
+ );
+}
+
+/**
+ * Shows details about a withdraw request.
+ */
+export function WithdrawDetailView(props: {
+ rci: walletTypes.ExchangeWithdrawDetails | null;
+}): JSX.Element {
+ const rci = props.rci;
+ return (
+ <div>
+ <Collapsible initiallyCollapsed={true} title="Fee and Spending Details">
+ <FeeDetailsView rci={rci} />
+ </Collapsible>
+ <Collapsible initiallyCollapsed={true} title="Auditor Details">
+ <AuditorDetailsView rci={rci} />
+ </Collapsible>
+ </div>
+ );
+}
+
+interface ExpanderTextProps {
+ text: string;
+}
+
+/**
+ * Show a heading with a toggle to show/hide the expandable content.
+ */
+export function ExpanderText({ text }: ExpanderTextProps): JSX.Element {
+ return <span>{text}</span>;
+}
+
+export interface LoadingButtonProps {
+ loading: boolean;
+}
+
+export function ProgressButton(
+ props: React.PropsWithChildren<LoadingButtonProps> &
+ React.DetailedHTMLProps<
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
+ HTMLButtonElement
+ >,
+): JSX.Element {
+ return (
+ <button
+ className="pure-button pure-button-primary"
+ type="button"
+ {...props}
+ >
+ {props.loading ? (
+ <span>
+ <object
+ className="svg-icon svg-baseline"
+ data="/img/spinner-bars.svg"
+ />
+ </span>
+ ) : null}{" "}
+ {props.children}
+ </button>
+ );
+}
+
+export function PageLink(
+ props: React.PropsWithChildren<{ pageName: string }>,
+): JSX.Element {
+ const url = chrome.extension.getURL(`/${props.pageName}`);
+ return (
+ <a
+ className="actionLink"
+ href={url}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {props.children}
+ </a>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts
new file mode 100644
index 000000000..ee86d90e5
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wxApi.ts
@@ -0,0 +1,239 @@
+/*
+ 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/>
+ */
+
+/**
+ * Interface to the wallet through WebExtension messaging.
+ */
+
+/**
+ * Imports.
+ */
+import { AmountJson, walletTypes } from "taler-wallet-core";
+
+
+export interface ExtendedPermissionsResponse {
+ newValue: boolean;
+}
+
+
+/**
+ * Response with information about available version upgrades.
+ */
+export interface UpgradeResponse {
+ /**
+ * Is a reset required because of a new DB version
+ * that can't be atomatically upgraded?
+ */
+ dbResetRequired: boolean;
+
+ /**
+ * Current database version.
+ */
+ currentDbVersion: string;
+
+ /**
+ * Old db version (if applicable).
+ */
+ oldDbVersion: string;
+}
+
+/**
+ * Error thrown when the function from the backend (via RPC) threw an error.
+ */
+export class WalletApiError extends Error {
+ constructor(message: string, public detail: any) {
+ super(message);
+ // restore prototype chain
+ Object.setPrototypeOf(this, new.target.prototype);
+ }
+}
+
+async function callBackend(
+ type: string,
+ detail: any,
+): Promise<any> {
+ return new Promise<any>((resolve, reject) => {
+ chrome.runtime.sendMessage({ type, detail }, (resp) => {
+ if (chrome.runtime.lastError) {
+ console.log("Error calling backend");
+ reject(
+ new Error(
+ `Error contacting backend: chrome.runtime.lastError.message`,
+ ),
+ );
+ }
+ if (typeof resp === "object" && resp && resp.error) {
+ console.warn("response error:", resp);
+ const e = new WalletApiError(resp.error.message, resp.error);
+ reject(e);
+ } else {
+ resolve(resp);
+ }
+ });
+ });
+}
+
+
+
+/**
+ * Start refreshing a coin.
+ */
+export function refresh(coinPub: string): Promise<void> {
+ return callBackend("refresh-coin", { coinPub });
+}
+
+/**
+ * Pay for a proposal.
+ */
+export function confirmPay(
+ proposalId: string,
+ sessionId: string | undefined,
+): Promise<walletTypes.ConfirmPayResult> {
+ return callBackend("confirm-pay", { proposalId, sessionId });
+}
+
+/**
+ * Check upgrade information
+ */
+export function checkUpgrade(): Promise<UpgradeResponse> {
+ return callBackend("check-upgrade", {});
+}
+
+/**
+ * Reset database
+ */
+export function resetDb(): Promise<void> {
+ return callBackend("reset-db", {});
+}
+
+/**
+ * Get balances for all currencies/exchanges.
+ */
+export function getBalance(): Promise<walletTypes.BalancesResponse> {
+ return callBackend("balances", {});
+}
+
+/**
+ * Return coins to a bank account.
+ */
+export function returnCoins(args: {
+ amount: AmountJson;
+ exchange: string;
+ senderWire: string;
+}): Promise<void> {
+ return callBackend("return-coins", args);
+}
+
+/**
+ * Look up a purchase in the wallet database from
+ * the contract terms hash.
+ */
+export function getPurchaseDetails(
+ proposalId: string,
+): Promise<walletTypes.PurchaseDetails> {
+ return callBackend("get-purchase-details", { proposalId });
+}
+
+/**
+ * Get the status of processing a tip.
+ */
+export function getTipStatus(talerTipUri: string): Promise<walletTypes.TipStatus> {
+ return callBackend("get-tip-status", { talerTipUri });
+}
+
+/**
+ * Mark a tip as accepted by the user.
+ */
+export function acceptTip(talerTipUri: string): Promise<void> {
+ return callBackend("accept-tip", { talerTipUri });
+}
+
+/**
+ * Download a refund and accept it.
+ */
+export function applyRefund(
+ refundUrl: string,
+): Promise<{ contractTermsHash: string; proposalId: string }> {
+ return callBackend("accept-refund", { refundUrl });
+}
+
+/**
+ * Abort a failed payment and try to get a refund.
+ */
+export function abortFailedPayment(contractTermsHash: string): Promise<void> {
+ return callBackend("abort-failed-payment", { contractTermsHash });
+}
+
+/**
+ * Abort a failed payment and try to get a refund.
+ */
+export function benchmarkCrypto(repetitions: number): Promise<walletTypes.BenchmarkResult> {
+ return callBackend("benchmark-crypto", { repetitions });
+}
+
+/**
+ * Get details about a pay operation.
+ */
+export function preparePay(talerPayUri: string): Promise<walletTypes.PreparePayResult> {
+ return callBackend("prepare-pay", { talerPayUri });
+}
+
+/**
+ * Get details about a withdraw operation.
+ */
+export function acceptWithdrawal(
+ talerWithdrawUri: string,
+ selectedExchange: string,
+): Promise<walletTypes.AcceptWithdrawalResponse> {
+ return callBackend("accept-withdrawal", {
+ talerWithdrawUri,
+ selectedExchange,
+ });
+}
+
+/**
+ * Get diagnostics information
+ */
+export function getDiagnostics(): Promise<walletTypes.WalletDiagnostics> {
+ return callBackend("get-diagnostics", {});
+}
+
+/**
+ * Get diagnostics information
+ */
+export function setExtendedPermissions(
+ value: boolean,
+): Promise<ExtendedPermissionsResponse> {
+ return callBackend("set-extended-permissions", { value });
+}
+
+/**
+ * Get diagnostics information
+ */
+export function getExtendedPermissions(): Promise<ExtendedPermissionsResponse> {
+ return callBackend("get-extended-permissions", {});
+}
+
+export function onUpdateNotification(f: () => void): () => void {
+ const port = chrome.runtime.connect({ name: "notifications" });
+ const listener = (): void => {
+ f();
+ };
+ port.onMessage.addListener(listener);
+ return () => {
+ port.onMessage.removeListener(listener);
+ };
+}
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();
+ });
+}