diff options
author | Florian Dold <florian.dold@gmail.com> | 2020-08-03 13:00:48 +0530 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2020-08-03 13:01:05 +0530 |
commit | ffd2a62c3f7df94365980302fef3bc3376b48182 (patch) | |
tree | 270af6f16b4cc7f5da2afdba55c8bc9dbea5eca5 /packages/taler-wallet-webextension | |
parent | aa481e42675fb7c4dcbbeec0ba1c61e1953b9596 (diff) |
modularize repo, use pnpm, improve typechecking
Diffstat (limited to 'packages/taler-wallet-webextension')
50 files changed, 6441 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json new file mode 100644 index 000000000..b60d4ea98 --- /dev/null +++ b/packages/taler-wallet-webextension/package.json @@ -0,0 +1,41 @@ +{ + "name": "taler-wallet-webextension", + "version": "0.0.15", + "description": "GNU Taler Wallet browser extension", + "main": "./build/index.js", + "types": "./build/index.d.ts", + "author": "Florian Dold", + "license": "AGPL-3.0-or-later", + "private": false, + "scripts": { + "test": "tsc && ava", + "compile": "tsc" + }, + "dependencies": { + "moment": "^2.27.0", + "taler-wallet-core": "workspace:*", + "tslib": "^2.0.0" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^14.0.0", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^8.4.0", + "@rollup/plugin-replace": "^2.3.3", + "@rollup/plugin-typescript": "^5.0.2", + "@types/chrome": "^0.0.103", + "@types/enzyme": "^3.10.5", + "@types/enzyme-adapter-react-16": "^1.0.6", + "@types/node": "^14.0.27", + "@types/react": "^16.9.44", + "@types/react-dom": "^16.9.8", + "ava": "3.11.0", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "rollup": "^2.23.0", + "rollup-plugin-sourcemaps": "^0.6.2", + "rollup-plugin-terser": "^6.1.0", + "typescript": "^3.9.7" + } +} diff --git a/packages/taler-wallet-webextension/rollup.config.js b/packages/taler-wallet-webextension/rollup.config.js new file mode 100644 index 000000000..25ce768b4 --- /dev/null +++ b/packages/taler-wallet-webextension/rollup.config.js @@ -0,0 +1,212 @@ +// rollup.config.js +import commonjs from "@rollup/plugin-commonjs"; +import nodeResolve from "@rollup/plugin-node-resolve"; +import json from "@rollup/plugin-json"; +import replace from "@rollup/plugin-replace"; +import builtins from "builtin-modules"; +import { terser } from "rollup-plugin-terser"; +import typescript from "@rollup/plugin-typescript"; + +// Base settings to use +const baseTypescriptCompilerSettings = { + target: "ES6", + jsx: "react", + reactNamespace: "React", + moduleResolution: "node", + sourceMap: true, + lib: ["es6", "dom"], + noImplicitReturns: true, + noFallthroughCasesInSwitch: true, + strict: true, + strictPropertyInitialization: false, + noImplicitAny: true, + noImplicitThis: true, + allowJs: true, + checkJs: true, + incremental: false, + esModuleInterop: true, + importHelpers: true, + module: "ESNext", + include: ["src/**/*.+(ts|tsx)"], + rootDir: "./src", +}; + +const walletCli = { + input: "src/headless/taler-wallet-cli.ts", + output: { + file: "dist/standalone/taler-wallet-cli.js", + format: "cjs", + }, + external: builtins, + plugins: [ + nodeResolve({ + preferBuiltins: true, + }), + + commonjs({ + include: ["node_modules/**", "dist/node/**"], + extensions: [".js", ".ts"], + ignoreGlobal: false, // Default: false + sourceMap: false, + ignore: ["taler-wallet"], + }), + + json(), + + typescript({ + tsconfig: false, + ...baseTypescriptCompilerSettings, + sourceMap: false, + }), + ], +}; + +const walletAndroid = { + input: "src/android/index.ts", + output: { + //dir: "dist/standalone", + file: "dist/standalone/taler-wallet-android.js", + format: "cjs", + exports: "named", + }, + external: builtins, + plugins: [ + json(), + + nodeResolve({ + preferBuiltins: true, + }), + + commonjs({ + include: ["node_modules/**"], + extensions: [".js"], + sourceMap: false, + ignore: ["taler-wallet"], + }), + + typescript({ + tsconfig: false, + ...baseTypescriptCompilerSettings, + sourceMap: false, + }), + ], +}; + +const webExtensionPageEntryPoint = { + input: "src/webex/pageEntryPoint.ts", + output: { + file: "dist/webextension/pageEntryPoint.js", + format: "iife", + exports: "none", + name: "webExtensionPageEntry", + }, + external: builtins, + plugins: [ + json(), + + nodeResolve({ + preferBuiltins: true, + }), + + terser(), + + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + + commonjs({ + include: ["node_modules/**", "dist/node/**"], + extensions: [".js"], + sourceMap: false, + ignore: ["taler-wallet"], + }), + + typescript({ + tsconfig: false, + ...baseTypescriptCompilerSettings, + sourceMap: false, + }), + ], +}; + +const webExtensionBackgroundPageScript = { + input: "src/webex/background.ts", + output: { + file: "dist/webextension/background.js", + format: "iife", + exports: "none", + name: "webExtensionBackgroundScript", + }, + external: builtins, + plugins: [ + json(), + + nodeResolve({ + preferBuiltins: true, + }), + + terser(), + + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + + commonjs({ + include: ["node_modules/**", "dist/node/**"], + extensions: [".js"], + sourceMap: false, + ignore: ["taler-wallet", "crypto"], + }), + + typescript({ + tsconfig: false, + ...baseTypescriptCompilerSettings, + sourceMap: false, + }), + ], +}; + +const webExtensionCryptoWorker = { + input: "src/crypto/workers/browserWorkerEntry.ts", + output: { + file: "dist/webextension/browserWorkerEntry.js", + format: "iife", + exports: "none", + name: "webExtensionCryptoWorker", + }, + external: builtins, + plugins: [ + json(), + + nodeResolve({ + preferBuiltins: true, + }), + + terser(), + + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + + commonjs({ + include: ["node_modules/**", "dist/node/**"], + extensions: [".js"], + sourceMap: false, + ignore: ["taler-wallet", "crypto"], + }), + + typescript({ + tsconfig: false, + ...baseTypescriptCompilerSettings, + sourceMap: false, + }), + ], +}; + +export default [ + walletCli, + walletAndroid, + webExtensionPageEntryPoint, + webExtensionBackgroundPageScript, + webExtensionCryptoWorker, +]; 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'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'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} {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(); + }); +} diff --git a/packages/taler-wallet-webextension/tsconfig.json b/packages/taler-wallet-webextension/tsconfig.json new file mode 100644 index 000000000..c3c4144bf --- /dev/null +++ b/packages/taler-wallet-webextension/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "composite": true, + "lib": ["es6", "DOM"], + "jsx": "react", + "reactNamespace": "React", + "module": "commonjs", + "target": "es5", + "noImplicitAny": true, + "outDir": "lib", + "declaration": true, + "noEmitOnError": true, + "strict": true, + "incremental": true, + "sourceMap": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +} diff --git a/packages/taler-wallet-webextension/webextension/manifest.json b/packages/taler-wallet-webextension/webextension/manifest.json new file mode 100644 index 000000000..b09e3ecbd --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/manifest.json @@ -0,0 +1,49 @@ +{ + "manifest_version": 2, + + "name": "GNU Taler Wallet (git)", + "description": "Privacy preserving and transparent payments", + "author": "GNU Taler Developers", + "version": "0.6.77.4", + "version_name": "0.7.1-dev.3", + + "minimum_chrome_version": "51", + "minimum_opera_version": "36", + + "applications": { + "gecko": { + "id": "wallet@taler.net", + "strict_min_version": "68.0" + } + }, + + "icons": { + "32": "img/icon.png", + "128": "img/logo.png" + }, + + "permissions": [ + "storage", + "activeTab" + ], + + "optional_permissions": [ + "webRequest", + "webRequestBlocking", + "http://*/*", + "https://*/*" + ], + + "browser_action": { + "default_icon": { + "32": "img/icon.png" + }, + "default_title": "Taler", + "default_popup": "popup.html" + }, + + "background": { + "page": "background.html", + "persistent": true + } +} diff --git a/packages/taler-wallet-webextension/webextension/pack.sh b/packages/taler-wallet-webextension/webextension/pack.sh new file mode 100755 index 000000000..ef005014f --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/pack.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -eu + +if [[ ! -e package.json ]]; then + echo "Please run this from the root of the repo.">&2 + exit 1 +fi + +vers_manifest=$(jq -r '.version' webextension/manifest.json) + +rm -rf dist/wx +mkdir -p dist/wx +cp webextension/manifest.json dist/wx/ +cp -r webextension/static/* dist/wx/ +cp -r dist/webextension/* dist/wx/ + +cd dist/wx + +zipfile="../taler-wallet-${vers_manifest}.zip" + +rm -f -- "$zipfile" +zip -r "$zipfile" ./* diff --git a/packages/taler-wallet-webextension/webextension/static/add-auditor.html b/packages/taler-wallet-webextension/webextension/static/add-auditor.html new file mode 100644 index 000000000..47a97c075 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/add-auditor.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + + <title>Taler Wallet: Add Auditor</title> + + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + + <link rel="icon" href="/img/icon.png" /> + + <script src="/pageEntryPoint.js"></script> + + <style> + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + .button-linky { + background: none; + color: black; + text-decoration: underline; + border: none; + } + </style> + </head> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/auditors.html b/packages/taler-wallet-webextension/webextension/static/auditors.html new file mode 100644 index 000000000..15261290d --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/auditors.html @@ -0,0 +1,35 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Auditors</title> + + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + + <link rel="icon" href="/img/icon.png" /> + + <script src="/dist/webextension/pageEntryPoint.js"></script> + + <style> + body { + font-size: 100%; + } + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + .button-linky { + background: none; + color: black; + text-decoration: underline; + border: none; + } + </style> + </head> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/background.html b/packages/taler-wallet-webextension/webextension/static/background.html new file mode 100644 index 000000000..b89c05588 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/background.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <script src="/background.js"></script> + <title>(wallet bg page)</title> + </head> + <body> + <img id="taler-logo" src="/img/icon.png" /> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/benchmark.html b/packages/taler-wallet-webextension/webextension/static/benchmark.html new file mode 100644 index 000000000..a29fe0725 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/benchmark.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Benchmarks</title> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <link rel="icon" href="/img/icon.png" /> + <script src="/pageEntryPoint.js"></script> + </head> + <body> + <section id="main"> + <h1>Benchmarks</h1> + <div id="container"></div> + </section> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/img/icon.png b/packages/taler-wallet-webextension/webextension/static/img/icon.png Binary files differnew file mode 100644 index 000000000..b4733bebc --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/img/icon.png diff --git a/packages/taler-wallet-webextension/webextension/static/img/logo-2015-medium.png b/packages/taler-wallet-webextension/webextension/static/img/logo-2015-medium.png Binary files differnew file mode 100644 index 000000000..acf84baaf --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/img/logo-2015-medium.png diff --git a/packages/taler-wallet-webextension/webextension/static/img/logo.png b/packages/taler-wallet-webextension/webextension/static/img/logo.png new file mode 120000 index 000000000..1ddb87d2c --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/img/logo.png @@ -0,0 +1 @@ +logo-2015-medium.png
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/webextension/static/img/spinner-bars.svg b/packages/taler-wallet-webextension/webextension/static/img/spinner-bars.svg new file mode 100644 index 000000000..f6f7dfcb3 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/img/spinner-bars.svg @@ -0,0 +1,53 @@ +<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL --> +<svg width="135" height="140" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#fff"> + <rect y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.5s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.5s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="30" y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.25s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.25s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="60" width="15" height="140" rx="6"> + <animate attributeName="height" + begin="0s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="90" y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.25s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.25s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="120" y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.5s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.5s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> +</svg> diff --git a/packages/taler-wallet-webextension/webextension/static/pay.html b/packages/taler-wallet-webextension/webextension/static/pay.html new file mode 100644 index 000000000..452c56df0 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/pay.html @@ -0,0 +1,73 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Confirm Contract</title> + + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <link rel="icon" href="/img/icon.png" /> + <script src="/pageEntryPoint.js"></script> + + <style> + button.accept { + background-color: #5757d2; + border: 1px solid black; + border-radius: 5px; + margin: 1em 0; + padding: 0.5em; + font-weight: bold; + color: white; + } + button.linky { + background: none !important; + border: none; + padding: 0 !important; + + font-family: arial, sans-serif; + color: #069; + text-decoration: underline; + cursor: pointer; + } + + input.url { + width: 25em; + } + + button.accept:disabled { + background-color: #dedbe8; + border: 1px solid white; + border-radius: 5px; + margin: 1em 0; + padding: 0.5em; + font-weight: bold; + color: #2c2c2c; + } + + .errorbox { + border: 1px solid; + display: inline-block; + margin: 1em; + padding: 1em; + font-weight: bold; + background: #ff8a8a; + } + + .okaybox { + border: 1px solid; + display: inline-block; + margin: 1em; + padding: 1em; + font-weight: bold; + background: #00fa9a; + } + </style> + </head> + + <body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <article id="container" class="fade"></article> + </section> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/payback.html b/packages/taler-wallet-webextension/webextension/static/payback.html new file mode 100644 index 000000000..7ca9dc974 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/payback.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Payback</title> + + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <link rel="icon" href="/img/icon.png" /> + <script src="/pageEntryPoint.js"></script> + + <style> + body { + font-size: 100%; + } + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + .button-linky { + background: none; + color: black; + text-decoration: underline; + border: none; + } + </style> + </head> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/popup.html b/packages/taler-wallet-webextension/webextension/static/popup.html new file mode 100644 index 000000000..83f2f2861 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/popup.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <link rel="stylesheet" type="text/css" href="/style/popup.css" /> + <link rel="icon" href="/img/icon.png" /> + <script src="/pageEntryPoint.js"></script> + </head> + + <body> + <div id="container" style="margin: 0; padding: 0;"></div> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/refund.html b/packages/taler-wallet-webextension/webextension/static/refund.html new file mode 100644 index 000000000..3c1d78a24 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/refund.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Refund Status</title> + + <link rel="icon" href="/img/icon.png" /> + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <script src="/pageEntryPoint.js"></script> + </head> + + <body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <article id="container" class="fade"></article> + </section> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/reset-required.html b/packages/taler-wallet-webextension/webextension/static/reset-required.html new file mode 100644 index 000000000..84943fbf1 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/reset-required.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Select Taler Provider</title> + + <link rel="icon" href="/img/icon.png" /> + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <script src="/pageEntryPoint.js"></script> + + <style> + body { + font-size: 100%; + overflow-y: scroll; + } + </style> + </head> + + <body> + <section id="main"> + <div id="container"></div> + </section> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/return-coins.html b/packages/taler-wallet-webextension/webextension/static/return-coins.html new file mode 100644 index 000000000..90703b447 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/return-coins.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Return Coins to Bank Account</title> + + <link rel="icon" href="/img/icon.png" /> + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <script src="/pageEntryPoint.js"></script> + </head> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/style/popup.css b/packages/taler-wallet-webextension/webextension/static/style/popup.css new file mode 100644 index 000000000..cca002399 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/style/popup.css @@ -0,0 +1,185 @@ +/** + * @author Gabor X. Toth + * @author Marcello Stanisci + * @author Florian Dold + */ + +body { + min-height: 20em; + width: 30em; + margin: 0; + padding: 0; + max-height: 800px; + overflow: hidden; + background-color: #f8faf7; + font-family: Arial, Helvetica, sans-serif; +} + +.nav { + background-color: #033; + padding: 0.5em 0; +} + +.nav a { + color: #f8faf7; + padding: 0.7em 1.4em; + text-decoration: none; +} + +.nav a.active { + background-color: #f8faf7; + color: #000; + font-weight: bold; +} + +.container { + overflow-y: scroll; + max-height: 400px; +} + +.abbrev { + text-decoration-style: dotted; +} + +#content { + padding: 1em; +} + +#wallet-table .amount { + text-align: right; +} + +.hidden { + display: none; +} + +#transactions-table th, +#transactions-table td { + padding: 0.2em 0.5em; +} + +#reserve-create table { + width: 100%; +} + +#reserve-create table td.label { + width: 5em; +} + +#reserve-create table .input input[type="text"] { + width: 100%; +} + +.historyItem { + min-width: 380px; + display: flex; + flex-direction: row; + border-bottom: 1px solid #d9dbd8; + padding: 0.5em; + align-items: center; +} + +.historyItem .amount { + font-size: 110%; + font-weight: bold; + text-align: right; +} + +.historyDate, +.historyTitle, +.historyText, +.historySmall { + margin: 0.3em; +} + +.historyDate { + font-size: 90%; + color: slategray; + text-align: right; +} + +.historyLeft { + display: flex; + flex-direction: column; + text-align: right; +} + +.historyContent { + flex: 1; +} + +.historyTitle { + font-weight: 400; +} + +.historyText { + font-size: 90%; +} + +.historySmall { + font-size: 70%; + text-transform: uppercase; +} + +.historyAmount { + flex-grow: 1; +} + +.historyAmount .primary { + font-size: 100%; +} + +.historyAmount .secondary { + font-size: 80%; +} + +.historyAmount .positive { + color: #088; +} + +.historyAmount .positive:before { + content: "+"; +} + +.historyAmount .negative { + color: #800; +} + +.historyAmount .negative:before { + color: #800; + content: "-"; +} +.icon { + margin: 0 10px; + text-align: center; + width: 35px; + font-size: 20px; + border-radius: 50%; + background: #ccc; + color: #fff; + padding-top: 4px; + height: 30px; +} + +.option { + text-transform: uppercase; + text-align: right; + padding: 0.4em; + font-size: 0.9em; +} + +input[type="checkbox"], +input[type="radio"] { + vertical-align: middle; + position: relative; + bottom: 1px; +} + +input[type="radio"] { + bottom: 2px; +} + +.balance { + text-align: center; + padding-top: 2em; +} diff --git a/packages/taler-wallet-webextension/webextension/static/style/pure.css b/packages/taler-wallet-webextension/webextension/static/style/pure.css new file mode 100644 index 000000000..88a4bb7d7 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/style/pure.css @@ -0,0 +1,1513 @@ +/*! +Pure v0.6.2 +Copyright 2013 Yahoo! +Licensed under the BSD License. +https://github.com/yahoo/pure/blob/master/LICENSE.md +*/ +/*! +normalize.css v^3.0 | MIT License | git.io/normalize +Copyright (c) Nicolas Gallagher and Jonathan Neal +*/ +/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS and IE text size adjust after device orientation change, + * without disabling user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined for any HTML5 element in IE 8/9. + * Correct `block` display not defined for `details` or `summary` in IE 10/11 + * and Firefox. + * Correct `block` display not defined for `main` in IE 11. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} + +/** + * 1. Correct `inline-block` display not defined in IE 8/9. + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. + */ + +audio, +canvas, +progress, +video { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9/10. + * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22. + */ + +[hidden], +template { + display: none; +} + +/* Links + ========================================================================== */ + +/** + * Remove the gray background color from active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * Improve readability of focused elements when they are also in an + * active/hover state. + */ + +a:active, +a:hover { + outline: 0; +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9/10. + */ + +img { + border: 0; +} + +/** + * Correct overflow not hidden in IE 9/10/11. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari. + */ + +figure { + margin: 1em 40px; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + box-sizing: content-box; + height: 0; +} + +/** + * Contain overflow in all browsers. + */ + +pre { + overflow: auto; +} + +/** + * Address odd `em`-unit font size rendering in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +/* Forms + ========================================================================== */ + +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ + +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ + +button, +input, +optgroup, +select, +textarea { + color: inherit; /* 1 */ + font: inherit; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ + +button { + overflow: visible; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +input { + line-height: normal; +} + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Fix the cursor style for Chrome's increment/decrement buttons. For certain + * `font-size` values of the `input`, it causes the cursor style of the + * decrement button to change from `default` to `text`. + */ + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome. + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + box-sizing: content-box; /* 2 */ +} + +/** + * Remove inner padding and search cancel button in Safari and Chrome on OS X. + * Safari (but not Chrome) clips the cancel button when the search input has + * padding (and `textfield` appearance). + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ + +textarea { + overflow: auto; +} + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ + +optgroup { + font-weight: bold; +} + +/* Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} + +td, +th { + padding: 0; +} + +/*csslint important:false*/ + +/* ========================================================================== + Pure Base Extras + ========================================================================== */ + +/** + * Extra rules that Pure adds on top of Normalize.css + */ + +/** + * Always hide an element when it has the `hidden` HTML attribute. + */ + +.hidden, +[hidden] { + display: none !important; +} + +/** + * Add this class to an image to make it fit within it's fluid parent wrapper while maintaining + * aspect ratio. + */ +.pure-img { + max-width: 100%; + height: auto; + display: block; +} + +/*csslint regex-selectors:false, known-properties:false, duplicate-properties:false*/ + +.pure-g { + letter-spacing: -0.31em; /* Webkit: collapse white-space between units */ + *letter-spacing: normal; /* reset IE < 8 */ + *word-spacing: -0.43em; /* IE < 8: collapse white-space between units */ + text-rendering: optimizespeed; /* Webkit: fixes text-rendering: optimizeLegibility */ + + /* + Sets the font stack to fonts known to work properly with the above letter + and word spacings. See: https://github.com/yahoo/pure/issues/41/ + + The following font stack makes Pure Grids work on all known environments. + + * FreeSans: Ships with many Linux distros, including Ubuntu + + * Arimo: Ships with Chrome OS. Arimo has to be defined before Helvetica and + Arial to get picked up by the browser, even though neither is available + in Chrome OS. + + * Droid Sans: Ships with all versions of Android. + + * Helvetica, Arial, sans-serif: Common font stack on OS X and Windows. + */ + font-family: FreeSans, Arimo, "Droid Sans", Helvetica, Arial, sans-serif; + + /* Use flexbox when possible to avoid `letter-spacing` side-effects. */ + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-flow: row wrap; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + + /* Prevents distributing space between rows */ + -webkit-align-content: flex-start; + -ms-flex-line-pack: start; + align-content: flex-start; +} + +/* IE10 display: -ms-flexbox (and display: flex in IE 11) does not work inside a table; fall back to block and rely on font hack */ +@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { + table .pure-g { + display: block; + } +} + +/* Opera as of 12 on Windows needs word-spacing. + The ".opera-only" selector is used to prevent actual prefocus styling + and is not required in markup. +*/ +.opera-only :-o-prefocus, +.pure-g { + word-spacing: -0.43em; +} + +.pure-u { + display: inline-block; + *display: inline; /* IE < 8: fake inline-block */ + zoom: 1; + letter-spacing: normal; + word-spacing: normal; + vertical-align: top; + text-rendering: auto; +} + +/* +Resets the font family back to the OS/browser's default sans-serif font, +this the same font stack that Normalize.css sets for the `body`. +*/ +.pure-g [class*="pure-u"] { + font-family: sans-serif; +} + +.pure-u-1, +.pure-u-1-1, +.pure-u-1-2, +.pure-u-1-3, +.pure-u-2-3, +.pure-u-1-4, +.pure-u-3-4, +.pure-u-1-5, +.pure-u-2-5, +.pure-u-3-5, +.pure-u-4-5, +.pure-u-5-5, +.pure-u-1-6, +.pure-u-5-6, +.pure-u-1-8, +.pure-u-3-8, +.pure-u-5-8, +.pure-u-7-8, +.pure-u-1-12, +.pure-u-5-12, +.pure-u-7-12, +.pure-u-11-12, +.pure-u-1-24, +.pure-u-2-24, +.pure-u-3-24, +.pure-u-4-24, +.pure-u-5-24, +.pure-u-6-24, +.pure-u-7-24, +.pure-u-8-24, +.pure-u-9-24, +.pure-u-10-24, +.pure-u-11-24, +.pure-u-12-24, +.pure-u-13-24, +.pure-u-14-24, +.pure-u-15-24, +.pure-u-16-24, +.pure-u-17-24, +.pure-u-18-24, +.pure-u-19-24, +.pure-u-20-24, +.pure-u-21-24, +.pure-u-22-24, +.pure-u-23-24, +.pure-u-24-24 { + display: inline-block; + *display: inline; + zoom: 1; + letter-spacing: normal; + word-spacing: normal; + vertical-align: top; + text-rendering: auto; +} + +.pure-u-1-24 { + width: 4.1667%; + *width: 4.1357%; +} + +.pure-u-1-12, +.pure-u-2-24 { + width: 8.3333%; + *width: 8.3023%; +} + +.pure-u-1-8, +.pure-u-3-24 { + width: 12.5%; + *width: 12.469%; +} + +.pure-u-1-6, +.pure-u-4-24 { + width: 16.6667%; + *width: 16.6357%; +} + +.pure-u-1-5 { + width: 20%; + *width: 19.969%; +} + +.pure-u-5-24 { + width: 20.8333%; + *width: 20.8023%; +} + +.pure-u-1-4, +.pure-u-6-24 { + width: 25%; + *width: 24.969%; +} + +.pure-u-7-24 { + width: 29.1667%; + *width: 29.1357%; +} + +.pure-u-1-3, +.pure-u-8-24 { + width: 33.3333%; + *width: 33.3023%; +} + +.pure-u-3-8, +.pure-u-9-24 { + width: 37.5%; + *width: 37.469%; +} + +.pure-u-2-5 { + width: 40%; + *width: 39.969%; +} + +.pure-u-5-12, +.pure-u-10-24 { + width: 41.6667%; + *width: 41.6357%; +} + +.pure-u-11-24 { + width: 45.8333%; + *width: 45.8023%; +} + +.pure-u-1-2, +.pure-u-12-24 { + width: 50%; + *width: 49.969%; +} + +.pure-u-13-24 { + width: 54.1667%; + *width: 54.1357%; +} + +.pure-u-7-12, +.pure-u-14-24 { + width: 58.3333%; + *width: 58.3023%; +} + +.pure-u-3-5 { + width: 60%; + *width: 59.969%; +} + +.pure-u-5-8, +.pure-u-15-24 { + width: 62.5%; + *width: 62.469%; +} + +.pure-u-2-3, +.pure-u-16-24 { + width: 66.6667%; + *width: 66.6357%; +} + +.pure-u-17-24 { + width: 70.8333%; + *width: 70.8023%; +} + +.pure-u-3-4, +.pure-u-18-24 { + width: 75%; + *width: 74.969%; +} + +.pure-u-19-24 { + width: 79.1667%; + *width: 79.1357%; +} + +.pure-u-4-5 { + width: 80%; + *width: 79.969%; +} + +.pure-u-5-6, +.pure-u-20-24 { + width: 83.3333%; + *width: 83.3023%; +} + +.pure-u-7-8, +.pure-u-21-24 { + width: 87.5%; + *width: 87.469%; +} + +.pure-u-11-12, +.pure-u-22-24 { + width: 91.6667%; + *width: 91.6357%; +} + +.pure-u-23-24 { + width: 95.8333%; + *width: 95.8023%; +} + +.pure-u-1, +.pure-u-1-1, +.pure-u-5-5, +.pure-u-24-24 { + width: 100%; +} +.pure-button { + /* Structure */ + display: inline-block; + zoom: 1; + line-height: normal; + white-space: nowrap; + vertical-align: middle; + text-align: center; + cursor: pointer; + -webkit-user-drag: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + box-sizing: border-box; +} + +/* Firefox: Get rid of the inner focus border */ +.pure-button::-moz-focus-inner { + padding: 0; + border: 0; +} + +/* Inherit .pure-g styles */ +.pure-button-group { + letter-spacing: -0.31em; /* Webkit: collapse white-space between units */ + *letter-spacing: normal; /* reset IE < 8 */ + *word-spacing: -0.43em; /* IE < 8: collapse white-space between units */ + text-rendering: optimizespeed; /* Webkit: fixes text-rendering: optimizeLegibility */ +} + +.opera-only :-o-prefocus, +.pure-button-group { + word-spacing: -0.43em; +} + +.pure-button-group .pure-button { + letter-spacing: normal; + word-spacing: normal; + vertical-align: top; + text-rendering: auto; +} + +/*csslint outline-none:false*/ + +.pure-button { + font-family: inherit; + font-size: 100%; + padding: 0.5em 1em; + color: #444; /* rgba not supported (IE 8) */ + color: rgba(0, 0, 0, 0.8); /* rgba supported */ + border: 1px solid #999; /*IE 6/7/8*/ + border: none rgba(0, 0, 0, 0); /*IE9 + everything else*/ + background-color: #e6e6e6; + text-decoration: none; + border-radius: 2px; +} + +.pure-button-hover, +.pure-button:hover, +.pure-button:focus { + /* csslint ignore:start */ + filter: alpha(opacity=90); + /* csslint ignore:end */ + background-image: -webkit-linear-gradient( + transparent, + rgba(0, 0, 0, 0.05) 40%, + rgba(0, 0, 0, 0.1) + ); + background-image: linear-gradient( + transparent, + rgba(0, 0, 0, 0.05) 40%, + rgba(0, 0, 0, 0.1) + ); +} +.pure-button:focus { + outline: 0; +} +.pure-button-active, +.pure-button:active { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset, + 0 0 6px rgba(0, 0, 0, 0.2) inset; + border-color: #000\9; +} + +.pure-button[disabled], +.pure-button-disabled, +.pure-button-disabled:hover, +.pure-button-disabled:focus, +.pure-button-disabled:active { + border: none; + background-image: none; + /* csslint ignore:start */ + filter: alpha(opacity=40); + /* csslint ignore:end */ + opacity: 0.4; + cursor: not-allowed; + box-shadow: none; + pointer-events: none; +} + +.pure-button-hidden { + display: none; +} + +.pure-button-primary, +.pure-button-selected, +a.pure-button-primary, +a.pure-button-selected { + background-color: rgb(0, 120, 231); + color: #fff; +} + +/* Button Groups */ +.pure-button-group .pure-button { + margin: 0; + border-radius: 0; + border-right: 1px solid #111; /* fallback color for rgba() for IE7/8 */ + border-right: 1px solid rgba(0, 0, 0, 0.2); +} + +.pure-button-group .pure-button:first-child { + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; +} +.pure-button-group .pure-button:last-child { + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + border-right: none; +} + +/*csslint box-model:false*/ +/* +Box-model set to false because we're setting a height on select elements, which +also have border and padding. This is done because some browsers don't render +the padding. We explicitly set the box-model for select elements to border-box, +so we can ignore the csslint warning. +*/ + +.pure-form input[type="text"], +.pure-form input[type="password"], +.pure-form input[type="email"], +.pure-form input[type="url"], +.pure-form input[type="date"], +.pure-form input[type="month"], +.pure-form input[type="time"], +.pure-form input[type="datetime"], +.pure-form input[type="datetime-local"], +.pure-form input[type="week"], +.pure-form input[type="number"], +.pure-form input[type="search"], +.pure-form input[type="tel"], +.pure-form input[type="color"], +.pure-form select, +.pure-form textarea { + padding: 0.5em 0.6em; + display: inline-block; + border: 1px solid #ccc; + box-shadow: inset 0 1px 3px #ddd; + border-radius: 4px; + vertical-align: middle; + box-sizing: border-box; +} + +/* +Need to separate out the :not() selector from the rest of the CSS 2.1 selectors +since IE8 won't execute CSS that contains a CSS3 selector. +*/ +.pure-form input:not([type]) { + padding: 0.5em 0.6em; + display: inline-block; + border: 1px solid #ccc; + box-shadow: inset 0 1px 3px #ddd; + border-radius: 4px; + box-sizing: border-box; +} + +/* Chrome (as of v.32/34 on OS X) needs additional room for color to display. */ +/* May be able to remove this tweak as color inputs become more standardized across browsers. */ +.pure-form input[type="color"] { + padding: 0.2em 0.5em; +} + +.pure-form input[type="text"]:focus, +.pure-form input[type="password"]:focus, +.pure-form input[type="email"]:focus, +.pure-form input[type="url"]:focus, +.pure-form input[type="date"]:focus, +.pure-form input[type="month"]:focus, +.pure-form input[type="time"]:focus, +.pure-form input[type="datetime"]:focus, +.pure-form input[type="datetime-local"]:focus, +.pure-form input[type="week"]:focus, +.pure-form input[type="number"]:focus, +.pure-form input[type="search"]:focus, +.pure-form input[type="tel"]:focus, +.pure-form input[type="color"]:focus, +.pure-form select:focus, +.pure-form textarea:focus { + outline: 0; + border-color: #129fea; +} + +/* +Need to separate out the :not() selector from the rest of the CSS 2.1 selectors +since IE8 won't execute CSS that contains a CSS3 selector. +*/ +.pure-form input:not([type]):focus { + outline: 0; + border-color: #129fea; +} + +.pure-form input[type="file"]:focus, +.pure-form input[type="radio"]:focus, +.pure-form input[type="checkbox"]:focus { + outline: thin solid #129fea; + outline: 1px auto #129fea; +} +.pure-form .pure-checkbox, +.pure-form .pure-radio { + margin: 0.5em 0; + display: block; +} + +.pure-form input[type="text"][disabled], +.pure-form input[type="password"][disabled], +.pure-form input[type="email"][disabled], +.pure-form input[type="url"][disabled], +.pure-form input[type="date"][disabled], +.pure-form input[type="month"][disabled], +.pure-form input[type="time"][disabled], +.pure-form input[type="datetime"][disabled], +.pure-form input[type="datetime-local"][disabled], +.pure-form input[type="week"][disabled], +.pure-form input[type="number"][disabled], +.pure-form input[type="search"][disabled], +.pure-form input[type="tel"][disabled], +.pure-form input[type="color"][disabled], +.pure-form select[disabled], +.pure-form textarea[disabled] { + cursor: not-allowed; + background-color: #eaeded; + color: #cad2d3; +} + +/* +Need to separate out the :not() selector from the rest of the CSS 2.1 selectors +since IE8 won't execute CSS that contains a CSS3 selector. +*/ +.pure-form input:not([type])[disabled] { + cursor: not-allowed; + background-color: #eaeded; + color: #cad2d3; +} +.pure-form input[readonly], +.pure-form select[readonly], +.pure-form textarea[readonly] { + background-color: #eee; /* menu hover bg color */ + color: #777; /* menu text color */ + border-color: #ccc; +} + +.pure-form input:focus:invalid, +.pure-form textarea:focus:invalid, +.pure-form select:focus:invalid { + color: #b94a48; + border-color: #e9322d; +} +.pure-form input[type="file"]:focus:invalid:focus, +.pure-form input[type="radio"]:focus:invalid:focus, +.pure-form input[type="checkbox"]:focus:invalid:focus { + outline-color: #e9322d; +} +.pure-form select { + /* Normalizes the height; padding is not sufficient. */ + height: 2.25em; + border: 1px solid #ccc; + background-color: white; +} +.pure-form select[multiple] { + height: auto; +} +.pure-form label { + margin: 0.5em 0 0.2em; +} +.pure-form fieldset { + margin: 0; + padding: 0.35em 0 0.75em; + border: 0; +} +.pure-form legend { + display: block; + width: 100%; + padding: 0.3em 0; + margin-bottom: 0.3em; + color: #333; + border-bottom: 1px solid #e5e5e5; +} + +.pure-form-stacked input[type="text"], +.pure-form-stacked input[type="password"], +.pure-form-stacked input[type="email"], +.pure-form-stacked input[type="url"], +.pure-form-stacked input[type="date"], +.pure-form-stacked input[type="month"], +.pure-form-stacked input[type="time"], +.pure-form-stacked input[type="datetime"], +.pure-form-stacked input[type="datetime-local"], +.pure-form-stacked input[type="week"], +.pure-form-stacked input[type="number"], +.pure-form-stacked input[type="search"], +.pure-form-stacked input[type="tel"], +.pure-form-stacked input[type="color"], +.pure-form-stacked input[type="file"], +.pure-form-stacked select, +.pure-form-stacked label, +.pure-form-stacked textarea { + display: block; + margin: 0.25em 0; +} + +/* +Need to separate out the :not() selector from the rest of the CSS 2.1 selectors +since IE8 won't execute CSS that contains a CSS3 selector. +*/ +.pure-form-stacked input:not([type]) { + display: block; + margin: 0.25em 0; +} +.pure-form-aligned input, +.pure-form-aligned textarea, +.pure-form-aligned select, +/* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */ +.pure-form-aligned .pure-help-inline, +.pure-form-message-inline { + display: inline-block; + *display: inline; + *zoom: 1; + vertical-align: middle; +} +.pure-form-aligned textarea { + vertical-align: top; +} + +/* Aligned Forms */ +.pure-form-aligned .pure-control-group { + margin-bottom: 0.5em; +} +.pure-form-aligned .pure-control-group label { + text-align: right; + display: inline-block; + vertical-align: middle; + width: 10em; + margin: 0 1em 0 0; +} +.pure-form-aligned .pure-controls { + margin: 1.5em 0 0 11em; +} + +/* Rounded Inputs */ +.pure-form input.pure-input-rounded, +.pure-form .pure-input-rounded { + border-radius: 2em; + padding: 0.5em 1em; +} + +/* Grouped Inputs */ +.pure-form .pure-group fieldset { + margin-bottom: 10px; +} +.pure-form .pure-group input, +.pure-form .pure-group textarea { + display: block; + padding: 10px; + margin: 0 0 -1px; + border-radius: 0; + position: relative; + top: -1px; +} +.pure-form .pure-group input:focus, +.pure-form .pure-group textarea:focus { + z-index: 3; +} +.pure-form .pure-group input:first-child, +.pure-form .pure-group textarea:first-child { + top: 1px; + border-radius: 4px 4px 0 0; + margin: 0; +} +.pure-form .pure-group input:first-child:last-child, +.pure-form .pure-group textarea:first-child:last-child { + top: 1px; + border-radius: 4px; + margin: 0; +} +.pure-form .pure-group input:last-child, +.pure-form .pure-group textarea:last-child { + top: -2px; + border-radius: 0 0 4px 4px; + margin: 0; +} +.pure-form .pure-group button { + margin: 0.35em 0; +} + +.pure-form .pure-input-1 { + width: 100%; +} +.pure-form .pure-input-3-4 { + width: 75%; +} +.pure-form .pure-input-2-3 { + width: 66%; +} +.pure-form .pure-input-1-2 { + width: 50%; +} +.pure-form .pure-input-1-3 { + width: 33%; +} +.pure-form .pure-input-1-4 { + width: 25%; +} + +/* Inline help for forms */ +/* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */ +.pure-form .pure-help-inline, +.pure-form-message-inline { + display: inline-block; + padding-left: 0.3em; + color: #666; + vertical-align: middle; + font-size: 0.875em; +} + +/* Block help for forms */ +.pure-form-message { + display: block; + color: #666; + font-size: 0.875em; +} + +@media only screen and (max-width: 480px) { + .pure-form button[type="submit"] { + margin: 0.7em 0 0; + } + + .pure-form input:not([type]), + .pure-form input[type="text"], + .pure-form input[type="password"], + .pure-form input[type="email"], + .pure-form input[type="url"], + .pure-form input[type="date"], + .pure-form input[type="month"], + .pure-form input[type="time"], + .pure-form input[type="datetime"], + .pure-form input[type="datetime-local"], + .pure-form input[type="week"], + .pure-form input[type="number"], + .pure-form input[type="search"], + .pure-form input[type="tel"], + .pure-form input[type="color"], + .pure-form label { + margin-bottom: 0.3em; + display: block; + } + + .pure-group input:not([type]), + .pure-group input[type="text"], + .pure-group input[type="password"], + .pure-group input[type="email"], + .pure-group input[type="url"], + .pure-group input[type="date"], + .pure-group input[type="month"], + .pure-group input[type="time"], + .pure-group input[type="datetime"], + .pure-group input[type="datetime-local"], + .pure-group input[type="week"], + .pure-group input[type="number"], + .pure-group input[type="search"], + .pure-group input[type="tel"], + .pure-group input[type="color"] { + margin-bottom: 0; + } + + .pure-form-aligned .pure-control-group label { + margin-bottom: 0.3em; + text-align: left; + display: block; + width: 100%; + } + + .pure-form-aligned .pure-controls { + margin: 1.5em 0 0 0; + } + + /* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */ + .pure-form .pure-help-inline, + .pure-form-message-inline, + .pure-form-message { + display: block; + font-size: 0.75em; + /* Increased bottom padding to make it group with its related input element. */ + padding: 0.2em 0 0.8em; + } +} + +/*csslint adjoining-classes: false, box-model:false*/ +.pure-menu { + box-sizing: border-box; +} + +.pure-menu-fixed { + position: fixed; + left: 0; + top: 0; + z-index: 3; +} + +.pure-menu-list, +.pure-menu-item { + position: relative; +} + +.pure-menu-list { + list-style: none; + margin: 0; + padding: 0; +} + +.pure-menu-item { + padding: 0; + margin: 0; + height: 100%; +} + +.pure-menu-link, +.pure-menu-heading { + display: block; + text-decoration: none; + white-space: nowrap; +} + +/* HORIZONTAL MENU */ +.pure-menu-horizontal { + width: 100%; + white-space: nowrap; +} + +.pure-menu-horizontal .pure-menu-list { + display: inline-block; +} + +/* Initial menus should be inline-block so that they are horizontal */ +.pure-menu-horizontal .pure-menu-item, +.pure-menu-horizontal .pure-menu-heading, +.pure-menu-horizontal .pure-menu-separator { + display: inline-block; + *display: inline; + zoom: 1; + vertical-align: middle; +} + +/* Submenus should still be display: block; */ +.pure-menu-item .pure-menu-item { + display: block; +} + +.pure-menu-children { + display: none; + position: absolute; + left: 100%; + top: 0; + margin: 0; + padding: 0; + z-index: 3; +} + +.pure-menu-horizontal .pure-menu-children { + left: 0; + top: auto; + width: inherit; +} + +.pure-menu-allow-hover:hover > .pure-menu-children, +.pure-menu-active > .pure-menu-children { + display: block; + position: absolute; +} + +/* Vertical Menus - show the dropdown arrow */ +.pure-menu-has-children > .pure-menu-link:after { + padding-left: 0.5em; + content: "\25B8"; + font-size: small; +} + +/* Horizontal Menus - show the dropdown arrow */ +.pure-menu-horizontal .pure-menu-has-children > .pure-menu-link:after { + content: "\25BE"; +} + +/* scrollable menus */ +.pure-menu-scrollable { + overflow-y: scroll; + overflow-x: hidden; +} + +.pure-menu-scrollable .pure-menu-list { + display: block; +} + +.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list { + display: inline-block; +} + +.pure-menu-horizontal.pure-menu-scrollable { + white-space: nowrap; + overflow-y: hidden; + overflow-x: auto; + -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; + /* a little extra padding for this style to allow for scrollbars */ + padding: 0.5em 0; +} + +.pure-menu-horizontal.pure-menu-scrollable::-webkit-scrollbar { + display: none; +} + +/* misc default styling */ + +.pure-menu-separator, +.pure-menu-horizontal .pure-menu-children .pure-menu-separator { + background-color: #ccc; + height: 1px; + margin: 0.3em 0; +} + +.pure-menu-horizontal .pure-menu-separator { + width: 1px; + height: 1.3em; + margin: 0 0.3em; +} + +/* Need to reset the separator since submenu is vertical */ +.pure-menu-horizontal .pure-menu-children .pure-menu-separator { + display: block; + width: auto; +} + +.pure-menu-heading { + text-transform: uppercase; + color: #565d64; +} + +.pure-menu-link { + color: #777; +} + +.pure-menu-children { + background-color: #fff; +} + +.pure-menu-link, +.pure-menu-disabled, +.pure-menu-heading { + padding: 0.5em 1em; +} + +.pure-menu-disabled { + opacity: 0.5; +} + +.pure-menu-disabled .pure-menu-link:hover { + background-color: transparent; +} + +.pure-menu-active > .pure-menu-link, +.pure-menu-link:hover, +.pure-menu-link:focus { + background-color: #eee; +} + +.pure-menu-selected .pure-menu-link, +.pure-menu-selected .pure-menu-link:visited { + color: #000; +} + +.pure-table { + /* Remove spacing between table cells (from Normalize.css) */ + border-collapse: collapse; + border-spacing: 0; + empty-cells: show; + border: 1px solid #cbcbcb; +} + +.pure-table caption { + color: #000; + font: italic 85%/1 arial, sans-serif; + padding: 1em 0; + text-align: center; +} + +.pure-table td, +.pure-table th { + border-left: 1px solid #cbcbcb; /* inner column border */ + border-width: 0 0 0 1px; + font-size: inherit; + margin: 0; + overflow: visible; /*to make ths where the title is really long work*/ + padding: 0.5em 1em; /* cell padding */ +} + +/* Consider removing this next declaration block, as it causes problems when +there's a rowspan on the first cell. Case added to the tests. issue#432 */ +.pure-table td:first-child, +.pure-table th:first-child { + border-left-width: 0; +} + +.pure-table thead { + background-color: #e0e0e0; + color: #000; + text-align: left; + vertical-align: bottom; +} + +/* +striping: + even - #fff (white) + odd - #f2f2f2 (light gray) +*/ +.pure-table td { + background-color: transparent; +} +.pure-table-odd td { + background-color: #f2f2f2; +} + +/* nth-child selector for modern browsers */ +.pure-table-striped tr:nth-child(2n-1) td { + background-color: #f2f2f2; +} + +/* BORDERED TABLES */ +.pure-table-bordered td { + border-bottom: 1px solid #cbcbcb; +} +.pure-table-bordered tbody > tr:last-child > td { + border-bottom-width: 0; +} + +/* HORIZONTAL BORDERED TABLES */ + +.pure-table-horizontal td, +.pure-table-horizontal th { + border-width: 0 0 1px 0; + border-bottom: 1px solid #cbcbcb; +} +.pure-table-horizontal tbody > tr:last-child > td { + border-bottom-width: 0; +} diff --git a/packages/taler-wallet-webextension/webextension/static/style/wallet.css b/packages/taler-wallet-webextension/webextension/static/style/wallet.css new file mode 100644 index 000000000..7c06f2386 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/style/wallet.css @@ -0,0 +1,290 @@ +body { + font-size: 100%; + overflow-y: scroll; + margin-top: 2em; +} + +#main { + border: solid 5px black; + border-radius: 10px; + margin-left: auto; + margin-right: auto; + padding-top: 2em; + max-width: 50%; + padding: 2em; +} + +header { + width: 100%; + height: 100px; + margin: 0; + padding: 0; +} + +header #logo { + float: left; + width: 100px; + height: 100px; + padding: 0; + margin: 0; + text-align: center; + background-image: url(/img/logo.png); + background-size: 100px; +} + +aside { + width: 100px; + float: left; +} + +section#main { + margin: auto; + padding: 20px; + height: 100%; + max-width: 50%; +} + +section#main h1:first-child { + margin-top: 0; +} + +h1 { + font-size: 160%; + font-family: "monospace"; +} + +h2 { + font-size: 140%; + font-family: "monospace"; +} + +h3 { + font-size: 120%; + font-family: "monospace"; +} + +h4, +h5, +h6 { + font-family: "monospace"; + font-size: 100%; +} + +.form-row { + padding-top: 5px; + padding-bottom: 5px; +} + +label { + padding-right: 1em; +} + +input.url { + width: 25em; +} + +.formish { +} + +.json-key { + color: brown; +} +.json-value { + color: navy; +} +.json-string { + color: olive; +} + +button { + font-size: 120%; + padding: 0.5em; +} + +button.confirm-pay { + float: right; +} + +/* We use fading to hide slower DOM updates */ +.fade { + -webkit-animation: fade 0.7s; + animation: fade 0.7s; + opacity: 1; +} + +@-webkit-keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +button.linky { + background: none !important; + border: none; + padding: 0 !important; + + font-family: arial, sans-serif; + color: #069; + text-decoration: underline; + cursor: pointer; +} + +.blacklink a:link, +.blacklink a:visited, +.blacklink a:hover, +.blacklink a:active { + color: #000; +} + +table, +th, +td { + border: 1px solid black; +} + +button.accept { + background-color: #5757d2; + border: 1px solid black; + border-radius: 5px; + margin: 1em 0; + padding: 0.5em; + font-weight: bold; + color: white; +} +button.linky { + background: none !important; + border: none; + padding: 0 !important; + + font-family: arial, sans-serif; + color: #069; + text-decoration: underline; + cursor: pointer; +} + +button.accept:disabled { + background-color: #dedbe8; + border: 1px solid white; + border-radius: 5px; + margin: 1em 0; + padding: 0.5em; + font-weight: bold; + color: #2c2c2c; +} + +input.url { + width: 25em; +} + +table { + border-collapse: collapse; +} + +td { + border-left: 1px solid black; + border-right: 1px solid black; + text-align: center; + padding: 0.3em; +} + +span.spacer { + padding-left: 0.5em; + padding-right: 0.5em; +} + +.button-success, +.button-destructive, +.button-warning, +.button-secondary { + color: white; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); +} + +.button-success { + background: rgb(28, 184, 65); +} + +.button-destructive { + background: rgb(202, 60, 60); +} + +.button-warning { + background: rgb(223, 117, 20); +} + +.button-secondary { + background: rgb(66, 184, 221); +} + +a.actionLink { + color: black; +} + +.errorbox { + border: 1px solid; + display: inline-block; + margin: 1em; + padding: 1em; + font-weight: bold; + background: #ff8a8a; +} + +.okaybox { + border: 1px solid; + display: inline-block; + margin: 1em; + padding: 1em; + font-weight: bold; + background: #00fa9a; +} + +a.opener { + color: black; +} +.opener-open::before { + content: "\25bc"; +} +.opener-collapsed::before { + content: "\25b6 "; +} + +.svg-icon { + display: inline-flex; + align-self: center; + position: relative; + height: 1em; + width: 1em; +} +.svg-icon svg { + height: 1em; + width: 1em; +} +object.svg-icon.svg-baseline { + transform: translate(0, 0.125em); +} + +.switch { + position: relative; + display: inline-block; + width: 60px; + height: 34px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +}
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/webextension/static/tip.html b/packages/taler-wallet-webextension/webextension/static/tip.html new file mode 100644 index 000000000..00ed4d248 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/tip.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Received Tip</title> + + <link rel="icon" href="/img/icon.png" /> + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <script src="/pageEntryPoint.js"></script> + </head> + + <body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <div id="container"></div> + </section> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/welcome.html b/packages/taler-wallet-webextension/webextension/static/welcome.html new file mode 100644 index 000000000..07ecac707 --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/welcome.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet Installed</title> + + <link rel="icon" href="/img/icon.png" /> + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <script src="/pageEntryPoint.js"></script> + </head> + + <body> + <section id="main"> + <div style="border-bottom: 3px dashed #aa3939; margin-bottom: 2em;"> + <h1 style="font-family: monospace; font-size: 250%;"> + <span style="color: #aa3939;">❰</span>Taler Wallet<span style="color: #aa3939;">❱</span> + </h1> + </div> + <h1>Browser Extension Installed!</h1> + <div id="container">Loading...</div> + </section> + </body> +</html> diff --git a/packages/taler-wallet-webextension/webextension/static/withdraw.html b/packages/taler-wallet-webextension/webextension/static/withdraw.html new file mode 100644 index 000000000..5137204bd --- /dev/null +++ b/packages/taler-wallet-webextension/webextension/static/withdraw.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>Taler Wallet: Withdraw</title> + <link rel="icon" href="/img/icon.png" /> + <link rel="stylesheet" type="text/css" href="/style/pure.css" /> + <link rel="stylesheet" type="text/css" href="/style/wallet.css" /> + <script src="/pageEntryPoint.js"></script> + </head> + + <body> + <section id="main"> + <div style="border-bottom: 3px dashed #aa3939; margin-bottom: 2em;"> + <h1 style="font-family: monospace; font-size: 250%;"> + <span style="color: #aa3939;">❰</span>Taler Wallet<span style="color: #aa3939;">❱</span> + </h1> + </div> + <div class="fade" id="container"></div> + </section> + </body> +</html> |